From 017081e0e9852e680829a63892f4728762c1f3ca Mon Sep 17 00:00:00 2001 From: DIIA Date: Mon, 11 Mar 2024 16:47:22 +0000 Subject: [PATCH] Releasing android-diia --- .gitignore | 36 + CONTRIBUTING.md | 32 + LICENSE.md | 287 + README.md | 62 +- address_search/.gitignore | 1 + address_search/README.md | 25 + address_search/build.gradle | 141 + address_search/consumer-rules.pro | 2 + address_search/excludes.jacoco | 2 + address_search/proguard-rules.pro | 23 + address_search/src/main/AndroidManifest.xml | 5 + .../di/AddressSearchApiModule.kt | 20 + .../models/AddressDefaultListItem.kt | 19 + .../models/AddressFieldApproveRequest.kt | 36 + .../models/AddressFieldInputType.kt | 6 + .../models/AddressFieldRequest.kt | 11 + .../models/AddressFieldRequestValue.kt | 15 + .../models/AddressFieldResponse.kt | 28 + .../models/AddressIdentifier.kt | 15 + .../diia/address_search/models/AddressItem.kt | 27 + .../models/AddressNationality.kt | 13 + .../address_search/models/AddressParameter.kt | 65 + .../models/AddressSearchRequest.kt | 29 + .../address_search/models/AddressSource.kt | 14 + .../models/AddressValidation.kt | 18 + .../address_search/models/NationalityItem.kt | 19 + .../diia/address_search/models/SearchType.kt | 5 + .../network/ApiAddressSearch.kt | 23 + .../ui/AddressParameterMapper.kt | 71 + .../ui/AddressSearchControllerF.kt | 58 + .../ui/AddressSearchFieldType.kt | 27 + .../diia/address_search/ui/AddressSearchVM.kt | 1210 + .../ui/CompoundAddressResultKey.kt | 20 + .../ui/CompoundAddressSearchF.kt | 167 + .../ui/CompoundAddressSearchVM.kt | 1073 + .../res/layout/fragment_address_search.xml | 379 + .../fragment_compound_address_search.xml | 378 + .../nav_compound_address_search.xml | 60 + .../src/main/res/values/strings.xml | 4 + .../diia/address_search/ExampleUnitTest.kt | 17 + .../diia/address_search/MainDispatcherRule.kt | 23 + .../models/AddressFieldApproveRequestTest.kt | 66 + .../models/AddressFieldResponseTest.kt | 24 + .../models/AddressParameterTest.kt | 69 + .../ui/AddressParameterMapperTest.kt | 165 + .../address_search/ui/AddressSearchVMTest.kt | 1612 + .../ui/CompoundAddressSearchVMTest.kt | 1526 + analytics/.gitignore | 1 + analytics/README.md | 18 + analytics/build.gradle | 100 + analytics/consumer-rules.pro | 0 analytics/excludes.jacoco | 3 + analytics/proguard-rules.pro | 21 + .../gov/diia/analytics/DiiaAnalyticsImpl.kt | 168 + .../crashlytics/WithCrashlyticsImpl.kt | 17 + .../gov/diia/analytics/DiiaAnalyticsImpl.kt | 144 + .../crashlytics/WithCrashlyticsImpl.kt | 13 + analytics/src/main/AndroidManifest.xml | 9 + .../ua/gov/diia/analytics/DiiaAnalytics.kt | 87 + .../gov/diia/analytics/di/AnalyticsModule.kt | 28 + .../diia/analytics/DiiaAnalyticsImplTest.kt | 36 + .../crashlytics/WithCrashlyticsImplTest.kt | 40 + .../crashlytics/WithCrashlyticsImplTest.kt | 43 + bankid/.gitignore | 1 + bankid/README.md | 33 + bankid/build.gradle | 138 + bankid/consumer-rules.pro | 5 + bankid/excludes.jacoco | 4 + bankid/proguard-rules.pro | 4 + bankid/src/main/AndroidManifest.xml | 2 + .../java/ua/gov/diia/bankid/BankIdConst.kt | 6 + .../ua/gov/diia/bankid/di/BankIdModule.kt | 64 + .../java/ua/gov/diia/bankid/model/AuthBank.kt | 16 + .../ua/gov/diia/bankid/model/AuthBanks.kt | 16 + .../gov/diia/bankid/model/BankAuthRequest.kt | 10 + .../diia/bankid/model/BankSelectionRequest.kt | 11 + .../ua/gov/diia/bankid/network/ApiBankId.kt | 13 + .../bankid/ui/VerificationMethodBankId.kt | 45 + .../gov/diia/bankid/ui/auth/BankAuthConst.kt | 7 + .../ua/gov/diia/bankid/ui/auth/BankAuthF.kt | 187 + .../gov/diia/bankid/ui/auth/BankAuthScreen.kt | 70 + .../ua/gov/diia/bankid/ui/auth/BankAuthVM.kt | 65 + .../bankid/ui/selection/BankSelectionF.kt | 119 + .../selection/BankSelectionScreenPreview.kt | 111 + .../bankid/ui/selection/BankSelectionVM.kt | 212 + .../src/main/res/drawable/ic_bankid_btn.xml | 22 + bankid/src/main/res/navigation/nav_bankid.xml | 50 + bankid/src/main/res/values/strings.xml | 10 + .../diia/bankid/rules/MainDispatcherRule.kt | 23 + .../bankid/ui/VerificationMethodBankIdTest.kt | 46 + .../gov/diia/bankid/ui/auth/BankAuthVMTest.kt | 62 + .../ui/selection/BankSelectionVMTest.kt | 183 + biometric/.gitignore | 1 + biometric/README.md | 43 + biometric/build.gradle | 129 + biometric/consumer-rules.pro | 0 biometric/excludes.jacoco | 4 + biometric/proguard-rules.pro | 0 biometric/src/main/AndroidManifest.xml | 2 + .../ua/gov/diia/biometric/AndroidBiometric.kt | 65 + .../java/ua/gov/diia/biometric/Biometric.kt | 21 + .../gov/diia/biometric/di/BiometricModule.kt | 25 + .../biometric/store/BiometricRepository.kt | 9 + .../store/BiometricRepositoryImpl.kt | 24 + .../diia/biometric/ui/BiometricAuthPrompt.kt | 38 + .../gov/diia/biometric/ui/BiometricSetupF.kt | 84 + .../gov/diia/biometric/ui/BiometricSetupVM.kt | 91 + .../ui/compose/BiometricSetupScreen.kt | 166 + .../src/main/res/navigation/nav_biometric.xml | 21 + biometric/src/main/res/values/strings.xml | 11 + .../diia/biometric/AndroidBiometricTest.kt | 182 + .../biometric/rules/MainDispatcherRule.kt | 23 + .../store/BiometricRepositoryImplTest.kt | 67 + .../diia/biometric/ui/BiometricSetupVMTest.kt | 88 + build.gradle | 49 + core/.gitignore | 1 + core/README.md | 11 + core/build.gradle | 153 + core/consumer-rules.pro | 8 + core/excludes.jacoco | 15 + core/proguard-rules.pro | 29 + .../gov/diia/core/ExampleInstrumentedTest.kt | 25 + core/src/main/AndroidManifest.xml | 6 + .../java/ua/gov/diia/core/CoreConstants.kt | 5 + .../core/ExcludeFromJacocoGeneratedReport.kt | 6 + .../diia/core/controller/DeeplinkProcessor.kt | 11 + .../core/controller/NotificationController.kt | 44 + .../diia/core/controller/PromoController.kt | 21 + .../data_source/file/PrivateFileDataSource.kt | 51 + .../data_source/network/api/ApiSettings.kt | 13 + .../notification/ApiNotificationsPublic.kt | 25 + .../core/data/repository/DataRepository.kt | 12 + .../core/data/repository/SystemRepository.kt | 7 + .../gov/diia/core/di/AppInfoProviderModule.kt | 25 + .../ua/gov/diia/core/di/DateProviderModule.kt | 18 + .../core/di/SystemServiceProviderModule.kt | 18 + .../java/ua/gov/diia/core/di/WorkersModule.kt | 30 + .../gov/diia/core/di/actions/Annotations.kt | 47 + .../core/di/data_source/http/Annotations.kt | 15 + .../core/di/fragment/HiltFragmentFactory.kt | 23 + .../core/di/fragment/HiltNavHostFragment.kt | 18 + .../ua/gov/diia/core/models/ActionDataLazy.kt | 6 + .../java/ua/gov/diia/core/models/AppStatus.kt | 12 + .../ua/gov/diia/core/models/ConsumableItem.kt | 34 + .../gov/diia/core/models/ConsumableString.kt | 22 + .../gov/diia/core/models/ContextMenuField.kt | 18 + .../java/ua/gov/diia/core/models/DiiaError.kt | 12 + .../main/java/ua/gov/diia/core/models/ITN.kt | 22 + .../java/ua/gov/diia/core/models/PushToken.kt | 10 + .../ua/gov/diia/core/models/RefreshToken.kt | 16 + .../core/models/SingleDeeplinkProcessor.kt | 9 + .../gov/diia/core/models/SuccessResponse.kt | 8 + .../ua/gov/diia/core/models/SystemDialog.kt | 15 + .../diia/core/models/TextWithParameters.kt | 16 + .../java/ua/gov/diia/core/models/Token.kt | 16 + .../java/ua/gov/diia/core/models/TokenData.kt | 22 + .../java/ua/gov/diia/core/models/UserType.kt | 5 + .../core/models/acquirer/AcquirerLinkType.kt | 5 + .../models/acquirer/AcquirerServiceType.kt | 11 + .../core/models/appversion/AppSettingsInfo.kt | 10 + .../java/ua/gov/diia/core/models/auth/Auth.kt | 13 + .../ua/gov/diia/core/models/auth/AuthV3.kt | 15 + .../diia/core/models/auth/FaceRecoConfig.kt | 13 + .../java/ua/gov/diia/core/models/auth/Fld.kt | 15 + .../diia/core/models/common/LoadActionData.kt | 28 + .../core/models/common/NavigationPanel.kt | 17 + .../models/common/menu/ContextMenuItem.kt | 29 + .../models/common/message/AttentionMessage.kt | 19 + .../message/AttentionMessageParameterized.kt | 20 + .../models/common/message/StatusMessage.kt | 22 + .../message/StatusMessageParameterized.kt | 18 + .../core/models/common/message/StubMessage.kt | 18 + .../message/StubMessageParameterized.kt | 19 + .../models/common/message/TextParameter.kt | 26 + .../template_dialogs/DynamicDialogData.kt | 19 + .../template_dialogs/SystemDialogData.kt | 33 + .../common_compose/NavigationBarMlcl.kt | 16 + .../atm/button/BtnPlainIconAtm.kt | 18 + .../common_compose/atm/button/PlayerBtnAtm.kt | 13 + .../common_compose/atm/chip/ChipStatusAtm.kt | 25 + .../atm/icon/BadgeCounterAtm.kt | 11 + .../common_compose/atm/icon/DoubleIconAtm.kt | 16 + .../models/common_compose/atm/icon/IconAtm.kt | 23 + .../common_compose/atm/icon/SmallIconAtm.kt | 21 + .../atm/indicators/DotNavigationAtm.kt | 11 + .../common_compose/atm/media/ArticlePicAtm.kt | 11 + .../atm/text/SectionTitleAtm.kt | 11 + .../common_compose/atm/text/TickerAtm.kt | 26 + .../models/common_compose/general/Action.kt | 18 + .../models/common_compose/general/Body.kt | 48 + .../common_compose/general/BottomGroup.kt | 12 + .../common_compose/general/ButtonStates.kt | 10 + .../common_compose/general/DiiaResponse.kt | 20 + .../models/common_compose/general/TopGroup.kt | 15 + .../mlc/button/BtnIconRoundedMlc.kt | 16 + .../common_compose/mlc/card/BlackCardMlc.kt | 25 + .../common_compose/mlc/card/HalvedCardMlc.kt | 20 + .../common_compose/mlc/card/IconCardMlc.kt | 16 + .../common_compose/mlc/card/ImageCardMlc.kt | 18 + .../mlc/card/SmallNotificationMlc.kt | 18 + .../mlc/card/VerticalCardMlc.kt | 21 + .../common_compose/mlc/card/WhiteCardMlc.kt | 25 + .../mlc/header/TitleGroupMlc.kt | 36 + .../common_compose/mlc/list/ListItemMlc.kt | 39 + .../mlc/media/ArticleVideoMlc.kt | 14 + .../mlc/text/SmallEmojiPanelMlc.kt | 16 + .../mlc/text/TextLabelContainerMlc.kt | 15 + .../org/button/BtnIconRoundedGroupOrg.kt | 18 + .../org/carousel/ArticlePicCarouselOrg.kt | 23 + .../org/carousel/HalvedCardCarouselOrg.kt | 25 + .../carousel/SmallNotificationCarouselOrg.kt | 24 + .../org/carousel/VerticalCardCarouselOrg.kt | 19 + .../common_compose/org/header/ChipTabsOrg.kt | 24 + .../org/header/MediaTitleOrg.kt | 16 + .../org/header/NavigationPanelMlc.kt | 14 + .../common_compose/org/header/TopGroupOrg.kt | 15 + .../org/list/ListItemGroupOrg.kt | 14 + .../models/common_compose/table/Action.kt | 17 + .../table/HeadingWithSubtitlesMlc.kt | 18 + .../core/models/common_compose/table/Item.kt | 25 + .../table/TableItemHorizontalMlc.kt | 28 + .../table/TableItemPrimaryMlc.kt | 20 + .../table/TableItemVerticalMlc.kt | 32 + .../table/TableMainHeadingMlc.kt | 19 + .../table/TableSecondaryHeadingMlc.kt | 19 + .../models/common_compose/table/ValueIcon.kt | 15 + .../table/tableBlockOrg/TableBlockOrg.kt | 22 + .../tableBlockPlaneOrg/TableBlockPlaneOrg.kt | 21 + .../TableBlockTwoColumnsOrg.kt | 20 + .../TableBlockTwoColumnsPlaneOrg.kt | 22 + .../core/models/deeplink/DeepLinkAction.kt | 47 + .../models/dialogs/TemplateDialogButton.kt | 19 + .../core/models/dialogs/TemplateDialogData.kt | 21 + .../models/dialogs/TemplateDialogModel.kt | 22 + .../TemplateDialogModelWithProcessCode.kt | 12 + .../notification/pull/EmptySelection.kt | 7 + .../pull/PullNotificationItemSelection.kt | 12 + .../notification/pull/message/Action.kt | 15 + .../pull/message/ArticlePicAtm.kt | 11 + .../pull/message/ArticleVideoMlc.kt | 13 + .../message/AuthorizedNotificationData.kt | 10 + .../models/notification/pull/message/Data.kt | 15 + .../models/notification/pull/message/Item.kt | 23 + .../notification/pull/message/LeftNavIcon.kt | 15 + .../pull/message/ListItemGroupOrg.kt | 13 + .../pull/message/MediumIconRight.kt | 13 + .../pull/message/MessageActions.kt | 6 + .../notification/pull/message/MessageTypes.kt | 7 + .../notification/pull/message/Notification.kt | 12 + .../pull/message/NotificationFull.kt | 18 + .../pull/message/NotificationMessageBody.kt | 41 + .../notification/pull/message/PlayerBtnAtm.kt | 13 + .../pull/message/TextLabelContainerMlc.kt | 16 + .../pull/message/TitleGroupMlc.kt | 17 + .../UnauthorizedNotificationMessage.kt | 10 + .../models/notification/push/PushAction.kt | 14 + .../notification/push/PushNotification.kt | 20 + .../core/models/proper_user/VerifyArgs.kt | 14 + .../diia/core/models/rating_service/Chip.kt | 18 + .../diia/core/models/rating_service/Chips.kt | 30 + .../core/models/rating_service/Comment.kt | 16 + .../diia/core/models/rating_service/Rating.kt | 16 + .../rating_service/RatingFormByInitiative.kt | 16 + .../models/rating_service/RatingFormModel.kt | 26 + .../core/models/rating_service/RatingItem.kt | 18 + .../models/rating_service/RatingRequest.kt | 30 + .../models/rating_service/RatingResult.kt | 10 + .../rating_service/SentRatingResponse.kt | 14 + .../diia/core/models/share/ShareByteArr.kt | 30 + .../java/ua/gov/diia/core/network/Http.kt | 26 + .../diia/core/network/annotation/Analytics.kt | 3 + .../ua/gov/diia/core/network/apis/ApiAuth.kt | 50 + .../connectivity/ConnectivityObserver.kt | 10 + .../core/push/BasePushNotificationAction.kt | 8 + .../core/ui/dynamicdialog/ActionsConst.kt | 116 + .../ua/gov/diia/core/util/CombinedLiveData.kt | 19 + .../java/ua/gov/diia/core/util/CommonConst.kt | 9 + .../java/ua/gov/diia/core/util/DateFormats.kt | 33 + .../gov/diia/core/util/DispatcherProvider.kt | 26 + .../java/ua/gov/diia/core/util/Exception.kt | 9 + .../util/alert/ClientAlertDialogsFactory.kt | 84 + .../core/util/datasource/DataSourceOwner.kt | 6 + .../core/util/date/CurrentDateProvider.kt | 17 + .../util/decorators/ListDelimiterDecorator.kt | 58 + .../util/deeplink/DeepLinkActionFactory.kt | 12 + .../core/util/delegation/WithAppConfig.kt | 7 + .../core/util/delegation/WithBuildConfig.kt | 16 + .../core/util/delegation/WithContextMenu.kt | 21 + .../core/util/delegation/WithCrashlytics.kt | 5 + .../util/delegation/WithDeeplinkHandling.kt | 18 + .../core/util/delegation/WithErrorHandling.kt | 27 + .../delegation/WithErrorHandlingOnFlow.kt | 27 + .../core/util/delegation/WithPermission.kt | 141 + .../core/util/delegation/WithPushHandling.kt | 12 + .../util/delegation/WithPushNotification.kt | 15 + .../core/util/delegation/WithRatingDialog.kt | 34 + .../util/delegation/WithRatingDialogOnFlow.kt | 34 + .../util/delegation/WithRetryLastAction.kt | 10 + .../download_files/WithDownloadFiles.kt | 26 + .../base64/DownloadableBase64File.kt | 14 + .../gov/diia/core/util/event/EventObserver.kt | 72 + .../ua/gov/diia/core/util/event/UiEvent.kt | 34 + .../core/util/extensions/ErrorHandlingExt.kt | 10 + .../diia/core/util/extensions/PadingExt.kt | 28 + .../util/extensions/ResourceValidation.kt | 33 + .../gov/diia/core/util/extensions/ShareExt.kt | 29 + .../extensions/activity/ActivityWindowExt.kt | 13 + .../context/ContextAppPackageInfoExt.kt | 15 + .../context/ContextGlobalAppControlExt.kt | 38 + .../extensions/context/ContextResourcesExt.kt | 56 + .../extensions/context/ContextServicesExt.kt | 25 + .../core/util/extensions/data/DoubleExt.kt | 11 + .../core/util/extensions/data/NumberExt.kt | 19 + .../util/extensions/date_time/DateTimeExt.kt | 165 + .../extensions/date_time/DisplayFormatExt.kt | 10 + .../extensions/fragment/FragmentActionsExt.kt | 40 + .../fragment/FragmentNavigationExt.kt | 52 + .../extensions/fragment/FragmentResultExt.kt | 161 + .../extensions/fragment/FragmentSendPdfExt.kt | 43 + .../fragment/FragmentTimeSelectionExt.kt | 33 + .../fragment/FragmentWindowControllExt.kt | 30 + .../lifecycle/LifecycleEventsExt.kt | 18 + .../util/extensions/lifecycle/LifecycleExt.kt | 38 + .../vm/ViewModelActionExecutionExt.kt | 84 + .../util/file/AndroidInternalFileManager.kt | 63 + .../ua/gov/diia/core/util/file/FileManager.kt | 42 + .../util/filter/DecimalDigitsInputFilter.kt | 34 + .../diia/core/util/filter/MoneyValueFilter.kt | 113 + .../gov/diia/core/util/html/HtmlGenerator.kt | 12 + .../util/navigation/KeepStateNavigator.kt | 82 + .../diia/core/util/phone/PhoneNumberExt.kt | 20 + .../settings_action/SettingsActionExecutor.kt | 10 + .../system/application/ApplicationLauncher.kt | 27 + .../InstalledApplicationInfoProvider.kt | 47 + .../system/service/SystemServiceProvider.kt | 6 + .../service/SystemServiceProviderImpl.kt | 13 + .../util/work/CheckAppVersionUpdatedWork.kt | 69 + .../DoApplicationSettingsProvisionWork.kt | 78 + .../gov/diia/core/util/work/WorkScheduler.kt | 8 + .../main/res/color/outlined_button_text.xml | 5 + core/src/main/res/color/text_clickable.xml | 6 + .../main/res/drawable/back_penalties_paid.xml | 28 + .../main/res/drawable/back_penalty_card.xml | 10 + .../main/res/drawable/back_white_round.xml | 10 + core/src/main/res/drawable/bg_radius_8.xml | 5 + .../res/drawable/black_button_enabled.xml | 5 + .../res/drawable/black_disable_button.xml | 9 + .../main/res/drawable/black_enable_button.xml | 6 + .../src/main/res/drawable/button_buy_card.xml | 20 + core/src/main/res/drawable/chips_selected.xml | 6 + .../main/res/drawable/chips_unselected.xml | 6 + .../main/res/drawable/delimiter_gradient.xml | 8 + .../res/drawable/diia_circular_progress.xml | 21 + core/src/main/res/drawable/divider.xml | 11 + core/src/main/res/drawable/doc_shadow.png | Bin 0 -> 5980 bytes .../main/res/drawable/gradient_progress.xml | 8 + .../main/res/drawable/green_radiobutton.xml | 18 + core/src/main/res/drawable/ic_add_item.xml | 10 + core/src/main/res/drawable/ic_arrow.xml | 10 + .../main/res/drawable/ic_arrow_disabled.xml | 10 + .../res/drawable/ic_arrow_forward_white.xml | 5 + core/src/main/res/drawable/ic_arrow_top.xml | 12 + core/src/main/res/drawable/ic_b_back.xml | 17 + core/src/main/res/drawable/ic_b_back_bold.xml | 10 + .../res/drawable/ic_b_back_bold_white.xml | 10 + core/src/main/res/drawable/ic_badge.xml | 6 + .../main/res/drawable/ic_check_for_btn.xml | 10 + .../drawable/ic_checkbox_green_cempty19.png | Bin 0 -> 444 bytes core/src/main/res/drawable/ic_close_gray.xml | 9 + .../res/drawable/ic_close_green_light.xml | 18 + .../res/drawable/ic_details_three_dots.xml | 15 + core/src/main/res/drawable/ic_dl_menu.xml | 20 + .../main/res/drawable/ic_empty_ellipse.xml | 8 + .../src/main/res/drawable/ic_full_ellipse.xml | 4 + core/src/main/res/drawable/ic_logo_diia.xml | 26 + .../main/res/drawable/ic_logo_diia_gerb.png | Bin 0 -> 2478 bytes core/src/main/res/drawable/ic_menu.xml | 15 + .../ic_radiobutton_green_checked19.png | Bin 0 -> 676 bytes .../main/res/drawable/ic_tips_and_tricks.xml | 49 + core/src/main/res/drawable/ic_view.xml | 12 + .../main/res/drawable/line_button_back.xml | 11 + .../res/drawable/line_button_black_back.xml | 6 + .../line_button_black_back_disabled.xml | 6 + .../line_button_black_back_focused.xml | 6 + .../res/drawable/line_button_black_select.xml | 6 + .../main/res/drawable/line_button_green.xml | 11 + .../res/drawable/line_button_green_select.xml | 6 + .../drawable/line_button_pressed_round.xml | 8 + .../main/res/drawable/line_button_select.xml | 5 + .../main/res/drawable/line_button_white.xml | 9 + .../drawable/line_button_white_disabled.xml | 9 + .../drawable/line_button_white_focused.xml | 9 + .../res/drawable/line_button_white_select.xml | 6 + .../res/drawable/outlined_button_black.xml | 6 + .../outlined_button_black_disabled.xml | 6 + .../outlined_button_black_focused.xml | 6 + .../outlined_button_black_selector.xml | 6 + core/src/main/res/drawable/outlined_card.xml | 6 + .../res/drawable/rounded_bottom_dialog.xml | 7 + .../main/res/drawable/selector_text_black.xml | 5 + .../main/res/drawable/service_card_shadow.png | Bin 0 -> 3008 bytes .../drawable/shape_card_notification_dot.xml | 5 + .../drawable/shape_outlined_box_primary.xml | 8 + .../res/drawable/shape_status_message.xml | 5 + .../main/res/drawable/shape_white_card.xml | 6 + core/src/main/res/drawable/thumb_selector.xml | 13 + core/src/main/res/drawable/track_selector.xml | 20 + core/src/main/res/values/colors.xml | 125 + core/src/main/res/values/dimens.xml | 65 + core/src/main/res/values/strings.xml | 123 + .../java/ua/gov/diia/core/LiveDataTestUtil.kt | 55 + .../diia/core/models/ConsumableEventTest.kt | 20 + .../diia/core/models/ConsumableItemTest.kt | 39 + .../diia/core/models/ConsumableStringTest.kt | 36 + .../gov/diia/core/rules/MainDispatcherRule.kt | 23 + .../ua/gov/diia/core/util/DateFormatsTest.kt | 30 + .../core/util/DiiaDispatcherProviderTest.kt | 42 + .../core/util/date/CurrentDateProviderTest.kt | 26 + .../util/event/UiDataEventObserverTest.kt | 38 + .../diia/core/util/event/UiDataEventTest.kt | 28 + .../core/util/event/UiEventObserverTest.kt | 36 + .../gov/diia/core/util/event/UiEventTest.kt | 23 + .../util/extensions/ErrorHandlingExtTest.kt | 64 + .../extensions/date_time/DateUtilsExtTest.kt | 67 + .../lifecycle/LifecycleEventsExtTest.kt | 58 + .../diia/core/util/html/HtmlGeneratorTest.kt | 34 + .../work/CheckAppVersionUpdatedWorkTest.kt | 84 + .../DoApplicationSettingsProvisionWorkTest.kt | 116 + dependencies.gradle | 139 + diia_storage/.gitignore | 1 + diia_storage/build.gradle | 111 + diia_storage/consumer-rules.pro | 0 diia_storage/excludes.jacoco | 8 + diia_storage/proguard-rules.pro | 21 + diia_storage/src/main/AndroidManifest.xml | 4 + .../diia/diia_storage/AndroidBase64Wrapper.kt | 11 + .../diia/diia_storage/AndroidKeyValueStore.kt | 74 + .../ua/gov/diia/diia_storage/Base64Wrapper.kt | 8 + .../diia/diia_storage/CommonPreferenceKeys.kt | 27 + .../ua/gov/diia/diia_storage/DiiaStorage.kt | 94 + .../EncryptedAndroidKeyValueStore.kt | 64 + .../gov/diia/diia_storage/MobileUidStore.kt | 6 + .../java/ua/gov/diia/diia_storage/PinStore.kt | 13 + .../diia_storage/PreferenceConfiguration.kt | 10 + .../diia/diia_storage/SecureDiiaStorage.kt | 51 + .../diia_storage/di/Base64WrapperModule.kt | 17 + .../diia/diia_storage/di/PreferenceStorage.kt | 22 + .../model/BaseSecuredKeyValueStore.kt | 87 + .../diia/diia_storage/model/KeyValueStore.kt | 29 + .../diia/diia_storage/model/PreferenceKey.kt | 3 + .../store/AbstractKeyValueDataSource.kt | 29 + .../diia/diia_storage/store/Preferences.kt | 69 + .../store/datasource/DataSource.kt | 12 + .../store/datasource/DataSourceDataResult.kt | 18 + .../store/datasource/itn/ItnDataRepository.kt | 6 + .../datasource/preferences/KotlinStoreImpl.kt | 34 + .../preferences/PreferenceDataSource.kt | 8 + .../authorization/AuthorizationRepository.kt | 28 + .../AuthorizationRepositoryImpl.kt | 85 + .../repository/system/SystemRepositoryImpl.kt | 25 + .../diia_storage/AndroidKeyValueStoreTest.kt | 318 + .../diia/diia_storage/MainDispatcherRule.kt | 23 + .../store/AbstractKeyValueDataSourceTest.kt | 93 + .../AuthorizationRepositoryImplTest.kt | 188 + .../system/SystemRepositoryImplTest.kt | 70 + doc_driver_license/.gitignore | 1 + doc_driver_license/README.md | 54 + doc_driver_license/build.gradle | 121 + doc_driver_license/consumer-rules.pro | 1 + doc_driver_license/excludes.jacoco | 5 + doc_driver_license/proguard-rules.pro | 1 + .../ExampleInstrumentedTest.kt | 28 + .../src/main/AndroidManifest.xml | 5 + .../DriverLicenceJsonAdapterDelegate.kt | 8 + .../DriverLicenceLocalizationChecker.kt | 18 + .../doc_driver_license/DriverLicenseConst.kt | 5 + .../DriverLicenseFullInfoComposeMapper.kt | 34 + .../doc_driver_license/DriverLicenseV2.kt | 315 + .../src/main/res/values/strings.xml | 4 + .../DriverLicenseLocalizationCheckerTest.kt | 55 + documents/.gitignore | 1 + documents/README.md | 168 + documents/build.gradle | 146 + documents/consumer-rules.pro | 2 + documents/excludes.jacoco | 12 + documents/proguard-rules.pro | 2 + .../diia/documents/ExampleInstrumentedTest.kt | 25 + documents/src/main/AndroidManifest.xml | 5 + .../diia/documents/barcode/DocumentBarcode.kt | 22 + .../barcode/DocumentBarcodeFactory.kt | 98 + .../barcode/DocumentBarcodeImageData.kt | 27 + .../barcode/DocumentBarcodeRepository.kt | 16 + .../gov/diia/documents/barcode/Extensions.kt | 8 + .../diia/documents/data/api/ApiDocuments.kt | 31 + .../data/datasource/local/BrokenDocFilter.kt | 14 + .../datasource/local/BrokenDocFilterImpl.kt | 24 + .../local/DefaultDocGroupUpdateBehavior.kt | 45 + .../local/DocGroupUpdateBehavior.kt | 22 + .../local/DocJsonAdapterDelegate.kt | 8 + .../local/DocumentsTransformation.kt | 11 + .../local/KeyValueDocumentsDataSource.kt | 149 + .../local/RemoveExpiredDocBehavior.kt | 14 + .../local/RemoveExpiredDocBehaviorImpl.kt | 32 + .../remote/NetworkDocumentsDataSource.kt | 66 + .../data/repository/BeforePublishAction.kt | 10 + .../repository/DocumentsDataRepository.kt | 34 + .../repository/DocumentsDataRepositoryImpl.kt | 219 + .../ua/gov/diia/documents/di/Annotations.kt | 11 + .../documents/di/DocumentDataSourceModule.kt | 53 + .../documents/di/ExpirationStrategyModule.kt | 20 + .../diia/documents/helper/DocumentsHelper.kt | 61 + .../diia/documents/models/DiiaDocuments.kt | 239 + .../models/DiiaDocumentsWithOrder.kt | 6 + .../ua/gov/diia/documents/models/DocEmpty.kt | 42 + .../ua/gov/diia/documents/models/DocOrder.kt | 35 + .../ua/gov/diia/documents/models/DocWeight.kt | 21 + .../gov/diia/documents/models/DocumentCard.kt | 13 + .../documents/models/FetchDocumentsResult.kt | 8 + .../documents/models/GeneratePdfFromDoc.kt | 6 + .../gov/diia/documents/models/ManualDocs.kt | 34 + .../gov/diia/documents/models/Preferences.kt | 5 + .../ua/gov/diia/documents/models/QRUrl.kt | 18 + .../gov/diia/documents/models/UpdatedDoc.kt | 15 + .../diia/documents/models/docgroups/Action.kt | 17 + .../models/docgroups/BaseDocumentGroup.kt | 31 + .../models/docgroups/TaxPayerCard.kt | 55 + .../documents/models/docgroups/v2/Content.kt | 16 + .../documents/models/docgroups/v2/Data.kt | 27 + .../DataForDisplayingInOrderConfigurations.kt | 23 + .../docgroups/v2/DocButtonHeadingOrg.kt | 28 + .../documents/models/docgroups/v2/DocCover.kt | 11 + .../documents/models/docgroups/v2/DocData.kt | 33 + .../models/docgroups/v2/DocHeadingOrg.kt | 23 + .../models/docgroups/v2/DocNumberCopyMlc.kt | 19 + .../diia/documents/models/docgroups/v2/EN.kt | 32 + .../models/docgroups/v2/FrontCard.kt | 15 + .../documents/models/docgroups/v2/FullInfo.kt | 23 + .../documents/models/docgroups/v2/IconLeft.kt | 13 + .../models/docgroups/v2/IconRight.kt | 13 + .../models/docgroups/v2/QrCheckStatus.kt | 18 + .../documents/models/docgroups/v2/StackMlc.kt | 17 + .../models/docgroups/v2/SubtitleLabelMlc.kt | 17 + .../diia/documents/models/docgroups/v2/UA.kt | 33 + .../v2/VaccinationCertificateBody.kt | 15 + .../models/docgroups/v2/VerificationAction.kt | 12 + .../documents/ui/BaseLocalizationChecker.kt | 7 + .../ua/gov/diia/documents/ui/BottomDoc.kt | 45 + .../java/ua/gov/diia/documents/ui/DocVM.kt | 36 + .../ua/gov/diia/documents/ui/DocsConst.kt | 7 + .../documents/ui/DocumentComposeMapper.kt | 129 + .../documents/ui/WithCheckLocalizationDocs.kt | 14 + .../ui/WithCheckLocalizationDocsImpl.kt | 24 + .../diia/documents/ui/WithPdfCertificate.kt | 15 + .../diia/documents/ui/WithRemoveDocument.kt | 33 + .../diia/documents/ui/actions/DocActions.kt | 40 + .../ui/actions/DocActionsDFCompose.kt | 244 + .../ui/actions/DocActionsNavigationHandler.kt | 14 + .../ui/actions/DocActionsProvider.kt | 20 + .../ui/actions/DocActionsProviderImpl.kt | 171 + .../ui/actions/DocActionsVMCompose.kt | 260 + .../ui/actions/VerificationActions.kt | 6 + .../ui/fullinfo/BaseFullInfoComposeMapper.kt | 13 + .../ui/fullinfo/DocFullInfoComposeMapper.kt | 12 + .../fullinfo/DocFullInfoComposeMapperImpl.kt | 18 + .../documents/ui/fullinfo/FullInfoFCompose.kt | 117 + .../ui/fullinfo/FullInfoFComposeVM.kt | 184 + .../fullinfo/compose/FullInfoBottomSheet.kt | 148 + .../diia/documents/ui/gallery/DocActions.kt | 6 + .../diia/documents/ui/gallery/DocFSettings.kt | 24 + .../ui/gallery/DocGalleryFCompose.kt | 243 + .../ui/gallery/DocGalleryNavigationHelper.kt | 18 + .../ui/gallery/DocGalleryVMCompose.kt | 745 + .../documents/ui/stack/DocStackFCompose.kt | 242 + .../documents/ui/stack/DocStackVMCompose.kt | 651 + .../documents/ui/stack/compose/StackScreen.kt | 102 + .../ui/stack/order/StackOrderFCompose.kt | 90 + .../ui/stack/order/StackOrderVMCompose.kt | 237 + .../util/BaseDocActionItemProcessor.kt | 32 + .../util/BaseDocumentActionProvider.kt | 13 + .../diia/documents/util/DocNameProvider.kt | 10 + .../documents/util/DocumentActionMapper.kt | 282 + .../documents/util/WithUpdateExpiredDocs.kt | 14 + .../util/WithUpdateExpiredDocsImpl.kt | 20 + .../util/datasource/ExpirationStrategy.kt | 41 + .../ua/gov/diia/documents/util/view/Ext.kt | 18 + documents/src/main/res/drawable/ic_alert.xml | 12 + .../src/main/res/drawable/ic_checked.xml | 16 + .../main/res/navigation/nav_doc_actions.xml | 61 + .../main/res/navigation/nav_doc_full_info.xml | 15 + .../main/res/navigation/nav_doc_gallery.xml | 16 + .../src/main/res/navigation/nav_doc_stack.xml | 20 + .../main/res/navigation/nav_stack_order.xml | 23 + documents/src/main/res/values/nav_ids.xml | 4 + documents/src/main/res/values/plurals.xml | 8 + documents/src/main/res/values/strings.xml | 43 + .../ua/gov/diia/documents/LiveDataTestUtil.kt | 55 + .../test/java/ua/gov/diia/documents/Mocks.kt | 149 + .../DefaultDocGroupUpdateBehaviorTest.kt | 142 + .../local/KeyValueDocumentsDataSourceTest.kt | 397 + .../remote/NetworkDocumentsDataSourceTest.kt | 164 + .../DocumentsDataRepositoryImplTest.kt | 301 + .../documents/rules/MainDispatcherRule.kt | 23 + .../ui/WithCheckLocalizationDocsImplTest.kt | 71 + .../ui/actions/DocActionsVMComposeTest.kt | 364 + .../ui/fullinfo/FullInfoFComposeVMTest.kt | 637 + .../ui/gallery/DocGalleryVMComposeTest.kt | 1917 + .../ui/stack/DocStackVMComposeTest.kt | 1792 + .../ui/stack/order/StackOrderVMComposeTest.kt | 260 + .../util/WithUpdateExpiredDocsImplTest.kt | 52 + .../util/datasource/ExpirationStrategyTest.kt | 70 + .../local/BrokenDocFilterImplTest.kt | 68 + .../local/RemoveExpiredDocBehaviorImplTest.kt | 82 + gradle.properties | 23 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 + gradlew.bat | 84 + home/.gitignore | 1 + home/README.md | 40 + home/build.gradle | 128 + home/consumer-rules.pro | 0 home/excludes.jacoco | 2 + home/proguard-rules.pro | 21 + home/src/main/AndroidManifest.xml | 5 + .../home/di/HomeScreenTabsMappersModule.kt | 19 + .../ua/gov/diia/home/helper/HomeHelper.kt | 23 + .../ua/gov/diia/home/model/HomeMenuItem.kt | 34 + .../java/ua/gov/diia/home/ui/HomeActions.kt | 9 + .../main/java/ua/gov/diia/home/ui/HomeF.kt | 192 + .../diia/home/ui/HomeScreenComposeMapper.kt | 45 + .../main/java/ua/gov/diia/home/ui/HomeVM.kt | 313 + .../ua/gov/diia/home/ui/views/DiiaAppBarCV.kt | 58 + home/src/main/res/drawable/ic_qr.xml | 17 + home/src/main/res/layout/fragment_home.xml | 57 + .../src/main/res/layout/view_diia_app_bar.xml | 47 + home/src/main/res/navigation/nav_home.xml | 15 + home/src/main/res/raw/gradient_bg.json | 1 + home/src/main/res/values/nav_ids.xml | 4 + home/src/main/res/values/strings.xml | 7 + .../ua/gov/diia/home/MainDispatcherRule.kt | 23 + .../ui/HomeScreenComposeMapperImplTest.kt | 51 + .../java/ua/gov/diia/home/ui/HomeVMTest.kt | 555 + jacoco.gradle | 139 + login/.gitignore | 1 + login/README.md | 47 + login/build.gradle | 134 + login/consumer-rules.pro | 3 + login/excludes.jacoco | 3 + login/proguard-rules.pro | 3 + login/src/main/AndroidManifest.xml | 2 + .../java/ua/gov/diia/login/di/LoginModule.kt | 29 + .../ua/gov/diia/login/model/LoginToken.kt | 11 + .../ua/gov/diia/login/network/ApiLogin.kt | 15 + .../java/ua/gov/diia/login/ui/LoginConst.kt | 8 + .../main/java/ua/gov/diia/login/ui/LoginF.kt | 121 + .../main/java/ua/gov/diia/login/ui/LoginVM.kt | 304 + .../ua/gov/diia/login/ui/PostLoginAction.kt | 9 + .../gov/diia/login/ui/compose/LoginScreen.kt | 220 + login/src/main/res/navigation/nav_login.xml | 41 + login/src/main/res/values/nav_ids.xml | 4 + login/src/main/res/values/strings.xml | 16 + .../diia/login/rules/MainDispatcherRule.kt | 23 + .../java/ua/gov/diia/login/ui/LoginVMTest.kt | 301 + menu/.gitignore | 1 + menu/README.md | 111 + menu/build.gradle | 121 + menu/consumer-rules.pro | 0 menu/excludes.jacoco | 4 + menu/proguard-rules.pro | 21 + menu/src/main/AndroidManifest.xml | 5 + .../ua/gov/diia/menu/MenuContentController.kt | 148 + .../java/ua/gov/diia/menu/models/EventType.kt | 6 + .../java/ua/gov/diia/menu/ui/MenuAction.kt | 20 + .../ua/gov/diia/menu/ui/MenuActionsKey.kt | 18 + .../java/ua/gov/diia/menu/ui/MenuComposeVM.kt | 233 + .../java/ua/gov/diia/menu/ui/MenuFCompose.kt | 280 + .../main/res/navigation/nav_menu_actions.xml | 100 + menu/src/main/res/values/config.xml | 5 + menu/src/main/res/values/nav_ids.xml | 11 + menu/src/main/res/values/strings.xml | 21 + .../ua/gov/diia/menu/MainDispatcherRule.kt | 23 + .../ua/gov/diia/menu/ui/MenuComposeVMTest.kt | 260 + notifications/.gitignore | 1 + notifications/README.md | 75 + notifications/build.gradle | 156 + notifications/consumer-rules.pro | 3 + notifications/excludes.jacoco | 8 + notifications/proguard-rules.pro | 25 + notifications/src/gplay/AndroidManifest.xml | 23 + .../ua/gov/diia/notifications/service/FCMS.kt | 30 + .../push/CloudPushTokenProvider.kt | 15 + notifications/src/huawei/AndroidManifest.xml | 19 + .../ua/gov/diia/notifications/service/HCMS.kt | 26 + .../push/CloudPushTokenProvider.kt | 19 + notifications/src/main/AndroidManifest.xml | 5 + .../NotificationControllerImpl.kt | 70 + .../diia/notifications/NotificationsConst.kt | 7 + .../notifications/action/ActionConstants.kt | 6 + .../DocumentSharingPushNotificationAction.kt | 11 + .../PushAccessibilityNotificationAction.kt | 12 + .../api/notification/ApiNotifications.kt | 45 + .../notifications/di/NotificationModule.kt | 27 + .../di/PushTokenProviderModule.kt | 16 + .../di/legacy/NotificationDataSourceModule.kt | 108 + .../NotificationEnabledCheckerModule.kt | 16 + .../di/legacy/NotificationManagementModule.kt | 20 + .../helper/NotificationHelper.kt | 47 + .../models/notification/LoadingState.java | 7 + .../models/notification/SubscribeResponse.kt | 13 + .../models/notification/Subscription.kt | 27 + .../models/notification/SubscriptionHash.kt | 16 + .../models/notification/Subscriptions.kt | 16 + .../pull/MessageIdentification.kt | 13 + .../notification/pull/PullNotification.kt | 26 + .../pull/PullNotificationMessage.kt | 17 + .../pull/PullNotificationSyncAction.kt | 5 + .../pull/PullNotificationsResponse.kt | 14 + .../pull/PullNotificationsToModify.kt | 10 + .../pull/UpdatePullNotificationResponse.kt | 10 + .../push/DiiaNotificationChannel.kt | 12 + .../notification/push/PushNotification.kt | 14 + .../diia/notifications/service/PushService.kt | 155 + .../store/NotificationsPreferences.kt | 30 + .../KeyValueNotificationDataSource.kt | 21 + .../KeyValueNotificationDataSourceImpl.kt | 59 + .../NetworkNotificationDataSource.kt | 26 + .../NotificationDataRepository.kt | 76 + .../NotificationDataRepositoryImpl.kt | 369 + .../ui/compose/mapper/media/MediaMapper.kt | 15 + .../adapters/SubscriptionAdapter.kt | 46 + .../compose/NotificationComposeVM.kt | 270 + .../compose/NotificationFCompose.kt | 120 + .../NotificationPagingSourseCompose.kt | 86 + .../compose/NotificationsActionKey.kt | 8 + .../compose/NotificationsMapperCompose.kt | 39 + .../di/NotificationsMapperModule.kt | 18 + .../NotificationSettingsF.kt | 75 + .../NotificationSettingsFVM.kt | 98 + .../notifications/NotificationFullAdapter.kt | 232 + .../NotificationVideoPlayerView.kt | 259 + .../compose/NotificationFullComposeVM.kt | 196 + .../compose/NotificationFullFCompose.kt | 132 + .../manager/DiiaAndroidNotificationManager.kt | 80 + .../manager/DiiaNotificationManager.kt | 14 + .../notification/push/PushTokenProvider.kt | 7 + .../util/push/MoshiPushParser.kt | 20 + .../notifications/util/push/PushParser.kt | 10 + .../AndroidNotificationEnabledChecker.kt | 15 + .../NotificationEnabledChecker.kt | 7 + .../PushTokenUpdateActionExecutor.kt | 51 + .../work/SendPushTokenProcessor.kt | 30 + .../notifications/work/SendPushTokenWork.kt | 59 + .../diia/notifications/work/SilentPushWork.kt | 79 + .../src/main/res/drawable-v21/ic_push.xml | 10 + .../res/drawable/ic_notification_next.xml | 10 + .../src/main/res/drawable/ic_push.png | Bin 0 -> 733 bytes .../layout/fragment_notification_settings.xml | 100 + .../res/layout/item_notification_divider.xml | 10 + ...tem_notification_download_arrowed_link.xml | 66 + .../res/layout/item_notification_image.xml | 20 + ...tem_notification_internal_arrowed_link.xml | 54 + .../res/layout/item_notification_text.xml | 54 + .../res/layout/item_notification_video.xml | 13 + .../src/main/res/layout/item_subscription.xml | 62 + .../res/layout/view_message_video_player.xml | 68 + .../navigation/nav_notification_details.xml | 17 + .../navigation/nav_notification_settings.xml | 11 + .../main/res/navigation/nav_notifications.xml | 24 + notifications/src/main/res/values/nav_ids.xml | 4 + notifications/src/main/res/values/strings.xml | 10 + .../diia/notifications/MainDispatcherRule.kt | 23 + .../NotificationControllerImplTest.kt | 195 + .../notifications/TestDispatcherProvider.kt | 14 + .../notifications/service/PushServiceTest.kt | 248 + .../KeyValueNotificationDataSourceImplTest.kt | 223 + .../NetworkNotificationDataSourceTest.kt | 63 + .../NotificationDataRepositoryImplTest.kt | 1023 + .../compose/mapper/media/MediaMapperTest.kt | 38 + .../compose/NotificationComposeVMTest.kt | 252 + .../NotificationSettingsVMTest.kt | 173 + .../compose/NotificationFullComposeVMTest.kt | 346 + .../NotificationPagingSourceComposeTest.kt | 286 + .../compose/NotificationsMapperComposeTest.kt | 68 + .../PushTokenUpdateActionExecutorTest.kt | 262 + .../work/SendPushTokenProcessorTest.kt | 77 + opensource/.gitignore | 2 + opensource/README.md | 21 + opensource/build.gradle | 223 + opensource/google-services.json | 29 + opensource/proguard-rules.pro | 21 + .../ua/gov/diia/opensource/VendorActivity.kt | 35 + .../ua/gov/diia/opensource/VendorActivity.kt | 13 + opensource/src/main/AndroidManifest.xml | 54 + .../main/java/ua/gov/diia/opensource/App.kt | 20 + .../data_source/itn/ItnDataRepositoryImpl.kt | 48 + .../data_source/itn/KeyValueItnDataSource.kt | 31 + .../data_source/itn/NetworkItnDataSource.kt | 18 + .../diia/opensource/data/network/ApiLogger.kt | 68 + .../network/NetworkConnectivityObserver.kt | 66 + .../data/network/TimeoutConstants.kt | 7 + .../opensource/data/network/api/ApiDocs.kt | 67 + .../HttpAppInfoHeaderInterceptor.kt | 31 + .../HttpAuthorizationInterceptor.kt | 104 + .../interceptors/HttpLoggingInterceptor.kt | 32 + .../interceptors/HttpMobileUuidInterceptor.kt | 36 + .../HttpProlongAuthorizationInterceptor.kt | 29 + .../ua/gov/diia/opensource/di/Annotations.kt | 11 + .../ua/gov/diia/opensource/di/AppModule.kt | 208 + .../gov/diia/opensource/di/DocumentsModule.kt | 293 + .../gov/diia/opensource/di/FeatureModule.kt | 128 + .../ua/gov/diia/opensource/di/GlobalUtils.kt | 94 + .../opensource/di/NotificationPublicModule.kt | 27 + .../di/ResourceIconProviderModule.kt | 20 + .../opensource/di/fragment/Annotations.kt | 9 + .../opensource/di/fragment/FragmentModule.kt | 19 + .../di/network/OkHttpClientModule.kt | 67 + .../di/network/RefreshLockerModule.kt | 21 + .../di/network/RetrofitClientModule.kt | 66 + .../di/network/UnAuthorizedApiModule.kt | 20 + .../diia/opensource/helper/HomeHelperImpl.kt | 37 + .../helper/NotificationHelperImpl.kt | 89 + .../helper/PSCriminalCertHelperImpl.kt | 41 + .../helper/PSNavigationHelperImpl.kt | 50 + .../diia/opensource/helper/PinHelperImpl.kt | 52 + .../helper/PublicServiceHelperImpl.kt | 44 + .../PublicServicesCategoriesTabMapperImpl.kt | 64 + .../opensource/helper/SplashHelperImpl.kt | 38 + .../helper/documents/ApiDocumentsWrapper.kt | 111 + .../DocActionsNavigationHandlerImpl.kt | 58 + .../DocGalleryNavigationHelperImpl.kt | 218 + .../opensource/helper/documents/DocName.kt | 6 + .../helper/documents/DocNameProviderImpl.kt | 15 + .../DocumentBarcodeRepositoryImpl.kt | 83 + .../documents/DocumentComposeMapperImpl.kt | 698 + .../helper/documents/DocumentsHelperImpl.kt | 103 + .../documents/DriverLicenceActionProvider.kt | 56 + .../documents/WithPdfCertificateImpl.kt | 21 + .../documents/WithRemoveDocumentImpl.kt | 27 + .../diia/opensource/model/documents/Docs.kt | 28 + .../PushNotificationActionType.kt | 14 + .../ps/PublicServiceDataRepository.kt | 75 + .../settings/AppSettingsRepository.kt | 13 + .../settings/AppSettingsRepositoryImpl.kt | 38 + .../ui/AndroidClientAlertDialogsFactory.kt | 504 + .../diia/opensource/ui/PromoControllerImpl.kt | 21 + .../opensource/ui/PublicServicesHomeConst.kt | 5 + .../opensource/ui/activities/MainActivity.kt | 167 + .../ui/activities/MainActivityVM.kt | 111 + .../compose/DiiaResourceIconProviderImpl.kt | 35 + .../opensource/ui/compose/TableBlockMapper.kt | 310 + .../gov/diia/opensource/ui/fragments/FeedF.kt | 6 + .../ui/fragments/context/ContextMenuDF.kt | 49 + .../context/ContextMenuListAdapter.kt | 51 + .../ui/fragments/settings/SettingsF.kt | 109 + .../ui/fragments/settings/SettingsFVM.kt | 80 + .../ui/fragments/system/SystemDialog.kt | 81 + .../ui/fragments/system/SystemDialogVM.kt | 78 + .../gov/diia/opensource/ui/work/LogoutWork.kt | 82 + .../util/AndroidDeepLinkActionFactory.kt | 116 + .../opensource/util/DeeplinkProcessorImpl.kt | 20 + .../util/DefaultDeeplinkHandleBehaviour.kt | 32 + .../util/DefaultErrorHandlingBehaviour.kt | 51 + .../DefaultErrorHandlingBehaviourOnFlow.kt | 53 + .../util/DefaultPushHandlerBehaviour.kt | 54 + .../util/DefaultPushNotificationBehaviour.kt | 59 + .../util/DefaultRatingDialogBehaviour.kt | 47 + .../DefaultRatingDialogBehaviourOnFlow.kt | 43 + .../util/DefaultRetryLastActionBehaviour.kt | 18 + .../util/DefaultSelfPermissionBehavior.kt | 180 + .../util/DefaultWithContextMenuBehaviour.kt | 40 + .../ua/gov/diia/opensource/util/EdgeToEdge.kt | 41 + .../diia/opensource/util/WithAppConfigImpl.kt | 16 + .../opensource/util/WithBuildConfigImpl.kt | 33 + .../opensource/util/ext/ActivityNavigation.kt | 16 + .../util/ext/FragmentNavigationExt.kt | 35 + .../opensource/util/ext/FragmentSendExt.kt | 52 + .../opensource/util/ext/OkhttpExtensions.kt | 11 + .../util/file/AndroidInternalFileManager.kt | 63 + .../diia/opensource/util/file/FileManager.kt | 42 + .../main/res/drawable/ic_diia_foreground.xml | 24 + .../main/res/drawable/ic_notifications.xml | 15 + opensource/src/main/res/drawable/ic_order.xml | 16 + .../res/drawable/ic_passcode_settings.xml | 19 + .../src/main/res/drawable/ic_touch_id.xml | 15 + .../src/main/res/layout/activity_main.xml | 13 + .../main/res/layout/dialog_context_menu.xml | 37 + .../src/main/res/layout/fragment_feed.xml | 6 + .../src/main/res/layout/fragment_settings.xml | 197 + .../res/layout/item_context_menu_field.xml | 30 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 9 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 1251 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 2447 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 943 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 1639 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 1783 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 3485 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 2568 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 5412 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 3542 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 7629 bytes .../main/res/navigation/nav_home_children.xml | 35 + .../src/main/res/navigation/nav_main.xml | 286 + opensource/src/main/res/values/strings.xml | 55 + opensource/src/main/res/xml/file_paths.xml | 6 + opensource/version.properties | 2 + pin/.gitignore | 1 + pin/README.md | 47 + pin/build.gradle | 133 + pin/consumer-rules.pro | 4 + pin/excludes.jacoco | 4 + pin/proguard-rules.pro | 5 + pin/src/main/AndroidManifest.xml | 2 + .../main/java/ua/gov/diia/pin/di/PinModule.kt | 16 + .../java/ua/gov/diia/pin/helper/PinHelper.kt | 32 + .../gov/diia/pin/model/CreatePinFlowType.kt | 5 + .../diia/pin/repository/LoginPinRepository.kt | 14 + .../pin/repository/LoginPinRepositoryImpl.kt | 35 + .../pin/ui/create/compose/CreatePinScreen.kt | 166 + .../diia/pin/ui/create/confirm/ConfirmPinF.kt | 115 + .../pin/ui/create/confirm/ConfirmPinVM.kt | 146 + .../diia/pin/ui/create/create/CreatePinF.kt | 78 + .../diia/pin/ui/create/create/CreatePinVM.kt | 80 + .../pin/ui/input/AlternativeAuthCallback.kt | 8 + .../ua/gov/diia/pin/ui/input/PinInputF.kt | 118 + .../ua/gov/diia/pin/ui/input/PinInputVM.kt | 240 + .../pin/ui/input/compose/PinInputScreen.kt | 118 + .../ua/gov/diia/pin/ui/reset/ResetPinF.kt | 100 + .../ua/gov/diia/pin/ui/reset/ResetPinVM.kt | 145 + .../pin/ui/reset/compose/ResetPinScreen.kt | 174 + .../main/res/navigation/nav_pin_create.xml | 55 + pin/src/main/res/navigation/nav_pin_input.xml | 30 + pin/src/main/res/navigation/nav_pin_reset.xml | 31 + pin/src/main/res/values/nav_ids.xml | 5 + pin/src/main/res/values/strings.xml | 35 + .../gov/diia/pin/rules/MainDispatcherRule.kt | 23 + .../pin/ui/create/confirm/ConfirmPinVMTest.kt | 188 + .../pin/ui/create/create/CreatePinVMTest.kt | 82 + .../gov/diia/pin/ui/input/PinInputVMTest.kt | 234 + .../gov/diia/pin/ui/reset/ResetPinVMTest.kt | 129 + .../diia/pin/utils/StubErrorHandlerOnFlow.kt | 28 + .../java/ua/gov/diia/pin/utils/TestUtils.kt | 26 + ps_criminal_cert/.gitignore | 1 + ps_criminal_cert/README.md | 53 + ps_criminal_cert/build.gradle | 156 + ps_criminal_cert/consumer-rules.pro | 0 ps_criminal_cert/excludes.jacoco | 4 + ps_criminal_cert/proguard-rules.pro | 21 + .../ExampleInstrumentedTest.kt | 28 + ps_criminal_cert/src/main/AndroidManifest.xml | 5 + .../di/CriminalCertApiModule.kt | 20 + .../helper/PSCriminalCertHelper.kt | 16 + .../gov/diia/ps_criminal_cert/models/Birth.kt | 10 + .../models/CriminalCertHomeState.kt | 9 + .../models/CriminalCertUserData.kt | 18 + .../ps_criminal_cert/models/PreviousNames.kt | 11 + .../CriminalCertApplicationInfoNextStep.kt | 6 + .../enums/CriminalCertLoadActionType.kt | 11 + .../models/enums/CriminalCertScreen.kt | 17 + .../models/enums/CriminalCertStatus.kt | 11 + .../models/enums/CriminalCertType.kt | 11 + .../CriminalCertConfirmationRequest.kt | 39 + .../models/request/PublicService.kt | 16 + .../models/response/CriminalCertBirthPlace.kt | 59 + .../response/CriminalCertConfirmation.kt | 91 + .../models/response/CriminalCertConfirmed.kt | 18 + .../models/response/CriminalCertContacts.kt | 19 + .../models/response/CriminalCertDetails.kt | 51 + .../models/response/CriminalCertFileData.kt | 13 + .../models/response/CriminalCertInfo.kt | 22 + .../models/response/CriminalCertListData.kt | 72 + .../response/CriminalCertNationalities.kt | 57 + .../models/response/CriminalCertReasons.kt | 28 + .../models/response/CriminalCertRequester.kt | 51 + .../models/response/CriminalCertTypes.kt | 31 + .../network/ApiCriminalCert.kt | 92 + .../ps_criminal_cert/ui/CriminalCertConst.kt | 11 + .../ui/CriminalCertRatingScreenCodes.kt | 15 + .../ui/details/CriminalCertDetailsF.kt | 157 + .../ui/details/CriminalCertDetailsVM.kt | 178 + .../details/CriminalCertLoadActionsAdapter.kt | 108 + .../ui/home/CriminalCertHomeF.kt | 169 + .../ui/home/CriminalCertHomeTabsAdapter.kt | 37 + .../ui/home/CriminalCertHomeVM.kt | 228 + .../ui/home/CriminalCertListAdapter.kt | 48 + .../ui/home/CriminalCertListF.kt | 110 + .../steps/address/CriminalCertStepAddressF.kt | 126 + .../address/CriminalCertStepAddressVM.kt | 82 + .../ui/steps/birth/CriminalCertStepBirthF.kt | 148 + .../ui/steps/birth/CriminalCertStepBirthVM.kt | 141 + .../steps/confirm/CriminalCertStepConfirmF.kt | 126 + .../confirm/CriminalCertStepConfirmVM.kt | 134 + .../contacts/CriminalCertStepContactsF.kt | 100 + .../contacts/CriminalCertStepContactsVM.kt | 89 + .../CriminalCertStepNationalityF.kt | 167 + .../CriminalCertStepNationalityVM.kt | 174 + .../reason/CriminalCertReasonsAdapter.kt | 92 + .../steps/reason/CriminalCertStepReasonsF.kt | 127 + .../steps/reason/CriminalCertStepReasonsVM.kt | 106 + .../requester/CriminalCertStepRequesterF.kt | 139 + .../requester/CriminalCertStepRequesterVM.kt | 227 + .../ui/steps/type/CriminalCertStepTypeF.kt | 131 + .../ui/steps/type/CriminalCertStepTypeVM.kt | 105 + .../ui/steps/type/CriminalCertTypesAdapter.kt | 91 + .../ui/welcome/CriminalCertWelcomeF.kt | 149 + .../ui/welcome/CriminalCertWelcomeVM.kt | 106 + .../layout/fragment_criminal_cert_details.xml | 166 + .../layout/fragment_criminal_cert_home.xml | 149 + .../layout/fragment_criminal_cert_list.xml | 67 + .../fragment_criminal_cert_step_address.xml | 231 + .../fragment_criminal_cert_step_birth.xml | 255 + .../fragment_criminal_cert_step_confirm.xml | 598 + .../fragment_criminal_cert_step_contacts.xml | 180 + ...ragment_criminal_cert_step_nationality.xml | 177 + .../fragment_criminal_cert_step_reasons.xml | 189 + .../fragment_criminal_cert_step_requester.xml | 221 + .../fragment_criminal_cert_step_type.xml | 191 + .../layout/fragment_criminal_cert_welcome.xml | 160 + .../main/res/layout/item_criminal_cert.xml | 112 + .../layout/item_criminal_cert_load_action.xml | 20 + .../res/layout/item_criminal_cert_reason.xml | 38 + .../res/layout/item_criminal_cert_type.xml | 50 + .../src/main/res/layout/stub_message.xml | 55 + .../main/res/navigation/nav_criminal_cert.xml | 456 + .../src/main/res/values/strings.xml | 26 + .../rules/MainDispatcherRule.kt | 23 + .../ui/details/CriminalCertDetailsVMTest.kt | 315 + .../ui/home/CriminalCertHomeVMTest.kt | 298 + .../address/CriminalCertStepAddressVMTest.kt | 119 + .../birth/CriminalCertStepBirthVMTest.kt | 321 + .../confirm/CriminalCertStepConfirmVMTest.kt | 258 + .../CriminalCertStepContactsVMTest.kt | 163 + .../CriminalCertStepNationalityVMTest.kt | 398 + .../reason/CriminalCertStepReasonsVMTest.kt | 320 + .../CriminalCertStepRequesterVMTest.kt | 241 + .../steps/type/CriminalCertStepTypeVMTest.kt | 335 + .../ui/welcome/CriminalCertWelcomeVMTest.kt | 227 + .../ps_criminal_cert/util/StubContextMenu.kt | 37 + .../ps_criminal_cert/util/StubErrorHandler.kt | 23 + .../ps_criminal_cert/util/StubRatingDialog.kt | 33 + .../diia/ps_criminal_cert/util/TestUtil.kt | 11 + publicservice/.gitignore | 1 + publicservice/README.md | 78 + publicservice/build.gradle | 146 + publicservice/consumer-rules.pro | 3 + publicservice/excludes.jacoco | 4 + publicservice/proguard-rules.pro | 24 + .../publicservice/ExampleInstrumentedTest.kt | 25 + publicservice/src/main/AndroidManifest.xml | 5 + .../gov/diia/publicservice/di/Annotation.kt | 7 + .../publicservice/di/PublicServicesModule.kt | 20 + .../helper/PSNavigationHelper.kt | 47 + .../helper/PublicServiceHelper.kt | 21 + .../publicservice/models/CategoryStatus.kt | 29 + .../diia/publicservice/models/ContextMenu.kt | 28 + .../publicservice/models/PublicService.kt | 30 + .../models/PublicServiceCategory.kt | 64 + .../publicservice/models/PublicServiceTab.kt | 25 + .../publicservice/models/PublicServiceView.kt | 24 + .../models/PublicServicesCategories.kt | 12 + .../network/ApiPublicServices.kt | 27 + .../PublicServicesCategoriesComposeF.kt | 108 + .../PublicServicesCategoriesComposeVM.kt | 242 + .../PublicServicesCategoriesTabMapper.kt | 23 + .../PublicServiceCategoryDetailsComposeF.kt | 81 + ...blicServiceCategoryDetailsComposeMapper.kt | 40 + .../PublicServiceCategoryDetailsComposeVM.kt | 95 + .../compose/PublicServicesSearchComposeF.kt | 80 + .../PublicServicesSearchComposeMapper.kt | 65 + .../compose/PublicServicesSearchComposeVM.kt | 218 + .../extensions/fragment/FragmentSendPdfExt.kt | 11 + .../src/main/res/drawable-hdpi/ic_google.png | Bin 0 -> 1730 bytes .../src/main/res/drawable-mdpi/ic_google.png | Bin 0 -> 943 bytes .../src/main/res/drawable-xhdpi/ic_google.png | Bin 0 -> 1844 bytes .../main/res/drawable-xxhdpi/ic_google.png | Bin 0 -> 3760 bytes .../main/res/drawable-xxxhdpi/ic_google.png | Bin 0 -> 2454 bytes .../src/main/res/drawable/chip_light.xml | 15 + .../main/res/drawable/chip_light_selected.xml | 6 + .../layout/layout_home_content_loading.xml | 27 + .../nav_public_service_categories.xml | 21 + .../nav_public_service_category_details.xml | 16 + .../navigation/nav_public_service_search.xml | 18 + publicservice/src/main/res/values/strings.xml | 4 + .../publicservice/rules/MainDispatcherRule.kt | 23 + .../PublicServicesCategoriesComposeVMTest.kt | 280 + ...blicServiceCategoryDetailsComposeVMTest.kt | 142 + .../PublicServicesSearchComposeVMTest.kt | 197 + .../util/StubErrorHandlerOnFlow.kt | 32 + .../util/TestDispatcherProvider.kt | 14 + .../gov/diia/publicservice/util/TestUtil.kt | 144 + search/.gitignore | 1 + search/README.md | 67 + search/build.gradle | 125 + search/consumer-rules.pro | 3 + search/excludes.jacoco | 3 + search/proguard-rules.pro | 25 + search/src/main/AndroidManifest.xml | 5 + .../diia/search/adapters/BindingAdapters.kt | 23 + .../gov/diia/search/adapters/SearchAdapter.kt | 85 + .../ua/gov/diia/search/models/SearchResult.kt | 6 + .../diia/search/models/SearchableBullet.kt | 9 + .../gov/diia/search/models/SearchableItem.kt | 10 + .../search/models/SearchableItemDoubleLine.kt | 15 + .../search/models/StringSearchableItem.kt | 11 + .../java/ua/gov/diia/search/ui/SearchF.kt | 91 + .../java/ua/gov/diia/search/ui/SearchFVM.kt | 82 + .../ui/bullet_selection/SearchBulletF.kt | 56 + .../ui/bullet_selection/SearchBulletVM.kt | 59 + .../res/drawable/ic_baseline_search_24.xml | 6 + .../src/main/res/layout/fragment_search.xml | 55 + .../res/layout/fragment_search_bullets.xml | 86 + search/src/main/res/layout/item_rv_search.xml | 35 + .../res/layout/item_rv_search_two_lines.xml | 56 + search/src/main/res/navigation/nav_search.xml | 20 + .../main/res/navigation/nav_search_bullet.xml | 25 + search/src/main/res/values/strings.xml | 4 + .../diia/search/rules/MainDispatcherRule.kt | 23 + .../ua/gov/diia/search/ui/SearchFVMTest.kt | 116 + .../ui/bullet_selection/SearchBulletVMTest.kt | 87 + .../search/util/StubErrorHandlerOnFlow.kt | 32 + .../search/util/TestDispatcherProvider.kt | 14 + .../diia/search/util/TestSearchableBullet.kt | 13 + .../diia/search/util/TestSearchableItem.kt | 15 + .../java/ua/gov/diia/search/util/TestUtil.kt | 11 + settings.gradle | 23 + splash/.gitignore | 1 + splash/README.md | 31 + splash/build.gradle | 131 + splash/consumer-rules.pro | 0 splash/excludes.jacoco | 6 + splash/proguard-rules.pro | 21 + .../diia/splash/ExampleInstrumentedTest.kt | 25 + splash/src/main/AndroidManifest.xml | 5 + .../ua/gov/diia/splash/helper/SplashHelper.kt | 26 + .../ua/gov/diia/splash/model/SplashJob.kt | 7 + .../java/ua/gov/diia/splash/ui/SplashF.kt | 113 + .../java/ua/gov/diia/splash/ui/SplashFVM.kt | 238 + .../diia/splash/ui/compose/SplashScreen.kt | 95 + splash/src/main/res/navigation/nav_splash.xml | 191 + splash/src/main/res/values/nav_ids.xml | 10 + splash/src/main/res/values/strings.xml | 7 + .../java/ua/gov/diia/splash/SplashFVMTest.kt | 190 + .../diia/splash/rules/MainDispatcherRule.kt | 20 + ui_base/.gitignore | 1 + ui_base/README.md | 11 + ui_base/build.gradle | 196 + ui_base/consumer-rules.pro | 0 ui_base/excludes.jacoco | 10 + ui_base/proguard-rules.pro | 21 + ui_base/src/main/AndroidManifest.xml | 10 + .../ui_base/adapters/LoadActionsAdapter.kt | 95 + .../binding/AccessibilityBindingAdapters.kt | 26 + .../adapters/binding/BindingAdapters.kt | 182 + .../binding/ButtonViewBindingAdapters.kt | 11 + .../binding/CardViewBindingAdapters.kt | 21 + .../binding/ImageViewBindingAdapters.kt | 66 + .../binding/TextViewBindingAdapters.kt | 246 + .../adapters/binding/ViewBindingAdapters.kt | 11 + .../binding/ViewGroupBindingAdapters.kt | 20 + .../binding/ViewPager2BindingAdapters.kt | 28 + .../adapters/common/PagingLoadStateAdapter.kt | 48 + .../ui_base/adapters/doc/DocsDecoration.kt | 29 + .../components/CommonDiiaResourceIcon.kt | 163 + .../ui_base/components/DiiaResourceIcon.kt | 23 + .../ui_base/components/ExtensionCompose.kt | 52 + .../components/atom/button/ActionLinkAtom.kt | 122 + .../atom/button/BtnAlertAdditionalAtm.kt | 97 + .../atom/button/BtnIconCircledWhiteAtm.kt | 63 + .../components/atom/button/BtnNumAtm.kt | 79 + .../components/atom/button/BtnPlainAtm.kt | 115 + .../components/atom/button/BtnPlainIconAtm.kt | 151 + .../atom/button/BtnPrimaryAdditionalAtm.kt | 103 + .../atom/button/BtnPrimaryDefaultAtm.kt | 123 + .../atom/button/BtnPrimaryLargeAtm.kt | 106 + .../atom/button/BtnStrokeDefaultAtm.kt | 112 + .../components/atom/button/ButtonIconAtom.kt | 108 + .../atom/button/ButtonIconCircledLargeAtm.kt | 75 + .../button/ButtonStrokeAdditionalButton.kt | 112 + .../atom/button/ButtonStrokeLargeAtom.kt | 122 + .../atom/button/ButtonSystemAtom.kt | 76 + .../atom/button/ButtonWhiteLargeAtom.kt | 109 + .../components/atom/button/NumButtonAtom.kt | 75 + .../atom/checkbox/CheckboxCircleAtom.kt | 164 + .../checkbox/CheckboxCircleGeneralAtom.kt | 168 + .../atom/divider/DividerSlimAtom.kt | 16 + .../atom/divider/DividerWithSpace.kt | 45 + .../atom/divider/GradientDividerAtom.kt | 47 + .../atom/divider/TableDividerAtm.kt | 35 + .../components/atom/icon/BadgeCounterAtm.kt | 15 + .../components/atom/icon/DoubleIconAtm.kt | 61 + .../atom/icon/EllipseStepperAtom.kt | 43 + .../ui_base/components/atom/icon/IconAtm.kt | 67 + .../atom/icon/IconAttentionEmojyAtom.kt | 36 + .../components/atom/icon/IconBackArrowAtom.kt | 33 + .../components/atom/icon/IconBiometricAtom.kt | 50 + .../atom/icon/IconEllipseMenuAtom.kt | 28 + .../atom/icon/IconNegativeFaceAtom.kt | 35 + .../components/atom/icon/IconRemoveNumAtom.kt | 54 + .../components/atom/icon/MrzScannerCvAtom.kt | 72 + .../components/atom/icon/SmallIconAtm.kt | 61 + .../components/atom/list/ActionItemAtom.kt | 92 + .../atom/list/DownloadListItemAtom.kt | 289 + .../components/atom/media/ArticlePicAtm.kt | 99 + .../atom/pager/BaseViewPagerIndicator.kt | 243 + .../atom/pager/DocDotNavigationAtm.kt | 61 + .../components/atom/pager/DotNavigationAtm.kt | 61 + .../components/atom/radio/RadioBtnAtm.kt | 401 + .../components/atom/radio/RadioBtnItem.kt | 3 + .../components/atom/space/SpacerAtm.kt | 29 + .../components/atom/status/ChipStatusAtm.kt | 106 + .../ui_base/components/atom/text/LinkAtm.kt | 96 + .../components/atom/text/SectionTitleAtm.kt | 37 + .../ui_base/components/atom/text/TickerAtm.kt | 251 + .../text/textwithparameter/TextParameter.kt | 15 + .../TextWithParametersAtom.kt | 282 + .../TextWithParametersConstants.kt | 7 + .../BottomSheetDetailsScreen.kt | 129 + .../infrastructure/BottomSheetScreen.kt | 122 + .../components/infrastructure/ComposeConst.kt | 6 + .../infrastructure/DataActionWrapper.kt | 7 + .../infrastructure/FlowExtension.kt | 30 + .../infrastructure/HomeScreenTab.kt | 76 + .../components/infrastructure/ListExt.kt | 38 + .../infrastructure/PublicServiceScreen.kt | 68 + .../infrastructure/ServiceScreen.kt | 78 + .../infrastructure/UIElementData.kt | 3 + .../components/infrastructure/ViewExt.kt | 19 + .../infrastructure/event/DocAction.kt | 4 + .../infrastructure/event/UIAction.kt | 56 + .../event/UIActionKeysCompose.kt | 145 + .../navigation/NavigationPath.kt | 3 + .../screen/BodyRootContainer.kt | 880 + .../screen/BottomBarRootContainer.kt | 63 + .../screen/ComposeHomeTabRoot.kt | 51 + .../screen/ComposeRootScreen.kt | 52 + .../screen/FullScreenGalleryScreen.kt | 125 + .../infrastructure/screen/StackOrderScreen.kt | 75 + .../screen/SystemDialogScreen.kt | 113 + .../screen/TabBarRootContainer.kt | 34 + .../screen/TabBodyRootContainer.kt | 462 + .../screen/TemplateDialogScreen.kt | 224 + .../screen/ToolbarRootContainer.kt | 126 + .../screen/TopBarRootContainer.kt | 68 + .../infrastructure/state/UIState.kt | 68 + .../utils/AutoSizeLimitedText.kt | 59 + .../infrastructure/utils/FloatExt.kt | 8 + .../infrastructure/utils/KeyboardExt.kt | 66 + .../infrastructure/utils/image/BlurBitmap.kt | 72 + .../utils/resource/ContextExt.kt | 20 + .../infrastructure/utils/resource/UiIcon.kt | 20 + .../infrastructure/utils/resource/UiText.kt | 29 + .../components/molecule/FullScreeVideoMlc.kt | 268 + .../molecule/button/BtnIconRoundedMlc.kt | 126 + .../molecule/button/BtnToggleMlc.kt | 98 + .../button/SmallButtonPanelMlcData.kt | 86 + .../components/molecule/card/AlertCardMlc.kt | 94 + .../components/molecule/card/BlackCardMlc.kt | 194 + .../components/molecule/card/CardFixedMlc.kt | 267 + .../components/molecule/card/CardMlc.kt | 480 + .../molecule/card/GalleryImageMolecule.kt | 75 + .../components/molecule/card/HalvedCardMlc.kt | 174 + .../components/molecule/card/IconCardMlc.kt | 94 + .../components/molecule/card/ImageCardMlc.kt | 197 + .../card/ProcessCardMoleculeDeprecated.kt | 444 + .../molecule/card/ServiceCardMlc.kt | 85 + .../molecule/card/SmallNotificationMlc.kt | 90 + .../molecule/card/VerticalCardMlc.kt | 151 + .../components/molecule/card/WhiteCardMlc.kt | 187 + .../molecule/card/WhiteMenuCardMlc.kt | 91 + .../molecule/checkbox/CheckBoxItem.kt | 3 + .../molecule/checkbox/CheckboxBorderedMlc.kt | 78 + .../molecule/checkbox/CheckboxBtnOrg.kt | 177 + .../molecule/checkbox/CheckboxRoundMlc.kt | 197 + .../checkbox/CheckboxRoundMolecule.kt | 239 + .../molecule/checkbox/CheckboxSquareMlc.kt | 211 + .../molecule/checkbox/RadioBtnMlc.kt | 15 + .../molecule/checkbox/RoundChipMolecule.kt | 146 + .../molecule/chip/MapChipMolecule.kt | 143 + .../components/molecule/doc/DocCoverMlc.kt | 88 + .../molecule/doc/DocNumberCopyMlc.kt | 93 + .../molecule/doc/DocNumberCopyWhiteMlc.kt | 95 + .../components/molecule/doc/StackMlc.kt | 67 + .../molecule/header/NavigationPanelMlc.kt | 103 + .../header/SheetNavigationBarMolecule.kt | 69 + .../molecule/header/TitleGroupMlc.kt | 210 + .../header/chiptabbar/ChipTabBarMolecule.kt | 198 + .../header/chiptabbar/ChipTabMolecule.kt | 141 + .../header/chiptabbar/ChipTabMoleculeV2.kt | 104 + .../header/chiptabbar/ChipTabsOrg.kt.kt | 129 + .../molecule/input/DateInputMolecule.kt | 266 + .../molecule/input/InputFormItem.kt | 5 + .../molecule/input/InputGroupMolecule.kt | 127 + .../molecule/input/InputNumberLargeMlc.kt | 358 + .../molecule/input/QuantityInputMolecule.kt | 199 + .../molecule/input/SearchInputMolecule.kt | 204 + .../molecule/input/SearchInputV2.kt | 307 + .../components/molecule/input/SelectorOrg.kt | 363 + .../molecule/input/TextInputMolecule.kt | 643 + .../molecule/input/TimeInputMolecule.kt | 225 + .../molecule/input/tile/NumButtonTileMlc.kt | 198 + .../input/tile/NumButtonTileMolecule.kt | 195 + .../molecule/list/ActionSheetMolecule.kt | 62 + .../molecule/list/BtnIconPlainGroupMlc.kt | 139 + .../molecule/list/ListItemDragMlc.kt | 171 + .../components/molecule/list/ListItemMlc.kt | 310 + .../molecule/list/ListItemsMlcV1.kt | 121 + .../list/checkbox/CheckboxMolecule.kt | 294 + .../list/checkbox/CheckboxTitleAtom.kt | 98 + .../molecule/list/radio/RadioBtnGroupOrg.kt | 203 + .../molecule/list/radio/SingleChoiceMlcl.kt | 192 + .../list/table/ContentGroupMolecule.kt | 110 + .../list/table/ContentGroupMoleculeV2.kt | 142 + .../contentgroup/AccordionListMolecule.kt | 87 + .../items/contentgroup/AccordionMolecule.kt | 202 + .../items/contentgroup/ContentGroupItem.kt | 3 + .../items/contentgroup/ContentGroupItemV2.kt | 3 + .../items/contentgroup/TableBlockMolecule.kt | 97 + .../table/items/contentgroup/TableBlockOrg.kt | 146 + .../DocTableItemHorizontalLongerMlc.kt | 207 + .../tableblock/DocTableItemHorizontalMlc.kt | 209 + .../table/items/tableblock/TableBlockItem.kt | 3 + .../items/tableblock/TableHeadingMolecule.kt | 149 + .../tableblock/TableItemHorizontalMlc.kt | 232 + .../items/tableblock/TableItemPrimaryMlc.kt | 167 + .../items/tableblock/TableItemVerticalMlc.kt | 180 + .../loading/FullScreenLoadingMolecule.kt | 47 + .../molecule/loading/LinearLoadingMolecule.kt | 43 + .../molecule/loading/TridentLoaderMolecule.kt | 37 + .../molecule/media/ArticleVideoMlc.kt | 273 + .../molecule/message/AttentionMessageMlc.kt | 143 + .../message/DraggableMessageMolecule.kt | 141 + .../message/EmptyStateErrorMolecule.kt | 66 + .../molecule/message/MessageMolecule.kt | 116 + .../message/PaymentStatusMessageMolecule.kt | 130 + .../molecule/message/StatusMessageMlc.kt | 91 + .../molecule/message/StubMessageMlc.kt | 143 + .../progress/EllipseStepperMolecule.kt | 71 + .../molecule/tab/TabItemMolecule.kt | 141 + .../molecule/text/DetailsBlockMolecule.kt | 146 + .../components/molecule/text/DetailsText.kt | 5 + .../text/DetailsTextDescriptionMolecule.kt | 155 + .../molecule/text/DetailsTextLabelMolecule.kt | 75 + .../molecule/text/HeadingWithSubtitlesMlc.kt | 74 + .../text/HeadingWithSubtitlesWhiteMlc.kt | 68 + .../molecule/text/PaymentInfoOrgData.kt | 148 + .../text/PlainDetailsBlockMolecule.kt | 133 + .../molecule/text/SubtitleLabelMlc.kt | 79 + .../molecule/text/TextLabelContainerMlc.kt | 68 + .../components/molecule/text/TextLabelMlc.kt | 68 + .../components/molecule/text/TitleLabelMlc.kt | 40 + .../molecule/tile/ServiceCardTileOrg.kt | 128 + .../molecule/tile/SmallEmojiPanelMlc.kt | 65 + .../components/organism/FullScreenVideoOrg.kt | 105 + .../organism/bottom/BottomGroupOrg.kt | 254 + .../organism/bottom/BottomGroupOrganism.kt | 147 + .../organism/bottom/BtnIconRoundedGroupOrg.kt | 95 + .../components/organism/bottom/TabBarOrg.kt | 172 + .../organism/bottom/TabBarOrganism.kt | 149 + .../carousel/ArticlePicCarouselOrg.kt | 68 + .../carousel/BaseSimpleCarouselInternal.kt | 164 + .../carousel/HalvedCardCarouselOrg.kt | 113 + .../carousel/SmallNotificationCarouselOrg.kt | 113 + .../carousel/VerticalCardCarouselOrg.kt | 76 + .../organism/carousel/ViewPagerIndicator.kt | 262 + .../organism/chip/MapChipTabsOrganism.kt | 85 + .../components/organism/document/AddDocOrg.kt | 81 + .../organism/document/ContentTableOrg.kt | 144 + .../organism/document/DocButtonHeadingOrg.kt | 169 + .../organism/document/DocCodeOrg.kt | 555 + .../organism/document/DocErrorOrg.kt | 94 + .../organism/document/DocHeadingOrg.kt | 67 + .../components/organism/document/DocOrg.kt | 195 + .../organism/document/DocPhotoOrg.kt | 498 + .../organism/document/TableBlockOrg.kt | 298 + .../organism/document/TableBlockPlaneOrg.kt | 211 + .../document/TableBlockTwoColumnsOrg.kt | 161 + .../document/TableBlockTwoColumnsPlainOrg.kt | 139 + .../organism/group/ToggleButtonGroup.kt | 87 + .../organism/header/MediaTitleOrg.kt | 104 + .../components/organism/header/TopGroupOrg.kt | 170 + .../components/organism/image/QRShareOrg.kt | 114 + .../organism/input/QuestionFormsOrg.kt | 226 + .../organism/input/QuestionFormsOrgLocal.kt | 308 + .../input/RadioBtnAdditionalInputOrg.kt | 111 + .../organism/list/ActionSheetOrganism.kt | 104 + .../organism/list/ActivityViewOrg.kt | 110 + .../components/organism/list/CardListOrg.kt | 105 + .../organism/list/CardsListOrgDeprecated.kt | 62 + .../CheckboxRoundGroupAccordionOrganism.kt | 103 + .../organism/list/CheckboxRoundGroupOrg.kt | 277 + .../list/CheckboxRoundGroupOrganism.kt | 114 + .../list/ChipsGridGroupAccordionOrganism.kt | 161 + .../organism/list/ContextMenuOrg.kt | 168 + .../list/DownloadListGroupOrganism.kt | 201 + .../organism/list/ItemListViewOrg.kt | 55 + .../organism/list/ListItemBorderedGroupOrg.kt | 279 + .../organism/list/ListItemDragOrg.kt | 86 + .../organism/list/ListItemGroupOrg.kt | 280 + .../organism/list/MessageListOrganism.kt | 195 + .../list/MultipleChoiceGroupOrganism.kt | 398 + .../organism/list/PaginatedCardListOrg.kt | 69 + .../list/PlainListWithSearchOrganism.kt | 131 + .../SingleChoiceWithAdditionalInputOrg.kt | 133 + .../list/SingleChoiceWithAltOrganism.kt | 146 + .../list/SingleChoiceWithButtonOrganism.kt | 114 + .../list/SingleChoiceWithSearchOrganism.kt | 155 + .../SimplePaginationListOrganism.kt | 84 + .../organism/pager/BaseCarouselOrg.kt | 192 + .../components/organism/pager/DocCardFlip.kt | 238 + .../organism/pager/DocCarouselOrg.kt | 245 + .../organism/pager/DocsCarouselItem.kt | 3 + .../components/organism/pager/FlipCard.kt | 216 + .../components/organism/pager/Flipper.kt | 173 + .../organism/table/ContentTableOrganism.kt | 138 + .../organism/tile/NumButtonTileOrganism.kt | 143 + .../border/DiiaRadialgradientBorder.kt | 61 + .../subatomic/border/GrayBorderSubatomic.kt | 33 + .../border/LinearGradientBorderSubatomic.kt | 48 + .../subatomic/border/WhiteBoarderSubatomic.kt | 33 + .../subatomic/icon/BadgeSubatomic.kt | 56 + .../subatomic/icon/IconBase64Subatomic.kt | 74 + .../subatomic/icon/IconWithBadge.kt | 131 + .../subatomic/icon/PhotoDocBase64Subatomic.kt | 87 + .../icon/PlusMinusClickableSubatomic.kt | 26 + .../subatomic/icon/SignIconBase64Subatomic.kt | 132 + .../subatomic/icon/UiIconWrapperSubatomic.kt | 142 + .../subatomic/loader/Ellipse23Subatom.kt | 58 + .../subatomic/loader/LineLoaderSubatomic.kt | 127 + .../subatomic/loader/TridentLoaderAtm.kt | 37 + .../subatomic/loader/TridentLoaderBlock.kt | 14 + .../TridentLoaderWithNavigationBlock.kt | 40 + .../loader/TridentLoaderWithUIBlocking.kt | 49 + .../subatomic/preview/PreviewBase64Icons.kt | 44 + .../subatomic/ticker/NoInternetTicker.kt | 28 + .../diia/ui_base/components/theme/Color.kt | 75 + .../gov/diia/ui_base/components/theme/Type.kt | 136 + .../ui_base/fragments/BaseBottomDialog.kt | 22 + .../diia/ui_base/fragments/BaseSheetDialog.kt | 19 + .../fragments/dialog/system/DiiaSystemDF.kt | 68 + .../fragments/dialog/system/DiiaSystemDFVM.kt | 86 + .../fragments/dynamicdialog/TemplateDialog.kt | 146 + .../dynamicdialog/TemplateDialogConst.kt | 11 + .../dynamicdialog/TemplateDialogVM.kt | 134 + .../fragments/errordialog/DialogError.kt | 105 + .../ui_base/fragments/errordialog/ErrorDF.kt | 61 + .../ui_base/fragments/errordialog/ErrorDVM.kt | 246 + .../fragments/errordialog/ErrorDialogConst.kt | 6 + .../errordialog/RequestTryCountTracker.kt | 11 + .../ua/gov/diia/ui_base/mappers/TextExt.kt | 48 + .../homescreen/HomeMenuItemConstructor.kt | 11 + .../diia/ui_base/navigation/BaseNavigation.kt | 12 + .../java/ua/gov/diia/ui_base/util/Mappers.kt | 479 + .../util/navigation/FragmentNavigationExt.kt | 24 + .../util/navigation/NavigationBarExt.kt | 54 + .../ui_base/util/paging/OffsetPagingData.kt | 78 + .../ua/gov/diia/ui_base/util/view/ViewExt.kt | 101 + .../diia/ui_base/util/view/ViewGroupExt.kt | 10 + .../diia/ui_base/util/view/ViewPagerExt.kt | 46 + .../gov/diia/ui_base/views/DiiaBulletsCV.kt | 91 + .../gov/diia/ui_base/views/DiiaMenuIconCV.kt | 95 + .../gov/diia/ui_base/views/DiiaProgressCV.kt | 39 + .../ua/gov/diia/ui_base/views/NameModel.kt | 12 + .../ua/gov/diia/ui_base/views/NameView.kt | 148 + .../ui_base/views/common/DiiaStatusLabel.kt | 84 + .../ui_base/views/common/MaskedEditText.kt | 505 + .../common/card_item/DiiaCardInputField.kt | 792 + .../card_item/DiiaCardNotifiableField.kt | 177 + .../common/messages/DiiaAttentionMessage.kt | 105 + .../common/messages/DiiaStatusMessage.kt | 132 + .../common/progress/DiiaProgressButton.kt | 200 + .../progress/DiiaProgressTextWithImage.kt | 141 + .../common/progress/DiiaProgressTitled.kt | 54 + .../common/progress/DiiaProgressWindow.kt | 33 + .../views/pager/ScrollingPagerIndicator.java | 455 + .../attachers/AbstractViewPagerAttacher.kt | 24 + .../views/pager/attachers/PagerAttacher.kt | 28 + .../pager/attachers/ViewPager2Attacher.kt | 71 + ui_base/src/main/res/anim/anim_fade_in.xml | 7 + ui_base/src/main/res/anim/anim_fade_out.xml | 7 + ui_base/src/main/res/anim/anim_progress.xml | 11 + ui_base/src/main/res/anim/anim_slide_down.xml | 9 + ui_base/src/main/res/anim/slide_in_down.xml | 8 + ui_base/src/main/res/anim/slide_in_left.xml | 9 + ui_base/src/main/res/anim/slide_in_right.xml | 9 + ui_base/src/main/res/anim/slide_in_up.xml | 8 + ui_base/src/main/res/anim/slide_out_down.xml | 8 + ui_base/src/main/res/anim/slide_out_left.xml | 9 + ui_base/src/main/res/anim/slide_out_right.xml | 9 + ui_base/src/main/res/anim/slide_out_up.xml | 8 + .../res/drawable/bg_blue_yellow_gradient.xml | 69 + .../bg_blue_yellow_gradient_with_bottom.xml | 72 + .../res/drawable/diia_article_placeholder.xml | 13 + .../src/main/res/drawable/diia_back_arrow.xml | 5 + .../src/main/res/drawable/diia_cellular.xml | 18 + .../src/main/res/drawable/diia_charging.xml | 28 + ui_base/src/main/res/drawable/diia_check.xml | 6 + .../diia_circular_progress_vector.xml | 14 + .../res/drawable/diia_close_rounded_icon.xml | 10 + .../res/drawable/diia_close_rounded_plain.xml | 14 + .../main/res/drawable/diia_ellipse_menu.xml | 20 + .../res/drawable/diia_ellipse_menu_black.xml | 18 + .../src/main/res/drawable/diia_generator.xml | 13 + .../src/main/res/drawable/diia_heating.xml | 12 + .../main/res/drawable/diia_ic_code_loader.xml | 9 + .../main/res/drawable/diia_ic_doc_stack.xml | 20 + .../main/res/drawable/diia_icon_calendar.xml | 26 + .../src/main/res/drawable/diia_icon_clock.xml | 9 + .../drawable/diia_icon_copy_to_clipboard.xml | 14 + .../src/main/res/drawable/diia_icon_minus.xml | 9 + .../src/main/res/drawable/diia_icon_nfc.xml | 27 + .../src/main/res/drawable/diia_icon_plus.xml | 9 + .../src/main/res/drawable/diia_internet.xml | 15 + .../main/res/drawable/diia_search_icon.xml | 10 + .../res/drawable/diia_smile_placeholder.xml | 106 + ui_base/src/main/res/drawable/diia_water.xml | 13 + .../src/main/res/drawable/doc_background.xml | 58 + ui_base/src/main/res/drawable/double_icon.xml | 31 + ui_base/src/main/res/drawable/drag.xml | 26 + .../main/res/drawable/ellipse_arrow_right.xml | 15 + .../src/main/res/drawable/ellipse_check.xml | 12 + .../drawable/ellipse_white_arrow_right.xml | 15 + .../drawable/ic_action_sheet_messenger.xml | 16 + .../res/drawable/ic_action_sheet_telegram.xml | 16 + .../res/drawable/ic_action_sheet_viber.xml | 16 + ui_base/src/main/res/drawable/ic_add.xml | 13 + .../src/main/res/drawable/ic_arrow_next.xml | 9 + .../src/main/res/drawable/ic_arrow_right.xml | 16 + .../main/res/drawable/ic_arrow_show_less.xml | 9 + .../main/res/drawable/ic_arrow_show_more.xml | 9 + .../src/main/res/drawable/ic_arrow_top.xml | 12 + ui_base/src/main/res/drawable/ic_bag.xml | 13 + .../main/res/drawable/ic_biometric_auth.xml | 14 + .../res/drawable/ic_btn_doc_scan_close.xml | 13 + .../main/res/drawable/ic_button_remove.xml | 19 + ui_base/src/main/res/drawable/ic_chip_all.xml | 23 + .../src/main/res/drawable/ic_chip_check.xml | 13 + .../src/main/res/drawable/ic_chip_point.xml | 12 + .../src/main/res/drawable/ic_chip_shelter.xml | 13 + ui_base/src/main/res/drawable/ic_close.xml | 9 + ui_base/src/main/res/drawable/ic_copy.xml | 14 + .../main/res/drawable/ic_copy_settings.xml | 14 + .../res/drawable/ic_copy_settings_white.xml | 15 + ui_base/src/main/res/drawable/ic_delete.xml | 16 + ui_base/src/main/res/drawable/ic_delivery.xml | 22 + ui_base/src/main/res/drawable/ic_device.xml | 14 + .../src/main/res/drawable/ic_doc_bonds.xml | 11 + .../src/main/res/drawable/ic_doc_cancel.xml | 25 + ui_base/src/main/res/drawable/ic_doc_cert.xml | 27 + .../src/main/res/drawable/ic_doc_delete.xml | 31 + ui_base/src/main/res/drawable/ic_doc_drag.xml | 24 + .../res/drawable/ic_doc_ean13_selected.xml | 21 + .../res/drawable/ic_doc_ean13_unselected.xml | 22 + .../main/res/drawable/ic_doc_edit_adress.xml | 13 + ui_base/src/main/res/drawable/ic_doc_faq.xml | 14 + ui_base/src/main/res/drawable/ic_doc_info.xml | 26 + .../res/drawable/ic_doc_order_delivery.xml | 34 + .../main/res/drawable/ic_doc_qr_selected.xml | 12 + .../res/drawable/ic_doc_qr_unselected.xml | 13 + .../src/main/res/drawable/ic_doc_refresh.xml | 18 + .../src/main/res/drawable/ic_doc_reorder.xml | 14 + .../src/main/res/drawable/ic_doc_share.xml | 15 + .../main/res/drawable/ic_doc_translate_ua.xml | 13 + .../main/res/drawable/ic_doc_verification.xml | 9 + ui_base/src/main/res/drawable/ic_docinfo.xml | 26 + ui_base/src/main/res/drawable/ic_document.xml | 27 + ui_base/src/main/res/drawable/ic_download.xml | 16 + .../res/drawable/ic_download_disabled.xml | 18 + .../main/res/drawable/ic_download_retry.xml | 9 + .../main/res/drawable/ic_download_update.xml | 9 + .../main/res/drawable/ic_ellipse_white.xml | 10 + ui_base/src/main/res/drawable/ic_faq.xml | 13 + .../src/main/res/drawable/ic_faq_settings.xml | 14 + ui_base/src/main/res/drawable/ic_forward.xml | 10 + ui_base/src/main/res/drawable/ic_history.xml | 20 + ui_base/src/main/res/drawable/ic_home.xml | 20 + ui_base/src/main/res/drawable/ic_homedoc.xml | 18 + .../src/main/res/drawable/ic_info_about.xml | 28 + .../src/main/res/drawable/ic_input_clear.xml | 12 + ui_base/src/main/res/drawable/ic_key.xml | 16 + .../src/main/res/drawable/ic_menu_history.xml | 9 + .../drawable/ic_menu_notifications_action.xml | 17 + ui_base/src/main/res/drawable/ic_message.xml | 17 + .../src/main/res/drawable/ic_my_location.xml | 39 + .../ic_notifications_top_bar_right.xml | 12 + .../res/drawable/ic_player_btn_atm_pause.xml | 16 + .../res/drawable/ic_player_btn_atm_play.xml | 13 + .../res/drawable/ic_player_btn_atm_retry.xml | 13 + .../src/main/res/drawable/ic_player_pause.xml | 12 + .../src/main/res/drawable/ic_player_play.xml | 9 + .../main/res/drawable/ic_player_replay.xml | 9 + ui_base/src/main/res/drawable/ic_police.xml | 14 + .../main/res/drawable/ic_ps_certificates.xml | 23 + .../res/drawable/ic_ps_military_donation.xml | 27 + ui_base/src/main/res/drawable/ic_rating.xml | 11 + .../src/main/res/drawable/ic_remove_num.xml | 13 + ui_base/src/main/res/drawable/ic_scan_doc.xml | 54 + .../src/main/res/drawable/ic_search_black.xml | 10 + ui_base/src/main/res/drawable/ic_settings.xml | 9 + ui_base/src/main/res/drawable/ic_share.xml | 14 + .../src/main/res/drawable/ic_stack_white.xml | 20 + ui_base/src/main/res/drawable/ic_star.xml | 10 + ui_base/src/main/res/drawable/ic_syringe.png | Bin 0 -> 16210 bytes .../drawable/ic_tab_documents_selected.xml | 26 + .../drawable/ic_tab_documents_unselected.xml | 26 + .../res/drawable/ic_tab_feed_selected.xml | 24 + .../res/drawable/ic_tab_feed_unselected.xml | 24 + .../res/drawable/ic_tab_menu_selected.xml | 23 + .../drawable/ic_tab_menu_selected_badge.xml | 28 + .../res/drawable/ic_tab_menu_unselected.xml | 23 + .../drawable/ic_tab_menu_unselected_badge.xml | 28 + .../res/drawable/ic_tab_services_selected.xml | 21 + .../drawable/ic_tab_services_unselected.xml | 21 + ui_base/src/main/res/drawable/ic_target.xml | 28 + .../main/res/drawable/ic_touch_id_large.xml | 726 + .../main/res/drawable/ic_translate_eng.xml | 12 + ui_base/src/main/res/drawable/ic_trident.xml | 11 + ui_base/src/main/res/drawable/ic_wallet.xml | 10 + .../res/drawable/invincibility_points.xml | 16 + ui_base/src/main/res/drawable/new_message.xml | 18 + .../main/res/drawable/notification_new.xml | 16 + ui_base/src/main/res/drawable/pn.xml | 12 + ui_base/src/main/res/drawable/qr_scan.xml | 16 + .../src/main/res/drawable/qr_scan_white.xml | 16 + ui_base/src/main/res/drawable/safety.xml | 23 + .../src/main/res/drawable/safety_large.xml | 31 + ui_base/src/main/res/drawable/shelter.xml | 17 + ui_base/src/main/res/drawable/target.xml | 28 + .../src/main/res/drawable/target_white.xml | 28 + ui_base/src/main/res/drawable/trident.xml | 11 + .../src/main/res/drawable/trident_white.xml | 11 + .../main/res/font/e_ukraine_head_regular.otf | Bin 0 -> 78132 bytes .../src/main/res/font/e_ukraine_medium.otf | Bin 0 -> 70100 bytes .../src/main/res/font/e_ukraine_regular.otf | Bin 0 -> 69092 bytes .../src/main/res/layout/item_load_action.xml | 30 + .../main/res/layout/item_loading_footer.xml | 52 + .../src/main/res/layout/item_view_name.xml | 39 + .../res/layout/view_attantion_message.xml | 60 + .../main/res/layout/view_diia_menu_icon.xml | 47 + .../main/res/layout/view_diia_progress.xml | 6 + .../src/main/res/layout/view_input_field.xml | 170 + ui_base/src/main/res/layout/view_label.xml | 30 + ui_base/src/main/res/layout/view_name.xml | 68 + .../main/res/layout/view_progress_button.xml | 107 + .../layout/view_progress_text_with_image.xml | 56 + .../main/res/layout/view_progress_titled.xml | 28 + .../main/res/layout/view_status_message.xml | 63 + .../layout/widget_card_notifiable_field.xml | 93 + .../res/layout/widget_progress_window.xml | 30 + ui_base/src/main/res/navigation/nav_error.xml | 20 + .../main/res/navigation/nav_system_dialog.xml | 24 + .../res/navigation/nav_template_dialog.xml | 29 + .../res/raw/card_viewholder_dots_black.json | 1 + .../res/raw/card_viewholder_dots_white.json | 1 + ui_base/src/main/res/raw/gradient_bg.json | 1 + ui_base/src/main/res/raw/loader.json | 1 + ui_base/src/main/res/raw/loader_white.json | 1 + ui_base/src/main/res/raw/splash_bg.json | 29272 ++++++++++++++++ ui_base/src/main/res/values-hdpi/dimen.xml | 8 + ui_base/src/main/res/values-xhdpi/dimen.xml | 8 + ui_base/src/main/res/values-xxhdpi/dimen.xml | 8 + ui_base/src/main/res/values-xxxhdpi/dimen.xml | 8 + ui_base/src/main/res/values/accessibility.xml | 36 + ui_base/src/main/res/values/attr.xml | 565 + ui_base/src/main/res/values/config.xml | 5 + ui_base/src/main/res/values/emoji.xml | 18 + ui_base/src/main/res/values/integer.xml | 8 + ui_base/src/main/res/values/nav_ids.xml | 5 + ui_base/src/main/res/values/strings.xml | 53 + ui_base/src/main/res/values/styles.xml | 571 + .../ua/gov/diia/ui_base/LiveDataTestUtil.kt | 55 + .../dynamicdialog/TemplateDialogVMTest.kt | 141 + .../fragments/errordialog/ErrorDVMTest.kt | 60 + .../errordialog/RequestTryCountTrackerTest.kt | 35 + .../fragments/system/DiiaSystemDFVMTest.kt | 100 + .../diia/ui_base/rules/MainDispatcherRule.kt | 23 + verification/.gitignore | 1 + verification/README.md | 43 + verification/build.gradle | 133 + verification/consumer-rules.pro | 4 + verification/excludes.jacoco | 5 + verification/proguard-rules.pro | 5 + verification/src/main/AndroidManifest.xml | 2 + .../gov/diia/verification/di/Annotations.kt | 7 + .../verification/di/VerificationModule.kt | 79 + .../di/VerificationProviderType.kt | 5 + .../model/ActivityViewActionButton.kt | 10 + .../model/BaseVerificationData.kt | 11 + .../model/VerificationFlowResult.kt | 16 + .../model/VerificationMethodView.kt | 11 + .../model/VerificationMethodsData.kt | 22 + .../model/VerificationMethodsView.kt | 12 + .../verification/model/VerificationResult.kt | 11 + .../verification/model/VerificationUrl.kt | 16 + .../verification/network/ApiVerification.kt | 37 + .../verification/ui/VerificationSchema.kt | 9 + .../controller/VerificationControllerConst.kt | 5 + .../ui/controller/VerificationControllerF.kt | 105 + .../VerificationControllerOnFlowF.kt | 115 + .../VerificationControllerOnFlowVM.kt | 319 + .../ui/controller/VerificationControllerVM.kt | 288 + .../ui/method_selection/BindingAdapters.kt | 90 + .../VerificationMethodSelectionDF.kt | 62 + .../VerificationMethodSelectionVM.kt | 43 + .../ui/methods/VerificationMethod.kt | 68 + .../ui/methods/VerificationNavRequest.kt | 11 + .../ui/methods/VerificationRequest.kt | 9 + .../layout/dialog_verification_methods.xml | 80 + .../src/main/res/layout/item_login_app.xml | 32 + .../main/res/navigation/nav_verification.xml | 21 + verification/src/main/res/values/nav_ids.xml | 4 + .../verification/rules/MainDispatcherRule.kt | 23 + .../TestVerificationControllerOnFlowVM.kt | 58 + .../TestVerificationControllerVM.kt | 54 + .../VerificationControllerOnFlowVMTest.kt | 374 + .../VerificationControllerVMTest.kt | 378 + .../VerificationMethodSelectionVMTest.kt | 63 + .../verification/util/StubErrorHandler.kt | 23 + .../util/StubErrorHandlerOnFlow.kt | 28 + .../util/StubVerificationMethod.kt | 31 + .../ua/gov/diia/verification/util/TestUtil.kt | 23 + web/.gitignore | 1 + web/README.md | 43 + web/build.gradle | 106 + web/consumer-rules.pro | 0 web/proguard-rules.pro | 21 + web/src/main/AndroidManifest.xml | 5 + web/src/main/java/ua/gov/diia/web/ui/WebF.kt | 60 + .../fragment/FragmentNavigationExt.kt | 34 + web/src/main/res/layout/fragment_web.xml | 19 + web/src/main/res/navigation/nav_web.xml | 40 + web/src/main/res/values/nav_ids.xml | 4 + web/src/main/res/values/strings.xml | 12 + 1732 files changed, 152765 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 address_search/.gitignore create mode 100644 address_search/README.md create mode 100644 address_search/build.gradle create mode 100644 address_search/consumer-rules.pro create mode 100644 address_search/excludes.jacoco create mode 100644 address_search/proguard-rules.pro create mode 100644 address_search/src/main/AndroidManifest.xml create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/di/AddressSearchApiModule.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressDefaultListItem.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldApproveRequest.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldInputType.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldRequest.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldRequestValue.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldResponse.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressIdentifier.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressItem.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressNationality.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressParameter.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressSearchRequest.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressSource.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/AddressValidation.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/NationalityItem.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/models/SearchType.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/network/ApiAddressSearch.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/ui/AddressParameterMapper.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchControllerF.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchFieldType.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchVM.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressResultKey.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressSearchF.kt create mode 100644 address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressSearchVM.kt create mode 100644 address_search/src/main/res/layout/fragment_address_search.xml create mode 100644 address_search/src/main/res/layout/fragment_compound_address_search.xml create mode 100644 address_search/src/main/res/navigation/nav_compound_address_search.xml create mode 100644 address_search/src/main/res/values/strings.xml create mode 100644 address_search/src/test/java/ua/gov/diia/address_search/ExampleUnitTest.kt create mode 100644 address_search/src/test/java/ua/gov/diia/address_search/MainDispatcherRule.kt create mode 100644 address_search/src/test/java/ua/gov/diia/address_search/models/AddressFieldApproveRequestTest.kt create mode 100644 address_search/src/test/java/ua/gov/diia/address_search/models/AddressFieldResponseTest.kt create mode 100644 address_search/src/test/java/ua/gov/diia/address_search/models/AddressParameterTest.kt create mode 100644 address_search/src/test/java/ua/gov/diia/address_search/ui/AddressParameterMapperTest.kt create mode 100644 address_search/src/test/java/ua/gov/diia/address_search/ui/AddressSearchVMTest.kt create mode 100644 address_search/src/test/java/ua/gov/diia/address_search/ui/CompoundAddressSearchVMTest.kt create mode 100644 analytics/.gitignore create mode 100644 analytics/README.md create mode 100644 analytics/build.gradle create mode 100644 analytics/consumer-rules.pro create mode 100644 analytics/excludes.jacoco create mode 100644 analytics/proguard-rules.pro create mode 100644 analytics/src/gplay/java/ua/gov/diia/analytics/DiiaAnalyticsImpl.kt create mode 100644 analytics/src/gplay/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImpl.kt create mode 100644 analytics/src/huawei/java/ua/gov/diia/analytics/DiiaAnalyticsImpl.kt create mode 100644 analytics/src/huawei/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImpl.kt create mode 100644 analytics/src/main/AndroidManifest.xml create mode 100644 analytics/src/main/java/ua/gov/diia/analytics/DiiaAnalytics.kt create mode 100644 analytics/src/main/java/ua/gov/diia/analytics/di/AnalyticsModule.kt create mode 100644 analytics/src/testGplay/java/ua/gov/diia/analytics/DiiaAnalyticsImplTest.kt create mode 100644 analytics/src/testGplay/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImplTest.kt create mode 100644 analytics/src/testHuawei/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImplTest.kt create mode 100644 bankid/.gitignore create mode 100644 bankid/README.md create mode 100644 bankid/build.gradle create mode 100644 bankid/consumer-rules.pro create mode 100644 bankid/excludes.jacoco create mode 100644 bankid/proguard-rules.pro create mode 100644 bankid/src/main/AndroidManifest.xml create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/BankIdConst.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/di/BankIdModule.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/model/AuthBank.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/model/AuthBanks.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/model/BankAuthRequest.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/model/BankSelectionRequest.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/network/ApiBankId.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/ui/VerificationMethodBankId.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthConst.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthF.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthScreen.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthVM.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionF.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionScreenPreview.kt create mode 100644 bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionVM.kt create mode 100644 bankid/src/main/res/drawable/ic_bankid_btn.xml create mode 100644 bankid/src/main/res/navigation/nav_bankid.xml create mode 100644 bankid/src/main/res/values/strings.xml create mode 100644 bankid/src/test/java/ua/gov/diia/bankid/rules/MainDispatcherRule.kt create mode 100644 bankid/src/test/java/ua/gov/diia/bankid/ui/VerificationMethodBankIdTest.kt create mode 100644 bankid/src/test/java/ua/gov/diia/bankid/ui/auth/BankAuthVMTest.kt create mode 100644 bankid/src/test/java/ua/gov/diia/bankid/ui/selection/BankSelectionVMTest.kt create mode 100644 biometric/.gitignore create mode 100644 biometric/README.md create mode 100644 biometric/build.gradle create mode 100644 biometric/consumer-rules.pro create mode 100644 biometric/excludes.jacoco create mode 100644 biometric/proguard-rules.pro create mode 100644 biometric/src/main/AndroidManifest.xml create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/AndroidBiometric.kt create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/Biometric.kt create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/di/BiometricModule.kt create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/store/BiometricRepository.kt create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/store/BiometricRepositoryImpl.kt create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricAuthPrompt.kt create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricSetupF.kt create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricSetupVM.kt create mode 100644 biometric/src/main/java/ua/gov/diia/biometric/ui/compose/BiometricSetupScreen.kt create mode 100644 biometric/src/main/res/navigation/nav_biometric.xml create mode 100644 biometric/src/main/res/values/strings.xml create mode 100644 biometric/src/test/java/ua/gov/diia/biometric/AndroidBiometricTest.kt create mode 100644 biometric/src/test/java/ua/gov/diia/biometric/rules/MainDispatcherRule.kt create mode 100644 biometric/src/test/java/ua/gov/diia/biometric/store/BiometricRepositoryImplTest.kt create mode 100644 biometric/src/test/java/ua/gov/diia/biometric/ui/BiometricSetupVMTest.kt create mode 100644 build.gradle create mode 100644 core/.gitignore create mode 100644 core/README.md create mode 100644 core/build.gradle create mode 100644 core/consumer-rules.pro create mode 100644 core/excludes.jacoco create mode 100644 core/proguard-rules.pro create mode 100644 core/src/androidTest/java/ua/gov/diia/core/ExampleInstrumentedTest.kt create mode 100644 core/src/main/AndroidManifest.xml create mode 100644 core/src/main/java/ua/gov/diia/core/CoreConstants.kt create mode 100644 core/src/main/java/ua/gov/diia/core/ExcludeFromJacocoGeneratedReport.kt create mode 100644 core/src/main/java/ua/gov/diia/core/controller/DeeplinkProcessor.kt create mode 100644 core/src/main/java/ua/gov/diia/core/controller/NotificationController.kt create mode 100644 core/src/main/java/ua/gov/diia/core/controller/PromoController.kt create mode 100644 core/src/main/java/ua/gov/diia/core/data/data_source/file/PrivateFileDataSource.kt create mode 100644 core/src/main/java/ua/gov/diia/core/data/data_source/network/api/ApiSettings.kt create mode 100644 core/src/main/java/ua/gov/diia/core/data/data_source/network/api/notification/ApiNotificationsPublic.kt create mode 100644 core/src/main/java/ua/gov/diia/core/data/repository/DataRepository.kt create mode 100644 core/src/main/java/ua/gov/diia/core/data/repository/SystemRepository.kt create mode 100644 core/src/main/java/ua/gov/diia/core/di/AppInfoProviderModule.kt create mode 100644 core/src/main/java/ua/gov/diia/core/di/DateProviderModule.kt create mode 100644 core/src/main/java/ua/gov/diia/core/di/SystemServiceProviderModule.kt create mode 100644 core/src/main/java/ua/gov/diia/core/di/WorkersModule.kt create mode 100644 core/src/main/java/ua/gov/diia/core/di/actions/Annotations.kt create mode 100644 core/src/main/java/ua/gov/diia/core/di/data_source/http/Annotations.kt create mode 100644 core/src/main/java/ua/gov/diia/core/di/fragment/HiltFragmentFactory.kt create mode 100644 core/src/main/java/ua/gov/diia/core/di/fragment/HiltNavHostFragment.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/ActionDataLazy.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/AppStatus.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/ConsumableItem.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/ConsumableString.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/ContextMenuField.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/DiiaError.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/ITN.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/PushToken.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/RefreshToken.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/SingleDeeplinkProcessor.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/SuccessResponse.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/SystemDialog.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/TextWithParameters.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/Token.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/TokenData.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/UserType.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/acquirer/AcquirerLinkType.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/acquirer/AcquirerServiceType.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/appversion/AppSettingsInfo.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/auth/Auth.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/auth/AuthV3.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/auth/FaceRecoConfig.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/auth/Fld.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/LoadActionData.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/NavigationPanel.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/menu/ContextMenuItem.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/message/AttentionMessage.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/message/AttentionMessageParameterized.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/message/StatusMessage.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/message/StatusMessageParameterized.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/message/StubMessage.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/message/StubMessageParameterized.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/message/TextParameter.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/template_dialogs/DynamicDialogData.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common/template_dialogs/SystemDialogData.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/NavigationBarMlcl.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/button/BtnPlainIconAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/button/PlayerBtnAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/chip/ChipStatusAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/BadgeCounterAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/DoubleIconAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/IconAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/SmallIconAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/indicators/DotNavigationAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/media/ArticlePicAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/text/SectionTitleAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/atm/text/TickerAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/general/Action.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/general/Body.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/general/BottomGroup.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/general/ButtonStates.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/general/DiiaResponse.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/general/TopGroup.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/button/BtnIconRoundedMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/BlackCardMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/HalvedCardMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/IconCardMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/ImageCardMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/SmallNotificationMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/VerticalCardMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/WhiteCardMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/header/TitleGroupMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/list/ListItemMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/media/ArticleVideoMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/text/SmallEmojiPanelMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/text/TextLabelContainerMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/button/BtnIconRoundedGroupOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/ArticlePicCarouselOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/HalvedCardCarouselOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/SmallNotificationCarouselOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/VerticalCardCarouselOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/ChipTabsOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/MediaTitleOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/NavigationPanelMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/TopGroupOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/org/list/ListItemGroupOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/Action.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/HeadingWithSubtitlesMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/Item.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemHorizontalMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemPrimaryMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemVerticalMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableMainHeadingMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableSecondaryHeadingMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/ValueIcon.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockOrg/TableBlockOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockPlaneOrg/TableBlockPlaneOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockTwoColumnsOrg/TableBlockTwoColumnsOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockTwoColumnsPlaneOrg/TableBlockTwoColumnsPlaneOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/deeplink/DeepLinkAction.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogButton.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogData.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogModel.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogModelWithProcessCode.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/EmptySelection.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/PullNotificationItemSelection.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Action.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ArticlePicAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ArticleVideoMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/AuthorizedNotificationData.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Data.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Item.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/LeftNavIcon.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ListItemGroupOrg.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MediumIconRight.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MessageActions.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MessageTypes.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Notification.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/NotificationFull.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/NotificationMessageBody.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/PlayerBtnAtm.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/TextLabelContainerMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/TitleGroupMlc.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/pull/message/UnauthorizedNotificationMessage.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/push/PushAction.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/notification/push/PushNotification.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/proper_user/VerifyArgs.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/Chip.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/Chips.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/Comment.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/Rating.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/RatingFormByInitiative.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/RatingFormModel.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/RatingItem.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/RatingRequest.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/RatingResult.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/rating_service/SentRatingResponse.kt create mode 100644 core/src/main/java/ua/gov/diia/core/models/share/ShareByteArr.kt create mode 100644 core/src/main/java/ua/gov/diia/core/network/Http.kt create mode 100644 core/src/main/java/ua/gov/diia/core/network/annotation/Analytics.kt create mode 100644 core/src/main/java/ua/gov/diia/core/network/apis/ApiAuth.kt create mode 100644 core/src/main/java/ua/gov/diia/core/network/connectivity/ConnectivityObserver.kt create mode 100644 core/src/main/java/ua/gov/diia/core/push/BasePushNotificationAction.kt create mode 100644 core/src/main/java/ua/gov/diia/core/ui/dynamicdialog/ActionsConst.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/CombinedLiveData.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/CommonConst.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/DateFormats.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/DispatcherProvider.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/Exception.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/alert/ClientAlertDialogsFactory.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/datasource/DataSourceOwner.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/date/CurrentDateProvider.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/decorators/ListDelimiterDecorator.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/deeplink/DeepLinkActionFactory.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithAppConfig.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithBuildConfig.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithContextMenu.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithCrashlytics.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithDeeplinkHandling.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithErrorHandling.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithErrorHandlingOnFlow.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithPermission.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithPushHandling.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithPushNotification.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithRatingDialog.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithRatingDialogOnFlow.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/WithRetryLastAction.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/download_files/WithDownloadFiles.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/delegation/download_files/base64/DownloadableBase64File.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/event/EventObserver.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/event/UiEvent.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/ErrorHandlingExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/PadingExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/ResourceValidation.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/ShareExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/activity/ActivityWindowExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextAppPackageInfoExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextGlobalAppControlExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextResourcesExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextServicesExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/data/DoubleExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/data/NumberExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/date_time/DateTimeExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/date_time/DisplayFormatExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentActionsExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentNavigationExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentResultExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentSendPdfExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentTimeSelectionExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentWindowControllExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleEventsExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/extensions/vm/ViewModelActionExecutionExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/file/AndroidInternalFileManager.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/file/FileManager.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/filter/DecimalDigitsInputFilter.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/filter/MoneyValueFilter.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/html/HtmlGenerator.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/navigation/KeepStateNavigator.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/phone/PhoneNumberExt.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/settings_action/SettingsActionExecutor.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/system/application/ApplicationLauncher.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/system/application/InstalledApplicationInfoProvider.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/system/service/SystemServiceProvider.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/system/service/SystemServiceProviderImpl.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/work/CheckAppVersionUpdatedWork.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/work/DoApplicationSettingsProvisionWork.kt create mode 100644 core/src/main/java/ua/gov/diia/core/util/work/WorkScheduler.kt create mode 100644 core/src/main/res/color/outlined_button_text.xml create mode 100644 core/src/main/res/color/text_clickable.xml create mode 100644 core/src/main/res/drawable/back_penalties_paid.xml create mode 100644 core/src/main/res/drawable/back_penalty_card.xml create mode 100644 core/src/main/res/drawable/back_white_round.xml create mode 100644 core/src/main/res/drawable/bg_radius_8.xml create mode 100644 core/src/main/res/drawable/black_button_enabled.xml create mode 100644 core/src/main/res/drawable/black_disable_button.xml create mode 100644 core/src/main/res/drawable/black_enable_button.xml create mode 100644 core/src/main/res/drawable/button_buy_card.xml create mode 100644 core/src/main/res/drawable/chips_selected.xml create mode 100644 core/src/main/res/drawable/chips_unselected.xml create mode 100644 core/src/main/res/drawable/delimiter_gradient.xml create mode 100644 core/src/main/res/drawable/diia_circular_progress.xml create mode 100644 core/src/main/res/drawable/divider.xml create mode 100644 core/src/main/res/drawable/doc_shadow.png create mode 100644 core/src/main/res/drawable/gradient_progress.xml create mode 100644 core/src/main/res/drawable/green_radiobutton.xml create mode 100644 core/src/main/res/drawable/ic_add_item.xml create mode 100644 core/src/main/res/drawable/ic_arrow.xml create mode 100644 core/src/main/res/drawable/ic_arrow_disabled.xml create mode 100644 core/src/main/res/drawable/ic_arrow_forward_white.xml create mode 100644 core/src/main/res/drawable/ic_arrow_top.xml create mode 100644 core/src/main/res/drawable/ic_b_back.xml create mode 100644 core/src/main/res/drawable/ic_b_back_bold.xml create mode 100644 core/src/main/res/drawable/ic_b_back_bold_white.xml create mode 100644 core/src/main/res/drawable/ic_badge.xml create mode 100644 core/src/main/res/drawable/ic_check_for_btn.xml create mode 100644 core/src/main/res/drawable/ic_checkbox_green_cempty19.png create mode 100644 core/src/main/res/drawable/ic_close_gray.xml create mode 100644 core/src/main/res/drawable/ic_close_green_light.xml create mode 100644 core/src/main/res/drawable/ic_details_three_dots.xml create mode 100644 core/src/main/res/drawable/ic_dl_menu.xml create mode 100644 core/src/main/res/drawable/ic_empty_ellipse.xml create mode 100644 core/src/main/res/drawable/ic_full_ellipse.xml create mode 100644 core/src/main/res/drawable/ic_logo_diia.xml create mode 100644 core/src/main/res/drawable/ic_logo_diia_gerb.png create mode 100644 core/src/main/res/drawable/ic_menu.xml create mode 100644 core/src/main/res/drawable/ic_radiobutton_green_checked19.png create mode 100644 core/src/main/res/drawable/ic_tips_and_tricks.xml create mode 100644 core/src/main/res/drawable/ic_view.xml create mode 100644 core/src/main/res/drawable/line_button_back.xml create mode 100644 core/src/main/res/drawable/line_button_black_back.xml create mode 100644 core/src/main/res/drawable/line_button_black_back_disabled.xml create mode 100644 core/src/main/res/drawable/line_button_black_back_focused.xml create mode 100644 core/src/main/res/drawable/line_button_black_select.xml create mode 100644 core/src/main/res/drawable/line_button_green.xml create mode 100644 core/src/main/res/drawable/line_button_green_select.xml create mode 100644 core/src/main/res/drawable/line_button_pressed_round.xml create mode 100644 core/src/main/res/drawable/line_button_select.xml create mode 100644 core/src/main/res/drawable/line_button_white.xml create mode 100644 core/src/main/res/drawable/line_button_white_disabled.xml create mode 100644 core/src/main/res/drawable/line_button_white_focused.xml create mode 100644 core/src/main/res/drawable/line_button_white_select.xml create mode 100644 core/src/main/res/drawable/outlined_button_black.xml create mode 100644 core/src/main/res/drawable/outlined_button_black_disabled.xml create mode 100644 core/src/main/res/drawable/outlined_button_black_focused.xml create mode 100644 core/src/main/res/drawable/outlined_button_black_selector.xml create mode 100644 core/src/main/res/drawable/outlined_card.xml create mode 100644 core/src/main/res/drawable/rounded_bottom_dialog.xml create mode 100644 core/src/main/res/drawable/selector_text_black.xml create mode 100644 core/src/main/res/drawable/service_card_shadow.png create mode 100644 core/src/main/res/drawable/shape_card_notification_dot.xml create mode 100644 core/src/main/res/drawable/shape_outlined_box_primary.xml create mode 100644 core/src/main/res/drawable/shape_status_message.xml create mode 100644 core/src/main/res/drawable/shape_white_card.xml create mode 100644 core/src/main/res/drawable/thumb_selector.xml create mode 100644 core/src/main/res/drawable/track_selector.xml create mode 100644 core/src/main/res/values/colors.xml create mode 100644 core/src/main/res/values/dimens.xml create mode 100644 core/src/main/res/values/strings.xml create mode 100644 core/src/test/java/ua/gov/diia/core/LiveDataTestUtil.kt create mode 100644 core/src/test/java/ua/gov/diia/core/models/ConsumableEventTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/models/ConsumableItemTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/models/ConsumableStringTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/rules/MainDispatcherRule.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/DateFormatsTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/DiiaDispatcherProviderTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/date/CurrentDateProviderTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/event/UiDataEventObserverTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/event/UiDataEventTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/event/UiEventObserverTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/event/UiEventTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/extensions/ErrorHandlingExtTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/extensions/date_time/DateUtilsExtTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleEventsExtTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/html/HtmlGeneratorTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/work/CheckAppVersionUpdatedWorkTest.kt create mode 100644 core/src/test/java/ua/gov/diia/core/util/work/DoApplicationSettingsProvisionWorkTest.kt create mode 100644 dependencies.gradle create mode 100644 diia_storage/.gitignore create mode 100644 diia_storage/build.gradle create mode 100644 diia_storage/consumer-rules.pro create mode 100644 diia_storage/excludes.jacoco create mode 100644 diia_storage/proguard-rules.pro create mode 100644 diia_storage/src/main/AndroidManifest.xml create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/AndroidBase64Wrapper.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/AndroidKeyValueStore.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/Base64Wrapper.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/CommonPreferenceKeys.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/DiiaStorage.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/EncryptedAndroidKeyValueStore.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/MobileUidStore.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/PinStore.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/PreferenceConfiguration.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/SecureDiiaStorage.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/di/Base64WrapperModule.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/di/PreferenceStorage.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/model/BaseSecuredKeyValueStore.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/model/KeyValueStore.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/model/PreferenceKey.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/AbstractKeyValueDataSource.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/Preferences.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/DataSource.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/DataSourceDataResult.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/itn/ItnDataRepository.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/preferences/KotlinStoreImpl.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/preferences/PreferenceDataSource.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepository.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepositoryImpl.kt create mode 100644 diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/system/SystemRepositoryImpl.kt create mode 100644 diia_storage/src/test/java/ua/gov/diia/diia_storage/AndroidKeyValueStoreTest.kt create mode 100644 diia_storage/src/test/java/ua/gov/diia/diia_storage/MainDispatcherRule.kt create mode 100644 diia_storage/src/test/java/ua/gov/diia/diia_storage/store/AbstractKeyValueDataSourceTest.kt create mode 100644 diia_storage/src/test/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepositoryImplTest.kt create mode 100644 diia_storage/src/test/java/ua/gov/diia/diia_storage/store/repository/system/SystemRepositoryImplTest.kt create mode 100644 doc_driver_license/.gitignore create mode 100644 doc_driver_license/README.md create mode 100644 doc_driver_license/build.gradle create mode 100644 doc_driver_license/consumer-rules.pro create mode 100644 doc_driver_license/excludes.jacoco create mode 100644 doc_driver_license/proguard-rules.pro create mode 100644 doc_driver_license/src/androidTest/java/ua/gov/diia/doc_driver_license/ExampleInstrumentedTest.kt create mode 100644 doc_driver_license/src/main/AndroidManifest.xml create mode 100644 doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenceJsonAdapterDelegate.kt create mode 100644 doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenceLocalizationChecker.kt create mode 100644 doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseConst.kt create mode 100644 doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseFullInfoComposeMapper.kt create mode 100644 doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseV2.kt create mode 100644 doc_driver_license/src/main/res/values/strings.xml create mode 100644 doc_driver_license/src/test/java/ua/gov/diia/doc_driver_license/DriverLicenseLocalizationCheckerTest.kt create mode 100644 documents/.gitignore create mode 100644 documents/README.md create mode 100644 documents/build.gradle create mode 100644 documents/consumer-rules.pro create mode 100644 documents/excludes.jacoco create mode 100644 documents/proguard-rules.pro create mode 100644 documents/src/androidTest/java/ua/gov/diia/documents/ExampleInstrumentedTest.kt create mode 100644 documents/src/main/AndroidManifest.xml create mode 100644 documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcode.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeFactory.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeImageData.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeRepository.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/barcode/Extensions.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/api/ApiDocuments.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/BrokenDocFilter.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/BrokenDocFilterImpl.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DefaultDocGroupUpdateBehavior.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocGroupUpdateBehavior.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocJsonAdapterDelegate.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocumentsTransformation.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/KeyValueDocumentsDataSource.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/RemoveExpiredDocBehavior.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/local/RemoveExpiredDocBehaviorImpl.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/datasource/remote/NetworkDocumentsDataSource.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/repository/BeforePublishAction.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/repository/DocumentsDataRepository.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/data/repository/DocumentsDataRepositoryImpl.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/di/Annotations.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/di/DocumentDataSourceModule.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/di/ExpirationStrategyModule.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/helper/DocumentsHelper.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/DiiaDocuments.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/DiiaDocumentsWithOrder.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/DocEmpty.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/DocOrder.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/DocWeight.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/DocumentCard.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/FetchDocumentsResult.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/GeneratePdfFromDoc.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/ManualDocs.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/Preferences.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/QRUrl.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/UpdatedDoc.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/Action.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/BaseDocumentGroup.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/TaxPayerCard.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/Content.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/Data.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DataForDisplayingInOrderConfigurations.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocButtonHeadingOrg.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocCover.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocData.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocHeadingOrg.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocNumberCopyMlc.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/EN.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/FrontCard.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/FullInfo.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/IconLeft.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/IconRight.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/QrCheckStatus.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/StackMlc.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/SubtitleLabelMlc.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/UA.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/VaccinationCertificateBody.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/VerificationAction.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/BaseLocalizationChecker.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/BottomDoc.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/DocVM.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/DocsConst.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/DocumentComposeMapper.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocs.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocsImpl.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/WithPdfCertificate.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/WithRemoveDocument.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActions.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsDFCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsNavigationHandler.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsProvider.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsProviderImpl.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsVMCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/actions/VerificationActions.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/BaseFullInfoComposeMapper.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/DocFullInfoComposeMapper.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/DocFullInfoComposeMapperImpl.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFComposeVM.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/compose/FullInfoBottomSheet.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocActions.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocFSettings.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryFCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryNavigationHelper.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryVMCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/stack/DocStackFCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/stack/DocStackVMCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/stack/compose/StackScreen.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/stack/order/StackOrderFCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/ui/stack/order/StackOrderVMCompose.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/util/BaseDocActionItemProcessor.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/util/BaseDocumentActionProvider.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/util/DocNameProvider.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/util/DocumentActionMapper.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/util/WithUpdateExpiredDocs.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/util/WithUpdateExpiredDocsImpl.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/util/datasource/ExpirationStrategy.kt create mode 100644 documents/src/main/java/ua/gov/diia/documents/util/view/Ext.kt create mode 100644 documents/src/main/res/drawable/ic_alert.xml create mode 100644 documents/src/main/res/drawable/ic_checked.xml create mode 100644 documents/src/main/res/navigation/nav_doc_actions.xml create mode 100644 documents/src/main/res/navigation/nav_doc_full_info.xml create mode 100644 documents/src/main/res/navigation/nav_doc_gallery.xml create mode 100644 documents/src/main/res/navigation/nav_doc_stack.xml create mode 100644 documents/src/main/res/navigation/nav_stack_order.xml create mode 100644 documents/src/main/res/values/nav_ids.xml create mode 100644 documents/src/main/res/values/plurals.xml create mode 100644 documents/src/main/res/values/strings.xml create mode 100644 documents/src/test/java/ua/gov/diia/documents/LiveDataTestUtil.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/Mocks.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/data/datasource/local/DefaultDocGroupUpdateBehaviorTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/data/datasource/local/KeyValueDocumentsDataSourceTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/data/datasource/remote/NetworkDocumentsDataSourceTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/data/repository/DocumentsDataRepositoryImplTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/rules/MainDispatcherRule.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocsImplTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/ui/actions/DocActionsVMComposeTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFComposeVMTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/ui/gallery/DocGalleryVMComposeTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/ui/stack/DocStackVMComposeTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/ui/stack/order/StackOrderVMComposeTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/util/WithUpdateExpiredDocsImplTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/util/datasource/ExpirationStrategyTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/util/datasource/local/BrokenDocFilterImplTest.kt create mode 100644 documents/src/test/java/ua/gov/diia/documents/util/datasource/local/RemoveExpiredDocBehaviorImplTest.kt create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 home/.gitignore create mode 100644 home/README.md create mode 100644 home/build.gradle create mode 100644 home/consumer-rules.pro create mode 100644 home/excludes.jacoco create mode 100644 home/proguard-rules.pro create mode 100644 home/src/main/AndroidManifest.xml create mode 100644 home/src/main/java/ua/gov/diia/home/di/HomeScreenTabsMappersModule.kt create mode 100644 home/src/main/java/ua/gov/diia/home/helper/HomeHelper.kt create mode 100644 home/src/main/java/ua/gov/diia/home/model/HomeMenuItem.kt create mode 100644 home/src/main/java/ua/gov/diia/home/ui/HomeActions.kt create mode 100644 home/src/main/java/ua/gov/diia/home/ui/HomeF.kt create mode 100644 home/src/main/java/ua/gov/diia/home/ui/HomeScreenComposeMapper.kt create mode 100644 home/src/main/java/ua/gov/diia/home/ui/HomeVM.kt create mode 100644 home/src/main/java/ua/gov/diia/home/ui/views/DiiaAppBarCV.kt create mode 100644 home/src/main/res/drawable/ic_qr.xml create mode 100644 home/src/main/res/layout/fragment_home.xml create mode 100644 home/src/main/res/layout/view_diia_app_bar.xml create mode 100644 home/src/main/res/navigation/nav_home.xml create mode 100644 home/src/main/res/raw/gradient_bg.json create mode 100644 home/src/main/res/values/nav_ids.xml create mode 100644 home/src/main/res/values/strings.xml create mode 100644 home/src/test/java/ua/gov/diia/home/MainDispatcherRule.kt create mode 100644 home/src/test/java/ua/gov/diia/home/ui/HomeScreenComposeMapperImplTest.kt create mode 100644 home/src/test/java/ua/gov/diia/home/ui/HomeVMTest.kt create mode 100644 jacoco.gradle create mode 100644 login/.gitignore create mode 100644 login/README.md create mode 100644 login/build.gradle create mode 100644 login/consumer-rules.pro create mode 100644 login/excludes.jacoco create mode 100644 login/proguard-rules.pro create mode 100644 login/src/main/AndroidManifest.xml create mode 100644 login/src/main/java/ua/gov/diia/login/di/LoginModule.kt create mode 100644 login/src/main/java/ua/gov/diia/login/model/LoginToken.kt create mode 100644 login/src/main/java/ua/gov/diia/login/network/ApiLogin.kt create mode 100644 login/src/main/java/ua/gov/diia/login/ui/LoginConst.kt create mode 100644 login/src/main/java/ua/gov/diia/login/ui/LoginF.kt create mode 100644 login/src/main/java/ua/gov/diia/login/ui/LoginVM.kt create mode 100644 login/src/main/java/ua/gov/diia/login/ui/PostLoginAction.kt create mode 100644 login/src/main/java/ua/gov/diia/login/ui/compose/LoginScreen.kt create mode 100644 login/src/main/res/navigation/nav_login.xml create mode 100644 login/src/main/res/values/nav_ids.xml create mode 100644 login/src/main/res/values/strings.xml create mode 100644 login/src/test/java/ua/gov/diia/login/rules/MainDispatcherRule.kt create mode 100644 login/src/test/java/ua/gov/diia/login/ui/LoginVMTest.kt create mode 100644 menu/.gitignore create mode 100644 menu/README.md create mode 100644 menu/build.gradle create mode 100644 menu/consumer-rules.pro create mode 100644 menu/excludes.jacoco create mode 100644 menu/proguard-rules.pro create mode 100644 menu/src/main/AndroidManifest.xml create mode 100644 menu/src/main/java/ua/gov/diia/menu/MenuContentController.kt create mode 100644 menu/src/main/java/ua/gov/diia/menu/models/EventType.kt create mode 100644 menu/src/main/java/ua/gov/diia/menu/ui/MenuAction.kt create mode 100644 menu/src/main/java/ua/gov/diia/menu/ui/MenuActionsKey.kt create mode 100644 menu/src/main/java/ua/gov/diia/menu/ui/MenuComposeVM.kt create mode 100644 menu/src/main/java/ua/gov/diia/menu/ui/MenuFCompose.kt create mode 100644 menu/src/main/res/navigation/nav_menu_actions.xml create mode 100644 menu/src/main/res/values/config.xml create mode 100644 menu/src/main/res/values/nav_ids.xml create mode 100644 menu/src/main/res/values/strings.xml create mode 100644 menu/src/test/java/ua/gov/diia/menu/MainDispatcherRule.kt create mode 100644 menu/src/test/java/ua/gov/diia/menu/ui/MenuComposeVMTest.kt create mode 100644 notifications/.gitignore create mode 100644 notifications/README.md create mode 100644 notifications/build.gradle create mode 100644 notifications/consumer-rules.pro create mode 100644 notifications/excludes.jacoco create mode 100644 notifications/proguard-rules.pro create mode 100644 notifications/src/gplay/AndroidManifest.xml create mode 100644 notifications/src/gplay/java/ua/gov/diia/notifications/service/FCMS.kt create mode 100644 notifications/src/gplay/java/ua/gov/diia/notifications/util/notification/push/CloudPushTokenProvider.kt create mode 100644 notifications/src/huawei/AndroidManifest.xml create mode 100644 notifications/src/huawei/java/ua/gov/diia/notifications/service/HCMS.kt create mode 100644 notifications/src/huawei/java/ua/gov/diia/notifications/util/notification/push/CloudPushTokenProvider.kt create mode 100644 notifications/src/main/AndroidManifest.xml create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/NotificationControllerImpl.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/NotificationsConst.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/action/ActionConstants.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/action/DocumentSharingPushNotificationAction.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/action/PushAccessibilityNotificationAction.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/data/data_source/network/api/notification/ApiNotifications.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/di/NotificationModule.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/di/PushTokenProviderModule.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationDataSourceModule.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationEnabledCheckerModule.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationManagementModule.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/helper/NotificationHelper.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/LoadingState.java create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/SubscribeResponse.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/Subscription.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/SubscriptionHash.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/Subscriptions.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/MessageIdentification.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotification.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationMessage.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationSyncAction.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationsResponse.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationsToModify.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/UpdatePullNotificationResponse.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/push/DiiaNotificationChannel.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/models/notification/push/PushNotification.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/service/PushService.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/store/NotificationsPreferences.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/KeyValueNotificationDataSource.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/KeyValueNotificationDataSourceImpl.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NetworkNotificationDataSource.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NotificationDataRepository.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NotificationDataRepositoryImpl.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/compose/mapper/media/MediaMapper.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/adapters/SubscriptionAdapter.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationComposeVM.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationFCompose.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationPagingSourseCompose.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationsActionKey.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationsMapperCompose.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/di/NotificationsMapperModule.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsF.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsFVM.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/NotificationFullAdapter.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/NotificationVideoPlayerView.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullComposeVM.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullFCompose.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/util/notification/manager/DiiaAndroidNotificationManager.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/util/notification/manager/DiiaNotificationManager.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/util/notification/push/PushTokenProvider.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/util/push/MoshiPushParser.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/util/push/PushParser.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/util/push/notification/AndroidNotificationEnabledChecker.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/util/push/notification/NotificationEnabledChecker.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/util/settings_action/PushTokenUpdateActionExecutor.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/work/SendPushTokenProcessor.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/work/SendPushTokenWork.kt create mode 100644 notifications/src/main/java/ua/gov/diia/notifications/work/SilentPushWork.kt create mode 100644 notifications/src/main/res/drawable-v21/ic_push.xml create mode 100644 notifications/src/main/res/drawable/ic_notification_next.xml create mode 100644 notifications/src/main/res/drawable/ic_push.png create mode 100644 notifications/src/main/res/layout/fragment_notification_settings.xml create mode 100644 notifications/src/main/res/layout/item_notification_divider.xml create mode 100644 notifications/src/main/res/layout/item_notification_download_arrowed_link.xml create mode 100644 notifications/src/main/res/layout/item_notification_image.xml create mode 100644 notifications/src/main/res/layout/item_notification_internal_arrowed_link.xml create mode 100644 notifications/src/main/res/layout/item_notification_text.xml create mode 100644 notifications/src/main/res/layout/item_notification_video.xml create mode 100644 notifications/src/main/res/layout/item_subscription.xml create mode 100644 notifications/src/main/res/layout/view_message_video_player.xml create mode 100644 notifications/src/main/res/navigation/nav_notification_details.xml create mode 100644 notifications/src/main/res/navigation/nav_notification_settings.xml create mode 100644 notifications/src/main/res/navigation/nav_notifications.xml create mode 100644 notifications/src/main/res/values/nav_ids.xml create mode 100644 notifications/src/main/res/values/strings.xml create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/MainDispatcherRule.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/NotificationControllerImplTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/TestDispatcherProvider.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/service/PushServiceTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/store/datasource/KeyValueNotificationDataSourceImplTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/store/datasource/NetworkNotificationDataSourceTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/store/datasource/NotificationDataRepositoryImplTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/ui/compose/mapper/media/MediaMapperTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationComposeVMTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsVMTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullComposeVMTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationPagingSourceComposeTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationsMapperComposeTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/util/settings_action/PushTokenUpdateActionExecutorTest.kt create mode 100644 notifications/src/test/java/ua/gov/diia/notifications/work/SendPushTokenProcessorTest.kt create mode 100644 opensource/.gitignore create mode 100644 opensource/README.md create mode 100644 opensource/build.gradle create mode 100644 opensource/google-services.json create mode 100644 opensource/proguard-rules.pro create mode 100644 opensource/src/gplay/java/ua/gov/diia/opensource/VendorActivity.kt create mode 100644 opensource/src/huawei/java/ua/gov/diia/opensource/VendorActivity.kt create mode 100644 opensource/src/main/AndroidManifest.xml create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/App.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/ItnDataRepositoryImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/KeyValueItnDataSource.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/NetworkItnDataSource.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/ApiLogger.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/NetworkConnectivityObserver.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/TimeoutConstants.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/api/ApiDocs.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpAppInfoHeaderInterceptor.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpAuthorizationInterceptor.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpLoggingInterceptor.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpMobileUuidInterceptor.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpProlongAuthorizationInterceptor.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/Annotations.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/AppModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/DocumentsModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/FeatureModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/GlobalUtils.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/NotificationPublicModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/ResourceIconProviderModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/fragment/Annotations.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/fragment/FragmentModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/network/OkHttpClientModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/network/RefreshLockerModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/network/RetrofitClientModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/di/network/UnAuthorizedApiModule.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/HomeHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/NotificationHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/PSCriminalCertHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/PSNavigationHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/PinHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/PublicServiceHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/PublicServicesCategoriesTabMapperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/SplashHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/ApiDocumentsWrapper.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocActionsNavigationHandlerImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocGalleryNavigationHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocName.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocNameProviderImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentBarcodeRepositoryImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentComposeMapperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentsHelperImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DriverLicenceActionProvider.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/WithPdfCertificateImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/helper/documents/WithRemoveDocumentImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/model/documents/Docs.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/model/notification/PushNotificationActionType.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/repository/ps/PublicServiceDataRepository.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/repository/settings/AppSettingsRepository.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/repository/settings/AppSettingsRepositoryImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/AndroidClientAlertDialogsFactory.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/PromoControllerImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/PublicServicesHomeConst.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/activities/MainActivity.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/activities/MainActivityVM.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/compose/DiiaResourceIconProviderImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/compose/TableBlockMapper.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/FeedF.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/context/ContextMenuDF.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/context/ContextMenuListAdapter.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/settings/SettingsF.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/settings/SettingsFVM.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/system/SystemDialog.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/system/SystemDialogVM.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/ui/work/LogoutWork.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/AndroidDeepLinkActionFactory.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DeeplinkProcessorImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultDeeplinkHandleBehaviour.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultErrorHandlingBehaviour.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultErrorHandlingBehaviourOnFlow.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultPushHandlerBehaviour.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultPushNotificationBehaviour.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRatingDialogBehaviour.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRatingDialogBehaviourOnFlow.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRetryLastActionBehaviour.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultSelfPermissionBehavior.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/DefaultWithContextMenuBehaviour.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/EdgeToEdge.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/WithAppConfigImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/WithBuildConfigImpl.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/ext/ActivityNavigation.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/ext/FragmentNavigationExt.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/ext/FragmentSendExt.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/ext/OkhttpExtensions.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/file/AndroidInternalFileManager.kt create mode 100644 opensource/src/main/java/ua/gov/diia/opensource/util/file/FileManager.kt create mode 100644 opensource/src/main/res/drawable/ic_diia_foreground.xml create mode 100644 opensource/src/main/res/drawable/ic_notifications.xml create mode 100644 opensource/src/main/res/drawable/ic_order.xml create mode 100644 opensource/src/main/res/drawable/ic_passcode_settings.xml create mode 100644 opensource/src/main/res/drawable/ic_touch_id.xml create mode 100644 opensource/src/main/res/layout/activity_main.xml create mode 100644 opensource/src/main/res/layout/dialog_context_menu.xml create mode 100644 opensource/src/main/res/layout/fragment_feed.xml create mode 100644 opensource/src/main/res/layout/fragment_settings.xml create mode 100644 opensource/src/main/res/layout/item_context_menu_field.xml create mode 100644 opensource/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 opensource/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 opensource/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 opensource/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 opensource/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 opensource/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 opensource/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 opensource/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 opensource/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 opensource/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 opensource/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 opensource/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 opensource/src/main/res/navigation/nav_home_children.xml create mode 100644 opensource/src/main/res/navigation/nav_main.xml create mode 100644 opensource/src/main/res/values/strings.xml create mode 100644 opensource/src/main/res/xml/file_paths.xml create mode 100644 opensource/version.properties create mode 100644 pin/.gitignore create mode 100644 pin/README.md create mode 100644 pin/build.gradle create mode 100644 pin/consumer-rules.pro create mode 100644 pin/excludes.jacoco create mode 100644 pin/proguard-rules.pro create mode 100644 pin/src/main/AndroidManifest.xml create mode 100644 pin/src/main/java/ua/gov/diia/pin/di/PinModule.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/helper/PinHelper.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/model/CreatePinFlowType.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/repository/LoginPinRepository.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/repository/LoginPinRepositoryImpl.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/create/compose/CreatePinScreen.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinF.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinVM.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/create/create/CreatePinF.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/create/create/CreatePinVM.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/input/AlternativeAuthCallback.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/input/PinInputF.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/input/PinInputVM.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/input/compose/PinInputScreen.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/reset/ResetPinF.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/reset/ResetPinVM.kt create mode 100644 pin/src/main/java/ua/gov/diia/pin/ui/reset/compose/ResetPinScreen.kt create mode 100644 pin/src/main/res/navigation/nav_pin_create.xml create mode 100644 pin/src/main/res/navigation/nav_pin_input.xml create mode 100644 pin/src/main/res/navigation/nav_pin_reset.xml create mode 100644 pin/src/main/res/values/nav_ids.xml create mode 100644 pin/src/main/res/values/strings.xml create mode 100644 pin/src/test/java/ua/gov/diia/pin/rules/MainDispatcherRule.kt create mode 100644 pin/src/test/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinVMTest.kt create mode 100644 pin/src/test/java/ua/gov/diia/pin/ui/create/create/CreatePinVMTest.kt create mode 100644 pin/src/test/java/ua/gov/diia/pin/ui/input/PinInputVMTest.kt create mode 100644 pin/src/test/java/ua/gov/diia/pin/ui/reset/ResetPinVMTest.kt create mode 100644 pin/src/test/java/ua/gov/diia/pin/utils/StubErrorHandlerOnFlow.kt create mode 100644 pin/src/test/java/ua/gov/diia/pin/utils/TestUtils.kt create mode 100644 ps_criminal_cert/.gitignore create mode 100644 ps_criminal_cert/README.md create mode 100644 ps_criminal_cert/build.gradle create mode 100644 ps_criminal_cert/consumer-rules.pro create mode 100644 ps_criminal_cert/excludes.jacoco create mode 100644 ps_criminal_cert/proguard-rules.pro create mode 100644 ps_criminal_cert/src/androidTest/java/ua/gov/diia/ps_criminal_cert/ExampleInstrumentedTest.kt create mode 100644 ps_criminal_cert/src/main/AndroidManifest.xml create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/di/CriminalCertApiModule.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/helper/PSCriminalCertHelper.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/Birth.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/CriminalCertHomeState.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/CriminalCertUserData.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/PreviousNames.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertApplicationInfoNextStep.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertLoadActionType.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertScreen.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertStatus.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertType.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/request/CriminalCertConfirmationRequest.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/request/PublicService.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertBirthPlace.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertConfirmation.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertConfirmed.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertContacts.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertDetails.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertFileData.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertInfo.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertListData.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertNationalities.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertReasons.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertRequester.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertTypes.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/network/ApiCriminalCert.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/CriminalCertConst.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/CriminalCertRatingScreenCodes.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertLoadActionsAdapter.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeTabsAdapter.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertListAdapter.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertListF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertReasonsAdapter.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeVM.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertTypesAdapter.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeF.kt create mode 100644 ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeVM.kt create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_details.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_home.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_list.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_address.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_birth.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_confirm.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_contacts.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_nationality.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_reasons.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_requester.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_type.xml create mode 100644 ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_welcome.xml create mode 100644 ps_criminal_cert/src/main/res/layout/item_criminal_cert.xml create mode 100644 ps_criminal_cert/src/main/res/layout/item_criminal_cert_load_action.xml create mode 100644 ps_criminal_cert/src/main/res/layout/item_criminal_cert_reason.xml create mode 100644 ps_criminal_cert/src/main/res/layout/item_criminal_cert_type.xml create mode 100644 ps_criminal_cert/src/main/res/layout/stub_message.xml create mode 100644 ps_criminal_cert/src/main/res/navigation/nav_criminal_cert.xml create mode 100644 ps_criminal_cert/src/main/res/values/strings.xml create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/rules/MainDispatcherRule.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeVMTest.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/util/StubContextMenu.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/util/StubErrorHandler.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/util/StubRatingDialog.kt create mode 100644 ps_criminal_cert/src/test/java/ua/gov/diia/ps_criminal_cert/util/TestUtil.kt create mode 100644 publicservice/.gitignore create mode 100644 publicservice/README.md create mode 100644 publicservice/build.gradle create mode 100644 publicservice/consumer-rules.pro create mode 100644 publicservice/excludes.jacoco create mode 100644 publicservice/proguard-rules.pro create mode 100644 publicservice/src/androidTest/java/ua/gov/diia/publicservice/ExampleInstrumentedTest.kt create mode 100644 publicservice/src/main/AndroidManifest.xml create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/di/Annotation.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/di/PublicServicesModule.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/helper/PSNavigationHelper.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/helper/PublicServiceHelper.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/models/CategoryStatus.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/models/ContextMenu.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/models/PublicService.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/models/PublicServiceCategory.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/models/PublicServiceTab.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/models/PublicServiceView.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/models/PublicServicesCategories.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/network/ApiPublicServices.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/categories/compose/PublicServicesCategoriesComposeF.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/categories/compose/PublicServicesCategoriesComposeVM.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/categories/compose/PublicServicesCategoriesTabMapper.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/compose/PublicServiceCategoryDetailsComposeF.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/compose/PublicServiceCategoryDetailsComposeMapper.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/compose/PublicServiceCategoryDetailsComposeVM.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/compose/PublicServicesSearchComposeF.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/compose/PublicServicesSearchComposeMapper.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/ui/compose/PublicServicesSearchComposeVM.kt create mode 100644 publicservice/src/main/java/ua/gov/diia/publicservice/util/extensions/fragment/FragmentSendPdfExt.kt create mode 100644 publicservice/src/main/res/drawable-hdpi/ic_google.png create mode 100644 publicservice/src/main/res/drawable-mdpi/ic_google.png create mode 100644 publicservice/src/main/res/drawable-xhdpi/ic_google.png create mode 100644 publicservice/src/main/res/drawable-xxhdpi/ic_google.png create mode 100644 publicservice/src/main/res/drawable-xxxhdpi/ic_google.png create mode 100644 publicservice/src/main/res/drawable/chip_light.xml create mode 100644 publicservice/src/main/res/drawable/chip_light_selected.xml create mode 100644 publicservice/src/main/res/layout/layout_home_content_loading.xml create mode 100644 publicservice/src/main/res/navigation/nav_public_service_categories.xml create mode 100644 publicservice/src/main/res/navigation/nav_public_service_category_details.xml create mode 100644 publicservice/src/main/res/navigation/nav_public_service_search.xml create mode 100644 publicservice/src/main/res/values/strings.xml create mode 100644 publicservice/src/test/java/ua/gov/diia/publicservice/rules/MainDispatcherRule.kt create mode 100644 publicservice/src/test/java/ua/gov/diia/publicservice/ui/categories/compose/PublicServicesCategoriesComposeVMTest.kt create mode 100644 publicservice/src/test/java/ua/gov/diia/publicservice/ui/compose/PublicServiceCategoryDetailsComposeVMTest.kt create mode 100644 publicservice/src/test/java/ua/gov/diia/publicservice/ui/compose/PublicServicesSearchComposeVMTest.kt create mode 100644 publicservice/src/test/java/ua/gov/diia/publicservice/util/StubErrorHandlerOnFlow.kt create mode 100644 publicservice/src/test/java/ua/gov/diia/publicservice/util/TestDispatcherProvider.kt create mode 100644 publicservice/src/test/java/ua/gov/diia/publicservice/util/TestUtil.kt create mode 100644 search/.gitignore create mode 100644 search/README.md create mode 100644 search/build.gradle create mode 100644 search/consumer-rules.pro create mode 100644 search/excludes.jacoco create mode 100644 search/proguard-rules.pro create mode 100644 search/src/main/AndroidManifest.xml create mode 100644 search/src/main/java/ua/gov/diia/search/adapters/BindingAdapters.kt create mode 100644 search/src/main/java/ua/gov/diia/search/adapters/SearchAdapter.kt create mode 100644 search/src/main/java/ua/gov/diia/search/models/SearchResult.kt create mode 100644 search/src/main/java/ua/gov/diia/search/models/SearchableBullet.kt create mode 100644 search/src/main/java/ua/gov/diia/search/models/SearchableItem.kt create mode 100644 search/src/main/java/ua/gov/diia/search/models/SearchableItemDoubleLine.kt create mode 100644 search/src/main/java/ua/gov/diia/search/models/StringSearchableItem.kt create mode 100644 search/src/main/java/ua/gov/diia/search/ui/SearchF.kt create mode 100644 search/src/main/java/ua/gov/diia/search/ui/SearchFVM.kt create mode 100644 search/src/main/java/ua/gov/diia/search/ui/bullet_selection/SearchBulletF.kt create mode 100644 search/src/main/java/ua/gov/diia/search/ui/bullet_selection/SearchBulletVM.kt create mode 100644 search/src/main/res/drawable/ic_baseline_search_24.xml create mode 100644 search/src/main/res/layout/fragment_search.xml create mode 100644 search/src/main/res/layout/fragment_search_bullets.xml create mode 100644 search/src/main/res/layout/item_rv_search.xml create mode 100644 search/src/main/res/layout/item_rv_search_two_lines.xml create mode 100644 search/src/main/res/navigation/nav_search.xml create mode 100644 search/src/main/res/navigation/nav_search_bullet.xml create mode 100644 search/src/main/res/values/strings.xml create mode 100644 search/src/test/java/ua/gov/diia/search/rules/MainDispatcherRule.kt create mode 100644 search/src/test/java/ua/gov/diia/search/ui/SearchFVMTest.kt create mode 100644 search/src/test/java/ua/gov/diia/search/ui/bullet_selection/SearchBulletVMTest.kt create mode 100644 search/src/test/java/ua/gov/diia/search/util/StubErrorHandlerOnFlow.kt create mode 100644 search/src/test/java/ua/gov/diia/search/util/TestDispatcherProvider.kt create mode 100644 search/src/test/java/ua/gov/diia/search/util/TestSearchableBullet.kt create mode 100644 search/src/test/java/ua/gov/diia/search/util/TestSearchableItem.kt create mode 100644 search/src/test/java/ua/gov/diia/search/util/TestUtil.kt create mode 100644 settings.gradle create mode 100644 splash/.gitignore create mode 100644 splash/README.md create mode 100644 splash/build.gradle create mode 100644 splash/consumer-rules.pro create mode 100644 splash/excludes.jacoco create mode 100644 splash/proguard-rules.pro create mode 100644 splash/src/androidTest/java/ua/gov/diia/splash/ExampleInstrumentedTest.kt create mode 100644 splash/src/main/AndroidManifest.xml create mode 100644 splash/src/main/java/ua/gov/diia/splash/helper/SplashHelper.kt create mode 100644 splash/src/main/java/ua/gov/diia/splash/model/SplashJob.kt create mode 100644 splash/src/main/java/ua/gov/diia/splash/ui/SplashF.kt create mode 100644 splash/src/main/java/ua/gov/diia/splash/ui/SplashFVM.kt create mode 100644 splash/src/main/java/ua/gov/diia/splash/ui/compose/SplashScreen.kt create mode 100644 splash/src/main/res/navigation/nav_splash.xml create mode 100644 splash/src/main/res/values/nav_ids.xml create mode 100644 splash/src/main/res/values/strings.xml create mode 100644 splash/src/test/java/ua/gov/diia/splash/SplashFVMTest.kt create mode 100644 splash/src/test/java/ua/gov/diia/splash/rules/MainDispatcherRule.kt create mode 100644 ui_base/.gitignore create mode 100644 ui_base/README.md create mode 100644 ui_base/build.gradle create mode 100644 ui_base/consumer-rules.pro create mode 100644 ui_base/excludes.jacoco create mode 100644 ui_base/proguard-rules.pro create mode 100644 ui_base/src/main/AndroidManifest.xml create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/LoadActionsAdapter.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/AccessibilityBindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/BindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/ButtonViewBindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/CardViewBindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/ImageViewBindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/TextViewBindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/ViewBindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/ViewGroupBindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/binding/ViewPager2BindingAdapters.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/common/PagingLoadStateAdapter.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/adapters/doc/DocsDecoration.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/CommonDiiaResourceIcon.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/DiiaResourceIcon.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/ExtensionCompose.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/ActionLinkAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnAlertAdditionalAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnIconCircledWhiteAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnNumAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnPlainAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnPlainIconAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnPrimaryAdditionalAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnPrimaryDefaultAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnPrimaryLargeAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/BtnStrokeDefaultAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/ButtonIconAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/ButtonIconCircledLargeAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/ButtonStrokeAdditionalButton.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/ButtonStrokeLargeAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/ButtonSystemAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/ButtonWhiteLargeAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/button/NumButtonAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/checkbox/CheckboxCircleAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/checkbox/CheckboxCircleGeneralAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/divider/DividerSlimAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/divider/DividerWithSpace.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/divider/GradientDividerAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/divider/TableDividerAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/BadgeCounterAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/DoubleIconAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/EllipseStepperAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/IconAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/IconAttentionEmojyAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/IconBackArrowAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/IconBiometricAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/IconEllipseMenuAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/IconNegativeFaceAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/IconRemoveNumAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/MrzScannerCvAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/icon/SmallIconAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/list/ActionItemAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/list/DownloadListItemAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/media/ArticlePicAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/pager/BaseViewPagerIndicator.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/pager/DocDotNavigationAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/pager/DotNavigationAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/radio/RadioBtnAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/radio/RadioBtnItem.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/space/SpacerAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/status/ChipStatusAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/text/LinkAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/text/SectionTitleAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/text/TickerAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/text/textwithparameter/TextParameter.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/text/textwithparameter/TextWithParametersAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/atom/text/textwithparameter/TextWithParametersConstants.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/BottomSheetDetailsScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/BottomSheetScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/ComposeConst.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/DataActionWrapper.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/FlowExtension.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/HomeScreenTab.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/ListExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/PublicServiceScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/ServiceScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/UIElementData.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/ViewExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/event/DocAction.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/event/UIAction.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/event/UIActionKeysCompose.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/navigation/NavigationPath.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/BodyRootContainer.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/BottomBarRootContainer.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/ComposeHomeTabRoot.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/ComposeRootScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/FullScreenGalleryScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/StackOrderScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/SystemDialogScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/TabBarRootContainer.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/TabBodyRootContainer.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/TemplateDialogScreen.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/ToolbarRootContainer.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/screen/TopBarRootContainer.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/state/UIState.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/utils/AutoSizeLimitedText.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/utils/FloatExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/utils/KeyboardExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/utils/image/BlurBitmap.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/utils/resource/ContextExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/utils/resource/UiIcon.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/infrastructure/utils/resource/UiText.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/FullScreeVideoMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/button/BtnIconRoundedMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/button/BtnToggleMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/button/SmallButtonPanelMlcData.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/AlertCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/BlackCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/CardFixedMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/CardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/GalleryImageMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/HalvedCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/IconCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/ImageCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/ProcessCardMoleculeDeprecated.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/ServiceCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/SmallNotificationMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/VerticalCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/WhiteCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/card/WhiteMenuCardMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/checkbox/CheckBoxItem.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/checkbox/CheckboxBorderedMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/checkbox/CheckboxBtnOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/checkbox/CheckboxRoundMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/checkbox/CheckboxRoundMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/checkbox/CheckboxSquareMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/checkbox/RadioBtnMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/checkbox/RoundChipMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/chip/MapChipMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/doc/DocCoverMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/doc/DocNumberCopyMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/doc/DocNumberCopyWhiteMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/doc/StackMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/header/NavigationPanelMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/header/SheetNavigationBarMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/header/TitleGroupMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/header/chiptabbar/ChipTabBarMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/header/chiptabbar/ChipTabMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/header/chiptabbar/ChipTabMoleculeV2.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/header/chiptabbar/ChipTabsOrg.kt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/DateInputMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/InputFormItem.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/InputGroupMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/InputNumberLargeMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/QuantityInputMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/SearchInputMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/SearchInputV2.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/SelectorOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/TextInputMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/TimeInputMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/tile/NumButtonTileMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/input/tile/NumButtonTileMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/ActionSheetMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/BtnIconPlainGroupMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/ListItemDragMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/ListItemMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/ListItemsMlcV1.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/checkbox/CheckboxMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/checkbox/CheckboxTitleAtom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/radio/RadioBtnGroupOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/radio/SingleChoiceMlcl.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/ContentGroupMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/ContentGroupMoleculeV2.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/contentgroup/AccordionListMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/contentgroup/AccordionMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/contentgroup/ContentGroupItem.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/contentgroup/ContentGroupItemV2.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/contentgroup/TableBlockMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/contentgroup/TableBlockOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/tableblock/DocTableItemHorizontalLongerMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/tableblock/DocTableItemHorizontalMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/tableblock/TableBlockItem.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/tableblock/TableHeadingMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/tableblock/TableItemHorizontalMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/tableblock/TableItemPrimaryMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/list/table/items/tableblock/TableItemVerticalMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/loading/FullScreenLoadingMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/loading/LinearLoadingMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/loading/TridentLoaderMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/media/ArticleVideoMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/message/AttentionMessageMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/message/DraggableMessageMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/message/EmptyStateErrorMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/message/MessageMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/message/PaymentStatusMessageMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/message/StatusMessageMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/message/StubMessageMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/progress/EllipseStepperMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/tab/TabItemMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/DetailsBlockMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/DetailsText.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/DetailsTextDescriptionMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/DetailsTextLabelMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/HeadingWithSubtitlesMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/HeadingWithSubtitlesWhiteMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/PaymentInfoOrgData.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/PlainDetailsBlockMolecule.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/SubtitleLabelMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/TextLabelContainerMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/TextLabelMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/text/TitleLabelMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/tile/ServiceCardTileOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/molecule/tile/SmallEmojiPanelMlc.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/FullScreenVideoOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/bottom/BottomGroupOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/bottom/BottomGroupOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/bottom/BtnIconRoundedGroupOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/bottom/TabBarOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/bottom/TabBarOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/carousel/ArticlePicCarouselOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/carousel/BaseSimpleCarouselInternal.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/carousel/HalvedCardCarouselOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/carousel/SmallNotificationCarouselOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/carousel/VerticalCardCarouselOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/carousel/ViewPagerIndicator.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/chip/MapChipTabsOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/AddDocOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/ContentTableOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/DocButtonHeadingOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/DocCodeOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/DocErrorOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/DocHeadingOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/DocOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/DocPhotoOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/TableBlockOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/TableBlockPlaneOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/TableBlockTwoColumnsOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/document/TableBlockTwoColumnsPlainOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/group/ToggleButtonGroup.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/header/MediaTitleOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/header/TopGroupOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/image/QRShareOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/input/QuestionFormsOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/input/QuestionFormsOrgLocal.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/input/RadioBtnAdditionalInputOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/ActionSheetOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/ActivityViewOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/CardListOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/CardsListOrgDeprecated.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/CheckboxRoundGroupAccordionOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/CheckboxRoundGroupOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/CheckboxRoundGroupOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/ChipsGridGroupAccordionOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/ContextMenuOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/DownloadListGroupOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/ItemListViewOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/ListItemBorderedGroupOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/ListItemDragOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/ListItemGroupOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/MessageListOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/MultipleChoiceGroupOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/PaginatedCardListOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/PlainListWithSearchOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/SingleChoiceWithAdditionalInputOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/SingleChoiceWithAltOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/SingleChoiceWithButtonOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/SingleChoiceWithSearchOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/list/pagination/SimplePaginationListOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/pager/BaseCarouselOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/pager/DocCardFlip.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/pager/DocCarouselOrg.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/pager/DocsCarouselItem.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/pager/FlipCard.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/pager/Flipper.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/table/ContentTableOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/organism/tile/NumButtonTileOrganism.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/border/DiiaRadialgradientBorder.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/border/GrayBorderSubatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/border/LinearGradientBorderSubatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/border/WhiteBoarderSubatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/icon/BadgeSubatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/icon/IconBase64Subatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/icon/IconWithBadge.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/icon/PhotoDocBase64Subatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/icon/PlusMinusClickableSubatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/icon/SignIconBase64Subatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/icon/UiIconWrapperSubatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/loader/Ellipse23Subatom.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/loader/LineLoaderSubatomic.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/loader/TridentLoaderAtm.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/loader/TridentLoaderBlock.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/loader/TridentLoaderWithNavigationBlock.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/loader/TridentLoaderWithUIBlocking.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/preview/PreviewBase64Icons.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/subatomic/ticker/NoInternetTicker.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/theme/Color.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/components/theme/Type.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/BaseBottomDialog.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/BaseSheetDialog.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/dialog/system/DiiaSystemDF.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/dialog/system/DiiaSystemDFVM.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/dynamicdialog/TemplateDialog.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/dynamicdialog/TemplateDialogConst.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/dynamicdialog/TemplateDialogVM.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/errordialog/DialogError.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/errordialog/ErrorDF.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/errordialog/ErrorDVM.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/errordialog/ErrorDialogConst.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/fragments/errordialog/RequestTryCountTracker.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/mappers/TextExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/models/homescreen/HomeMenuItemConstructor.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/navigation/BaseNavigation.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/util/Mappers.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/util/navigation/FragmentNavigationExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/util/navigation/NavigationBarExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/util/paging/OffsetPagingData.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/util/view/ViewExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/util/view/ViewGroupExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/util/view/ViewPagerExt.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/DiiaBulletsCV.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/DiiaMenuIconCV.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/DiiaProgressCV.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/NameModel.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/NameView.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/DiiaStatusLabel.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/MaskedEditText.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/card_item/DiiaCardInputField.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/card_item/DiiaCardNotifiableField.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/messages/DiiaAttentionMessage.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/messages/DiiaStatusMessage.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/progress/DiiaProgressButton.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/progress/DiiaProgressTextWithImage.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/progress/DiiaProgressTitled.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/common/progress/DiiaProgressWindow.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/pager/ScrollingPagerIndicator.java create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/pager/attachers/AbstractViewPagerAttacher.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/pager/attachers/PagerAttacher.kt create mode 100644 ui_base/src/main/java/ua/gov/diia/ui_base/views/pager/attachers/ViewPager2Attacher.kt create mode 100644 ui_base/src/main/res/anim/anim_fade_in.xml create mode 100644 ui_base/src/main/res/anim/anim_fade_out.xml create mode 100644 ui_base/src/main/res/anim/anim_progress.xml create mode 100644 ui_base/src/main/res/anim/anim_slide_down.xml create mode 100644 ui_base/src/main/res/anim/slide_in_down.xml create mode 100644 ui_base/src/main/res/anim/slide_in_left.xml create mode 100644 ui_base/src/main/res/anim/slide_in_right.xml create mode 100644 ui_base/src/main/res/anim/slide_in_up.xml create mode 100644 ui_base/src/main/res/anim/slide_out_down.xml create mode 100644 ui_base/src/main/res/anim/slide_out_left.xml create mode 100644 ui_base/src/main/res/anim/slide_out_right.xml create mode 100644 ui_base/src/main/res/anim/slide_out_up.xml create mode 100644 ui_base/src/main/res/drawable/bg_blue_yellow_gradient.xml create mode 100644 ui_base/src/main/res/drawable/bg_blue_yellow_gradient_with_bottom.xml create mode 100644 ui_base/src/main/res/drawable/diia_article_placeholder.xml create mode 100644 ui_base/src/main/res/drawable/diia_back_arrow.xml create mode 100644 ui_base/src/main/res/drawable/diia_cellular.xml create mode 100644 ui_base/src/main/res/drawable/diia_charging.xml create mode 100644 ui_base/src/main/res/drawable/diia_check.xml create mode 100644 ui_base/src/main/res/drawable/diia_circular_progress_vector.xml create mode 100644 ui_base/src/main/res/drawable/diia_close_rounded_icon.xml create mode 100644 ui_base/src/main/res/drawable/diia_close_rounded_plain.xml create mode 100644 ui_base/src/main/res/drawable/diia_ellipse_menu.xml create mode 100644 ui_base/src/main/res/drawable/diia_ellipse_menu_black.xml create mode 100644 ui_base/src/main/res/drawable/diia_generator.xml create mode 100644 ui_base/src/main/res/drawable/diia_heating.xml create mode 100644 ui_base/src/main/res/drawable/diia_ic_code_loader.xml create mode 100644 ui_base/src/main/res/drawable/diia_ic_doc_stack.xml create mode 100644 ui_base/src/main/res/drawable/diia_icon_calendar.xml create mode 100644 ui_base/src/main/res/drawable/diia_icon_clock.xml create mode 100644 ui_base/src/main/res/drawable/diia_icon_copy_to_clipboard.xml create mode 100644 ui_base/src/main/res/drawable/diia_icon_minus.xml create mode 100644 ui_base/src/main/res/drawable/diia_icon_nfc.xml create mode 100644 ui_base/src/main/res/drawable/diia_icon_plus.xml create mode 100644 ui_base/src/main/res/drawable/diia_internet.xml create mode 100644 ui_base/src/main/res/drawable/diia_search_icon.xml create mode 100644 ui_base/src/main/res/drawable/diia_smile_placeholder.xml create mode 100644 ui_base/src/main/res/drawable/diia_water.xml create mode 100644 ui_base/src/main/res/drawable/doc_background.xml create mode 100644 ui_base/src/main/res/drawable/double_icon.xml create mode 100644 ui_base/src/main/res/drawable/drag.xml create mode 100644 ui_base/src/main/res/drawable/ellipse_arrow_right.xml create mode 100644 ui_base/src/main/res/drawable/ellipse_check.xml create mode 100644 ui_base/src/main/res/drawable/ellipse_white_arrow_right.xml create mode 100644 ui_base/src/main/res/drawable/ic_action_sheet_messenger.xml create mode 100644 ui_base/src/main/res/drawable/ic_action_sheet_telegram.xml create mode 100644 ui_base/src/main/res/drawable/ic_action_sheet_viber.xml create mode 100644 ui_base/src/main/res/drawable/ic_add.xml create mode 100644 ui_base/src/main/res/drawable/ic_arrow_next.xml create mode 100644 ui_base/src/main/res/drawable/ic_arrow_right.xml create mode 100644 ui_base/src/main/res/drawable/ic_arrow_show_less.xml create mode 100644 ui_base/src/main/res/drawable/ic_arrow_show_more.xml create mode 100644 ui_base/src/main/res/drawable/ic_arrow_top.xml create mode 100644 ui_base/src/main/res/drawable/ic_bag.xml create mode 100644 ui_base/src/main/res/drawable/ic_biometric_auth.xml create mode 100644 ui_base/src/main/res/drawable/ic_btn_doc_scan_close.xml create mode 100644 ui_base/src/main/res/drawable/ic_button_remove.xml create mode 100644 ui_base/src/main/res/drawable/ic_chip_all.xml create mode 100644 ui_base/src/main/res/drawable/ic_chip_check.xml create mode 100644 ui_base/src/main/res/drawable/ic_chip_point.xml create mode 100644 ui_base/src/main/res/drawable/ic_chip_shelter.xml create mode 100644 ui_base/src/main/res/drawable/ic_close.xml create mode 100644 ui_base/src/main/res/drawable/ic_copy.xml create mode 100644 ui_base/src/main/res/drawable/ic_copy_settings.xml create mode 100644 ui_base/src/main/res/drawable/ic_copy_settings_white.xml create mode 100644 ui_base/src/main/res/drawable/ic_delete.xml create mode 100644 ui_base/src/main/res/drawable/ic_delivery.xml create mode 100644 ui_base/src/main/res/drawable/ic_device.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_bonds.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_cancel.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_cert.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_delete.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_drag.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_ean13_selected.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_ean13_unselected.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_edit_adress.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_faq.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_info.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_order_delivery.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_qr_selected.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_qr_unselected.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_refresh.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_reorder.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_share.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_translate_ua.xml create mode 100644 ui_base/src/main/res/drawable/ic_doc_verification.xml create mode 100644 ui_base/src/main/res/drawable/ic_docinfo.xml create mode 100644 ui_base/src/main/res/drawable/ic_document.xml create mode 100644 ui_base/src/main/res/drawable/ic_download.xml create mode 100644 ui_base/src/main/res/drawable/ic_download_disabled.xml create mode 100644 ui_base/src/main/res/drawable/ic_download_retry.xml create mode 100644 ui_base/src/main/res/drawable/ic_download_update.xml create mode 100644 ui_base/src/main/res/drawable/ic_ellipse_white.xml create mode 100644 ui_base/src/main/res/drawable/ic_faq.xml create mode 100644 ui_base/src/main/res/drawable/ic_faq_settings.xml create mode 100644 ui_base/src/main/res/drawable/ic_forward.xml create mode 100644 ui_base/src/main/res/drawable/ic_history.xml create mode 100644 ui_base/src/main/res/drawable/ic_home.xml create mode 100644 ui_base/src/main/res/drawable/ic_homedoc.xml create mode 100644 ui_base/src/main/res/drawable/ic_info_about.xml create mode 100644 ui_base/src/main/res/drawable/ic_input_clear.xml create mode 100644 ui_base/src/main/res/drawable/ic_key.xml create mode 100644 ui_base/src/main/res/drawable/ic_menu_history.xml create mode 100644 ui_base/src/main/res/drawable/ic_menu_notifications_action.xml create mode 100644 ui_base/src/main/res/drawable/ic_message.xml create mode 100644 ui_base/src/main/res/drawable/ic_my_location.xml create mode 100644 ui_base/src/main/res/drawable/ic_notifications_top_bar_right.xml create mode 100644 ui_base/src/main/res/drawable/ic_player_btn_atm_pause.xml create mode 100644 ui_base/src/main/res/drawable/ic_player_btn_atm_play.xml create mode 100644 ui_base/src/main/res/drawable/ic_player_btn_atm_retry.xml create mode 100644 ui_base/src/main/res/drawable/ic_player_pause.xml create mode 100644 ui_base/src/main/res/drawable/ic_player_play.xml create mode 100644 ui_base/src/main/res/drawable/ic_player_replay.xml create mode 100644 ui_base/src/main/res/drawable/ic_police.xml create mode 100644 ui_base/src/main/res/drawable/ic_ps_certificates.xml create mode 100644 ui_base/src/main/res/drawable/ic_ps_military_donation.xml create mode 100644 ui_base/src/main/res/drawable/ic_rating.xml create mode 100644 ui_base/src/main/res/drawable/ic_remove_num.xml create mode 100644 ui_base/src/main/res/drawable/ic_scan_doc.xml create mode 100644 ui_base/src/main/res/drawable/ic_search_black.xml create mode 100644 ui_base/src/main/res/drawable/ic_settings.xml create mode 100644 ui_base/src/main/res/drawable/ic_share.xml create mode 100644 ui_base/src/main/res/drawable/ic_stack_white.xml create mode 100644 ui_base/src/main/res/drawable/ic_star.xml create mode 100644 ui_base/src/main/res/drawable/ic_syringe.png create mode 100644 ui_base/src/main/res/drawable/ic_tab_documents_selected.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_documents_unselected.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_feed_selected.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_feed_unselected.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_menu_selected.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_menu_selected_badge.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_menu_unselected.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_menu_unselected_badge.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_services_selected.xml create mode 100644 ui_base/src/main/res/drawable/ic_tab_services_unselected.xml create mode 100644 ui_base/src/main/res/drawable/ic_target.xml create mode 100644 ui_base/src/main/res/drawable/ic_touch_id_large.xml create mode 100644 ui_base/src/main/res/drawable/ic_translate_eng.xml create mode 100644 ui_base/src/main/res/drawable/ic_trident.xml create mode 100644 ui_base/src/main/res/drawable/ic_wallet.xml create mode 100644 ui_base/src/main/res/drawable/invincibility_points.xml create mode 100644 ui_base/src/main/res/drawable/new_message.xml create mode 100644 ui_base/src/main/res/drawable/notification_new.xml create mode 100644 ui_base/src/main/res/drawable/pn.xml create mode 100644 ui_base/src/main/res/drawable/qr_scan.xml create mode 100644 ui_base/src/main/res/drawable/qr_scan_white.xml create mode 100644 ui_base/src/main/res/drawable/safety.xml create mode 100644 ui_base/src/main/res/drawable/safety_large.xml create mode 100644 ui_base/src/main/res/drawable/shelter.xml create mode 100644 ui_base/src/main/res/drawable/target.xml create mode 100644 ui_base/src/main/res/drawable/target_white.xml create mode 100644 ui_base/src/main/res/drawable/trident.xml create mode 100644 ui_base/src/main/res/drawable/trident_white.xml create mode 100644 ui_base/src/main/res/font/e_ukraine_head_regular.otf create mode 100644 ui_base/src/main/res/font/e_ukraine_medium.otf create mode 100644 ui_base/src/main/res/font/e_ukraine_regular.otf create mode 100644 ui_base/src/main/res/layout/item_load_action.xml create mode 100644 ui_base/src/main/res/layout/item_loading_footer.xml create mode 100644 ui_base/src/main/res/layout/item_view_name.xml create mode 100644 ui_base/src/main/res/layout/view_attantion_message.xml create mode 100644 ui_base/src/main/res/layout/view_diia_menu_icon.xml create mode 100644 ui_base/src/main/res/layout/view_diia_progress.xml create mode 100644 ui_base/src/main/res/layout/view_input_field.xml create mode 100644 ui_base/src/main/res/layout/view_label.xml create mode 100644 ui_base/src/main/res/layout/view_name.xml create mode 100644 ui_base/src/main/res/layout/view_progress_button.xml create mode 100644 ui_base/src/main/res/layout/view_progress_text_with_image.xml create mode 100644 ui_base/src/main/res/layout/view_progress_titled.xml create mode 100644 ui_base/src/main/res/layout/view_status_message.xml create mode 100644 ui_base/src/main/res/layout/widget_card_notifiable_field.xml create mode 100644 ui_base/src/main/res/layout/widget_progress_window.xml create mode 100644 ui_base/src/main/res/navigation/nav_error.xml create mode 100644 ui_base/src/main/res/navigation/nav_system_dialog.xml create mode 100644 ui_base/src/main/res/navigation/nav_template_dialog.xml create mode 100644 ui_base/src/main/res/raw/card_viewholder_dots_black.json create mode 100644 ui_base/src/main/res/raw/card_viewholder_dots_white.json create mode 100644 ui_base/src/main/res/raw/gradient_bg.json create mode 100644 ui_base/src/main/res/raw/loader.json create mode 100644 ui_base/src/main/res/raw/loader_white.json create mode 100644 ui_base/src/main/res/raw/splash_bg.json create mode 100644 ui_base/src/main/res/values-hdpi/dimen.xml create mode 100644 ui_base/src/main/res/values-xhdpi/dimen.xml create mode 100644 ui_base/src/main/res/values-xxhdpi/dimen.xml create mode 100644 ui_base/src/main/res/values-xxxhdpi/dimen.xml create mode 100644 ui_base/src/main/res/values/accessibility.xml create mode 100644 ui_base/src/main/res/values/attr.xml create mode 100644 ui_base/src/main/res/values/config.xml create mode 100644 ui_base/src/main/res/values/emoji.xml create mode 100644 ui_base/src/main/res/values/integer.xml create mode 100644 ui_base/src/main/res/values/nav_ids.xml create mode 100644 ui_base/src/main/res/values/strings.xml create mode 100644 ui_base/src/main/res/values/styles.xml create mode 100644 ui_base/src/test/java/ua/gov/diia/ui_base/LiveDataTestUtil.kt create mode 100644 ui_base/src/test/java/ua/gov/diia/ui_base/fragments/dynamicdialog/TemplateDialogVMTest.kt create mode 100644 ui_base/src/test/java/ua/gov/diia/ui_base/fragments/errordialog/ErrorDVMTest.kt create mode 100644 ui_base/src/test/java/ua/gov/diia/ui_base/fragments/errordialog/RequestTryCountTrackerTest.kt create mode 100644 ui_base/src/test/java/ua/gov/diia/ui_base/fragments/system/DiiaSystemDFVMTest.kt create mode 100644 ui_base/src/test/java/ua/gov/diia/ui_base/rules/MainDispatcherRule.kt create mode 100644 verification/.gitignore create mode 100644 verification/README.md create mode 100644 verification/build.gradle create mode 100644 verification/consumer-rules.pro create mode 100644 verification/excludes.jacoco create mode 100644 verification/proguard-rules.pro create mode 100644 verification/src/main/AndroidManifest.xml create mode 100644 verification/src/main/java/ua/gov/diia/verification/di/Annotations.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/di/VerificationModule.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/di/VerificationProviderType.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/model/ActivityViewActionButton.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/model/BaseVerificationData.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/model/VerificationFlowResult.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/model/VerificationMethodView.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/model/VerificationMethodsData.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/model/VerificationMethodsView.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/model/VerificationResult.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/model/VerificationUrl.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/network/ApiVerification.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/VerificationSchema.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/controller/VerificationControllerConst.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/controller/VerificationControllerF.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/controller/VerificationControllerOnFlowF.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/controller/VerificationControllerOnFlowVM.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/controller/VerificationControllerVM.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/method_selection/BindingAdapters.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/method_selection/VerificationMethodSelectionDF.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/method_selection/VerificationMethodSelectionVM.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/methods/VerificationMethod.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/methods/VerificationNavRequest.kt create mode 100644 verification/src/main/java/ua/gov/diia/verification/ui/methods/VerificationRequest.kt create mode 100644 verification/src/main/res/layout/dialog_verification_methods.xml create mode 100644 verification/src/main/res/layout/item_login_app.xml create mode 100644 verification/src/main/res/navigation/nav_verification.xml create mode 100644 verification/src/main/res/values/nav_ids.xml create mode 100644 verification/src/test/java/ua/gov/diia/verification/rules/MainDispatcherRule.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/ui/controller/TestVerificationControllerOnFlowVM.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/ui/controller/TestVerificationControllerVM.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/ui/controller/VerificationControllerOnFlowVMTest.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/ui/controller/VerificationControllerVMTest.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/ui/method_selection/VerificationMethodSelectionVMTest.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/util/StubErrorHandler.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/util/StubErrorHandlerOnFlow.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/util/StubVerificationMethod.kt create mode 100644 verification/src/test/java/ua/gov/diia/verification/util/TestUtil.kt create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/build.gradle create mode 100644 web/consumer-rules.pro create mode 100644 web/proguard-rules.pro create mode 100644 web/src/main/AndroidManifest.xml create mode 100644 web/src/main/java/ua/gov/diia/web/ui/WebF.kt create mode 100644 web/src/main/java/ua/gov/diia/web/util/extensions/fragment/FragmentNavigationExt.kt create mode 100644 web/src/main/res/layout/fragment_web.xml create mode 100644 web/src/main/res/navigation/nav_web.xml create mode 100644 web/src/main/res/values/nav_ids.xml create mode 100644 web/src/main/res/values/strings.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..651db22 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +*.iml +.gradle +local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +!Gemfile.lock +/build/ +/app/build +/app/build/ +/app/buildSrc/ +/app/keys/ +/.idea/ +/captures +*.externalNativeBuild +*.cxx +.externalNativeBuild +.cxx +app/stage/output.json +app/stage/app-stage.apk +~/tmp/full-r8-config.txt +/idnfc/build/ +app/stage/output-metadata.json +app/signing.properties +*signing.properties +*.jks +*.keystore +/app/huawei/ +/app/gplay/ +.ruby-version +!buildSrc +buildSrc/build/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6cc5215 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to the Diia project +We're pleased that you're interested in contributing to the Diia project. At the moment we're welcoming contributions in various forms and we want to make contributing as easy and transparent as possible. You're welcome to contribute in any of the following ways: + +- Reporting a bug +- Discussing the current state of the code +- Proposing new features or ideas + +In the future we'll be considering welcoming code contributions and expanding our contributor community. + +## Report using Issues + +We use GitHub issues to track public bugs. Report a bug, feature, idea or open a discussion point by [opening a new issue](../../issues/new); it's that easy! + +For bugs related to vulnerabilities or security concerns please feel free to contact us directly at [modt.opensource@thedigital.gov.ua](mailto:modt.opensource@thedigital.gov.ua). + +We'd also request that you detail bug reports with detail, background and sample code. Typically a great bug report includes: + +- A quick summary and/or background +- Steps to reproduce +- Be specific and provide sample code if you can. +- What you expected would happen +- What actually happens +- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work) + +For ideas, suggestions and discussion you're free to use any format that you find suitable. + +## Licensing +By contributing, you agree that your contributions will be licensed under the EUPL. + +You may obtain a copy of the License at [https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12). + +Questions regarding the Diia project, the License and any re-use should be directed to [modt.opensource@thedigital.gov.ua](mailto:modt.opensource@thedigital.gov.ua). \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c29ce2f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. \ No newline at end of file diff --git a/README.md b/README.md index 151e369..84dec6c 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ -# android-diia \ No newline at end of file + + +# Diia + + +This repository provides an overview over the flagship product [**Diia**](https://diia.gov.ua/) developed by the [**Ministry of Digital Transformation of Ukraine**](https://thedigital.gov.ua/). + +**Diia** is an app with access to citizen’s digital documents and government services. + +The application was created so that Ukrainians could interact with the state in a few clicks, without spending their time on queues and paperwork - **Diia** open source application will help countries, companies and communities build a foundation for long-term relationships. At the heart of these relations are openness, efficiency and humanity. + +We're pleased to share the **Diia** project with you. + +## Useful Links + +|Topic|Link|Description| +|--|--|--| +|Ministry of Digital Transformation of Ukraine|https://thedigital.gov.ua/|The Official homepage of the Ministry of Digital Transformation of Ukraine| +|Diia App|https://diia.gov.ua/|The Official website for the Diia application + + +## Getting Started + +## Build Process + +To build you are required to have the dependency [Android Studio](https://developer.android.com/studio) installed. You can then follow these instructions: + +1. Clone or download this repository +2. Open the project in Android Studio and run it from there or build an APK directly through Gradle: + ``` ./gradlew :opensource:assembleGplayDebug``` + *NOTE: Android SDK should be added to PATH environment variable for this to work.* + +Deploy to Device/Emulator: +```./gradlew :opensource:installGplayDebug``` +*NOTE: You can also replace the "Debug" with "Release" to get an optimized release binary.* + +Before building Huawei specific app generate and place agconnect-services.json file in opensource module. + +For build Huawei specific APK file use next command: +```./gradlew :opensource:assembleHuaweiDebug``` + +To deploy to device/emulator for Huawei use this: +```./gradlew :opensource:installHuaweiDebug``` + +## How to test + +To get mock user for testing please refer to the [TESTING.md](https://github.com/diia-open-source/diia-setup-howto/blob/main/TESTING.md) file for details. + +## How to contribute + +The Diia project welcomes contributions into this solution; please refer to the [CONTRIBUTING.md](./CONTRIBUTING.md) file for details + +## Licensing + +Copyright (C) Diia and all other contributors. + +Licensed under the **EUPL** (the "License"); you may not use this file except in compliance with the License. Re-use is permitted, although not encouraged, under the EUPL, with the exception of source files that contain a different license. + +You may obtain a copy of the License at [https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12](https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12). + +Questions regarding the Diia project, the License and any re-use should be directed to [modt.opensource@thedigital.gov.ua](mailto:modt.opensource@thedigital.gov.ua). diff --git a/address_search/.gitignore b/address_search/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/address_search/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/address_search/README.md b/address_search/README.md new file mode 100644 index 0000000..b342abc --- /dev/null +++ b/address_search/README.md @@ -0,0 +1,25 @@ +# Description + +This module is responsible address search screen and logic around it + +# How to install +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':address_search') +``` + +2. Module requires next modules to work +```groovy + implementation project(':core') + implementation project(':ui_base') + implementation project(':search') +``` +3. Add next nav graphs to main navigation graph to use compound address search +```xml + +``` + +4. Or extend following classes to make your custom address search screen implementation +`ua.gov.diia.address_search.ui.AddressSearchControllerF` +`ua.gov.diia.address_search.ui.AddressSearchControllerVM` \ No newline at end of file diff --git a/address_search/build.gradle b/address_search/build.gradle new file mode 100644 index 0000000..9527b68 --- /dev/null +++ b/address_search/build.gradle @@ -0,0 +1,141 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + id 'androidx.navigation.safeargs.kotlin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.address_search' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + // work-runtime-ktx 2.1.0 and above now requires Java 8 + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation project(':core') + implementation project(':ui_base') + implementation project(':search') + implementation deps.fragment_ktx + implementation deps.appcompat + implementation deps.constraint_layout + implementation deps.recyclerview + implementation deps.viewpager + implementation deps.cardview + implementation deps.lifecycle_livedata_ktx + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + kapt deps.hilt_compiler + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + + //lottie + implementation deps.lottie + + //Compose + implementation deps.activity_compose + + //test + + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.hamcrest_library + testImplementation deps.mockwebserver + testImplementation deps.json + testImplementation deps.turbine + testImplementation deps.mockk_android + testImplementation deps.mockk_agent + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/address_search/consumer-rules.pro b/address_search/consumer-rules.pro new file mode 100644 index 0000000..8a93c68 --- /dev/null +++ b/address_search/consumer-rules.pro @@ -0,0 +1,2 @@ +-keep public enum ua.gov.diia.address_search.models.AddressFieldInputType {*;} +-keep public class ua.gov.diia.address_search.models.AddressFieldResponse \ No newline at end of file diff --git a/address_search/excludes.jacoco b/address_search/excludes.jacoco new file mode 100644 index 0000000..3520c54 --- /dev/null +++ b/address_search/excludes.jacoco @@ -0,0 +1,2 @@ +ua/gov/diia/address_search/ui/**/*F.* +ua/gov/diia/address_search/**/*$*.* \ No newline at end of file diff --git a/address_search/proguard-rules.pro b/address_search/proguard-rules.pro new file mode 100644 index 0000000..be44d73 --- /dev/null +++ b/address_search/proguard-rules.pro @@ -0,0 +1,23 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile +-keep public enum ua.gov.diia.address_search.models.AddressFieldInputType {*;} +-keep public class ua.gov.diia.address_search.models.AddressFieldResponse \ No newline at end of file diff --git a/address_search/src/main/AndroidManifest.xml b/address_search/src/main/AndroidManifest.xml new file mode 100644 index 0000000..24cac77 --- /dev/null +++ b/address_search/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/di/AddressSearchApiModule.kt b/address_search/src/main/java/ua/gov/diia/address_search/di/AddressSearchApiModule.kt new file mode 100644 index 0000000..95036c9 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/di/AddressSearchApiModule.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.address_search.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import ua.gov.diia.address_search.network.ApiAddressSearch +import ua.gov.diia.core.di.data_source.http.AuthorizedClient + +@Module +@InstallIn(SingletonComponent::class) +object AddressSearchApiModule { + + @Provides + @AuthorizedClient + fun provideApiAddressSearch( + @AuthorizedClient retrofit: Retrofit + ): ApiAddressSearch = retrofit.create(ApiAddressSearch::class.java) +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressDefaultListItem.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressDefaultListItem.kt new file mode 100644 index 0000000..64fe864 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressDefaultListItem.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.address_search.models + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class AddressDefaultListItem( + @Json(name = "id") + val id: String, + @Json(name = "name") + val name: String, + @Json(name = "errorMessage") + val errorMessage: String? +) : Parcelable{ + fun toAddressItem() = AddressItem(id,name, errorMessage) +} diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldApproveRequest.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldApproveRequest.kt new file mode 100644 index 0000000..afebe6d --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldApproveRequest.kt @@ -0,0 +1,36 @@ +package ua.gov.diia.address_search.models + +import java.util.regex.Pattern + +data class AddressFieldApproveRequest( + val mandatory: Boolean, + val data: Any?, + val regex: String? +) { + + fun approved(): Boolean = when (data) { + is String -> { + val passedValidation = if (regex != null) { + approveValidation(data) + } else true + + if (mandatory) { + data.isNotBlank() && passedValidation + } else { + passedValidation + } + } + else -> if (mandatory) data != null else true + } + + private val validationPattern: Pattern? by lazy { + regex.let { + Pattern.compile( + it + ) + } + } + + private fun approveValidation(value: String?): Boolean = + validationPattern?.matcher(value ?: "")?.matches() ?: true +} diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldInputType.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldInputType.kt new file mode 100644 index 0000000..3cd72b0 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldInputType.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.address_search.models + +@Suppress("EnumEntryName") +enum class AddressFieldInputType { + list, singleCheck, textField +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldRequest.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldRequest.kt new file mode 100644 index 0000000..c3c24cd --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldRequest.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.address_search.models + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AddressFieldRequest( + @Json(name = "values") + val values: List? = null +) \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldRequestValue.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldRequestValue.kt new file mode 100644 index 0000000..91ee927 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldRequestValue.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.address_search.models + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AddressFieldRequestValue( + @Json(name = "id") + val id: String?, + @Json(name = "type") + val type: String?, + @Json(name = "value") + val value: String? +) \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldResponse.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldResponse.kt new file mode 100644 index 0000000..e23922f --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressFieldResponse.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.address_search.models + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@Parcelize +@JsonClass(generateAdapter = true) +data class AddressFieldResponse( + @Json(name = "title") + val title: String?, + @Json(name = "description") + val description: String?, + @Json(name = "parameters") + val parameters: List?, + @Json(name = "address") + val address: AddressIdentifier?, + @Json(name = "processCode") + val processCode: Int?, + @Json(name = "template") + val template: TemplateDialogModel? +) : Parcelable{ + + fun isEndForAddressSelection() : Boolean = address != null +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressIdentifier.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressIdentifier.kt new file mode 100644 index 0000000..de7c468 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressIdentifier.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.address_search.models + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class AddressIdentifier( + @Json(name = "fullName") + val fullName: String?, + @Json(name = "resourceId") + val resourceId: String? +): Parcelable \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressItem.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressItem.kt new file mode 100644 index 0000000..89a53b0 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressItem.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.address_search.models + + +import android.content.Context +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.search.models.SearchableBullet +import ua.gov.diia.search.models.SearchableItem + +@Parcelize +@JsonClass(generateAdapter = true) +data class AddressItem( + @Json(name = "id") + val id: String?, + @Json(name = "name") + val name: String?, + @Json(name = "errorMessage") + val errorMessage: String? +) : SearchableBullet, SearchableItem { + + override fun getDisplayName(context: Context): String = name ?: "Unknown" + + override fun getDisplayTitle(): String = name ?: "Unknown" + + override fun getQueryString(): String = name ?: "Unknown" +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressNationality.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressNationality.kt new file mode 100644 index 0000000..ac381bc --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressNationality.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.address_search.models + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class AddressNationality( + @Json(name = "nationalities") + val nationalities: List +) : Parcelable \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressParameter.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressParameter.kt new file mode 100644 index 0000000..5f9788f --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressParameter.kt @@ -0,0 +1,65 @@ +package ua.gov.diia.address_search.models + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class AddressParameter( + @Json(name = "type") + val type: String?, + @Json(name = "label") + val label: String?, + @Json(name = "hint") + val hint: String?, + @Json(name = "input") + val input: AddressFieldInputType?, + @Json(name = "mandatory") + val mandatory: Boolean?, + @Json(name = "validation") + val validation: AddressValidation?, + @Json(name = "source") + val source: AddressSource?, + @Json(name = "defaultListItem") + val defaultListItem: AddressDefaultListItem?, + @Json(name = "defaultText") + val defaultTextItem: String? +) : Parcelable { + + + fun getSearchType(): SearchType = when (input) { + AddressFieldInputType.singleCheck -> SearchType.BULLET + else -> SearchType.LIST + } + + fun getFieldMode() : AddressFieldMode = when(input){ + AddressFieldInputType.textField -> AddressFieldMode.EDITABLE + else -> AddressFieldMode.BUTTON + } + + fun isEditableMode() : Boolean = input == AddressFieldInputType.textField + + fun getItems(): Array = source?.items?.toTypedArray() ?: arrayOf() + + fun hasContent(): Boolean = source?.items?.size ?: 0 > 0 + + fun hasDefault(): Boolean = defaultListItem != null || defaultTextItem != null + + fun isFieldVisible() : Boolean = hasContent() || hasDefault() + + fun getDefaultAddress(): AddressItem? = + defaultListItem?.toAddressItem() + ?: if (defaultTextItem != null) { + AddressItem(id = null, name = defaultTextItem, errorMessage = null) + } else { + null + } + +} + +enum class AddressFieldMode{ + BUTTON, EDITABLE +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressSearchRequest.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressSearchRequest.kt new file mode 100644 index 0000000..0289add --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressSearchRequest.kt @@ -0,0 +1,29 @@ +package ua.gov.diia.address_search.models + +data class AddressSearchRequest( + val resultCode: String, + val searchType: SearchType, + val items: Array +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as AddressSearchRequest + + if (resultCode != other.resultCode) return false + if (searchType != other.searchType) return false + if (!items.contentEquals(other.items)) return false + + return true + } + + override fun hashCode(): Int { + var result = resultCode.hashCode() + result = 31 * result + searchType.hashCode() + result = 31 * result + items.contentHashCode() + return result + } + + +} diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressSource.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressSource.kt new file mode 100644 index 0000000..387b704 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressSource.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.address_search.models + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class AddressSource( + @Json(name = "items") + val items: List? +) : Parcelable \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/AddressValidation.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressValidation.kt new file mode 100644 index 0000000..da23675 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/AddressValidation.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.address_search.models + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class AddressValidation( + @Json(name = "errorMessage") + val errorMessage: String?, + @Json(name = "flags") + val flags: List?, + @Json(name = "regexp") + val regexp: String? +): Parcelable \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/NationalityItem.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/NationalityItem.kt new file mode 100644 index 0000000..b724a3d --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/NationalityItem.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.address_search.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.search.models.SearchableItem + +@Parcelize +@JsonClass(generateAdapter = true) +data class NationalityItem( + @Json(name = "code") + val code: String, + @Json(name = "name") + val name: String +) : SearchableItem { + override fun getDisplayTitle(): String = name ?: "Unknown" + + override fun getQueryString(): String = name ?: "Unknown" +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/models/SearchType.kt b/address_search/src/main/java/ua/gov/diia/address_search/models/SearchType.kt new file mode 100644 index 0000000..06b813b --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/models/SearchType.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.address_search.models + +enum class SearchType { + LIST, BULLET +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/network/ApiAddressSearch.kt b/address_search/src/main/java/ua/gov/diia/address_search/network/ApiAddressSearch.kt new file mode 100644 index 0000000..8fd7c42 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/network/ApiAddressSearch.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.address_search.network + +import retrofit2.http.* +import ua.gov.diia.address_search.models.AddressFieldRequest +import ua.gov.diia.address_search.models.AddressFieldResponse +import ua.gov.diia.address_search.models.AddressNationality +import ua.gov.diia.core.network.annotation.Analytics + +interface ApiAddressSearch { + + @Analytics("getAddressFieldContext") + @POST("api/v2/address/{publicService}/{addressType}") + suspend fun getFieldContext( + @Path("publicService") featureCode: String, + @Path("addressType") addressTemplateCode: String, + @Body request: AddressFieldRequest = AddressFieldRequest() + ) : AddressFieldResponse + + @Analytics("getNationalities") + @GET("api/v1/address/nationalities") + suspend fun getNationalities(): AddressNationality + +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressParameterMapper.kt b/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressParameterMapper.kt new file mode 100644 index 0000000..110fdba --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressParameterMapper.kt @@ -0,0 +1,71 @@ +package ua.gov.diia.address_search.ui + +import ua.gov.diia.address_search.models.AddressFieldApproveRequest +import ua.gov.diia.address_search.models.AddressFieldMode +import ua.gov.diia.address_search.models.AddressFieldRequestValue +import ua.gov.diia.address_search.models.AddressItem +import ua.gov.diia.address_search.models.AddressParameter +import javax.inject.Inject + +class AddressParameterMapper @Inject constructor() { + + fun toFieldApproveRequest(list: List): AddressFieldApproveRequest { + list.apply { + val params: AddressParameter? = find { value -> value is AddressParameter } + as? AddressParameter + + //Gets data as per the button mode requested from the server + //-If the field mode has been requested as BUTTON it means that user will select item + //from the list and we should get an AddressItem. + //-If the field mode has been requested as EDITABLE it means that user will input + //free String text through two-way data binding + val data = params?.getFieldMode()?.let { mode -> + when (mode) { + AddressFieldMode.BUTTON -> find { value -> value is AddressItem } + as? AddressItem + + AddressFieldMode.EDITABLE -> find { value -> value is String } + as? String + } + } + + val mandatory = params?.mandatory ?: false + + val validation = params?.validation?.regexp + + return AddressFieldApproveRequest(mandatory, data, validation) + } + } + + fun getViewMode(param: AddressParameter?): Int { + return param?.getFieldMode()?.getViewMode() ?: return 0 + } + + private fun AddressFieldMode.getViewMode() = when (this) { + AddressFieldMode.BUTTON -> 0 + AddressFieldMode.EDITABLE -> 1 + } + + + fun getEditableModeFieldRequest( + value: String?, + params: AddressParameter? + ): AddressFieldRequestValue? { + return if (params != null && value != null) { + if (params.isEditableMode()) { + AddressFieldRequestValue(id = null, params.type, value) + } else { + null + } + } else { + return null + } + } + + + fun approveFiledData(value: Any?): Boolean = when (value) { + is AddressFieldApproveRequest -> value.approved() + //approve for nulls (values which won't be selected withing flow) + else -> true + } +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchControllerF.kt b/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchControllerF.kt new file mode 100644 index 0000000..3106546 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchControllerF.kt @@ -0,0 +1,58 @@ +package ua.gov.diia.address_search.ui + +import android.os.Bundle +import android.view.View +import androidx.annotation.CallSuper +import androidx.fragment.app.Fragment +import ua.gov.diia.address_search.models.SearchType +import ua.gov.diia.address_search.models.AddressSearchRequest +import ua.gov.diia.search.models.SearchableBullet +import ua.gov.diia.search.models.SearchableItem +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResultOnce + +abstract class AddressSearchControllerF : Fragment() { + + abstract val viewModel: AddressSearchVM + + @CallSuper + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.navigateToAddressSelection.observeUiDataEvent(viewLifecycleOwner,this::navigateToSearch) + + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_REAL_ESTATE, viewModel::setSelectedRealEstate) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_PRECISION, viewModel::setSelectedPrecision) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_DESC, viewModel::setSelectedDescription) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_COUNTRY, viewModel::setSelectedCountry) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_REGION, viewModel::setSelectedRegion) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_DISTRICT, viewModel::setSelectedDistrict) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_CITY_TYPE, viewModel::setSelectedCityType) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_CITY, viewModel::setSelectedCity) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_POST_OFFICE, viewModel::setSelectedPostOffice) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_STREET_TYPE, viewModel::setSelectedStreetType) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_STREET, viewModel::setSelectedStreet) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_HOUSE, viewModel::setSelectedHouse) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_CORP, viewModel::setSelectedCorp) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_APARTMENT, viewModel::setSelectedApartment) + registerForNavigationResultOnce(CompoundAddressResultKey.RESULT_KEY_ZIP, viewModel::setSelectedZip) + } + + private fun navigateToSearch(request: AddressSearchRequest) { + @Suppress("UNCHECKED_CAST") + when (request.searchType) { + SearchType.LIST -> navigateToListSearch( + data = request.items as Array, + resultKey = request.resultCode + ) + SearchType.BULLET -> navigateToBulletSearch( + data = request.items as Array, + resultKey = request.resultCode + ) + } + } + + abstract fun navigateToListSearch(data: Array, resultKey: String) + + abstract fun navigateToBulletSearch(data: Array, resultKey: String) +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchFieldType.kt b/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchFieldType.kt new file mode 100644 index 0000000..d045978 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchFieldType.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.address_search.ui + +object AddressSearchFieldType { + + object FieldType{ + const val REAL_ESTATE = "realEstateType" + const val PRECISION = "precision" + const val DESCRIPTION = "description" + + const val COUNTRY = "country" + const val REGION = "region" + const val TEXT_REGION = "textRegion" + const val DISTRICT = "district" + const val TEXT_DISTRICT = "textDistrict" + const val CITY_TYPE = "cityType" + const val CITY = "city" + const val TEXT_CITY = "textCity" + const val POST_OFFICE = "postOffice" + const val TEXT_POST_OFFICE = "textPostOffice" + const val STREET_TYPE = "streetType" + const val STREET = "street" + const val HOUSE = "house" + const val CORPS = "corps" + const val APARTMENT = "apartment" + const val ZIP = "zip" + } +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchVM.kt b/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchVM.kt new file mode 100644 index 0000000..52b0ac4 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/ui/AddressSearchVM.kt @@ -0,0 +1,1210 @@ +package ua.gov.diia.address_search.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import ua.gov.diia.address_search.models.AddressFieldApproveRequest +import ua.gov.diia.address_search.models.AddressFieldRequest +import ua.gov.diia.address_search.models.AddressFieldRequestValue +import ua.gov.diia.address_search.models.AddressFieldResponse +import ua.gov.diia.address_search.models.AddressIdentifier +import ua.gov.diia.address_search.models.AddressItem +import ua.gov.diia.address_search.models.AddressParameter +import ua.gov.diia.address_search.models.AddressSearchRequest +import ua.gov.diia.address_search.network.ApiAddressSearch +import ua.gov.diia.core.util.CombinedLiveData +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import java.util.regex.Pattern + +open class AddressSearchVM( + private val apiAddressSearch: ApiAddressSearch, + private val addressParameterMapper: AddressParameterMapper, + private val errorHandling: WithErrorHandling, + private val retryLastAction: WithRetryLastAction, +) : ViewModel(), + WithErrorHandling by errorHandling, + WithRetryLastAction by retryLastAction { + + private var _lastAddressFieldRequest: AddressFieldRequest? = null + + private val _addressResult = MutableLiveData>() + val addressResult = _addressResult.asLiveData() + + private val _template = MutableLiveData>() + val template = _template.asLiveData() + + // ---------- Config -------------------- + + private var _featureCode: String? = null + private var _addressSchema: String? = null + + private val _navigateToAddressSelection = MutableLiveData>() + val navigateToAddressSelection = _navigateToAddressSelection + + /** + * To be able to start the address selection flow the initial args should be set + */ + protected fun setAddressSearchArs( + data: AddressFieldResponse, + code: String, + schema: String, + goneDescription: Boolean = false + ) { + _screenHeader.value = data.title + _addressDescription.value = data.description + _showAddressSearchTitle.value = + if (goneDescription) false else !data.description.isNullOrEmpty() + _featureCode = code + _addressSchema = schema + setFieldParams(data) + } + + //-------------- UI controllers ------------------------- + + private val _loadingFieldData = MutableLiveData() + val loadingFieldData = _loadingFieldData.asLiveData() + + private val _loadingResult = MutableLiveData() + val loadingResult = _loadingResult.asLiveData() + + private val _screenHeader = MutableLiveData() + val screenHeader = _screenHeader.asLiveData() + + private val _addressDescription = MutableLiveData() + val addressDescription = _addressDescription.asLiveData() + + private val _showAddressSearchTitle = MutableLiveData() + val showFlowTitle = _showAddressSearchTitle.asLiveData() + + //------------ Description type ------------------- + + private val _descriptionFieldParams = MutableLiveData() + val descriptionFieldParams = _descriptionFieldParams.asLiveData() + + val showDescriptionField: LiveData = _descriptionFieldParams.map { params -> + params != null + } + + val descriptionFieldMode: LiveData = _descriptionFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedDescription = MutableLiveData() + val selectedDescription = _selectedDescription.asLiveData() + + val descriptionInput = MutableLiveData() + + fun selectDescription() { + _descriptionFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_DESC, params) + } + } + + fun setSelectedDescription(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _descriptionFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { + _selectedDescription.value = item + } + ) + } + + //------------ Real estate type ------------------- + + private val _realEstateTypeFieldParams = MutableLiveData() + val realEstateTypeFieldParams = _realEstateTypeFieldParams.asLiveData() + + val showRealEstateField: LiveData = _realEstateTypeFieldParams.map { params -> + params != null + } + + val realEstateFieldMode: LiveData = _realEstateTypeFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedRealEstate = MutableLiveData() + val selectedRealEstate = _selectedRealEstate.asLiveData() + + val realEstateInput = MutableLiveData() + + fun selectRealEstate() { + _realEstateTypeFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_REAL_ESTATE, params) + } + } + + fun setSelectedRealEstate(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _realEstateTypeFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpPrecisionGroup, + nextFieldLoaded = { _selectedRealEstate.value = item } + ) + } + + //------------ Precision type ------------------- + + private val _precisionTypeFieldParams = MutableLiveData() + val precisionTypeFieldParams = _precisionTypeFieldParams.asLiveData() + + val showPrecisionField: LiveData = _precisionTypeFieldParams.map { params -> + params != null + } + + val precisionFieldMode: LiveData = _precisionTypeFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedPrecision = MutableLiveData() + val selectedPrecision = _selectedPrecision.asLiveData() + + val precisionInput = MutableLiveData() + + fun selectPrecision() { + _precisionTypeFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_PRECISION, params) + } + } + + fun setSelectedPrecision(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _precisionTypeFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpCountryGroup, + nextFieldLoaded = { _selectedPrecision.value = item } + ) + } + + //------------ Country ------------------- + + private val _countryFieldParams = MutableLiveData() + val countryFieldParams = _countryFieldParams.asLiveData() + + val showCountryField: LiveData = _countryFieldParams.map { params -> + params != null + } + + val countryFieldMode: LiveData = _countryFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedCountry = MutableLiveData() + val selectedCountry = _selectedCountry.asLiveData() + + val countryInput = MutableLiveData() + + fun selectCountry() { + _countryFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_COUNTRY, params) + } + } + + fun setSelectedCountry(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _countryFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpCountryGroup, + nextFieldLoaded = { _selectedCountry.value = item } + ) + } + + //------------ Region --------------------- + + private val _regionFieldParams = MutableLiveData() + val regionFieldParams = _regionFieldParams.asLiveData() + + val showRegionsField: LiveData = _regionFieldParams.map { params -> + params != null + } + + val regionFieldMode: LiveData = _regionFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedRegion = MutableLiveData() + val selectedRegion = _selectedRegion.asLiveData() + + val showRegionFieldError: LiveData = selectedRegion.map { region -> + region?.errorMessage != null + } + + val regionInput = MutableLiveData() + + fun selectRegion() { + _regionFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_REGION, params) + } + } + + fun setSelectedRegion(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _regionFieldParams.value?.type, + value = item.name + ) + if (item.errorMessage != null) { + _selectedRegion.value = item + cleanUpRegionGroup() + } else { + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpRegionGroup, + nextFieldLoaded = { _selectedRegion.value = item } + ) + } + } + + //------------ District ------------------- + + private val _districtFieldParams = MutableLiveData() + val districtFieldParams = _districtFieldParams.asLiveData() + + val showDistrictField: LiveData = _districtFieldParams.map { params -> + params != null + } + + val districtFieldMode: LiveData = _districtFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedDistrict = MutableLiveData() + val selectedDistrict = _selectedDistrict.asLiveData() + + val districtInput = MutableLiveData() + + fun selectDistrict() { + _districtFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_DISTRICT, params) + } + } + + fun setSelectedDistrict(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _districtFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpDistrictGroup, + nextFieldLoaded = { _selectedDistrict.value = item } + ) + } + + //------------ City type ------------------- + + private val _cityTypeFieldParams = MutableLiveData() + val cityTypeFieldParams = _cityTypeFieldParams.asLiveData() + + val showCityTypeField: LiveData = _cityTypeFieldParams.map { params -> + params != null + } + + val cityTypeFieldMode: LiveData = _cityTypeFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedCityType = MutableLiveData() + val selectedCityType = _selectedCityType.asLiveData() + + val cityTypeInput = MutableLiveData() + + fun selectCityType() { + _cityTypeFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_CITY_TYPE, params) + } + } + + fun setSelectedCityType(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _cityTypeFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpCityTypeGroup, + nextFieldLoaded = { _selectedCityType.value = item } + ) + } + + //------------ City ------------------- + + private val _cityFieldParams = MutableLiveData() + val cityFieldParams = _cityFieldParams.asLiveData() + + val showCityField: LiveData = _cityFieldParams.map { params -> + params != null + } + + val cityFieldMode: LiveData = _cityFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedCity = MutableLiveData() + val selectedCity = _selectedCity.asLiveData() + + val cityInput = MutableLiveData() + + fun selectCity() { + _cityFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_CITY, params) + } + } + + fun setSelectedCity(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _cityFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpCityGroup, + nextFieldLoaded = { _selectedCity.value = item } + ) + } + + //------------ PostOffice ------------------- + + private val _postOfficeFieldParams = MutableLiveData() + val postOfficeFieldParams = _postOfficeFieldParams.asLiveData() + + val showPostOfficeField: LiveData = _postOfficeFieldParams.map { params -> + params != null + } + + val postOfficeFieldMode: LiveData = _postOfficeFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedPostOffice = MutableLiveData() + val selectedPostOffice = _selectedPostOffice.asLiveData() + + val postOfficeInput = MutableLiveData() + + fun selectPostOffice() { + _postOfficeFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_POST_OFFICE, params) + } + } + + fun setSelectedPostOffice(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _postOfficeFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedPostOffice.value = item } + ) + } + + //------------ Street type ------------------- + + private val _streetTypeFieldParams = MutableLiveData() + val streetTypeFieldParams = _streetTypeFieldParams.asLiveData() + + val showStreetTypeField: LiveData = _streetTypeFieldParams.map { params -> + params != null + } + + val streetTypeFieldMode: LiveData = _streetTypeFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedStreetType = MutableLiveData() + val selectedStreetType = _selectedStreetType.asLiveData() + + val streetTypeInput = MutableLiveData() + + fun selectStreetType() { + _streetTypeFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_STREET_TYPE, params) + } + } + + fun setSelectedStreetType(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _streetTypeFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpStreetTypeGroup, + nextFieldLoaded = { _selectedStreetType.value = item } + ) + } + + //------------ Street ------------------- + + private val _streetFieldParams = MutableLiveData() + val streetFieldParams = _streetFieldParams.asLiveData() + + val showStreetField: LiveData = _streetFieldParams.map { params -> + params != null + } + + val streetFieldMode: LiveData = _streetFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedStreet = MutableLiveData() + val selectedStreet = _selectedStreet.asLiveData() + + val streetInput = MutableLiveData() + + fun selectStreet() { + _streetFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_STREET, params) + } + } + + fun setSelectedStreet(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _streetFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestData = request, + cleanUpEvent = this::cleanUpStreetGroup, + nextFieldLoaded = { _selectedStreet.value = item } + ) + } + + //------------ House ------------------- + + private val _houseFieldParams = MutableLiveData() + val houseFieldParams = _houseFieldParams.asLiveData() + + val showHouseField: LiveData = _houseFieldParams.map { params -> + params != null + } + + val houseFieldMode: LiveData = _houseFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedHouse = MutableLiveData() + val selectedHouse = _selectedHouse.asLiveData() + + val house = MutableLiveData() + + private val houseValidationRegex = MutableLiveData() + + private val houseValidationPattern: Pattern by lazy { + houseValidationRegex.value.let { + Pattern.compile( + it ?: "" + ) + } + } + + private fun approveHouseField(value: String): Boolean = + houseValidationPattern.matcher(value).matches() + + val showHouseFieldError: LiveData = house.map { house -> + if (house != null) { + !approveHouseField(house) + } else { + false + } + } + + fun selectHouse() { + _houseFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_HOUSE, params) + } + } + + fun setSelectedHouse(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _houseFieldParams.value?.type, + value = null + ) + + requestNextField( + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedHouse.value = item } + ) + } + + //------------ Apartment ------------------- + + private val _apartmentFieldParams = MutableLiveData() + val apartmentFieldParams = _apartmentFieldParams.asLiveData() + + val showApartmentField: LiveData = _apartmentFieldParams.map { params -> + params != null + } + + val apartmentFieldMode: LiveData = _apartmentFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedApartment = MutableLiveData() + val selectedApartment = _selectedApartment.asLiveData() + + val apartment = MutableLiveData() + + private val apartmentValidationRegex = MutableLiveData() + + private val apartmentValidationPattern: Pattern? by lazy { + apartmentValidationRegex.value.let { + Pattern.compile( + it + ) + } + } + + + private fun approveApartmentField(value: String): Boolean = + apartmentValidationPattern?.matcher(value)?.matches() ?: true + + val showApartmentFieldError: LiveData = apartment.map { apartment -> + if (apartment != null) { + !approveApartmentField(apartment) + } else { + false + } + } + + fun selectApartment() { + _apartmentFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_APARTMENT, params) + } + } + + fun setSelectedApartment(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _apartmentFieldParams.value?.type, + value = null + ) + + requestNextField( + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedApartment.value = item } + ) + } + + //------------ Corps ------------------- + + private val _corpsFieldParams = MutableLiveData() + val corpsFieldParams = _corpsFieldParams.asLiveData() + + val showCorpsField: LiveData = _corpsFieldParams.map { params -> + params != null + } + + val corpFieldMode: LiveData = _corpsFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedCorp = MutableLiveData() + val selectedCorp = _selectedCorp.asLiveData() + + val corps = MutableLiveData() + + private val corpsValidationRegex = MutableLiveData() + + private val corpsValidationPattern: Pattern? by lazy { + corpsValidationRegex.value.let { + Pattern.compile( + it + ) + } + } + + private fun approveCorpsField(value: String): Boolean = + corpsValidationPattern?.matcher(value)?.matches() ?: true + + val showCorpsFieldError: LiveData = corps.map { corps -> + if (corps != null) { + !approveCorpsField(corps) + } else { + false + } + } + + fun selectCorp() { + _corpsFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_CORP, params) + } + } + + fun setSelectedCorp(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _corpsFieldParams.value?.type, + value = null + ) + + requestNextField( + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedCorp.value = item } + ) + } + + //------------ Zip ------------------- + + private val _zipFieldParams = MutableLiveData() + val zipFieldParams = _zipFieldParams.asLiveData() + + val showZipField: LiveData = _zipFieldParams.map { params -> + params != null + } + + val zipFieldMode: LiveData = _corpsFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedZip = MutableLiveData() + val selectedZip = _selectedZip.asLiveData() + + val zip = MutableLiveData() + + private val zipValidationRegex = MutableLiveData() + + private val zipValidationPattern: Pattern? by lazy { + zipValidationRegex.value.let { + Pattern.compile( + it + ) + } + } + + private fun approveZipField(value: String): Boolean = + zipValidationPattern?.matcher(value)?.matches() ?: true + + val showZipFieldError: LiveData = zip.map { zip -> + if (zip != null) { + !approveZipField(zip) + } else { + false + } + } + + fun selectZip() { + _zipFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_ZIP, params) + } + } + + fun setSelectedZip(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _zipFieldParams.value?.type, + value = null + ) + + requestNextField( + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedZip.value = item } + ) + } + + //----------- Field params control -------- + + private fun setFieldParams(data: AddressFieldResponse) { + //If this is the end of selection we'll get address identifier object and + //we should setup it to notify VM that this this the end of the selection process + if (data.isEndForAddressSelection()) { + isEndForAddressSelection = data.isEndForAddressSelection() + _addressIdentifier = data.address + return + } + + data.parameters?.forEach { param -> + //setup corresponding field with the parameters and default values + getFieldSetupAction(param.type).invoke(param, param.getDefaultAddress()) + } + } + + private fun getFieldSetupAction(type: String?): (param: AddressParameter, default: AddressItem?) -> Unit = + when (type) { + AddressSearchFieldType.FieldType.REAL_ESTATE -> { p, d -> + _realEstateTypeFieldParams.value = p + d?.let(_selectedRealEstate::setValue) + } + + AddressSearchFieldType.FieldType.PRECISION -> { p, d -> + _precisionTypeFieldParams.value = p + d?.let(_selectedPrecision::setValue) + } + + AddressSearchFieldType.FieldType.DESCRIPTION -> { p, d -> + _descriptionFieldParams.value = p + d?.let(_selectedDescription::setValue) + } + + AddressSearchFieldType.FieldType.COUNTRY -> { p, d -> + _countryFieldParams.value = p + d?.let(_selectedCountry::setValue) + } + + AddressSearchFieldType.FieldType.REGION -> { p, d -> + _regionFieldParams.value = p + d?.let(_selectedRegion::setValue) + } + + AddressSearchFieldType.FieldType.DISTRICT -> { p, d -> + _districtFieldParams.value = p + d?.let(_selectedDistrict::setValue) + } + + AddressSearchFieldType.FieldType.CITY_TYPE -> { p, d -> + _cityTypeFieldParams.value = p + d?.let(_selectedCityType::setValue) + } + + AddressSearchFieldType.FieldType.CITY -> { p, d -> + _cityFieldParams.value = p + d?.let(_selectedCity::setValue) + } + + AddressSearchFieldType.FieldType.POST_OFFICE -> { p, d -> + _postOfficeFieldParams.value = p + d?.let(_selectedPostOffice::setValue) + } + + AddressSearchFieldType.FieldType.STREET_TYPE -> { p, d -> + _streetTypeFieldParams.value = p + d?.let(_selectedStreetType::setValue) + } + + AddressSearchFieldType.FieldType.STREET -> { p, d -> + _streetFieldParams.value = p + d?.let(_selectedStreet::setValue) + } + + AddressSearchFieldType.FieldType.HOUSE -> { p, d -> + _houseFieldParams.value = p + houseValidationRegex.value = p.validation?.regexp + d?.name?.let(house::setValue) + } + + AddressSearchFieldType.FieldType.APARTMENT -> { p, d -> + _apartmentFieldParams.value = p + apartmentValidationRegex.value = p.validation?.regexp + d?.name?.let(apartment::setValue) + } + + AddressSearchFieldType.FieldType.CORPS -> { p, d -> + _corpsFieldParams.value = p + corpsValidationRegex.value = p.validation?.regexp + d?.name?.let(corps::setValue) + } + + AddressSearchFieldType.FieldType.ZIP -> { p, d -> + _zipFieldParams.value = p + zipValidationRegex.value = p.validation?.regexp + d?.name?.let(zip::setValue) + } + + else -> { _, _ -> } + } + + private fun cleanUpPrecisionGroup() { + _precisionTypeFieldParams.value = null + _selectedPrecision.value = null + precisionInput.value = null + cleanUpCountryGroup() + } + + fun cleanUpCountryGroup() { + _regionFieldParams.value = null + _selectedRegion.value = null + regionInput.value = null + cleanUpRegionGroup() + } + + private fun cleanUpRegionGroup() { + _districtFieldParams.value = null + _selectedDistrict.value = null + districtInput.value = null + cleanUpDistrictGroup() + } + + private fun cleanUpDistrictGroup() { + _cityTypeFieldParams.value = null + _selectedCityType.value = null + cityTypeInput.value = null + cleanUpCityTypeGroup() + } + + private fun cleanUpCityTypeGroup() { + _cityFieldParams.value = null + _selectedCity.value = null + cityInput.value = null + cleanUpCityGroup() + } + + private fun cleanUpCityGroup() { + _streetTypeFieldParams.value = null + _selectedStreetType.value = null + streetTypeInput.value = null + cleanUpStreetTypeGroup() + } + + private fun cleanUpPostOfficeGroup() { + _postOfficeFieldParams.value = null + _selectedPostOffice.value = null + postOfficeInput.value = null + cleanUpStreetGroup() + } + + private fun cleanUpStreetTypeGroup() { + _streetFieldParams.value = null + _selectedStreet.value = null + streetInput.value = null + cleanUpPostOfficeGroup() + } + + private fun cleanUpStreetGroup() { + _houseFieldParams.value = null + house.value = null + + _apartmentFieldParams.value = null + apartment.value = null + + _corpsFieldParams.value = null + corps.value = null + + _zipFieldParams.value = null + zip.value = null + } + + //----------- Action button control ------ + + private val _realEstateApproveData: LiveData = + CombinedLiveData( + _realEstateTypeFieldParams, _selectedRealEstate, realEstateInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _precisionApproveData: LiveData = + CombinedLiveData( + _precisionTypeFieldParams, _selectedPrecision, precisionInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _descriptionApproveData: LiveData = + CombinedLiveData( + _descriptionFieldParams, _selectedDescription, descriptionInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _countryApproveData: LiveData = + CombinedLiveData( + _countryFieldParams, _selectedCountry, countryInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _regionApproveData: LiveData = + CombinedLiveData( + _regionFieldParams, _selectedRegion, regionInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _districtApproveData: LiveData = + CombinedLiveData( + _districtFieldParams, _selectedDistrict, districtInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _cityTypeApproveData: LiveData = + CombinedLiveData( + _cityTypeFieldParams, _selectedCityType, cityTypeInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _cityApproveData: LiveData = CombinedLiveData( + _cityFieldParams, _selectedCity, cityInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _postOfficeApproveData: LiveData = CombinedLiveData( + _postOfficeFieldParams, _selectedPostOffice, postOfficeInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _streetTypeApproveData: LiveData = + CombinedLiveData( + _streetTypeFieldParams, _selectedStreetType, streetTypeInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _streetApproveData: LiveData = + CombinedLiveData( + _streetFieldParams, _selectedStreet, streetInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _houseApproveData: LiveData = CombinedLiveData( + _houseFieldParams, _selectedHouse, house + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _apartmentApproveData: LiveData = + CombinedLiveData( + _apartmentFieldParams, _selectedApartment, apartment + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _corpApproveData: LiveData = CombinedLiveData( + _corpsFieldParams, _selectedCorp, corps + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _zipApproveData: LiveData = CombinedLiveData( + _zipFieldParams, _selectedZip, zip + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _resultsSourceApproved: LiveData = CombinedLiveData( + _realEstateApproveData, + _precisionApproveData, + _descriptionApproveData, + _countryApproveData, + _regionApproveData, + _districtApproveData, + _cityTypeApproveData, + _cityApproveData, + _postOfficeApproveData, + _streetTypeApproveData, + _streetApproveData, + _houseApproveData, + _apartmentApproveData, + _corpApproveData, + _zipApproveData + ) { values -> + values.all { value -> addressParameterMapper.approveFiledData(value) } + } + + //Enable button when all mandatory fields was field by the user. + //NOTE!! if some editable fields present we should check it in [setAddressSelectionResult] + //and send it to the back-end to get [addressIdentifier] to complete the flow + private val approvedField: LiveData = _resultsSourceApproved.map { isFieldApproved -> + isFieldApproved + } + + val enableActionButton: LiveData = + CombinedLiveData(_loadingFieldData, approvedField) { values -> + values.first() == false && values.last() == true + } + + private var _addressIdentifier: AddressIdentifier? = null + private var isEndForAddressSelection: Boolean = false + + //----------- Common logic ----------------- + + private fun startSelectionProcess( + resultKey: String, + fieldParams: AddressParameter + ) { + val request = AddressSearchRequest( + resultCode = resultKey, + searchType = fieldParams.getSearchType(), + items = fieldParams.getItems() + ) + + _navigateToAddressSelection.value = UiDataEvent(request) + } + + private fun requestNextField( + requestData: AddressFieldRequestValue, + cleanUpEvent: () -> Unit, + nextFieldLoaded: () -> Unit + ) { + val code = _featureCode ?: return + val schema = _addressSchema ?: return + executeAction(progressIndicator = _loadingFieldData) { + val request = AddressFieldRequest(listOf(requestData)) + val result = apiAddressSearch.getFieldContext(code, schema, request) + result.template?.let { + _template.value = UiDataEvent(it) + } + + //clean up dependent fields before sent a new one + cleanUpEvent.invoke() + //sets field params for the corresponding fields + setFieldParams(result) + //after data has been loaded and set into appropriate field notifies caller + //to set his selection + nextFieldLoaded.invoke() + } + } + + //----------- Request result data ------- + + /** + * This method should be called from the controller to request + * the address selection result data. + */ + fun requestSelectionResult() { + val addressIdentifier = _addressIdentifier + val request = AddressFieldRequest(getRequestList()) + if (addressIdentifier != null && _lastAddressFieldRequest == request) { + //If the [AddressIdentifier] is present and fields does not changed -> it means that all mandatory fields data was + //collected and sent to the server + _addressResult.postValue(UiDataEvent(addressIdentifier)) + } else { + //If [AddressIdentifier] is absent or fields has changed -> we need to collect data from editable fields and send it + //to the backend to get the [AddressIdentifier] to complete flow. + val code = _featureCode ?: return + val schema = _addressSchema ?: return + executeAction(progressIndicator = _loadingResult) { + val result = apiAddressSearch.getFieldContext(code, schema, request) + //return result form the address selection if the [AddressIdentifier] isn't a null + result.address?.also { aI -> + _addressIdentifier = aI + _lastAddressFieldRequest = request + _addressResult.postValue(UiDataEvent(aI)) + } + } + } + } + + fun setAddressSelectionResult() { + val addressIdentifier = _addressIdentifier + if (addressIdentifier != null && isEndForAddressSelection) { + _addressResult.postValue(UiDataEvent(addressIdentifier)) + } else { + val code = _featureCode ?: return + val schema = _addressSchema ?: return + executeAction(progressIndicator = _loadingResult) { + val request = AddressFieldRequest(getRequestList()) + val result = apiAddressSearch.getFieldContext(code, schema, request) + result.address?.let { aI -> + _addressIdentifier = aI + _addressResult.postValue(UiDataEvent(aI)) + } + } + } + } + + private fun getRequestList(): List { + val realEstate = addressParameterMapper.getEditableModeFieldRequest( + value = realEstateInput.value, + params = _realEstateTypeFieldParams.value + ) + + val precision = addressParameterMapper.getEditableModeFieldRequest( + value = precisionInput.value, + params = _precisionTypeFieldParams.value + ) + + val description = addressParameterMapper.getEditableModeFieldRequest( + value = descriptionInput.value, + params = _descriptionFieldParams.value + ) + + val country = addressParameterMapper.getEditableModeFieldRequest( + value = countryInput.value, + params = _countryFieldParams.value + ) + + val region = addressParameterMapper.getEditableModeFieldRequest( + value = regionInput.value, + params = _regionFieldParams.value + ) + + val district = addressParameterMapper.getEditableModeFieldRequest( + value = districtInput.value, + params = _districtFieldParams.value + ) + + val cityType = addressParameterMapper.getEditableModeFieldRequest( + value = cityTypeInput.value, + params = _cityTypeFieldParams.value + ) + + val city = addressParameterMapper.getEditableModeFieldRequest( + value = cityInput.value, + params = _cityFieldParams.value + ) + + val postOffice = addressParameterMapper.getEditableModeFieldRequest( + value = postOfficeInput.value, + params = _postOfficeFieldParams.value + ) + + val streetType = addressParameterMapper.getEditableModeFieldRequest( + value = streetTypeInput.value, + params = _streetTypeFieldParams.value + ) + + val street = addressParameterMapper.getEditableModeFieldRequest( + value = streetInput.value, + params = _streetFieldParams.value + ) + + val house = addressParameterMapper.getEditableModeFieldRequest( + value = house.value, + params = _houseFieldParams.value + ) + + val apartment = addressParameterMapper.getEditableModeFieldRequest( + value = apartment.value, + params = _apartmentFieldParams.value + ) + + val corp = addressParameterMapper.getEditableModeFieldRequest( + value = corps.value, + params = _corpsFieldParams.value + ) + + val zip = addressParameterMapper.getEditableModeFieldRequest( + value = zip.value, + params = _zipFieldParams.value + ) + + return listOfNotNull( + realEstate, + precision, + description, + country, + region, + district, + cityType, + city, + postOffice, + streetType, + street, + house, + apartment, + corp, + zip + ) + } + +} diff --git a/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressResultKey.kt b/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressResultKey.kt new file mode 100644 index 0000000..753c02d --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressResultKey.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.address_search.ui + +object CompoundAddressResultKey { + const val RESULT_KEY_REAL_ESTATE = "selected_real_estate" + const val RESULT_KEY_PRECISION = "selected_precision" + const val RESULT_KEY_DESC = "selected_description" + + const val RESULT_KEY_COUNTRY = "selected_country" + const val RESULT_KEY_REGION = "selected_region" + const val RESULT_KEY_DISTRICT = "selected_district" + const val RESULT_KEY_CITY_TYPE = "selected_city_type" + const val RESULT_KEY_CITY = "selected_city" + const val RESULT_KEY_POST_OFFICE = "postOffice" + const val RESULT_KEY_STREET_TYPE = "selected_street_type" + const val RESULT_KEY_STREET = "selected_street" + const val RESULT_KEY_HOUSE = "selected_house" + const val RESULT_KEY_CORP = "selected_corp" + const val RESULT_KEY_APARTMENT = "selected_apartment" + const val RESULT_KEY_ZIP = "selected_zip" +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressSearchF.kt b/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressSearchF.kt new file mode 100644 index 0000000..168ffe6 --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressSearchF.kt @@ -0,0 +1,167 @@ +package ua.gov.diia.address_search.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.address_search.databinding.FragmentCompoundAddressSearchBinding +import ua.gov.diia.address_search.models.SearchType +import ua.gov.diia.address_search.models.AddressIdentifier +import ua.gov.diia.address_search.models.AddressItem +import ua.gov.diia.address_search.models.AddressSearchRequest +import ua.gov.diia.search.models.SearchableBullet +import ua.gov.diia.search.models.SearchableItem +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.* +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog + +@AndroidEntryPoint +class CompoundAddressSearchF : Fragment() { + + private val viewModel: CompoundAddressSearchVM by viewModels() + private val args: CompoundAddressSearchFArgs by navArgs() + + private var binding: FragmentCompoundAddressSearchBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentCompoundAddressSearchBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + + ivBack.setOnClickListener { findNavController().popBackStack() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.setArs(args.initialDataSet, args.featureCode, args.schemaCode) + + viewModel.showTemplateDialog + .observeUiDataEvent(viewLifecycleOwner, this::openTemplateDialog) + + viewModel.navigateToAddressSelection + .observeUiDataEvent(viewLifecycleOwner, this::navigateToSearch) + + viewModel.setAddressSelection + .observeUiDataEvent(viewLifecycleOwner, this::setSelectionResult) + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.launchRetryAction() + + ActionsConst.ERROR_DIALOG_DEAL_WITH_IT, + ActionsConst.DIALOG_ACTION_CODE_CLOSE -> navigateToCaller() + } + } + + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_COUNTRY, + viewModel::setSelectedCountry + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_REGION, + viewModel::setSelectedRegion + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_DISTRICT, + viewModel::setSelectedDistrict + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_CITY_TYPE, + viewModel::setSelectedCityType + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_CITY, + viewModel::setSelectedCity + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_POST_OFFICE, + viewModel::setSelectedPostOffice + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_STREET_TYPE, + viewModel::setSelectedStreetType + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_STREET, + viewModel::setSelectedStreet + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_HOUSE, + viewModel::setSelectedHouse + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_CORP, + viewModel::setSelectedCorp + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_APARTMENT, + viewModel::setSelectedApartment + ) + registerForNavigationResultOnce( + CompoundAddressResultKey.RESULT_KEY_ZIP, + viewModel::setSelectedZip + ) + } + + private fun navigateToSearch(request: AddressSearchRequest) { + when (request.searchType) { + SearchType.LIST -> navigateToListSearch(request.items, request.resultCode) + SearchType.BULLET -> navigateToBulletSearch(request.items, request.resultCode) + } + } + + private fun navigateToListSearch(data: Array, resultKey: String) { + @Suppress("UNCHECKED_CAST") + navigate( + CompoundAddressSearchFDirections.actionDestinationCompoundAddressToDestinationSearchF( + key = resultKey, searchableList = data as Array + ) + ) + } + + private fun navigateToBulletSearch(data: Array, resultKey: String) { + val header = args.initialDataSet.title ?: return + val title = args.initialDataSet.description ?: return + @Suppress("UNCHECKED_CAST") + navigate( + CompoundAddressSearchFDirections.actionDestinationCompoundAddressToDestinationSearchBulletF( + screenHeader = header, + contentTitle = title, + resultKey = resultKey, + data = data as Array + ), + findNavController() + ) + } + + private fun setSelectionResult(address: AddressIdentifier) { + setNavigationResult(result = address, key = args.resultKey) + findNavController().popBackStack() + } + + private fun navigateToCaller() { + //close dialog + findNavController().popBackStack() + //pop to the caller + findNavController().popBackStack() + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } +} \ No newline at end of file diff --git a/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressSearchVM.kt b/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressSearchVM.kt new file mode 100644 index 0000000..c825c9e --- /dev/null +++ b/address_search/src/main/java/ua/gov/diia/address_search/ui/CompoundAddressSearchVM.kt @@ -0,0 +1,1073 @@ +package ua.gov.diia.address_search.ui + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import ua.gov.diia.address_search.models.AddressFieldApproveRequest +import ua.gov.diia.address_search.models.AddressFieldRequest +import ua.gov.diia.address_search.models.AddressFieldRequestValue +import ua.gov.diia.address_search.models.AddressFieldResponse +import ua.gov.diia.address_search.models.AddressIdentifier +import ua.gov.diia.address_search.models.AddressItem +import ua.gov.diia.address_search.models.AddressParameter +import ua.gov.diia.address_search.models.AddressSearchRequest +import ua.gov.diia.address_search.network.ApiAddressSearch +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.ui_base.fragments.errordialog.RequestTryCountTracker +import ua.gov.diia.core.util.CombinedLiveData +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.noInternetException +import java.util.regex.Pattern +import javax.inject.Inject + +@HiltViewModel +class CompoundAddressSearchVM @Inject constructor( + @AuthorizedClient private val apiAddressSearch: ApiAddressSearch, + private val clientAlertDialogsFactory: ClientAlertDialogsFactory, + private val addressParameterMapper: AddressParameterMapper, +) : ViewModel() { + + private val _loading = MutableLiveData() + val loading = _loading.asLiveData() + + private val _actionButtonLoading = MutableLiveData() + val actionButtonLoading = _actionButtonLoading.asLiveData() + + private val _showTemplateDialog = MutableLiveData>() + val showTemplateDialog = _showTemplateDialog.asLiveData() + + private val _screenHeader = MutableLiveData() + val screenHeader = _screenHeader.asLiveData() + + private val _addressDescription = MutableLiveData() + val addressDescription = _addressDescription.asLiveData() + + //---------- Retry request logic --------------- + companion object{ + private const val TRY_REQUEST_COUNT = 1 + } + + private val _tryLoadRequestCount = RequestTryCountTracker() + + private var _currentRequest: Request? = null + + private enum class Request { + REQUEST_RESULT, + COUNTRY, + REGION, + DISTRICT, + CITY_TYPE, + CITY, + POST_OFFICE, + STREET_TYPE, + STREET, + HOUSE, + APARTMENT, + CORP, + ZIP + } + + fun launchRetryAction() { + when (_currentRequest) { + Request.REQUEST_RESULT -> setAddressSelectionResult() + Request.COUNTRY -> selectCountry() + Request.REGION -> selectRegion() + Request.DISTRICT -> selectDistrict() + Request.CITY_TYPE -> selectCityType() + Request.CITY -> selectCity() + Request.POST_OFFICE -> selectPostOffice() + Request.STREET_TYPE -> selectStreetType() + Request.STREET -> selectStreet() + Request.HOUSE -> selectHouse() + Request.APARTMENT -> selectApartment() + Request.CORP -> selectCorp() + Request.ZIP -> selectZip() + else -> {} + } + } + + // ---------- Config -------------------- + + private var _featureCode: String? = null + private var _addressSchema: String? = null + + private val _navigateToAddressSelection = MutableLiveData>() + val navigateToAddressSelection = _navigateToAddressSelection.asLiveData() + + private var _isArgsHasBeenHandled: Boolean = false + + fun setArs(data: AddressFieldResponse, code: String, schema: String) { + if (!_isArgsHasBeenHandled) { + _isArgsHasBeenHandled = true + _screenHeader.value = data.title + _addressDescription.value = data.description + _featureCode = code + _addressSchema = schema + setFieldParams(data) + } + } + + //------------ Country ------------------- + + private val _countryFieldParams = MutableLiveData() + val countryFieldParams = _countryFieldParams.asLiveData() + + val showCountryField: LiveData = _countryFieldParams.map { params -> + params != null + } + + val countryFieldMode: LiveData = _countryFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedCountry = MutableLiveData() + val selectedCountry = _selectedCountry.asLiveData() + + val countryInput = MutableLiveData() + + fun selectCountry() { + _countryFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_COUNTRY, params) + } + } + + fun setSelectedCountry(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _countryFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.COUNTRY, + requestData = request, + cleanUpEvent = this::cleanUpCountryGroup, + nextFieldLoaded = { _selectedCountry.value = item } + ) + } + + //------------ Region --------------------- + + private val _regionFieldParams = MutableLiveData() + val regionFieldParams = _regionFieldParams.asLiveData() + + val showRegionsField: LiveData = _regionFieldParams.map { params -> + params != null + } + + val regionFieldMode: LiveData = _regionFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedRegion = MutableLiveData() + val selectedRegion = _selectedRegion.asLiveData() + + val regionInput = MutableLiveData() + + fun selectRegion() { + _regionFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_REGION, params) + } + } + + fun setSelectedRegion(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _regionFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.REGION, + requestData = request, + cleanUpEvent = this::cleanUpRegionGroup, + nextFieldLoaded = { _selectedRegion.value = item } + ) + } + + //------------ District ------------------- + + private val _districtFieldParams = MutableLiveData() + val districtFieldParams = _districtFieldParams.asLiveData() + + val showDistrictField: LiveData = _districtFieldParams.map { params -> + params != null + } + + val districtFieldMode: LiveData = _districtFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedDistrict = MutableLiveData() + val selectedDistrict = _selectedDistrict.asLiveData() + + val districtInput = MutableLiveData() + + fun selectDistrict() { + _districtFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_DISTRICT, params) + } + } + + fun setSelectedDistrict(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _districtFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.DISTRICT, + requestData = request, + cleanUpEvent = this::cleanUpDistrictGroup, + nextFieldLoaded = { _selectedDistrict.value = item } + ) + } + + //------------ City type ------------------- + + private val _cityTypeFieldParams = MutableLiveData() + val cityTypeFieldParams = _cityTypeFieldParams.asLiveData() + + val showCityTypeField: LiveData = _cityTypeFieldParams.map { params -> + params != null + } + + val cityTypeFieldMode: LiveData = _cityTypeFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedCityType = MutableLiveData() + val selectedCityType = _selectedCityType.asLiveData() + + val cityTypeInput = MutableLiveData() + + fun selectCityType() { + _cityTypeFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_CITY_TYPE, params) + } + } + + fun setSelectedCityType(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _cityTypeFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.CITY_TYPE, + requestData = request, + cleanUpEvent = this::cleanUpCityTypeGroup, + nextFieldLoaded = { _selectedCityType.value = item } + ) + } + + //------------ City ------------------- + + private val _cityFieldParams = MutableLiveData() + val cityFieldParams = _cityFieldParams.asLiveData() + + val showCityField: LiveData = _cityFieldParams.map { params -> + params != null + } + + val cityFieldMode: LiveData = _cityFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedCity = MutableLiveData() + val selectedCity = _selectedCity.asLiveData() + + val cityInput = MutableLiveData() + + fun selectCity() { + _cityFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_CITY, params) + } + } + + fun setSelectedCity(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _cityFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.CITY, + requestData = request, + cleanUpEvent = this::cleanUpCityGroup, + nextFieldLoaded = { _selectedCity.value = item } + ) + } + + //------------ PostOffice ------------------- + + private val _postOfficeFieldParams = MutableLiveData() + val postOfficeFieldParams = _postOfficeFieldParams.asLiveData() + + val showPostOfficeField: LiveData = _postOfficeFieldParams.map { params -> + params != null + } + + val postOfficeFieldMode: LiveData = _postOfficeFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedPostOffice = MutableLiveData() + val selectedPostOffice = _selectedPostOffice.asLiveData() + + val postOfficeInput = MutableLiveData() + + fun selectPostOffice() { + _postOfficeFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_POST_OFFICE, params) + } + } + + fun setSelectedPostOffice(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _postOfficeFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.POST_OFFICE, + requestData = request, + cleanUpEvent = this::cleanUpPostOfficeGroup, + nextFieldLoaded = { _selectedPostOffice.value = item } + ) + } + + //------------ Street type ------------------- + + private val _streetTypeFieldParams = MutableLiveData() + val streetTypeFieldParams = _streetTypeFieldParams.asLiveData() + + val showStreetTypeField: LiveData = _streetTypeFieldParams.map { params -> + params != null + } + + val streetTypeFieldMode: LiveData = _streetTypeFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedStreetType = MutableLiveData() + val selectedStreetType = _selectedStreetType.asLiveData() + + val streetTypeInput = MutableLiveData() + + fun selectStreetType() { + _streetTypeFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_STREET_TYPE, params) + } + } + + fun setSelectedStreetType(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _streetTypeFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.STREET_TYPE, + requestData = request, + cleanUpEvent = this::cleanUpStreetTypeGroup, + nextFieldLoaded = { _selectedStreetType.value = item } + ) + } + + //------------ Street ------------------- + + private val _streetFieldParams = MutableLiveData() + val streetFieldParams = _streetFieldParams.asLiveData() + + val showStreetField: LiveData = _streetFieldParams.map { params -> + params != null + } + + val streetFieldMode: LiveData = _streetFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedStreet = MutableLiveData() + val selectedStreet = _selectedStreet.asLiveData() + + val streetInput = MutableLiveData() + + fun selectStreet() { + _streetFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_STREET, params) + } + } + + fun setSelectedStreet(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _streetFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.STREET, + requestData = request, + cleanUpEvent = this::cleanUpStreetGroup, + nextFieldLoaded = { _selectedStreet.value = item } + ) + } + + //------------ House ------------------- + + private val _houseFieldParams = MutableLiveData() + val houseFieldParams = _houseFieldParams.asLiveData() + + val showHouseField: LiveData = _houseFieldParams.map { params -> + params != null + } + + val houseFieldMode: LiveData = _houseFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedHouse = MutableLiveData() + val selectedHouse = _selectedHouse.asLiveData() + + val house = MutableLiveData() + + private val houseValidationRegex = MutableLiveData() + + private val houseValidationPattern: Pattern? by lazy { + houseValidationRegex.value.let { + Pattern.compile( + it + ) + } + } + + private fun approveHouseField(value: String): Boolean = + houseValidationPattern?.matcher(value)?.matches() ?: true + + val showHouseFieldError: LiveData = house.map { house -> + if (house != null) { + !approveHouseField(house) + } else { + false + } + } + + fun selectHouse() { + _houseFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_HOUSE, params) + } + } + + fun setSelectedHouse(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _houseFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.HOUSE, + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedHouse.value = item } + ) + } + + //------------ Apartment ------------------- + + private val _apartmentFieldParams = MutableLiveData() + val apartmentFieldParams = _apartmentFieldParams.asLiveData() + + val showApartmentField: LiveData = _apartmentFieldParams.map { params -> + params != null + } + + val apartmentFieldMode: LiveData = _apartmentFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedApartment = MutableLiveData() + val selectedApartment = _selectedApartment.asLiveData() + + val apartment = MutableLiveData() + + private val apartmentValidationRegex = MutableLiveData() + + private val apartmentValidationPattern: Pattern? by lazy { + apartmentValidationRegex.value.let { + Pattern.compile( + it + ) + } + } + + private fun approveApartmentField(value: String): Boolean = + apartmentValidationPattern?.matcher(value)?.matches() ?: true + + val showApartmentFieldError: LiveData = apartment.map { apartment -> + if (apartment != null) { + !approveApartmentField(apartment) + } else { + false + } + } + + fun selectApartment() { + _apartmentFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_APARTMENT, params) + } + } + + fun setSelectedApartment(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _apartmentFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.APARTMENT, + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedApartment.value = item } + ) + } + + //------------ Corps ------------------- + + private val _corpsFieldParams = MutableLiveData() + val corpsFieldParams = _corpsFieldParams.asLiveData() + + val showCorpsField: LiveData = _apartmentFieldParams.map { params -> + params != null + } + + val corpFieldMode: LiveData = _corpsFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedCorp = MutableLiveData() + val selectedCorp = _selectedCorp.asLiveData() + + val corps = MutableLiveData() + + private val corpsValidationRegex = MutableLiveData() + + private val corpsValidationPattern: Pattern? by lazy { + corpsValidationRegex.value.let { + Pattern.compile( + it + ) + } + } + + private fun approveCorpsField(value: String): Boolean = + corpsValidationPattern?.matcher(value)?.matches() ?: true + + val showCorpsFieldError: LiveData = corps.map { corps -> + if (corps != null) { + !approveCorpsField(corps) + } else { + false + } + } + + fun selectCorp() { + _corpsFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_CORP, params) + } + } + + fun setSelectedCorp(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _corpsFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.CORP, + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedCorp.value = item } + ) + } + + //------------ Zip ------------------- + + private val _zipFieldParams = MutableLiveData() + val zipFieldParams = _zipFieldParams.asLiveData() + + val showZipField: LiveData = _zipFieldParams.map { params -> + params != null + } + + val zipFieldMode: LiveData = _corpsFieldParams.map { params -> + addressParameterMapper.getViewMode(params) + } + + private val _selectedZip = MutableLiveData() + val selectedZip = _selectedZip.asLiveData() + + val zip = MutableLiveData() + + private val zipValidationRegex = MutableLiveData() + + private val zipValidationPattern: Pattern? by lazy { + zipValidationRegex.value.let { + Pattern.compile( + it + ) + } + } + + private fun approveZipField(value: String): Boolean = + zipValidationPattern?.matcher(value)?.matches() ?: true + + val showZipFieldError: LiveData = zip.map { zip -> + if (zip != null) { + !approveZipField(zip) + } else { + false + } + } + + fun selectZip() { + _zipFieldParams.value?.let { params -> + startSelectionProcess(CompoundAddressResultKey.RESULT_KEY_ZIP, params) + } + } + + fun setSelectedZip(item: AddressItem) { + val request = AddressFieldRequestValue( + id = item.id, + type = _zipFieldParams.value?.type, + value = item.name + ) + + requestNextField( + requestKey = Request.ZIP, + requestData = request, + cleanUpEvent = {}, + nextFieldLoaded = { _selectedZip.value = item } + ) + } + + //----------- Field params control -------- + + private fun setFieldParams(data: AddressFieldResponse) { + //If this is the end of selection we'll get address identifier object and + //we should setup it to notify VM that this this the end of the selection process + if (data.isEndForAddressSelection()) { + _addressIdentifier = data.address + return + } + + data.parameters?.forEach { param -> + //setup corresponding field with the parameters and default values + getFieldSetupAction(param.type).invoke(param, param.getDefaultAddress()) + } + } + + private fun getFieldSetupAction(type: String?): (param: AddressParameter, default: AddressItem?) -> Unit = + when (type) { + AddressSearchFieldType.FieldType.COUNTRY -> { p, d -> + _countryFieldParams.value = p + d?.let(_selectedCountry::setValue) + } + AddressSearchFieldType.FieldType.REGION, AddressSearchFieldType.FieldType.TEXT_REGION -> { p, d -> + _regionFieldParams.value = p + d?.let(_selectedRegion::setValue) + } + AddressSearchFieldType.FieldType.DISTRICT, AddressSearchFieldType.FieldType.TEXT_DISTRICT -> { p, d -> + _districtFieldParams.value = p + d?.let(_selectedDistrict::setValue) + } + AddressSearchFieldType.FieldType.CITY_TYPE -> { p, d -> + _cityTypeFieldParams.value = p + d?.let(_selectedCityType::setValue) + } + AddressSearchFieldType.FieldType.CITY, AddressSearchFieldType.FieldType.TEXT_CITY -> { p, d -> + _cityFieldParams.value = p + d?.let(_selectedCity::setValue) + } + AddressSearchFieldType.FieldType.POST_OFFICE, AddressSearchFieldType.FieldType.TEXT_POST_OFFICE -> { p, d -> + _postOfficeFieldParams.value = p + d?.let(_selectedPostOffice::setValue) + } + AddressSearchFieldType.FieldType.STREET_TYPE -> { p, d -> + _streetTypeFieldParams.value = p + d?.let(_selectedStreetType::setValue) + } + AddressSearchFieldType.FieldType.STREET -> { p, d -> + _streetFieldParams.value = p + d?.let(_selectedStreet::setValue) + } + AddressSearchFieldType.FieldType.HOUSE -> { p, d -> + _houseFieldParams.value = p + houseValidationRegex.value = p.validation?.regexp + d?.name?.let(house::setValue) + } + AddressSearchFieldType.FieldType.APARTMENT -> { p, d -> + _apartmentFieldParams.value = p + apartmentValidationRegex.value = p.validation?.regexp + d?.name?.let(apartment::setValue) + } + AddressSearchFieldType.FieldType.CORPS -> { p, d -> + _corpsFieldParams.value = p + corpsValidationRegex.value = p.validation?.regexp + d?.name?.let(corps::setValue) + } + AddressSearchFieldType.FieldType.ZIP -> { p, d -> + _zipFieldParams.value = p + zipValidationRegex.value = p.validation?.regexp + d?.name?.let(zip::setValue) + } + else -> { _, _ -> } + } + + private fun cleanUpCountryGroup() { + _regionFieldParams.value = null + _selectedRegion.value = null + regionInput.value = null + cleanUpRegionGroup() + } + + private fun cleanUpRegionGroup() { + _districtFieldParams.value = null + _selectedDistrict.value = null + districtInput.value = null + cleanUpDistrictGroup() + } + + private fun cleanUpDistrictGroup() { + _cityTypeFieldParams.value = null + _selectedCityType.value = null + cityTypeInput.value = null + cleanUpCityTypeGroup() + } + + private fun cleanUpCityTypeGroup() { + _cityFieldParams.value = null + _selectedCity.value = null + cityInput.value = null + cleanUpCityGroup() + } + + private fun cleanUpCityGroup() { + _streetTypeFieldParams.value = null + _selectedStreetType.value = null + streetTypeInput.value = null + cleanUpStreetTypeGroup() + cleanUpPostOfficeGroup() + } + + private fun cleanUpPostOfficeGroup() { + _postOfficeFieldParams.value = null + _selectedPostOffice.value = null + postOfficeInput.value = null +// cleanUpStreetTypeGroup() + } + + private fun cleanUpStreetTypeGroup() { + _streetFieldParams.value = null + _selectedStreet.value = null + streetInput.value = null + cleanUpStreetGroup() + } + + private fun cleanUpStreetGroup() { + _houseFieldParams.value = null + _selectedHouse.value = null + house.value = null + + _apartmentFieldParams.value = null + _selectedApartment.value = null + apartment.value = null + + _corpsFieldParams.value = null + _selectedCorp.value = null + corps.value = null + + _zipFieldParams.value = null + _selectedZip.value = null + zip.value = null + } + + //----------- Action button control ------ + + private val _countryApproveData: LiveData = + CombinedLiveData( + _countryFieldParams, _selectedCountry, countryInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _regionApproveData: LiveData = + CombinedLiveData( + _regionFieldParams, _selectedRegion, regionInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _districtApproveData: LiveData = + CombinedLiveData( + _districtFieldParams, _selectedDistrict, districtInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _cityTypeApproveData: LiveData = + CombinedLiveData( + _cityTypeFieldParams, _selectedCityType, cityTypeInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _cityApproveData: LiveData = CombinedLiveData( + _cityFieldParams, _selectedCity, cityInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _postOfficeApproveData: LiveData = CombinedLiveData( + _postOfficeFieldParams, _selectedPostOffice, postOfficeInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _streetTypeApproveData: LiveData = + CombinedLiveData( + _streetTypeFieldParams, _selectedStreetType, streetTypeInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _streetApproveData: LiveData = + CombinedLiveData( + _streetFieldParams, _selectedStreet, streetInput + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _houseApproveData: LiveData = CombinedLiveData( + _houseFieldParams, _selectedHouse, house + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _apartmentApproveData: LiveData = + CombinedLiveData( + _apartmentFieldParams, _selectedApartment, apartment + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _corpApproveData: LiveData = CombinedLiveData( + _corpsFieldParams, _selectedCorp, corps + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _zipApproveData: LiveData = CombinedLiveData( + _zipFieldParams, _selectedZip, zip + ) { values -> addressParameterMapper.toFieldApproveRequest(values) } + + private val _resultsSourceApproved: LiveData = CombinedLiveData( + _countryApproveData, + _regionApproveData, + _districtApproveData, + _cityTypeApproveData, + _cityApproveData, + _postOfficeApproveData, + _streetTypeApproveData, + _streetApproveData, + _houseApproveData, + _apartmentApproveData, + _corpApproveData, + _zipApproveData + ) { values -> + values.all { value -> addressParameterMapper.approveFiledData(value) } + } + + //Enable button when all mandatory fields was field by the user. + //NOTE!! if some editable fields present we should check it in [setAddressSelectionResult] + //and send it to the back-end to get [addressIdentifier] to complete the flow + val enableActionButton: LiveData = _resultsSourceApproved.map { isFieldApproved -> + isFieldApproved + } + + private var _addressIdentifier: AddressIdentifier? = null + + //----------- Common logic ----------------- + + private fun startSelectionProcess( + resultKey: String, + fieldParams: AddressParameter + ) { + val request = AddressSearchRequest( + resultCode = resultKey, + searchType = fieldParams.getSearchType(), + items = fieldParams.getItems() + ) + + _navigateToAddressSelection.value = UiDataEvent(request) + } + + private fun requestNextField( + requestKey: Request, + requestData: AddressFieldRequestValue, + cleanUpEvent: () -> Unit, + nextFieldLoaded: () -> Unit + ) { + _currentRequest = requestKey + + viewModelScope.launch { + try { + //validates request input data + val code = _featureCode ?: return@launch + val schema = _addressSchema ?: return@launch + //creates request object + val request = AddressFieldRequest(listOf(requestData)) + //starting loading process + _loading.value = true + val result = apiAddressSearch.getFieldContext(code, schema, request) + //clean up dependent fields before sent a new one + cleanUpEvent.invoke() + //sets field params for the corresponding fields + setFieldParams(result) + //after data has been loaded and set into appropriate field notifies caller + //to set his selection + nextFieldLoaded.invoke() + //resets the loading counter + _tryLoadRequestCount.reset() + } catch (e: Exception) { + consumeException(e) + _tryLoadRequestCount.increment() + } finally { + _loading.value = false + } + } + } + + private infix fun consumeException(e: Exception) { + val templateData = if (e.noInternetException()) { + clientAlertDialogsFactory.alertNoInternet() + } else { + val closable = _tryLoadRequestCount.tryCount < TRY_REQUEST_COUNT + clientAlertDialogsFactory.unknownErrorAlert(closable, e = e) + } + _showTemplateDialog.value = UiDataEvent(templateData) + } + + //----------- Complete selection form ------- + + private val _setAddressSelection = MutableLiveData>() + val setAddressSelection = _setAddressSelection.asLiveData() + + fun setAddressSelectionResult() { + _currentRequest = Request.REQUEST_RESULT + + val addressIdentifier = _addressIdentifier + + //If the [AddressIdentifier] is present -> it means that all mandatory fields data was + //collected and sent to the server + if (addressIdentifier != null) { + _setAddressSelection.value = UiDataEvent(addressIdentifier) + } + //If this value is absent -> we need to collect data from editable fields and send it + //to the backend to get the [AddressIdentifier] to complete flow. + else { + viewModelScope.launch { + try { + _actionButtonLoading.value = true + val code = _featureCode ?: return@launch + val schema = _addressSchema ?: return@launch + val request = AddressFieldRequest(getRequestList()) + + val result = apiAddressSearch.getFieldContext(code, schema, request) + //return result form the address selection if the [AddressIdentifier] isn't a null + result.address?.let { addressIdentifier -> + _setAddressSelection.value = UiDataEvent(addressIdentifier) + } + _tryLoadRequestCount.reset() + } catch (e: Exception) { + consumeException(e) + _tryLoadRequestCount.increment() + } finally { + _actionButtonLoading.value = false + } + } + } + } + + fun getRequestList(): List { + + val country = addressParameterMapper.getEditableModeFieldRequest( + value = countryInput.value, + params = _countryFieldParams.value + ) + + val region = addressParameterMapper.getEditableModeFieldRequest( + value = regionInput.value, + params = _regionFieldParams.value + ) + + val district = addressParameterMapper.getEditableModeFieldRequest( + value = districtInput.value, + params = _districtFieldParams.value + ) + + val cityType = addressParameterMapper.getEditableModeFieldRequest( + value = cityTypeInput.value, + params = _cityTypeFieldParams.value + ) + + val city = addressParameterMapper.getEditableModeFieldRequest( + value = cityInput.value, + params = _cityFieldParams.value + ) + + val postOffice = addressParameterMapper.getEditableModeFieldRequest( + value = postOfficeInput.value, + params = _postOfficeFieldParams.value + ) + + val streetType = addressParameterMapper.getEditableModeFieldRequest( + value = streetTypeInput.value, + params = _streetTypeFieldParams.value + ) + + val street = addressParameterMapper.getEditableModeFieldRequest( + value = streetInput.value, + params = _streetFieldParams.value + ) + + val house = addressParameterMapper.getEditableModeFieldRequest( + value = house.value, + params = _houseFieldParams.value + ) + + val apartment = addressParameterMapper.getEditableModeFieldRequest( + value = apartment.value, + params = _apartmentFieldParams.value + ) + + val corp = addressParameterMapper.getEditableModeFieldRequest( + value = corps.value, + params = _corpsFieldParams.value + ) + + val zip = addressParameterMapper.getEditableModeFieldRequest( + value = zip.value, + params = _zipFieldParams.value + ) + + return listOfNotNull( + country, + region, + district, + cityType, + city, + postOffice, + streetType, + street, + house, + apartment, + corp, + zip + ) + } + +} \ No newline at end of file diff --git a/address_search/src/main/res/layout/fragment_address_search.xml b/address_search/src/main/res/layout/fragment_address_search.xml new file mode 100644 index 0000000..f248cc6 --- /dev/null +++ b/address_search/src/main/res/layout/fragment_address_search.xml @@ -0,0 +1,379 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/address_search/src/main/res/layout/fragment_compound_address_search.xml b/address_search/src/main/res/layout/fragment_compound_address_search.xml new file mode 100644 index 0000000..9462f35 --- /dev/null +++ b/address_search/src/main/res/layout/fragment_compound_address_search.xml @@ -0,0 +1,378 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/address_search/src/main/res/navigation/nav_compound_address_search.xml b/address_search/src/main/res/navigation/nav_compound_address_search.xml new file mode 100644 index 0000000..81dcf3a --- /dev/null +++ b/address_search/src/main/res/navigation/nav_compound_address_search.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/address_search/src/main/res/values/strings.xml b/address_search/src/main/res/values/strings.xml new file mode 100644 index 0000000..dcc9864 --- /dev/null +++ b/address_search/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Зберегти + \ No newline at end of file diff --git a/address_search/src/test/java/ua/gov/diia/address_search/ExampleUnitTest.kt b/address_search/src/test/java/ua/gov/diia/address_search/ExampleUnitTest.kt new file mode 100644 index 0000000..fe646cc --- /dev/null +++ b/address_search/src/test/java/ua/gov/diia/address_search/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.address_search + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/address_search/src/test/java/ua/gov/diia/address_search/MainDispatcherRule.kt b/address_search/src/test/java/ua/gov/diia/address_search/MainDispatcherRule.kt new file mode 100644 index 0000000..4a91dce --- /dev/null +++ b/address_search/src/test/java/ua/gov/diia/address_search/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.address_search + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/address_search/src/test/java/ua/gov/diia/address_search/models/AddressFieldApproveRequestTest.kt b/address_search/src/test/java/ua/gov/diia/address_search/models/AddressFieldApproveRequestTest.kt new file mode 100644 index 0000000..93bf6f3 --- /dev/null +++ b/address_search/src/test/java/ua/gov/diia/address_search/models/AddressFieldApproveRequestTest.kt @@ -0,0 +1,66 @@ +package ua.gov.diia.address_search.models + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class AddressFieldApproveRequestTest { + + @Test + fun `test that approve returns value regarding regex value if data is string and not mandatory`() { + + val model = AddressFieldApproveRequest(false, "data", "-?\\d+") + assertFalse(model.approved()) + + val model2 = AddressFieldApproveRequest(false, "123", "-?\\d+") + assertTrue(model2.approved()) + + val model3 = AddressFieldApproveRequest(false, "123", null) + assertTrue(model3.approved()) + + val model4 = AddressFieldApproveRequest(false, "", null) + assertTrue(model4.approved()) + } + + @Test + fun `test that approve returns value regarding regex value if data is string and model is mandatory`() { + + val model = AddressFieldApproveRequest(true, "data", "-?\\d+") + assertFalse(model.approved()) + + val model2 = AddressFieldApproveRequest(true, "123", "-?\\d+") + assertTrue(model2.approved()) + + val model3 = AddressFieldApproveRequest(true, "123", null) + assertTrue(model3.approved()) + + val model4 = AddressFieldApproveRequest(true, "", null) + assertFalse(model4.approved()) + } + + @Test + fun `test that approved returns true if not mandatory`() { + val model = AddressFieldApproveRequest(false, 1, "-?\\d+") + + assertTrue(model.approved()) + } + + @Test + fun `test that approved returns true if data exist and mandatory`() { + val model = AddressFieldApproveRequest(true, 1, "-?\\d+") + + assertTrue(model.approved()) + } + + @Test + fun `test that approved returns false if data is null and mandatory`() { + val model = AddressFieldApproveRequest(true, null, "-?\\d+") + + assertFalse(model.approved()) + } +} \ No newline at end of file diff --git a/address_search/src/test/java/ua/gov/diia/address_search/models/AddressFieldResponseTest.kt b/address_search/src/test/java/ua/gov/diia/address_search/models/AddressFieldResponseTest.kt new file mode 100644 index 0000000..dc5d677 --- /dev/null +++ b/address_search/src/test/java/ua/gov/diia/address_search/models/AddressFieldResponseTest.kt @@ -0,0 +1,24 @@ +package ua.gov.diia.address_search.models + +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class AddressFieldResponseTest { + + @Test + fun `test isEndForAddressSelection return value regarding address`() { + val model = AddressFieldResponse("title", "description", listOf(), mockk(), null, null) + assertTrue(model.isEndForAddressSelection()) + + val model2 = AddressFieldResponse("title", "description", listOf(), null, null, null) + assertFalse(model2.isEndForAddressSelection()) + + } +} \ No newline at end of file diff --git a/address_search/src/test/java/ua/gov/diia/address_search/models/AddressParameterTest.kt b/address_search/src/test/java/ua/gov/diia/address_search/models/AddressParameterTest.kt new file mode 100644 index 0000000..c5c49eb --- /dev/null +++ b/address_search/src/test/java/ua/gov/diia/address_search/models/AddressParameterTest.kt @@ -0,0 +1,69 @@ +package ua.gov.diia.address_search.models + +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class AddressParameterTest { + + @Test + fun `test AddressParameter returns BULLET type if input is singleCheck`() { + val model = AddressParameter("type", "label", "hint", AddressFieldInputType.singleCheck, true, mockk(), mockk(), mockk(), "defaultText") + + assertEquals(SearchType.BULLET, model.getSearchType()) + } + + @Test + fun `test AddressParameter returns LIST type if input is not singleCheck`() { + val model = AddressParameter("type", "label", "hint", AddressFieldInputType.list, true, mockk(), mockk(), mockk(), "defaultText") + + assertEquals(SearchType.LIST, model.getSearchType()) + } + + @Test + fun `test AddressParameter returns EDITABLE from getFieldMode if input is textField`() { + val model = AddressParameter("type", "label", "hint", AddressFieldInputType.textField, true, mockk(), mockk(), mockk(), "defaultText") + + assertEquals(AddressFieldMode.EDITABLE, model.getFieldMode()) + } + + @Test + fun `test AddressParameter returns BUTTON from getFieldMode if input is not textField`() { + val model = AddressParameter("type", "label", "hint", AddressFieldInputType.list, true, mockk(), mockk(), mockk(), "defaultText") + + assertEquals(AddressFieldMode.BUTTON, model.getFieldMode()) + } + + @Test + fun `test isEditableMode returns true if input if textField`() { + val model = AddressParameter("type", "label", "hint", AddressFieldInputType.textField, true, mockk(), mockk(), mockk(), "defaultText") + + assertTrue(model.isEditableMode()) + } + + @Test + fun `test isEditableMode returns false if input if textField`() { + val model = AddressParameter("type", "label", "hint", AddressFieldInputType.list, true, mockk(), mockk(), mockk(), "defaultText") + + assertFalse(model.isEditableMode()) + } + + @Test + fun `test hasDefault returns true if defaultListItem or defaultTextItem are not null`() { + val model = AddressParameter("type", "label", "hint", AddressFieldInputType.list, true, mockk(), mockk(), mockk(), null) + assertTrue(model.hasDefault()) + + val model2 = AddressParameter("type", "label", "hint", AddressFieldInputType.list, true, mockk(), mockk(), null, "defaultText") + assertTrue(model2.hasDefault()) + + val model3 = AddressParameter("type", "label", "hint", AddressFieldInputType.list, true, mockk(), mockk(), null, null) + assertFalse(model3.hasDefault()) + } +} \ No newline at end of file diff --git a/address_search/src/test/java/ua/gov/diia/address_search/ui/AddressParameterMapperTest.kt b/address_search/src/test/java/ua/gov/diia/address_search/ui/AddressParameterMapperTest.kt new file mode 100644 index 0000000..290d1b1 --- /dev/null +++ b/address_search/src/test/java/ua/gov/diia/address_search/ui/AddressParameterMapperTest.kt @@ -0,0 +1,165 @@ +package ua.gov.diia.address_search.ui + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.address_search.models.AddressFieldApproveRequest +import ua.gov.diia.address_search.models.AddressFieldMode +import ua.gov.diia.address_search.models.AddressItem +import ua.gov.diia.address_search.models.AddressParameter +import ua.gov.diia.address_search.models.AddressValidation + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class AddressParameterMapperTest { + + lateinit var addressParameterMapper: AddressParameterMapper + + @Before + fun setUp() { + addressParameterMapper = AddressParameterMapper() + } + + @Test + fun `test getViewMode`() { + val buttonParam = mockk() + every { buttonParam.getFieldMode() } returns AddressFieldMode.BUTTON + assertEquals(0, addressParameterMapper.getViewMode(buttonParam)) + + val editableParam = mockk() + every { editableParam.getFieldMode() } returns AddressFieldMode.EDITABLE + assertEquals(1, addressParameterMapper.getViewMode(editableParam)) + + assertEquals(0, addressParameterMapper.getViewMode(null)) + } + + @Test + fun `test getEditableModeFieldRequest`() { + val value = "value" + val type = "type" + + val param = mockk() + every { param.isEditableMode() } returns true + every { param.type } returns type + val addressFieldRequestValue = addressParameterMapper.getEditableModeFieldRequest(value, param) + assertNotNull(addressFieldRequestValue) + assertEquals(type, addressFieldRequestValue!!.type) + assertEquals(value, addressFieldRequestValue.value) + + + val param2 = mockk() + every { param2.isEditableMode() } returns false + assertNull(addressParameterMapper.getEditableModeFieldRequest(value, param2)) + + + assertNull(addressParameterMapper.getEditableModeFieldRequest(null, param2)) + assertNull(addressParameterMapper.getEditableModeFieldRequest(value, null)) + assertNull(addressParameterMapper.getEditableModeFieldRequest(null, null)) + } + + @Test + fun `test toFieldApproveRequest process BUTTON field mode`() { + val regex = "regex" + val param = mockk() + val addressItemParam = mockk() + val addressValidation = mockk() + every { param.getFieldMode() } returns AddressFieldMode.BUTTON + every { param.mandatory } returns true + every { param.validation } returns addressValidation + every { addressValidation.regexp } returns regex + + val addressFieldApproveRequest = addressParameterMapper.toFieldApproveRequest(listOf(param, addressItemParam)) + + assertEquals(true, addressFieldApproveRequest.mandatory) + assertEquals(regex, addressFieldApproveRequest.regex) + assertEquals(addressItemParam, addressFieldApproveRequest.data) + } + + @Test + fun `test toFieldApproveRequest process EDITABLE field mode`() { + val regex = "regex" + val param = mockk() + val editableStr = "editablestr" + val addressValidation = mockk() + every { param.getFieldMode() } returns AddressFieldMode.EDITABLE + every { param.mandatory } returns true + every { param.validation } returns addressValidation + every { addressValidation.regexp } returns regex + + val addressFieldApproveRequest = addressParameterMapper.toFieldApproveRequest(listOf(param, editableStr)) + + assertEquals(true, addressFieldApproveRequest.mandatory) + assertEquals(regex, addressFieldApproveRequest.regex) + assertEquals(editableStr, addressFieldApproveRequest.data) + } + + @Test + fun `test toFieldApproveRequest process without field mode`() { + val regex = "regex" + val param = mockk(relaxed = true) + val addressValidation = mockk(relaxed = true) + every { param.mandatory } returns true + every { param.validation } returns addressValidation + every { addressValidation.regexp } returns regex + + val addressFieldApproveRequest = addressParameterMapper.toFieldApproveRequest(listOf(param)) + + assertEquals(true, addressFieldApproveRequest.mandatory) + assertEquals(regex, addressFieldApproveRequest.regex) + } + @Test + fun `test toFieldApproveRequest process mandatory not set`() { + val param = mockk(relaxed = true) + every { param.validation } returns null + + val addressFieldApproveRequest = addressParameterMapper.toFieldApproveRequest(listOf(param)) + + assertEquals(false, addressFieldApproveRequest.mandatory) + assertEquals(null, addressFieldApproveRequest.regex) + assertEquals(null, addressFieldApproveRequest.data) + } + + @Test + fun `test toFieldApproveRequest process without address params`() { + + val addressFieldApproveRequest = addressParameterMapper.toFieldApproveRequest(listOf("param")) + + assertEquals(false, addressFieldApproveRequest.mandatory) + assertEquals(null, addressFieldApproveRequest.regex) + assertEquals(null, addressFieldApproveRequest.data) + } + @Test + fun `test toFieldApproveRequest process mandatory set as false`() { + val regex = "regex" + val param = mockk(relaxed = true) + val addressValidation = mockk(relaxed = true) + every { param.mandatory } returns false + every { param.validation } returns addressValidation + every { addressValidation.regexp } returns regex + + val addressFieldApproveRequest = addressParameterMapper.toFieldApproveRequest(listOf(param)) + + assertEquals(false, addressFieldApproveRequest.mandatory) + assertEquals(regex, addressFieldApproveRequest.regex) + } + + @Test + fun `test approveFiledData`() { + assertTrue(addressParameterMapper.approveFiledData("string")) + + val request = mockk() + every { request.approved() } returns false + assertFalse(addressParameterMapper.approveFiledData(request)) + verify(exactly = 1) { request.approved() } + } +} \ No newline at end of file diff --git a/address_search/src/test/java/ua/gov/diia/address_search/ui/AddressSearchVMTest.kt b/address_search/src/test/java/ua/gov/diia/address_search/ui/AddressSearchVMTest.kt new file mode 100644 index 0000000..fead490 --- /dev/null +++ b/address_search/src/test/java/ua/gov/diia/address_search/ui/AddressSearchVMTest.kt @@ -0,0 +1,1612 @@ +package ua.gov.diia.address_search.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.address_search.MainDispatcherRule +import ua.gov.diia.address_search.models.AddressFieldResponse +import ua.gov.diia.address_search.models.AddressIdentifier +import ua.gov.diia.address_search.models.AddressItem +import ua.gov.diia.address_search.models.AddressParameter +import ua.gov.diia.address_search.models.AddressValidation +import ua.gov.diia.address_search.models.SearchType +import ua.gov.diia.address_search.network.ApiAddressSearch +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRetryLastAction + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class AddressSearchVMTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var addressSearchVM: AddressSearchVMImpl + + lateinit var apiAddressSearch: ApiAddressSearch + lateinit var errorHandling: WithErrorHandling + lateinit var retryLastAction: WithRetryLastAction + + lateinit var addressParam: AddressParameter + lateinit var addressItem: AddressItem + lateinit var addressValidation: AddressValidation + + lateinit var addressParameterMapper: AddressParameterMapper + + @Before + fun setUp() { + apiAddressSearch = mockk() + errorHandling = mockk(relaxed = true) + retryLastAction = mockk(relaxed = true) + addressParameterMapper = mockk(relaxed = true) + addressSearchVM = AddressSearchVMImpl(apiAddressSearch, errorHandling, retryLastAction, addressParameterMapper) + } + + lateinit var paramsGetItems: Array + + fun prepareMockForGetFieldSetupAction(filedType: String, description: String = "description", name: String? = "address_name"): AddressFieldResponse { + addressParam = mockk(relaxed = true) + addressItem = mockk(relaxed = true) + paramsGetItems = arrayOf(addressItem) + addressValidation = mockk() + every { addressItem.name } returns name + every { addressValidation.regexp } returns "regexp" + every { addressParam.getDefaultAddress() } returns addressItem + every { addressParam.type } returns filedType + every { addressParam.getSearchType() } returns SearchType.LIST + every { addressParam.getItems() } returns paramsGetItems + every { addressParam.validation } returns addressValidation + + val addressParameterList = mutableListOf() + addressParameterList.add(addressParam) + return AddressFieldResponse("title", description, addressParameterList, null, null, null) + } + + @Test + fun `test set address search`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REAL_ESTATE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + assertEquals("title", addressSearchVM.screenHeader.value) + assertEquals("description", addressSearchVM.addressDescription.value) + assertEquals(true, addressSearchVM.showFlowTitle.value) + } + } + + @Test + fun `test set address search set false to search title if description is gone`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REAL_ESTATE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme", true) + assertEquals(false, addressSearchVM.showFlowTitle.value) + } + } + + @Test + fun `test set address search set false to search title if description is empty`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REAL_ESTATE, description = "") + + addressSearchVM.setAddressSearchArsImplWithoutSetGone(data, "code", "scheme") + assertEquals(false, addressSearchVM.showFlowTitle.value) + } + } + + @Test + fun `test getFieldSetupAction set real estate`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REAL_ESTATE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.realEstateTypeFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedRealEstate.value) + } + } + + @Test + fun `test getFieldSetupAction set precision`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.PRECISION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.precisionTypeFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedPrecision.value) + } + } + + @Test + fun `test getFieldSetupAction set description`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DESCRIPTION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.descriptionFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedDescription.value) + } + } + + @Test + fun `test selectDescription`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DESCRIPTION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectDescription() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals( + CompoundAddressResultKey.RESULT_KEY_DESC, + addressSelection.resultCode + ) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test getFieldSetupAction set country`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.countryFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedCountry.value) + } + } + + @Test + fun `test getFieldSetupAction set region`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.regionFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedRegion.value) + } + } + + @Test + fun `test getFieldSetupAction set district`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DISTRICT) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.districtFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedDistrict.value) + } + } + + @Test + fun `test getFieldSetupAction set city type`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY_TYPE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.cityTypeFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedCityType.value) + } + } + + @Test + fun `test getFieldSetupAction set city`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.cityFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedCity.value) + } + } + + @Test + fun `test getFieldSetupAction set street type`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET_TYPE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.streetTypeFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedStreetType.value) + } + } + + @Test + fun `test getFieldSetupAction set post office`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.postOfficeFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedPostOffice.value) + } + } + + @Test + fun `test getFieldSetupAction set street`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.streetFieldParams.value) + assertEquals(addressItem, addressSearchVM.selectedStreet.value) + } + } + + @Test + fun `test getFieldSetupAction set house`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.houseFieldParams.value) + assertEquals("address_name", addressSearchVM.house.value) + } + } + + @Test + fun `test getFieldSetupAction set apartment`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.apartmentFieldParams.value) + assertEquals("address_name", addressSearchVM.apartment.value) + } + } + + @Test + fun `test getFieldSetupAction set corps`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.corpsFieldParams.value) + assertEquals("address_name", addressSearchVM.corps.value) + } + } + + @Test + fun `test getFieldSetupAction set zip`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(addressParam, addressSearchVM.zipFieldParams.value) + assertEquals("address_name", addressSearchVM.zip.value) + } + } + + @Test + fun `test setFieldParams isEndForAddressSelection is true`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DESCRIPTION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", "errormessage") + addressSearchVM.setSelectedDescription(item) + + coVerify(exactly = 2) { response.isEndForAddressSelection() } + coVerify(exactly = 1) { response.address } + } + } + @Test + fun `test setSelectedDescription requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DESCRIPTION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", "errormessage") + addressSearchVM.setSelectedDescription(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedDescription.value) + } + } + + @Test + fun `test selectRealEstate`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REAL_ESTATE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + addressSearchVM.selectRealEstate() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals( + CompoundAddressResultKey.RESULT_KEY_REAL_ESTATE, + addressSelection.resultCode + ) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedRealEstate requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REAL_ESTATE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", "errormessage") + addressSearchVM.setSelectedRealEstate(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedRealEstate.value) + } + } + + @Test + fun `test selectPrecision`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.PRECISION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectPrecision() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_PRECISION, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedPrecision requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.PRECISION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", "errormessage") + addressSearchVM.setSelectedPrecision(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedPrecision.value) + } + } + + @Test + fun `test selectCountry`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectCountry() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_COUNTRY, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedCountry requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", "errormessage") + addressSearchVM.setSelectedCountry(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedCountry.value) + } + } + + @Test + fun `test selectRegion`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectRegion() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_REGION, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedRegion requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedRegion(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedRegion.value) + } + } + + @Test + fun `test setSelectedRegion error message`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", "Error message") + addressSearchVM.setSelectedRegion(item) + + coVerify(exactly = 0) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedRegion.value) + } + } + + @Test + fun `test selectDistrict`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DISTRICT) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectDistrict() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_DISTRICT, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedDistrict requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DISTRICT) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedDistrict(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedDistrict.value) + } + } + + @Test + fun `test selectCityType`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY_TYPE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectCityType() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_CITY_TYPE, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedCityType requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY_TYPE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedCityType(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedCityType.value) + } + } + + @Test + fun `test selectCity`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectCity() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_CITY, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedCity requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedCity(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedCity.value) + } + } + + @Test + fun `test selectPostOffice`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectPostOffice() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals( + CompoundAddressResultKey.RESULT_KEY_POST_OFFICE, + addressSelection.resultCode + ) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedPostOffice requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedPostOffice(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedPostOffice.value) + } + } + + @Test + fun `test selectStreetType`() { + runTest { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET_TYPE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectStreetType() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals( + CompoundAddressResultKey.RESULT_KEY_STREET_TYPE, + addressSelection.resultCode + ) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedStreetType requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET_TYPE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedStreetType(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedStreetType.value) + } + } + + @Test + fun `test selectStreet`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectStreet() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_STREET, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedStreet requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedStreet(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedStreet.value) + } + } + + @Test + fun `test selectHouse`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectHouse() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_HOUSE, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedHouse requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedHouse(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedHouse.value) + } + } + + @Test + fun `test selectApartment`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectApartment() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_APARTMENT, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedApartment requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedApartment(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedApartment.value) + } + } + + @Test + fun `test selectCorp`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectCorp() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_CORP, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedCorp requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedCorp(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedCorp.value) + } + } + + @Test + fun `test selectZip`() { + runTest { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + addressSearchVM.selectZip() + + val addressSelection = addressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_ZIP, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedZip requestNextField`() { + runTest { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedZip(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + assertEquals(item, addressSearchVM.selectedZip.value) + } + } + + @Test + fun `test requestSelectionResult`() { + val response = mockk(relaxed = true) + val addressIdentifier = mockk(relaxed = true) + every { response.address } returns addressIdentifier + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + addressSearchVM.requestSelectionResult() + coVerify(exactly = 1) { apiAddressSearch.getFieldContext(any(), any(), any()) } + assertEquals(addressIdentifier, addressSearchVM.addressResult.value!!.peekContent()) + } + + @Test + fun `test requestSelectionResult post already loaded identifier`() { + val response = mockk(relaxed = true) + val addressIdentifier = mockk(relaxed = true) + every { response.address } returns addressIdentifier + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + addressSearchVM.requestSelectionResult() + clearMocks(apiAddressSearch) + addressSearchVM.requestSelectionResult() + + coVerify(exactly = 0) { apiAddressSearch.getFieldContext(any(), any(), any()) } + assertEquals(addressIdentifier, addressSearchVM.addressResult.value!!.peekContent()) + } + + @Test + fun `test setAddressSelectionResult`() { + val response = mockk(relaxed = true) + val addressIdentifier = mockk(relaxed = true) + every { response.address } returns addressIdentifier + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + addressSearchVM.setAddressSelectionResult() + coVerify(exactly = 1) { apiAddressSearch.getFieldContext(any(), any(), any()) } + assertEquals(addressIdentifier, addressSearchVM.addressResult.value!!.peekContent()) + } + + @Test + fun `test setAddressSelectionResult if already loaded`() { + val response = mockk(relaxed = true) + val addressIdentifier = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + every { response.address } returns addressIdentifier + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + addressSearchVM.setSelectedCorp(item) + + clearMocks(apiAddressSearch) + addressSearchVM.setAddressSelectionResult() + coVerify(exactly = 0) { apiAddressSearch.getFieldContext(any(), any(), any()) } + assertEquals(addressIdentifier, addressSearchVM.addressResult.value!!.peekContent()) + } + + @Test + fun `test update live data after set REAL_ESTATE args`() { + runTest { + var showCountryFiles = false + var showCountryFieldMode = -1 + val showCountryFieldObserver = Observer() { + showCountryFiles = it + } + val countryFieldModeObserver = Observer() { + showCountryFieldMode = it + } + addressSearchVM.showRealEstateField.observeForever(showCountryFieldObserver) + addressSearchVM.realEstateFieldMode.observeForever(countryFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REAL_ESTATE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.realEstateTypeFieldParams.value) + Assert.assertTrue(showCountryFiles) + assertEquals(0, showCountryFieldMode) + + addressSearchVM.showRealEstateField.removeObserver(showCountryFieldObserver) + addressSearchVM.realEstateFieldMode.removeObserver(countryFieldModeObserver) + } + } + + @Test + fun `test update live data after set PRECISION args`() { + runTest { + var showCountryFiles = false + var showCountryFieldMode = -1 + val showCountryFieldObserver = Observer() { + showCountryFiles = it + } + val countryFieldModeObserver = Observer() { + showCountryFieldMode = it + } + addressSearchVM.showPrecisionField.observeForever(showCountryFieldObserver) + addressSearchVM.precisionFieldMode.observeForever(countryFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.PRECISION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.precisionTypeFieldParams.value) + Assert.assertTrue(showCountryFiles) + assertEquals(0, showCountryFieldMode) + + addressSearchVM.showPrecisionField.removeObserver(showCountryFieldObserver) + addressSearchVM.precisionFieldMode.removeObserver(countryFieldModeObserver) + } + } + @Test + fun `test update live data after set COUNTRY args`() { + runTest { + var showCountryFiles = false + var showCountryFieldMode = -1 + val showCountryFieldObserver = Observer() { + showCountryFiles = it + } + val countryFieldModeObserver = Observer() { + showCountryFieldMode = it + } + addressSearchVM.showCountryField.observeForever(showCountryFieldObserver) + addressSearchVM.countryFieldMode.observeForever(countryFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.countryFieldParams.value) + Assert.assertTrue(showCountryFiles) + assertEquals(0, showCountryFieldMode) + + addressSearchVM.showCountryField.removeObserver(showCountryFieldObserver) + addressSearchVM.countryFieldMode.removeObserver(countryFieldModeObserver) + } + } + + @Test + fun `test update live data after set REGION args`() { + runTest { + var showField = false + var showFieldMode = -1 + var showError = false + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + val errorFieldObserver = Observer() { + showError = it + } + addressSearchVM.showRegionsField.observeForever(showObserver) + addressSearchVM.regionFieldMode.observeForever(regionFieldModeObserver) + addressSearchVM.showRegionFieldError.observeForever(errorFieldObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.regionFieldParams.value) + Assert.assertTrue(showField) + Assert.assertTrue(showError) + assertEquals(0, showFieldMode) + + addressSearchVM.showRegionsField.removeObserver(showObserver) + addressSearchVM.regionFieldMode.removeObserver(regionFieldModeObserver) + addressSearchVM.showRegionFieldError.removeObserver(errorFieldObserver) + } + } + + @Test + fun `test update live data after set DISTRICT args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + addressSearchVM.showDistrictField.observeForever(showObserver) + addressSearchVM.districtFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DISTRICT) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.districtFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showDistrictField.removeObserver(showObserver) + addressSearchVM.districtFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set CITY_TYPE args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + addressSearchVM.showCityTypeField.observeForever(showObserver) + addressSearchVM.cityTypeFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY_TYPE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.cityTypeFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showCityTypeField.removeObserver(showObserver) + addressSearchVM.cityTypeFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set CITY args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + addressSearchVM.showCityField.observeForever(showObserver) + addressSearchVM.cityFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.cityFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showCityField.removeObserver(showObserver) + addressSearchVM.cityFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set POST_OFFICE args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + addressSearchVM.showPostOfficeField.observeForever(showObserver) + addressSearchVM.postOfficeFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.postOfficeFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showPostOfficeField.removeObserver(showObserver) + addressSearchVM.postOfficeFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set STREET_TYPE args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + addressSearchVM.showStreetTypeField.observeForever(showObserver) + addressSearchVM.streetTypeFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET_TYPE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.streetTypeFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showStreetTypeField.removeObserver(showObserver) + addressSearchVM.streetTypeFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set STREET args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + addressSearchVM.showStreetField.observeForever(showObserver) + addressSearchVM.streetFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.streetFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showStreetField.removeObserver(showObserver) + addressSearchVM.streetFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set HOUSE args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + addressSearchVM.showHouseField.observeForever(showObserver) + addressSearchVM.houseFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.houseFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showHouseField.removeObserver(showObserver) + addressSearchVM.houseFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set APARTMENT args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + + addressSearchVM.showApartmentField.observeForever(showObserver) + addressSearchVM.apartmentFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.apartmentFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showCorpsField.removeObserver(showObserver) + addressSearchVM.showApartmentField.removeObserver(showObserver) + addressSearchVM.apartmentFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set DESCRIPTION args`() { + runTest { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + + addressSearchVM.showDescriptionField.observeForever(showObserver) + addressSearchVM.descriptionFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DESCRIPTION) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.descriptionFieldParams.value) + Assert.assertTrue(showField) + assertEquals(0, showFieldMode) + + addressSearchVM.showDescriptionField.removeObserver(showObserver) + addressSearchVM.descriptionFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set CORPS args`() { + runTest { + var showFieldMode = -1 + var showZipFieldMode = -1 + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + val zipFieldModeObserver = Observer() { + showZipFieldMode = it + } + + addressSearchVM.corpFieldMode.observeForever(regionFieldModeObserver) + addressSearchVM.zipFieldMode.observeForever(zipFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.corpsFieldParams.value) + assertEquals(0, showFieldMode) + assertEquals(0, showZipFieldMode) + + addressSearchVM.corpFieldMode.removeObserver(regionFieldModeObserver) + addressSearchVM.zipFieldMode.removeObserver(zipFieldModeObserver) + } + } + + @Test + fun `test update live data after set ZIP args`() { + runTest { + var showField = false + val showObserver = Observer() { + showField = it + } + addressSearchVM.showZipField.observeForever(showObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP) + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertEquals(data.parameters!![0], addressSearchVM.zipFieldParams.value) + Assert.assertTrue(showField) + + addressSearchVM.showZipField.removeObserver(showObserver) + } + } + + @Test + fun `test approveZipField returns true if data is valid for error`() { + runTest { + var showError = false + val showObserver = Observer() { + showError = it + } + addressSearchVM.showZipFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP, name = "regexp") + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertFalse(showError) + + addressSearchVM.showZipFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveZipField returns false if data is valid`() { + runTest { + var showError = true + val showObserver = Observer() { + showError = it + } + addressSearchVM.showZipFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP, name = "zipcode") + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertTrue(showError) + + addressSearchVM.showZipFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveCorpsField returns true if data is valid for error`() { + runTest { + var showError = false + val showObserver = Observer() { + showError = it + } + addressSearchVM.showCorpsFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS, name = "regexp") + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertFalse(showError) + + addressSearchVM.showCorpsFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveCorpsField returns false if data is valid`() { + runTest { + var showError = true + val showObserver = Observer() { + showError = it + } + addressSearchVM.showCorpsFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS, name = "zipcode") + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertTrue(showError) + + addressSearchVM.showCorpsFieldError.removeObserver(showObserver) + } + } + + + @Test + fun `test approveApartmentField returns true if data is valid for error`() { + runTest { + var showError = false + val showObserver = Observer() { + showError = it + } + addressSearchVM.showApartmentFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT, name = "regexp") + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertFalse(showError) + + addressSearchVM.showApartmentFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveApartmentField returns false if data is valid`() { + runTest { + var showError = true + val showObserver = Observer() { + showError = it + } + addressSearchVM.showApartmentFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT, name = "zipcode") + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertTrue(showError) + + addressSearchVM.showApartmentFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveHouseField returns true if data is valid for error`() { + runTest { + var showError = false + val showObserver = Observer() { + showError = it + } + addressSearchVM.showHouseFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE, name = "regexp") + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertFalse(showError) + + addressSearchVM.showHouseFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveHouseField returns false if data is valid`() { + runTest { + var showError = true + val showObserver = Observer() { + showError = it + } + addressSearchVM.showHouseFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE, name = "zipcode") + + addressSearchVM.setAddressSearchArsImpl(data, "code", "scheme") + + assertTrue(showError) + + addressSearchVM.showHouseFieldError.removeObserver(showObserver) + } + } + + @Test + fun `no data loaded`() = runTest { + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REAL_ESTATE) + addressSearchVM.selectRealEstate() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedRealEstate(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + addressSearchVM.selectRegion() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedRegion(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET_TYPE) + addressSearchVM.selectStreetType() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedStreetType(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DISTRICT) + addressSearchVM.selectDistrict() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedDistrict(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY_TYPE) + addressSearchVM.selectCityType() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedCityType(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE) + addressSearchVM.selectHouse() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedHouse(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET) + addressSearchVM.selectStreet() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedStreet(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DESCRIPTION) + addressSearchVM.selectDescription() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedDescription(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + addressSearchVM.selectCorp() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedCorp(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + addressSearchVM.selectPostOffice() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedPostOffice(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.PRECISION) + addressSearchVM.selectPrecision() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedPrecision(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP) + addressSearchVM.selectZip() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedZip(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + addressSearchVM.selectCountry() + Assert.assertNull(addressSearchVM.navigateToAddressSelection.value?.getContentIfNotHandled()) + addressSearchVM.setSelectedCountry(addressItem) + assertFalse(addressSearchVM.loadingFieldData.value == true) + } +} + +class AddressSearchVMImpl( + apiAddressSearch: ApiAddressSearch, + errorHandling: WithErrorHandling, + retryLastAction: WithRetryLastAction, + addressParameterMapper: AddressParameterMapper, +) : AddressSearchVM( + apiAddressSearch, + addressParameterMapper, + errorHandling, retryLastAction +) { + fun setAddressSearchArsImplWithoutSetGone( + data: AddressFieldResponse, + code: String, + schema: String + ) { + setAddressSearchArs(data, code, schema) + } + + fun setAddressSearchArsImpl( + data: AddressFieldResponse, + code: String, + schema: String, + goneDescription: Boolean = false + ) { + setAddressSearchArs(data, code, schema, goneDescription) + } + + +} \ No newline at end of file diff --git a/address_search/src/test/java/ua/gov/diia/address_search/ui/CompoundAddressSearchVMTest.kt b/address_search/src/test/java/ua/gov/diia/address_search/ui/CompoundAddressSearchVMTest.kt new file mode 100644 index 0000000..690394c --- /dev/null +++ b/address_search/src/test/java/ua/gov/diia/address_search/ui/CompoundAddressSearchVMTest.kt @@ -0,0 +1,1526 @@ +package ua.gov.diia.address_search.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.address_search.MainDispatcherRule +import ua.gov.diia.address_search.models.AddressFieldResponse +import ua.gov.diia.address_search.models.AddressIdentifier +import ua.gov.diia.address_search.models.AddressItem +import ua.gov.diia.address_search.models.AddressParameter +import ua.gov.diia.address_search.models.AddressValidation +import ua.gov.diia.address_search.models.SearchType +import ua.gov.diia.address_search.network.ApiAddressSearch +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import java.net.SocketTimeoutException + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class CompoundAddressSearchVMTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var compoundAddressSearchVM: CompoundAddressSearchVM + + lateinit var apiAddressSearch: ApiAddressSearch + lateinit var clientAlertDialogsFactory: ClientAlertDialogsFactory + lateinit var addressParameterMapper: AddressParameterMapper + + @Before + fun setUp() { + apiAddressSearch = mockk() + clientAlertDialogsFactory = mockk(relaxed = true) + addressParameterMapper = mockk(relaxed = true) + compoundAddressSearchVM = + CompoundAddressSearchVM(apiAddressSearch, clientAlertDialogsFactory, addressParameterMapper) + } + + lateinit var addressParam: AddressParameter + lateinit var addressItem: AddressItem + lateinit var addressValidation: AddressValidation + lateinit var paramsGetItems: Array + + val fieldTitle = "title" + val fieldDescription = "description" + fun prepareMockForGetFieldSetupAction(filedType: String, name: String = "address_name", initAddress: Boolean = false): AddressFieldResponse { + addressParam = mockk(relaxed = true) + addressItem = mockk() + paramsGetItems = arrayOf(addressItem) + addressValidation = mockk() + every { addressItem.name } returns name + every { addressValidation.regexp } returns "regexp" + every { addressParam.getDefaultAddress() } returns addressItem + every { addressParam.type } returns filedType + every { addressParam.getSearchType() } returns SearchType.LIST + every { addressParam.getItems() } returns paramsGetItems + every { addressParam.validation } returns addressValidation + + val addressParameterList = mutableListOf() + addressParameterList.add(addressParam) + + var address: AddressIdentifier? = null + if(initAddress) { + address = mockk() + } + return AddressFieldResponse(fieldTitle, fieldDescription, addressParameterList, address, null, null) + } + + @Test + fun `test setArgs set title and description`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + val response = mockk(relaxed = true) + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + Assert.assertEquals(fieldTitle, compoundAddressSearchVM.screenHeader.value) + Assert.assertEquals(fieldDescription, compoundAddressSearchVM.addressDescription.value) + } + } + + + @Test + fun `test setArgs not set twice set title and description`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + val response = mockk(relaxed = true) + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val addressParam = mockk(relaxed = true) + val addressItem = mockk() + val paramsGetItems = arrayOf(addressItem) + val addressValidation = mockk() + every { addressItem.name } returns "address_name" + every { addressValidation.regexp } returns "regexp" + every { addressParam.getDefaultAddress() } returns addressItem + every { addressParam.type } returns AddressSearchFieldType.FieldType.COUNTRY + every { addressParam.getSearchType() } returns SearchType.LIST + every { addressParam.getItems() } returns paramsGetItems + every { addressParam.validation } returns addressValidation + + val addressParameterList = mutableListOf() + addressParameterList.add(addressParam) + val fieldTitle2: String = "fieldTitle2" + val fieldDescription2: String = "fieldDescription2" + val secondData = + AddressFieldResponse(fieldTitle, fieldDescription, addressParameterList, null, null, null) + + compoundAddressSearchVM.setArs(secondData, "code", "scheme") + + Assert.assertNotEquals(fieldTitle2, compoundAddressSearchVM.screenHeader.value) + Assert.assertNotEquals( + fieldDescription2, + compoundAddressSearchVM.addressDescription.value + ) + } + } + + @Test + fun `test selectCountry`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + compoundAddressSearchVM.selectCountry() + + var addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + assertEquals(CompoundAddressResultKey.RESULT_KEY_COUNTRY, addressSelection.resultCode) + assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectCountry no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectCountry() + + var addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setFieldParams isEndForAddressSelection true and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + val item = AddressItem("id", "name", "errormessage") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + compoundAddressSearchVM.setSelectedCountry(item) + + coVerify(exactly = 1) { response.isEndForAddressSelection() } + coVerify(exactly = 1) { response.address } + + compoundAddressSearchVM.launchRetryAction() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_COUNTRY, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test setSelectedCountry requestNextField`() { + runBlocking { + val loadingState = mutableListOf() + val observer = Observer { + loadingState.add(it) + } + compoundAddressSearchVM.loading.observeForever(observer) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", "errormessage") + compoundAddressSearchVM.setSelectedCountry(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedCountry.value) + + Assert.assertEquals(false, compoundAddressSearchVM.loading.value) + Assert.assertEquals(true, loadingState[0]) + Assert.assertEquals(false, loadingState[1]) + compoundAddressSearchVM.loading.removeObserver(observer) + } + } + + @Test + fun `test setAddressSelectionResult`() { + val loadingState = mutableListOf() + val observer = Observer { + loadingState.add(it) + } + + compoundAddressSearchVM.actionButtonLoading.observeForever(observer) + val response = mockk(relaxed = true) + val addressIdentifier = mockk(relaxed = true) + every { response.address } returns addressIdentifier + coEvery { apiAddressSearch.getFieldContext(any(), any(), any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + clearMocks(apiAddressSearch) + coEvery { apiAddressSearch.getFieldContext(any(), any(), any()) } returns response + + compoundAddressSearchVM.setAddressSelectionResult() + coVerify(exactly = 1) { apiAddressSearch.getFieldContext(any(), any(), any()) } + Assert.assertEquals( + addressIdentifier, + compoundAddressSearchVM.setAddressSelection.value!!.peekContent() + ) + + clearMocks(apiAddressSearch) + compoundAddressSearchVM.launchRetryAction() + coVerify(exactly = 1) { apiAddressSearch.getFieldContext(any(), any(), any()) } + Assert.assertEquals( + addressIdentifier, + compoundAddressSearchVM.setAddressSelection.value!!.peekContent() + ) + Assert.assertEquals(true, loadingState[0]) + Assert.assertEquals(false, loadingState[1]) + compoundAddressSearchVM.actionButtonLoading.removeObserver(observer) + } + + @Test + fun `test setAddressSelectionResult reset address selection if address identifier was set`() { + val loadingState = mutableListOf() + val observer = Observer { + loadingState.add(it) + } + + compoundAddressSearchVM.actionButtonLoading.observeForever(observer) + val response = mockk(relaxed = true) + val addressIdentifier = mockk(relaxed = true) + every { response.address } returns addressIdentifier + coEvery { apiAddressSearch.getFieldContext(any(), any(), any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS, initAddress = true) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + clearMocks(apiAddressSearch) + coEvery { apiAddressSearch.getFieldContext(any(), any(), any()) } returns response + + compoundAddressSearchVM.setAddressSelectionResult() + + assertEquals( + data.address, + compoundAddressSearchVM.setAddressSelection.value!!.peekContent() + ) + } + @Test + fun `test selectRegion`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectRegion() + + var addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_REGION, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + + } + } + + @Test + fun `test selectRegion no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectRegion() + + var addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedRegion requestNextField and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedRegion(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedRegion.value) + + clearMocks(apiAddressSearch) + compoundAddressSearchVM.launchRetryAction() + var addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_REGION, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectDistrict`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DISTRICT) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectDistrict() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_DISTRICT, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectDistrict no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectDistrict() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedDistrict requestNextField and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DISTRICT) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedDistrict(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedDistrict.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_DISTRICT, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectCityType`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY_TYPE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectCityType() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_CITY_TYPE, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectCityType no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectCityType() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedCityType requestNextField and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY_TYPE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedCityType(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedCityType.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_CITY_TYPE, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectCity`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectCity() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_CITY, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectCity no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectCity() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedCity requestNextField`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedCity(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedCity.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_CITY, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectPostOffice`() { + runBlocking { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectPostOffice() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_POST_OFFICE, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectPostOffice no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectPostOffice() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedPostOffice requestNextField`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedPostOffice(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedPostOffice.value) + } + } + + @Test + fun `test retry of selectPostOffice`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { + apiAddressSearch.getFieldContext( + "code", + "scheme", + any() + ) + } throws RuntimeException("error") + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedPostOffice(item) + + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_POST_OFFICE, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test consume exception of no internet`() { + runBlocking { + val alertNoInternetTemplate = mockk() + every { clientAlertDialogsFactory.alertNoInternet() } returns alertNoInternetTemplate + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { + apiAddressSearch.getFieldContext( + any(), + any(), + any() + ) + } throws SocketTimeoutException("error") + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedPostOffice(item) + assertEquals( + alertNoInternetTemplate, + compoundAddressSearchVM.showTemplateDialog.value!!.peekContent() + ) + } + } + + @Test + fun `test consume runtime exception`() { + runBlocking { + val err = java.lang.RuntimeException("error") + val alertTemplate = mockk() + every { + clientAlertDialogsFactory.unknownErrorAlert( + any(), + e = err + ) + } returns alertTemplate + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext(any(), any(), any()) } throws err + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedPostOffice(item) + assertEquals( + alertTemplate, + compoundAddressSearchVM.showTemplateDialog.value!!.peekContent() + ) + } + } + + @Test + fun `test selectStreetType`() { + runBlocking { + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET_TYPE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectStreetType() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_STREET_TYPE, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + @Test + fun `test selectStreetType no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectStreetType() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedStreetType requestNextField and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = + prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET_TYPE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedStreetType(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedStreetType.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_STREET_TYPE, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectStreet`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectStreet() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_STREET, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectStreet no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectStreet() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedStreet requestNextField and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedStreet(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedStreet.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_STREET, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectHouse`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectHouse() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_HOUSE, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectHouse no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectHouse() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedHouse requestNextField and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedHouse(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedHouse.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_HOUSE, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectApartment`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectApartment() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_APARTMENT, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectApartment no processing if not value`() { + runBlocking { + compoundAddressSearchVM.selectApartment() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedApartment requestNextField and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedApartment(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedApartment.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_APARTMENT, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectCorp`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectCorp() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_CORP, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectCorp no prcess if not value`() { + runBlocking { + compoundAddressSearchVM.selectCorp() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedCorp requestNextField and retry`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedCorp(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedCorp.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_CORP, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectZip`() { + runBlocking { + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + compoundAddressSearchVM.selectZip() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_ZIP, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test selectZip not act if no zipFieldParam`() { + runBlocking { + compoundAddressSearchVM.selectZip() + + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value + assertNull(addressSelection) + } + } + + @Test + fun `test setSelectedZip requestNextField`() { + runBlocking { + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + val item = AddressItem("id", "name", null) + compoundAddressSearchVM.setSelectedZip(item) + + coVerify(exactly = 1) { apiAddressSearch.getFieldContext("code", "scheme", any()) } + Assert.assertEquals(item, compoundAddressSearchVM.selectedZip.value) + + compoundAddressSearchVM.launchRetryAction() + val addressSelection = + compoundAddressSearchVM.navigateToAddressSelection.value!!.peekContent() + + Assert.assertEquals( + CompoundAddressResultKey.RESULT_KEY_ZIP, + addressSelection.resultCode + ) + Assert.assertEquals(SearchType.LIST, addressSelection.searchType) + Assert.assertArrayEquals(paramsGetItems, addressSelection.items) + } + } + + @Test + fun `test update live data after set COUNTRY args`() { + runBlocking { + var showCountryFiles = false + var showCountryFieldMode = -1 + val showCountryFieldObserver = Observer() { + showCountryFiles = it + } + val countryFieldModeObserver = Observer() { + showCountryFieldMode = it + } + compoundAddressSearchVM.showCountryField.observeForever(showCountryFieldObserver) + compoundAddressSearchVM.countryFieldMode.observeForever(countryFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.COUNTRY) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.countryFieldParams.value) + assertTrue(showCountryFiles) + assertEquals(0, showCountryFieldMode) + + compoundAddressSearchVM.showCountryField.removeObserver(showCountryFieldObserver) + compoundAddressSearchVM.countryFieldMode.removeObserver(countryFieldModeObserver) + } + } + + @Test + fun `test update live data after set REGION args`() { + runBlocking { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showRegionsField.observeForever(showObserver) + compoundAddressSearchVM.regionFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.REGION) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.regionFieldParams.value) + assertTrue(showField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showRegionsField.removeObserver(showObserver) + compoundAddressSearchVM.regionFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set DISTRICT args`() { + runBlocking { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showDistrictField.observeForever(showObserver) + compoundAddressSearchVM.districtFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.DISTRICT) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.districtFieldParams.value) + assertTrue(showField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showDistrictField.removeObserver(showObserver) + compoundAddressSearchVM.districtFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set CITY_TYPE args`() { + runBlocking { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showCityTypeField.observeForever(showObserver) + compoundAddressSearchVM.cityTypeFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY_TYPE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.cityTypeFieldParams.value) + assertTrue(showField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showCityTypeField.removeObserver(showObserver) + compoundAddressSearchVM.cityTypeFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set CITY args`() { + runBlocking { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showCityField.observeForever(showObserver) + compoundAddressSearchVM.cityFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CITY) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.cityFieldParams.value) + assertTrue(showField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showCityField.removeObserver(showObserver) + compoundAddressSearchVM.cityFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set POST_OFFICE args`() { + runBlocking { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showPostOfficeField.observeForever(showObserver) + compoundAddressSearchVM.postOfficeFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.POST_OFFICE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.postOfficeFieldParams.value) + assertTrue(showField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showPostOfficeField.removeObserver(showObserver) + compoundAddressSearchVM.postOfficeFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set STREET_TYPE args`() { + runBlocking { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showStreetTypeField.observeForever(showObserver) + compoundAddressSearchVM.streetTypeFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET_TYPE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.streetTypeFieldParams.value) + assertTrue(showField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showStreetTypeField.removeObserver(showObserver) + compoundAddressSearchVM.streetTypeFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set STREET args`() { + runBlocking { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showStreetField.observeForever(showObserver) + compoundAddressSearchVM.streetFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.STREET) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.streetFieldParams.value) + assertTrue(showField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showStreetField.removeObserver(showObserver) + compoundAddressSearchVM.streetFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set HOUSE args`() { + runBlocking { + var showField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showHouseField.observeForever(showObserver) + compoundAddressSearchVM.houseFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.houseFieldParams.value) + assertTrue(showField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showHouseField.removeObserver(showObserver) + compoundAddressSearchVM.houseFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set APARTMENT args`() { + runBlocking { + var showField = false + var showCorpsField = false + var showFieldMode = -1 + val showObserver = Observer() { + showField = it + } + val showCorpsObserver = Observer() { + showCorpsField = it + } + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + compoundAddressSearchVM.showCorpsField.observeForever(showCorpsObserver) + compoundAddressSearchVM.showApartmentField.observeForever(showObserver) + compoundAddressSearchVM.apartmentFieldMode.observeForever(regionFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.apartmentFieldParams.value) + assertTrue(showField) + assertTrue(showCorpsField) + assertEquals(0, showFieldMode) + + compoundAddressSearchVM.showCorpsField.removeObserver(showObserver) + compoundAddressSearchVM.showApartmentField.removeObserver(showObserver) + compoundAddressSearchVM.apartmentFieldMode.removeObserver(regionFieldModeObserver) + } + } + + @Test + fun `test update live data after set CORPS args`() { + runBlocking { + var showFieldMode = -1 + var showZipFieldMode = -1 + val regionFieldModeObserver = Observer() { + showFieldMode = it + } + val zipFieldModeObserver = Observer() { + showZipFieldMode = it + } + + compoundAddressSearchVM.corpFieldMode.observeForever(regionFieldModeObserver) + compoundAddressSearchVM.zipFieldMode.observeForever(zipFieldModeObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.corpsFieldParams.value) + assertEquals(0, showFieldMode) + assertEquals(0, showZipFieldMode) + + compoundAddressSearchVM.corpFieldMode.removeObserver(regionFieldModeObserver) + compoundAddressSearchVM.zipFieldMode.removeObserver(zipFieldModeObserver) + } + } + + @Test + fun `test update live data after set ZIP args`() { + runBlocking { + var showField = false + val showObserver = Observer() { + showField = it + } + compoundAddressSearchVM.showZipField.observeForever(showObserver) + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP) + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertEquals(data.parameters!![0], compoundAddressSearchVM.zipFieldParams.value) + assertTrue(showField) + + compoundAddressSearchVM.showZipField.removeObserver(showObserver) + } + } + + @Test + fun `test approveZipField returns true if data is valid for error`() { + runBlocking { + var showError = false + val showObserver = Observer() { + showError = it + } + compoundAddressSearchVM.showZipFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP, name = "regexp") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + Assert.assertFalse(showError) + + compoundAddressSearchVM.showZipFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveZipField returns false if data is valid`() { + runBlocking { + var showError = true + val showObserver = Observer() { + showError = it + } + compoundAddressSearchVM.showZipFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.ZIP, name = "zipcode") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertTrue(showError) + + compoundAddressSearchVM.showZipFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveCorpsField returns true if data is valid for error`() { + runBlocking { + var showError = false + val showObserver = Observer() { + showError = it + } + compoundAddressSearchVM.showCorpsFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS, name = "regexp") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + Assert.assertFalse(showError) + + compoundAddressSearchVM.showCorpsFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveCorpsField returns false if data is valid`() { + runBlocking { + var showError = true + val showObserver = Observer() { + showError = it + } + compoundAddressSearchVM.showCorpsFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.CORPS, name = "zipcode") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertTrue(showError) + + compoundAddressSearchVM.showCorpsFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveApartmentField returns true if data is valid for error`() { + runBlocking { + var showError = false + val showObserver = Observer() { + showError = it + } + compoundAddressSearchVM.showApartmentFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT, name = "regexp") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + Assert.assertFalse(showError) + + compoundAddressSearchVM.showApartmentFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveApartmentField returns false if data is valid`() { + runBlocking { + var showError = true + val showObserver = Observer() { + showError = it + } + compoundAddressSearchVM.showApartmentFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.APARTMENT, name = "zipcode") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertTrue(showError) + + compoundAddressSearchVM.showApartmentFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveHouseField returns true if data is valid for error`() { + runBlocking { + var showError = false + val showObserver = Observer() { + showError = it + } + compoundAddressSearchVM.showHouseFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE, name = "regexp") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + Assert.assertFalse(showError) + + compoundAddressSearchVM.showHouseFieldError.removeObserver(showObserver) + } + } + + @Test + fun `test approveHouseField returns false if data is valid`() { + runBlocking { + var showError = true + val showObserver = Observer() { + showError = it + } + compoundAddressSearchVM.showHouseFieldError.observeForever(showObserver) + val response = mockk(relaxed = true) + every { response.isEndForAddressSelection() } returns true + coEvery { apiAddressSearch.getFieldContext("code", "scheme", any()) } returns response + val data = prepareMockForGetFieldSetupAction(AddressSearchFieldType.FieldType.HOUSE, name = "zipcode") + + compoundAddressSearchVM.setArs(data, "code", "scheme") + + assertTrue(showError) + + compoundAddressSearchVM.showHouseFieldError.removeObserver(showObserver) + } + } +} \ No newline at end of file diff --git a/analytics/.gitignore b/analytics/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/analytics/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/analytics/README.md b/analytics/README.md new file mode 100644 index 0000000..efb83df --- /dev/null +++ b/analytics/README.md @@ -0,0 +1,18 @@ +# Description + +This is module responsible for analytics collection and crash reporting. The module implements +functionality for the Google Play and Hauwei platforms. + +# How to install + +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':analytics') +``` + +2. Module requires next modules to work + +```groovy +implementation project(':core') +``` diff --git a/analytics/build.gradle b/analytics/build.gradle new file mode 100644 index 0000000..11ea873 --- /dev/null +++ b/analytics/build.gradle @@ -0,0 +1,100 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.analytics' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + // work-runtime-ktx 2.1.0 and above now requires Java 8 + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + +} + +dependencies { + implementation project(':core') + + implementation deps.hilt_android + kapt deps.hilt_android_compiler + kapt deps.hilt_compiler + + gplayImplementation deps.gplay_firebase_analytics + gplayImplementation deps.gplay_firebase_crashlytics + + huaweiImplementation deps.huawei_analytics + huaweiImplementation deps.huawei_agconnect_crash + + testImplementation deps.junit + testImplementation deps.mockk +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/analytics/consumer-rules.pro b/analytics/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/analytics/excludes.jacoco b/analytics/excludes.jacoco new file mode 100644 index 0000000..afe8e03 --- /dev/null +++ b/analytics/excludes.jacoco @@ -0,0 +1,3 @@ +ua/gov/diia/analytics/ui/**/*F.* +ua/gov/diia/analytics/**/*$*.* +ua/gov/diia/analytics/DiiaAnalyticsImpl* \ No newline at end of file diff --git a/analytics/proguard-rules.pro b/analytics/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/analytics/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/analytics/src/gplay/java/ua/gov/diia/analytics/DiiaAnalyticsImpl.kt b/analytics/src/gplay/java/ua/gov/diia/analytics/DiiaAnalyticsImpl.kt new file mode 100644 index 0000000..145ca91 --- /dev/null +++ b/analytics/src/gplay/java/ua/gov/diia/analytics/DiiaAnalyticsImpl.kt @@ -0,0 +1,168 @@ +package ua.gov.diia.analytics + +import android.content.Context +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import ua.gov.diia.analytics.DiiaAnalytics.Companion.ACTION +import ua.gov.diia.analytics.DiiaAnalytics.Companion.BANK_ID +import ua.gov.diia.analytics.DiiaAnalytics.Companion.EXTRA_DATA +import ua.gov.diia.analytics.DiiaAnalytics.Companion.FACE_RECO_INIT +import ua.gov.diia.analytics.DiiaAnalytics.Companion.FACE_RECO_RESULT +import ua.gov.diia.analytics.DiiaAnalytics.Companion.INIT_LOGIN_BY_BANK_APP +import ua.gov.diia.analytics.DiiaAnalytics.Companion.INIT_LOGIN_BY_BANK_ID +import ua.gov.diia.analytics.DiiaAnalytics.Companion.INIT_LOGIN_BY_ID_CARD +import ua.gov.diia.analytics.DiiaAnalytics.Companion.MESSAGE_DATA +import ua.gov.diia.analytics.DiiaAnalytics.Companion.NETWORK_INIT_API_CALL +import ua.gov.diia.analytics.DiiaAnalytics.Companion.NETWORK_RESULT_API_CALL +import ua.gov.diia.analytics.DiiaAnalytics.Companion.NFC_READING_INIT +import ua.gov.diia.analytics.DiiaAnalytics.Companion.NFC_READING_RESULT +import ua.gov.diia.analytics.DiiaAnalytics.Companion.NOTIFICATION_RECEIVED +import ua.gov.diia.analytics.DiiaAnalytics.Companion.PUSH_NOTIFICATION_ID +import ua.gov.diia.analytics.DiiaAnalytics.Companion.PUSH_NOTIFICATION_RECEIVED +import ua.gov.diia.analytics.DiiaAnalytics.Companion.PUSH_NOTIFICATION_SHOWN +import ua.gov.diia.analytics.DiiaAnalytics.Companion.PUSH_TOKEN_PROPERTY +import ua.gov.diia.analytics.DiiaAnalytics.Companion.PUSH_TOKEN_RECEIVED +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_FAIL +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_LOGIN_BY_BANK_APP +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_LOGIN_BY_BANK_ID +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_LOGIN_BY_ID_CARD +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_SUCCESS +import ua.gov.diia.analytics.DiiaAnalytics.Companion.SELECTED_OPTION +import ua.gov.diia.analytics.DiiaAnalytics.Companion.STATE +import ua.gov.diia.analytics.DiiaAnalytics.Companion.TOKEN +import ua.gov.diia.analytics.DiiaAnalytics.Companion.UUID + +internal class DiiaAnalyticsImpl(context: Context) : DiiaAnalytics { + + private val firebaseAnalytics: FirebaseAnalytics = FirebaseAnalytics.getInstance(context) + + override fun setUserId(userId: String) { + firebaseAnalytics.setUserId(userId) + } + + override fun setPushToken(pushToken: String) { + firebaseAnalytics.setUserProperty(PUSH_TOKEN_PROPERTY, pushToken) + } + + override fun networkRequest(action: String) { + val bundle = newBundle() + bundle.putString(ACTION, action) + firebaseAnalytics.logEvent(NETWORK_INIT_API_CALL, bundle) + } + + override fun networkResponse(action: String, success: Boolean, reasonFail: String?) { + val bundle = newResultBundle(success, reasonFail) + bundle.putString(ACTION, action) + firebaseAnalytics.logEvent(NETWORK_RESULT_API_CALL, bundle) + } + + override fun initLoginByBankApp(selectedOption: String) { + val bundle = newBundle() + bundle.putString(SELECTED_OPTION, selectedOption) + firebaseAnalytics.logEvent(INIT_LOGIN_BY_BANK_APP, bundle) + } + + override fun resultLoginByBankApp( + selectedOption: String, + success: Boolean, + reasonFail: String? + ) { + val bundle = newResultBundle(success, reasonFail) + bundle.putString(SELECTED_OPTION, selectedOption) + firebaseAnalytics.logEvent(RESULT_LOGIN_BY_BANK_APP, bundle) + } + + override fun initLoginByBankId(bankId: String) { + val bundle = newBundle() + bundle.putString(BANK_ID, bankId) + firebaseAnalytics.logEvent(INIT_LOGIN_BY_BANK_ID, bundle) + } + + override fun resultLoginByBankId(bankId: String, success: Boolean, reasonFail: String?) { + val bundle = newResultBundle(success, reasonFail) + bundle.putString(BANK_ID, bankId) + firebaseAnalytics.logEvent(RESULT_LOGIN_BY_BANK_ID, bundle) + } + + override fun initLoginByIdCard() { + val bundle = newBundle() + firebaseAnalytics.logEvent(INIT_LOGIN_BY_ID_CARD, bundle) + } + + override fun resultLoginByIdCard(success: Boolean, reasonFail: String?) { + val bundle = newResultBundle(success, reasonFail) + firebaseAnalytics.logEvent(RESULT_LOGIN_BY_ID_CARD, bundle) + } + + override fun refreshToken(mobileUid: String, pushToken: String) { + val bundle = newBundle() + bundle.putString(UUID, mobileUid) + bundle.putString(TOKEN, pushToken) + firebaseAnalytics.logEvent(PUSH_TOKEN_RECEIVED, bundle) + } + + override fun notificationReceived(messageBody: String) { + val bundle = newBundle() + bundle.putString(MESSAGE_DATA, messageBody) + firebaseAnalytics.logEvent(NOTIFICATION_RECEIVED, bundle) + } + + override fun nfcReadingInit(mobileUid: String, action: String) { + firebaseAnalytics.logEvent( + NFC_READING_INIT, + newBundle().apply { + putString(UUID, mobileUid) + putString(STATE, action) + } + ) + } + + override fun nfcReadingResult( + mobileUid: String, + action: String, + success: Boolean, + reasonFail: String? + ) { + firebaseAnalytics.logEvent( + NFC_READING_RESULT, + newResultBundle(success, reasonFail).apply { + putString(UUID, mobileUid) + putString(STATE, action) + } + ) + } + + override fun faceRecognitionInit() { + firebaseAnalytics.logEvent(FACE_RECO_INIT, newBundle()) + } + + override fun faceRecognitionResult(success: Boolean, reasonFail: String?) { + firebaseAnalytics.logEvent(FACE_RECO_RESULT, newResultBundle(success, reasonFail)) + } + + override fun pushReceived(notificationId: String) { + val bundle = newBundle() + bundle.putString(PUSH_NOTIFICATION_ID, notificationId) + firebaseAnalytics.logEvent(PUSH_NOTIFICATION_RECEIVED, bundle) + } + + override fun pushShown(notificationId: String) { + val bundle = newBundle() + bundle.putString(PUSH_NOTIFICATION_ID, notificationId) + firebaseAnalytics.logEvent(PUSH_NOTIFICATION_SHOWN, bundle) + } + + private fun newBundle(): Bundle { + return Bundle() + } + + private fun newResultBundle(success: Boolean, reasonFail: String?): Bundle { + val bundle = newBundle() + bundle.putString(RESULT, if (success) RESULT_SUCCESS else RESULT_FAIL) + reasonFail?.let { + bundle.putString(EXTRA_DATA, reasonFail) + } + return bundle + } +} diff --git a/analytics/src/gplay/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImpl.kt b/analytics/src/gplay/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImpl.kt new file mode 100644 index 0000000..e473e9e --- /dev/null +++ b/analytics/src/gplay/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImpl.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.analytics.crashlytics + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import ua.gov.diia.core.util.delegation.WithCrashlytics +import javax.inject.Inject + +internal class WithCrashlyticsImpl @Inject constructor() : WithCrashlytics { + + private companion object { + const val KEY_MESSAGE = "message" + } + + override fun sendNonFatalError(e: Throwable) { + e.message?.let { FirebaseCrashlytics.getInstance().setCustomKey(KEY_MESSAGE, it) } + FirebaseCrashlytics.getInstance().recordException(e) + } +} diff --git a/analytics/src/huawei/java/ua/gov/diia/analytics/DiiaAnalyticsImpl.kt b/analytics/src/huawei/java/ua/gov/diia/analytics/DiiaAnalyticsImpl.kt new file mode 100644 index 0000000..05fdfa2 --- /dev/null +++ b/analytics/src/huawei/java/ua/gov/diia/analytics/DiiaAnalyticsImpl.kt @@ -0,0 +1,144 @@ +package ua.gov.diia.analytics + +import android.content.Context +import android.os.Bundle +import com.huawei.hms.analytics.HiAnalytics +import com.huawei.hms.analytics.HiAnalyticsInstance +import ua.gov.diia.analytics.DiiaAnalytics.Companion.ACTION +import ua.gov.diia.analytics.DiiaAnalytics.Companion.BANK_ID +import ua.gov.diia.analytics.DiiaAnalytics.Companion.EXTRA_DATA +import ua.gov.diia.analytics.DiiaAnalytics.Companion.INIT_LOGIN_BY_BANK_APP +import ua.gov.diia.analytics.DiiaAnalytics.Companion.INIT_LOGIN_BY_BANK_ID +import ua.gov.diia.analytics.DiiaAnalytics.Companion.INIT_LOGIN_BY_ID_CARD +import ua.gov.diia.analytics.DiiaAnalytics.Companion.MESSAGE_DATA +import ua.gov.diia.analytics.DiiaAnalytics.Companion.NETWORK_INIT_API_CALL +import ua.gov.diia.analytics.DiiaAnalytics.Companion.NETWORK_RESULT_API_CALL +import ua.gov.diia.analytics.DiiaAnalytics.Companion.NOTIFICATION_RECEIVED +import ua.gov.diia.analytics.DiiaAnalytics.Companion.PUSH_NOTIFICATION_RECEIVED +import ua.gov.diia.analytics.DiiaAnalytics.Companion.PUSH_TOKEN_PROPERTY +import ua.gov.diia.analytics.DiiaAnalytics.Companion.PUSH_TOKEN_RECEIVED +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_FAIL +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_LOGIN_BY_BANK_APP +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_LOGIN_BY_BANK_ID +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_LOGIN_BY_ID_CARD +import ua.gov.diia.analytics.DiiaAnalytics.Companion.RESULT_SUCCESS +import ua.gov.diia.analytics.DiiaAnalytics.Companion.SELECTED_OPTION +import ua.gov.diia.analytics.DiiaAnalytics.Companion.TOKEN +import ua.gov.diia.analytics.DiiaAnalytics.Companion.UUID + +internal class DiiaAnalyticsImpl(context: Context) : DiiaAnalytics { + + private val huaweiAnalytics: HiAnalyticsInstance = HiAnalytics.getInstance(context) + + override fun setUserId(userId: String) { + huaweiAnalytics.setUserId(userId) + } + + override fun setPushToken(pushToken: String) { + huaweiAnalytics.setUserProfile(PUSH_TOKEN_PROPERTY, pushToken) + } + + override fun networkRequest(action: String) { + val bundle = newBundle() + bundle.putString(ACTION, action) + huaweiAnalytics.onEvent(NETWORK_INIT_API_CALL, bundle) + } + + override fun networkResponse(action: String, success: Boolean, reasonFail: String?) { + val bundle = newResultBundle(success, reasonFail) + bundle.putString(ACTION, action) + huaweiAnalytics.onEvent(NETWORK_RESULT_API_CALL, bundle) + } + + override fun initLoginByBankApp(selectedOption: String) { + val bundle = newBundle() + bundle.putString(SELECTED_OPTION, selectedOption) + huaweiAnalytics.onEvent(INIT_LOGIN_BY_BANK_APP, bundle) + } + + override fun resultLoginByBankApp( + selectedOption: String, + success: Boolean, + reasonFail: String? + ) { + val bundle = newResultBundle(success, reasonFail) + bundle.putString(SELECTED_OPTION, selectedOption) + huaweiAnalytics.onEvent(RESULT_LOGIN_BY_BANK_APP, bundle) + } + + override fun initLoginByBankId(bankId: String) { + val bundle = newBundle() + bundle.putString(BANK_ID, bankId) + huaweiAnalytics.onEvent(INIT_LOGIN_BY_BANK_ID, bundle) + } + + override fun resultLoginByBankId(bankId: String, success: Boolean, reasonFail: String?) { + val bundle = newResultBundle(success, reasonFail) + bundle.putString(BANK_ID, bankId) + huaweiAnalytics.onEvent(RESULT_LOGIN_BY_BANK_ID, bundle) + } + + override fun initLoginByIdCard() { + val bundle = newBundle() + huaweiAnalytics.onEvent(INIT_LOGIN_BY_ID_CARD, bundle) + } + + override fun resultLoginByIdCard(success: Boolean, reasonFail: String?) { + val bundle = newResultBundle(success, reasonFail) + huaweiAnalytics.onEvent(RESULT_LOGIN_BY_ID_CARD, bundle) + } + + override fun refreshToken(mobileUid: String, pushToken: String) { + val bundle = newBundle() + bundle.putString(UUID, mobileUid) + bundle.putString(TOKEN, pushToken) + huaweiAnalytics.onEvent(PUSH_TOKEN_RECEIVED, bundle) + } + + override fun notificationReceived(messageBody: String) { + val bundle = newBundle() + bundle.putString(MESSAGE_DATA, messageBody) + huaweiAnalytics.onEvent(NOTIFICATION_RECEIVED, bundle) + } + + override fun pushReceived(notificationId: String) { + val bundle = newBundle() + bundle.putString(DiiaAnalytics.PUSH_NOTIFICATION_ID, notificationId) + huaweiAnalytics.onEvent(PUSH_NOTIFICATION_RECEIVED, bundle) + } + + override fun pushShown(notificationId: String) { + val bundle = newBundle() + bundle.putString(DiiaAnalytics.PUSH_NOTIFICATION_ID, notificationId) + huaweiAnalytics.onEvent(DiiaAnalytics.PUSH_NOTIFICATION_SHOWN, bundle) + } + + private fun newBundle(): Bundle { + return Bundle() + } + + private fun newResultBundle(success: Boolean, reasonFail: String?): Bundle { + val bundle = newBundle() + bundle.putString(RESULT, if (success) RESULT_SUCCESS else RESULT_FAIL) + reasonFail?.let { bundle.putString(EXTRA_DATA, it) } + return bundle + } + + override fun nfcReadingInit(mobileUid: String, action: String) { + } + + override fun nfcReadingResult( + mobileUid: String, + action: String, + success: Boolean, + reasonFail: String? + ) { + } + + override fun faceRecognitionInit() { + } + + override fun faceRecognitionResult(success: Boolean, reasonFail: String?) { + } +} diff --git a/analytics/src/huawei/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImpl.kt b/analytics/src/huawei/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImpl.kt new file mode 100644 index 0000000..9bf86c9 --- /dev/null +++ b/analytics/src/huawei/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImpl.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.analytics.crashlytics + +import com.huawei.agconnect.crash.AGConnectCrash +import ua.gov.diia.core.util.delegation.WithCrashlytics + +internal class WithCrashlyticsImpl : WithCrashlytics { + + override fun sendNonFatalError(e: Throwable) { + runCatching { + AGConnectCrash.getInstance().recordException(e) + } + } +} \ No newline at end of file diff --git a/analytics/src/main/AndroidManifest.xml b/analytics/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10f6c24 --- /dev/null +++ b/analytics/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/analytics/src/main/java/ua/gov/diia/analytics/DiiaAnalytics.kt b/analytics/src/main/java/ua/gov/diia/analytics/DiiaAnalytics.kt new file mode 100644 index 0000000..e167c36 --- /dev/null +++ b/analytics/src/main/java/ua/gov/diia/analytics/DiiaAnalytics.kt @@ -0,0 +1,87 @@ +package ua.gov.diia.analytics + +interface DiiaAnalytics { + + fun setUserId(userId: String) + + fun setPushToken(pushToken: String) + + fun networkRequest(action: String) + + fun networkResponse(action: String, success: Boolean, reasonFail: String? = null) + + fun initLoginByBankApp(selectedOption: String) + + fun resultLoginByBankApp(selectedOption: String, success: Boolean, reasonFail: String? = null) + + fun initLoginByBankId(bankId: String) + + fun resultLoginByBankId(bankId: String, success: Boolean, reasonFail: String? = null) + + fun initLoginByIdCard() + + fun resultLoginByIdCard(success: Boolean, reasonFail: String? = null) + + fun refreshToken(mobileUid: String, pushToken: String) + + fun notificationReceived(messageBody: String) + + fun nfcReadingInit(mobileUid: String, action: String) + + fun nfcReadingResult( + mobileUid: String, + action: String, + success: Boolean, + reasonFail: String? = null + ) + + fun faceRecognitionInit() + + fun faceRecognitionResult(success: Boolean, reasonFail: String? = null) + + fun pushReceived(notificationId: String) + + fun pushShown(notificationId: String) + + companion object { + + const val PUSH_TOKEN_PROPERTY = "PUSH_TOKEN" + + const val NETWORK_INIT_API_CALL = "NETWORK_INIT_API_CALL" + const val NETWORK_RESULT_API_CALL = "NETWORK_RESULT_API_CALL" + + const val ACTION = "ACTION" + const val RESULT = "RESULT" + const val EXTRA_DATA = "EXTRA_DATA" + + const val RESULT_SUCCESS = "success" + const val RESULT_FAIL = "fail" + + const val SELECTED_OPTION = "SELECTED_OPTION" + const val INIT_LOGIN_BY_BANK_APP = "INIT_LOGIN_BY_BANK_APP" + const val RESULT_LOGIN_BY_BANK_APP = "RESULT_LOGIN_BY_BANK_APP" + const val BANK_ID = "BANK_ID" + const val INIT_LOGIN_BY_BANK_ID = "INIT_LOGIN_BY_BANK_ID" + const val RESULT_LOGIN_BY_BANK_ID = "RESULT_LOGIN_BY_BANK_ID" + const val INIT_LOGIN_BY_ID_CARD = "INIT_LOGIN_BY_ID_CARD" + const val RESULT_LOGIN_BY_ID_CARD = "RESULT_LOGIN_BY_ID_CARD" + + const val NFC_READING_INIT = "NFC_READING_INIT" + const val NFC_READING_RESULT = "NFC_READING_RESULT" + + const val NOTIFICATION_RECEIVED = "NOTIFICATION_RECEIVED" + const val MESSAGE_DATA = "MESSAGE_DATA" + const val PUSH_TOKEN_RECEIVED = "PUSH_TOKEN_RECEIVED" + const val UUID = "UUID" + const val TOKEN = "TOKEN" + const val STATE = "STATE" + + const val FACE_RECO_INIT = "FACE_RECO_INIT" + const val FACE_RECO_RESULT = "FACE_RECO_RESULT" + + const val PUSH_NOTIFICATION_RECEIVED = "NOTIFICATION_RECEIVED" + const val PUSH_NOTIFICATION_SHOWN = "NOTIFICATION_SHOWN" + const val PUSH_NOTIFICATION_ID = "NOTIFICATION_ID" + + } +} \ No newline at end of file diff --git a/analytics/src/main/java/ua/gov/diia/analytics/di/AnalyticsModule.kt b/analytics/src/main/java/ua/gov/diia/analytics/di/AnalyticsModule.kt new file mode 100644 index 0000000..beb02b6 --- /dev/null +++ b/analytics/src/main/java/ua/gov/diia/analytics/di/AnalyticsModule.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.analytics.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.analytics.DiiaAnalytics +import ua.gov.diia.analytics.DiiaAnalyticsImpl +import ua.gov.diia.analytics.crashlytics.WithCrashlyticsImpl +import ua.gov.diia.core.util.delegation.WithCrashlytics +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AnalyticsModule { + + @Provides + @Singleton + fun provideDiiaAnalytics( + @ApplicationContext context: Context + ): DiiaAnalytics = DiiaAnalyticsImpl(context) + + @Provides + @Singleton + fun provideCrashlytics(): WithCrashlytics = WithCrashlyticsImpl() +} diff --git a/analytics/src/testGplay/java/ua/gov/diia/analytics/DiiaAnalyticsImplTest.kt b/analytics/src/testGplay/java/ua/gov/diia/analytics/DiiaAnalyticsImplTest.kt new file mode 100644 index 0000000..667b4d4 --- /dev/null +++ b/analytics/src/testGplay/java/ua/gov/diia/analytics/DiiaAnalyticsImplTest.kt @@ -0,0 +1,36 @@ +package ua.gov.diia.analytics + +import com.google.firebase.analytics.FirebaseAnalytics +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class DiiaAnalyticsImplTest { + + private lateinit var firebaseAnalytics: FirebaseAnalytics + private lateinit var analytics: DiiaAnalytics + + @Before + fun setUp() { + firebaseAnalytics = mockk(relaxed = true) + mockkStatic(FirebaseAnalytics::class) + every { FirebaseAnalytics.getInstance(any()) } returns firebaseAnalytics + + analytics = DiiaAnalyticsImpl(mockk(relaxed = true)) + } + + @Test + fun setUserId() { + analytics.setUserId("test") + verify { firebaseAnalytics.setUserId("test") } + } + + @Test + fun setPushToken() { + analytics.setPushToken("test") + verify { firebaseAnalytics.setUserProperty(DiiaAnalytics.PUSH_TOKEN_PROPERTY, "test") } + } +} diff --git a/analytics/src/testGplay/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImplTest.kt b/analytics/src/testGplay/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImplTest.kt new file mode 100644 index 0000000..38682ef --- /dev/null +++ b/analytics/src/testGplay/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImplTest.kt @@ -0,0 +1,40 @@ +package ua.gov.diia.analytics.crashlytics + +import com.google.firebase.crashlytics.FirebaseCrashlytics +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Before +import org.junit.Test +import java.io.IOException + +class WithCrashlyticsImplTest { + + private lateinit var crashlyticsMock: FirebaseCrashlytics + + private val impl = WithCrashlyticsImpl() + + @Before + fun setUp() { + crashlyticsMock = mockk(relaxed = true) + mockkStatic(FirebaseCrashlytics::class) + every { FirebaseCrashlytics.getInstance() } returns crashlyticsMock + } + + @Test + fun `error with message`() { + val error = RuntimeException("test") + impl.sendNonFatalError(error) + verify { crashlyticsMock.setCustomKey("message", "test") } + verify { crashlyticsMock.recordException(error) } + } + + @Test + fun `error no message`() { + val error = IOException() + impl.sendNonFatalError(error) + verify(inverse = true) { crashlyticsMock.setCustomKey(any(), any()) } + verify { crashlyticsMock.recordException(error) } + } +} diff --git a/analytics/src/testHuawei/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImplTest.kt b/analytics/src/testHuawei/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImplTest.kt new file mode 100644 index 0000000..a1ae066 --- /dev/null +++ b/analytics/src/testHuawei/java/ua/gov/diia/analytics/crashlytics/WithCrashlyticsImplTest.kt @@ -0,0 +1,43 @@ +package ua.gov.diia.analytics.crashlytics + +import android.util.Log +import com.huawei.agconnect.AGConnectInstance +import com.huawei.agconnect.crash.AGConnectCrash +import com.huawei.agconnect.crash.ICrash +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Before +import org.junit.Test + +class WithCrashlyticsImplTest { + + private lateinit var agConnectCrashMock: AGConnectCrash + + private val impl = WithCrashlyticsImpl() + + @Before + fun setUp() { + mockkStatic(Log::class) + every { Log.w(any(), any() as String) } returns 0 + every { Log.w(any(), any() as Throwable?) } returns 0 + + mockkStatic(AGConnectInstance::class) + val agConnectMock: AGConnectInstance = mockk(relaxed = true) + every { AGConnectInstance.getInstance() } returns agConnectMock + every { agConnectMock.getService(any()) } returns mockk(relaxed = true) + + agConnectCrashMock = mockk(relaxed = true) + + mockkStatic(AGConnectCrash::class) + every { AGConnectCrash.getInstance() } returns agConnectCrashMock + } + + @Test + fun sendNonFatalError() { + val error = RuntimeException("test") + impl.sendNonFatalError(error) + verify { agConnectCrashMock.recordException(error) } + } +} \ No newline at end of file diff --git a/bankid/.gitignore b/bankid/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/bankid/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/bankid/README.md b/bankid/README.md new file mode 100644 index 0000000..37c952c --- /dev/null +++ b/bankid/README.md @@ -0,0 +1,33 @@ +# Description + +This is module responsible for BankID verification method. + +# How to install + +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':bankid') +``` + +2. Module requires next modules to work + +```groovy +implementation project(':core') +implementation project(':verification') +implementation project(':ui_base') +``` + +3. Add next nav graphs to main navigation graph + +```xml + +``` + +4. The following action should be added into the root navigation graph + +```xml + +``` diff --git a/bankid/build.gradle b/bankid/build.gradle new file mode 100644 index 0000000..9c72095 --- /dev/null +++ b/bankid/build.gradle @@ -0,0 +1,138 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + id 'androidx.navigation.safeargs.kotlin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.bankid' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + testOptions { + unitTests.returnDefaultValues = true + unitTests.includeAndroidResources = true + } + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation project(':core') + implementation project(':verification') + implementation project(':ui_base') + implementation deps.fragment_ktx + implementation deps.appcompat + implementation deps.constraint_layout + implementation deps.recyclerview + implementation deps.material + implementation deps.flexbox + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + kapt deps.hilt_compiler + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + //navigation + implementation deps.navigation_fragment_ktx + implementation deps.navigation_ui_ktx + //Compose + implementation deps.activity_compose + implementation deps.compose_ui + implementation deps.compose_material + implementation deps.compose_ui_tooling + implementation deps.compose_ui_tooling_preview + + //test + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.hamcrest_library + testImplementation deps.json + testImplementation deps.turbine + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/bankid/consumer-rules.pro b/bankid/consumer-rules.pro new file mode 100644 index 0000000..45f83e1 --- /dev/null +++ b/bankid/consumer-rules.pro @@ -0,0 +1,5 @@ +-keep enum * { *; } + +-keep public class ua.gov.diia.bankid.model.BankAuthRequest +-keep public class ua.gov.diia.bankid.model.BankSelectionRequest +-keep public class ua.gov.diia.bankid.model.BankAuthRequest \ No newline at end of file diff --git a/bankid/excludes.jacoco b/bankid/excludes.jacoco new file mode 100644 index 0000000..fbfda37 --- /dev/null +++ b/bankid/excludes.jacoco @@ -0,0 +1,4 @@ +ua/gov/diia/bankid/ui/**/*F.* +ua/gov/diia/bankid/**/*$*.* +ua/gov/diia/bankid/ui/**/*Screen*Kt.* +ua/gov/diia/bankid/ui/**/*ScreenData.* \ No newline at end of file diff --git a/bankid/proguard-rules.pro b/bankid/proguard-rules.pro new file mode 100644 index 0000000..38a41df --- /dev/null +++ b/bankid/proguard-rules.pro @@ -0,0 +1,4 @@ +-keep enum * { *; } + +-keep public class ua.gov.diia.bankid.model.BankAuthRequest +-keep public class ua.gov.diia.bankid.model.BankSelectionRequest \ No newline at end of file diff --git a/bankid/src/main/AndroidManifest.xml b/bankid/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ce9bc47 --- /dev/null +++ b/bankid/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/BankIdConst.kt b/bankid/src/main/java/ua/gov/diia/bankid/BankIdConst.kt new file mode 100644 index 0000000..09b26fb --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/BankIdConst.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.bankid + +object BankIdConst { + + const val METHOD_BANK_ID = "bankid" +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/di/BankIdModule.kt b/bankid/src/main/java/ua/gov/diia/bankid/di/BankIdModule.kt new file mode 100644 index 0000000..7c22e15 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/di/BankIdModule.kt @@ -0,0 +1,64 @@ +package ua.gov.diia.bankid.di + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoMap +import dagger.multibindings.StringKey +import retrofit2.Retrofit +import ua.gov.diia.bankid.BankIdConst +import ua.gov.diia.bankid.network.ApiBankId +import ua.gov.diia.bankid.ui.VerificationMethodBankId +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.di.data_source.http.ProlongClient +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.verification.di.ProviderVerifiedClient +import ua.gov.diia.verification.di.VerificationProviderType +import ua.gov.diia.verification.ui.methods.VerificationMethod + +@Module +@InstallIn(SingletonComponent::class) +interface BankIdModule { + + companion object { + + @Provides + @UnauthorizedClient + fun provideApiBankIdUnauthorized( + @UnauthorizedClient retrofit: Retrofit + ): ApiBankId = retrofit.create(ApiBankId::class.java) + + + @Provides + @ProlongClient + fun provideApiBankIdProlong( + @ProlongClient retrofit: Retrofit + ): ApiBankId = retrofit.create(ApiBankId::class.java) + + @Provides + @AuthorizedClient + fun provideApiBankIdAuthorized( + @AuthorizedClient retrofit: Retrofit + ): ApiBankId = retrofit.create(ApiBankId::class.java) + + @Provides + @ProviderVerifiedClient + fun provideApiBankId( + providerType: VerificationProviderType, + @AuthorizedClient apiBankIdAuthorized: ApiBankId, + @UnauthorizedClient apiBankIdUnauthorized: ApiBankId, + @ProlongClient apiBankIdProlong: ApiBankId + ): ApiBankId = when (providerType) { + VerificationProviderType.AUTHORIZED -> apiBankIdAuthorized + VerificationProviderType.UNAUTHORIZED -> apiBankIdUnauthorized + VerificationProviderType.PROLONG -> apiBankIdProlong + } + } + + @Binds + @IntoMap + @StringKey(BankIdConst.METHOD_BANK_ID) + fun bindBankIdVerificationMethod(method: VerificationMethodBankId): VerificationMethod +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/model/AuthBank.kt b/bankid/src/main/java/ua/gov/diia/bankid/model/AuthBank.kt new file mode 100644 index 0000000..4b6102b --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/model/AuthBank.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.bankid.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AuthBank( + @Json(name = "id") + val id: String?, + @Json(name = "logoUrl") + val logoUrl: String?, + @Json(name = "name") + val name: String?, + @Json(name = "workable") + val workable: Boolean? +) \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/model/AuthBanks.kt b/bankid/src/main/java/ua/gov/diia/bankid/model/AuthBanks.kt new file mode 100644 index 0000000..2a2c120 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/model/AuthBanks.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.bankid.model + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AuthBanks( + @Json(name = "banks") + val value: List? +){ + val hasBanks: Boolean + get() = !value.isNullOrEmpty() + + val banks: List + get() = value ?: emptyList() +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/model/BankAuthRequest.kt b/bankid/src/main/java/ua/gov/diia/bankid/model/BankAuthRequest.kt new file mode 100644 index 0000000..f249e08 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/model/BankAuthRequest.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.bankid.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class BankAuthRequest( + val authUrl: String, + val bankCode: String +) : Parcelable diff --git a/bankid/src/main/java/ua/gov/diia/bankid/model/BankSelectionRequest.kt b/bankid/src/main/java/ua/gov/diia/bankid/model/BankSelectionRequest.kt new file mode 100644 index 0000000..9d9fa08 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/model/BankSelectionRequest.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.bankid.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class BankSelectionRequest( + val schema: String, + val processId: String, + val verificationMethodCode: String +) : Parcelable diff --git a/bankid/src/main/java/ua/gov/diia/bankid/network/ApiBankId.kt b/bankid/src/main/java/ua/gov/diia/bankid/network/ApiBankId.kt new file mode 100644 index 0000000..f78be84 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/network/ApiBankId.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.bankid.network + +import retrofit2.http.GET +import ua.gov.diia.bankid.model.AuthBanks +import ua.gov.diia.core.network.annotation.Analytics + +interface ApiBankId { + + @Analytics("getBanksList") + @GET("api/v1/auth/banks") + suspend fun getBanksList(): AuthBanks + +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/ui/VerificationMethodBankId.kt b/bankid/src/main/java/ua/gov/diia/bankid/ui/VerificationMethodBankId.kt new file mode 100644 index 0000000..3a3021d --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/ui/VerificationMethodBankId.kt @@ -0,0 +1,45 @@ +package ua.gov.diia.bankid.ui + +import ua.gov.diia.bankid.BankIdConst.METHOD_BANK_ID +import ua.gov.diia.bankid.NavBankidDirections +import ua.gov.diia.bankid.R +import ua.gov.diia.bankid.model.BankSelectionRequest +import ua.gov.diia.verification.ui.methods.VerificationMethod +import ua.gov.diia.verification.ui.methods.VerificationRequest +import javax.inject.Inject + +class VerificationMethodBankId @Inject constructor() : VerificationMethod() { + + override val name = METHOD_BANK_ID + + override val isAvailable = true + + override val iconResId = R.drawable.ic_bankid_btn + + override val titleResId = R.string.bank_id_title + + override val descriptionResId = R.string.accessibility_login_screen_bank_list_item1 + + //for the BANK_ID verification we should skip the getting AUTH token step because + //we will be able to do it only after the preferred bank will be selected and we will do + //this operation in the BankSelectionF before showing the authentication WebView to the user + override suspend fun getVerificationRequest( + verificationSchema: String, + processId: String + ): VerificationRequest { + val request = BankSelectionRequest( + schema = verificationSchema, + processId = processId, + verificationMethodCode = name, + ) + return VerificationRequest( + navRequest = { currentDestinationId, resultKey -> + NavBankidDirections.actionGlobalDestinationBankSelection( + resultDestination = currentDestinationId, + resultKey = resultKey, + request = request, + ) + } + ) + } +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthConst.kt b/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthConst.kt new file mode 100644 index 0000000..910b307 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthConst.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.bankid.ui.auth + +internal object BankAuthConst { + const val CODE = "code" + const val PROGRESS_ACTIVE = "pageLoadingProgressActive" + const val PROGRESS_INACTIVE = "pageLoadingProgressInActive" +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthF.kt b/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthF.kt new file mode 100644 index 0000000..1d72b71 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthF.kt @@ -0,0 +1,187 @@ +package ua.gov.diia.bankid.ui.auth + +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import android.graphics.Bitmap +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.bankid.ui.auth.BankAuthConst.PROGRESS_ACTIVE +import ua.gov.diia.bankid.ui.auth.BankAuthConst.PROGRESS_INACTIVE +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.extensions.fragment.hideKeyboard +import ua.gov.diia.core.util.extensions.fragment.setNavigationResult +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.verification.model.VerificationFlowResult +import javax.inject.Inject + +@AndroidEntryPoint +internal class BankAuthF : Fragment() { + + private companion object { + const val DII_APP = "app" + const val DII_MARKET = "market" + const val X_DII_HEADER = "x-diia-version" + } + + private val viewModel: BankAuthVM by viewModels() + private val args: BankAuthFArgs by navArgs() + private var composeView: ComposeView? = null + + @Inject + lateinit var withBuildConfig: WithBuildConfig + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.doInit(args.requestData.bankCode) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + composeView?.setContent { + with(viewModel) { + navigation.collectAsEffect { navigation -> + when (navigation) { + is BankAuthVM.Navigation.CompleteAuth -> { + completeAuth(navigation.data) + } + + is BaseNavigation.Back -> { + findNavController().popBackStack() + } + } + } + } + + BankAuthScreen( + dataState = viewModel.uiData, + configureWebView = { + it.webViewClient = bankIdWebClient() + it.settings.allowFileAccess = false + it.settings.allowFileAccessFromFileURLs = false + it.settings.allowUniversalAccessFromFileURLs = false + it.settings.allowContentAccess = false + it.settings.domStorageEnabled = true + it.settings.javaScriptEnabled = true + it.loadUrl(args.requestData.authUrl) + }, + onUIAction = { viewModel.onUIAction(it) } + ) + } + } + + override fun onPause() { + super.onPause() + hideKeyboard() + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun bankIdWebClient() = object : WebViewClient() { + + @Deprecated("Deprecated in Java") + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean = + if (view != null && url != null) { + handleUrlOverride(view, url) + } else { + false + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest? + ): Boolean { + val url = request?.url?.toString() + return if (view != null && url != null) { + handleUrlOverride(view, url) + } else { + false + } + } + + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { + super.onPageStarted(view, url, favicon) + viewModel.onUIAction(UIAction(PROGRESS_ACTIVE)) + } + + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + viewModel.onUIAction(UIAction(PROGRESS_INACTIVE)) + } + } + + private fun handleUrlOverride(view: WebView, url: String): Boolean { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + + val activities: List = + activity?.packageManager?.queryIntentActivities( + intent, + PackageManager.MATCH_DEFAULT_ONLY + ) ?: emptyList() + + val hasAppToHandle = activities.isNotEmpty() + + when { + url.startsWith(withBuildConfig.getBankIdCallbackUrl()) -> { + viewModel.parseAuthCode(url) + } + + url.startsWith(DII_APP) -> if (hasAppToHandle) { + startActivity(intent) + } + + url.startsWith(DII_MARKET) -> if (!hasAppToHandle) { + startActivity(intent) + } + + else -> { + view.loadUrl(url, getCustomHeaders()) + } + } + return true + } + + private fun getCustomHeaders(): MutableMap { + val headers: MutableMap = HashMap() + headers[X_DII_HEADER] = "Android:${withBuildConfig.getVersionName()}" + return headers + } + + private fun completeAuth(result: VerificationFlowResult) { + setNavigationResult( + arbitraryDestination = args.resultDestination, + key = args.resultKey, + data = result + ) + findNavController().popBackStack(args.resultDestination, false) + } + +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthScreen.kt b/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthScreen.kt new file mode 100644 index 0000000..026e048 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthScreen.kt @@ -0,0 +1,70 @@ +package ua.gov.diia.bankid.ui.auth + +import android.view.ViewGroup +import android.webkit.WebView +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.molecule.header.NavigationPanelMlc +import ua.gov.diia.ui_base.components.molecule.header.NavigationPanelMlcData +import ua.gov.diia.ui_base.components.subatomic.loader.LoaderCircularEclipse23Subatomic + + +@Composable +internal fun BankAuthScreen( + modifier: Modifier = Modifier, + dataState: State, + configureWebView: (WebView) -> Unit, + onUIAction: (UIAction) -> Unit +) { + val data = dataState.value + Box( + modifier = modifier + .fillMaxSize() + .safeDrawingPadding(), + contentAlignment = Alignment.Center + ) { + Column(modifier = modifier.fillMaxSize()) { + NavigationPanelMlc( + data = NavigationPanelMlcData(isContextMenuExist = false), + onUIAction = onUIAction + ) + AndroidView( + modifier = modifier.fillMaxSize(), + factory = { + WebView(it).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + configureWebView(this) + } + } + ) + } + if (data.progressLoadState) { + LoaderCircularEclipse23Subatomic(modifier = Modifier.size(24.dp)) + } + } +} + +internal data class BankAuthScreenData(val progressLoadState: Boolean = false) + +@Composable +@Preview +internal fun BankAuthScreenPreview() { + val state = remember { mutableStateOf(BankAuthScreenData(progressLoadState = true)) } + BankAuthScreen(dataState = state, configureWebView = { }, onUIAction = {}) +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthVM.kt b/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthVM.kt new file mode 100644 index 0000000..6a37003 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/ui/auth/BankAuthVM.kt @@ -0,0 +1,65 @@ +package ua.gov.diia.bankid.ui.auth + +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import okhttp3.HttpUrl +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.bankid.ui.auth.BankAuthConst.CODE +import ua.gov.diia.bankid.ui.auth.BankAuthConst.PROGRESS_ACTIVE +import ua.gov.diia.bankid.ui.auth.BankAuthConst.PROGRESS_INACTIVE +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.verification.model.VerificationFlowResult +import javax.inject.Inject + +@HiltViewModel +internal class BankAuthVM @Inject constructor() : ViewModel() { + + private var bankCode: String? = null + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _uiData = mutableStateOf(BankAuthScreenData(progressLoadState = true)) + val uiData: State = _uiData + + fun doInit(bankCode: String) { + this.bankCode = bankCode + } + + fun parseAuthCode(callbackUrl: String) { + HttpUrl.get(callbackUrl).queryParameter(CODE)?.let { requestId -> + val request = VerificationFlowResult.CompleteVerificationStep(requestId, bankCode) + _navigation.tryEmit(Navigation.CompleteAuth(request)) + } + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + PROGRESS_ACTIVE -> { + _uiData.value = _uiData.value.copy(progressLoadState = true) + } + + PROGRESS_INACTIVE -> { + _uiData.value = _uiData.value.copy(progressLoadState = false) + } + } + } + + sealed class Navigation : NavigationPath { + data class CompleteAuth(val data: VerificationFlowResult) : Navigation() + } +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionF.kt b/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionF.kt new file mode 100644 index 0000000..6a0aa20 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionF.kt @@ -0,0 +1,119 @@ +package ua.gov.diia.bankid.ui.selection + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.bankid.model.BankAuthRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.extensions.fragment.doOnSystemBackPressed +import ua.gov.diia.core.util.extensions.fragment.hideKeyboard +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.ServiceScreen +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import javax.inject.Inject + +@AndroidEntryPoint +class BankSelectionF : Fragment() { + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private val args: BankSelectionFArgs by navArgs() + private val viewModel: BankSelectionVM by viewModels() + private var composeView: ComposeView? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.doInit(args.request) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + doOnSystemBackPressed { navigateBack() } + viewModel.loadBanks() + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + composeView?.setContent { + val toolbar = viewModel.toolbarData + val body = viewModel.bodyData + val contentLoaded = viewModel.contentLoaded.collectAsState( + initial = Pair( + UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_BACK_NAVIGATION, + true + ) + ) + + with(viewModel) { + showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + navigation.collectAsEffect { navigation -> + when (navigation) { + is BankSelectionVM.Navigation.ToBankAuth -> { + navigateToBankAuth(navigation.data) + } + + is BaseNavigation.Back -> navigateBack() + } + } + } + + ServiceScreen( + toolbar = toolbar, + body = body, + contentLoaded = contentLoaded.value, + onEvent = { viewModel.onUIAction(it) }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.DIALOG_ACTION_CANCEL -> navigateBack() + } + } + } + + private fun navigateBack() { + hideKeyboard() + findNavController().popBackStack() + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun navigateToBankAuth(request: BankAuthRequest) { + navigate( + BankSelectionFDirections.actionDestinationBankSelectionToDestinationBankAuth( + resultDestination = args.resultDestination, + resultKey = args.resultKey, + requestData = request + ) + ) + } + +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionScreenPreview.kt b/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionScreenPreview.kt new file mode 100644 index 0000000..1511457 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionScreenPreview.kt @@ -0,0 +1,111 @@ +package ua.gov.diia.bankid.ui.selection + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.tooling.preview.Preview +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.R +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.atom.space.SpacerAtmData +import ua.gov.diia.ui_base.components.atom.space.SpacerAtmType +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.ServiceScreen +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.input.SearchInputV2Data +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import ua.gov.diia.ui_base.components.molecule.message.StubMessageMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.PlainListWithSearchOrganismData + +@Composable +@Preview +fun BankSelectionScreenPreview() { + val _toolbarData = remember { mutableStateListOf() } + val toolbarData: SnapshotStateList = _toolbarData + val _bodyData = remember { mutableStateListOf() } + val bodyData: SnapshotStateList = _bodyData + _toolbarData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.DynamicString("Оберіть свій банк"), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + accessibilityDescription = UiText.StringResource(R.string.accessibility_back_button), + action = DataActionWrapper( + type = ActionsConst.ACTION_NAVIGATE_BACK, + subtype = null, + resource = null + ) + ) + ) + ) + ) + + _bodyData.add( + TextLabelMlcData( + text = UiText.DynamicString("Для авторизації за допомогою BankID, оберіть свій банк та увійдіть до системи інтернет-банкінгу. Авторизація не передбачає жодного доступу до фінансової інформації.") + ) + ) + + val list = + ListItemGroupOrgData(itemsList = SnapshotStateList().apply { + add( + ListItemMlcData( + label = UiText.DynamicString("Label"), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.MESSAGE.code), + ) + ) + add( + ListItemMlcData( + label = UiText.DynamicString("Label"), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.MESSAGE.code), + ) + ) + add( + ListItemMlcData( + label = UiText.DynamicString("Label"), + description = UiText.DynamicString("Description"), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.MESSAGE.code), + ) + ) + add( + ListItemMlcData( + label = UiText.DynamicString("Label"), + description = UiText.DynamicString("Description"), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.OUT_LINK.code), + ) + ) + }) + _bodyData.add( + PlainListWithSearchOrganismData( + searchData = SearchInputV2Data(placeholder = UiText.DynamicString("Пошук")), + fullList = list, + displayedList = list, + emptyListData = StubMessageMlcData( + icon = UiText.DynamicString("\uD83D\uDD90"), + title = UiText.DynamicString("На жаль, сталася помилка"), + description = UiText.DynamicString("Перелік банків недоступний. Спробуйте трошки пізніше.") + ) + ) + ) + _bodyData.add(SpacerAtmData(SpacerAtmType.SPACER_32)) + ServiceScreen( + toolbar = toolbarData, + body = bodyData, + contentLoaded = Pair( + UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_BACK_NAVIGATION, + true + ), + onEvent = { }, + diiaResourceIconProvider = DiiaResourceIconProvider.forPreview(), + ) +} \ No newline at end of file diff --git a/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionVM.kt b/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionVM.kt new file mode 100644 index 0000000..64ba2a1 --- /dev/null +++ b/bankid/src/main/java/ua/gov/diia/bankid/ui/selection/BankSelectionVM.kt @@ -0,0 +1,212 @@ +package ua.gov.diia.bankid.ui.selection + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import ua.gov.diia.bankid.R +import ua.gov.diia.bankid.model.AuthBank +import ua.gov.diia.bankid.model.BankAuthRequest +import ua.gov.diia.bankid.model.BankSelectionRequest +import ua.gov.diia.bankid.network.ApiBankId +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.atom.space.SpacerAtmData +import ua.gov.diia.ui_base.components.atom.space.SpacerAtmType +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.input.SearchInputV2Data +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import ua.gov.diia.ui_base.components.molecule.message.StubMessageMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.PlainListWithSearchOrganismData +import ua.gov.diia.verification.di.ProviderVerifiedClient +import ua.gov.diia.verification.network.ApiVerification +import javax.inject.Inject + +@HiltViewModel +class BankSelectionVM @Inject constructor( + @ProviderVerifiedClient private val apiBankId: ApiBankId, + @ProviderVerifiedClient private val apiVerification: ApiVerification, + private val errorHandling: WithErrorHandlingOnFlow, + private val retryLastAction: WithRetryLastAction +) : ViewModel(), + WithRetryLastAction by retryLastAction, + WithErrorHandlingOnFlow by errorHandling { + + private var selectedBankCode: String? = null + private var bankSelectionRequest: BankSelectionRequest? = null + + private val _progressIndicator = MutableStateFlow(false) + val progressIndicator get() = _progressIndicator.asStateFlow() + + private val _contentLoadedKey = + MutableStateFlow(UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_BACK_NAVIGATION) + private val _contentLoaded = MutableStateFlow(true) + val contentLoaded: Flow> = + _contentLoaded.combine(_contentLoadedKey) { value, key -> + key to value + } + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _toolbarData = mutableStateListOf() + val toolbarData: SnapshotStateList = _toolbarData + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + + fun doInit(data: BankSelectionRequest) { + bankSelectionRequest = data + } + + fun loadBanks() { + _toolbarData.clear() + _bodyData.clear() + executeActionOnFlow(contentLoadedIndicator = _contentLoaded.also { + _contentLoadedKey.value = UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_BACK_NAVIGATION + }) { + val response = apiBankId.getBanksList() + val list = response.banks.mapToPlainItemListMolecule() + val data = PlainListWithSearchOrganismData( + searchData = SearchInputV2Data( + placeholder = UiText.StringResource(R.string.bank_selection_search_text), + contentDescription = UiText.StringResource(R.string.accessibility_bank_selection_search) + ), + fullList = list, + displayedList = list, + emptyListData = StubMessageMlcData( + icon = UiText.StringResource(R.string.bank_selection_empty_icon), + title = UiText.StringResource(R.string.bank_selection_empty_title), + description = UiText.StringResource(R.string.bank_selection_empty_description) + ) + ) + displayStaticPagePart() + _bodyData.add(data) + _bodyData.add(SpacerAtmData(SpacerAtmType.SPACER_32)) + } + } + + private fun displayStaticPagePart() { + _toolbarData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.StringResource(R.string.bank_selection_title_text), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + accessibilityDescription = UiText.StringResource(R.string.accessibility_back_button), + action = DataActionWrapper( + type = UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK, + subtype = null, + resource = null + ) + ) + ) + ) + ) + _bodyData.add( + TextLabelMlcData(text = UiText.StringResource(R.string.bank_selection_description_text)) + ) + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + UIActionKeysCompose.TITLE_GROUP_MLC -> { + uiAction.action?.let { + when (it.type) { + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + else -> {} + } + } + } + + UIActionKeysCompose.LIST_ITEM_GROUP_ORG -> { + uiAction.data?.let { setBankCode(it) } + } + + UIActionKeysCompose.SEARCH_INPUT -> { + val searchQ = uiAction.data ?: return + _bodyData.findAndChangeFirstByInstance { + it.onSearch(searchQ) + } + } + } + } + + private fun setBankCode(code: String) { + selectedBankCode = code + getAuthUrl(code) + } + + private fun getAuthUrl(bankCode: String) { + val request = bankSelectionRequest ?: return + executeActionOnFlow(progressIndicator = _progressIndicator) { + apiVerification.getAuthUrl( + request.verificationMethodCode, + request.processId, + bankCode + ).apply { + template?.let { showTemplateDialog(it) } + authUrl?.let { + _navigation.tryEmit(Navigation.ToBankAuth(BankAuthRequest(it, bankCode))) + } + } + } + } + + private fun List.mapToPlainItemListMolecule(): ListItemGroupOrgData { + val result = mutableStateListOf() + forEach { result.add(it.toAtomData()) } + return ListItemGroupOrgData(itemsList = result) + } + + private fun AuthBank.toAtomData(): ListItemMlcData { + val bankName = this.name ?: "" + return ListItemMlcData( + id = id, + label = UiText.DynamicString(bankName), + iconRight = UiIcon.DrawableResource(CommonDiiaResourceIcon.OUT_LINK.code), + iconRightContentDescription = UiText.StringResource( + R.string.accessibility_bank_selection_item_name, + bankName + ), + interactionState = UIState.Interaction.Enabled, + ) + } + + sealed class Navigation : NavigationPath { + data class ToBankAuth(val data: BankAuthRequest) : Navigation() + } +} \ No newline at end of file diff --git a/bankid/src/main/res/drawable/ic_bankid_btn.xml b/bankid/src/main/res/drawable/ic_bankid_btn.xml new file mode 100644 index 0000000..1e2ede0 --- /dev/null +++ b/bankid/src/main/res/drawable/ic_bankid_btn.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/bankid/src/main/res/navigation/nav_bankid.xml b/bankid/src/main/res/navigation/nav_bankid.xml new file mode 100644 index 0000000..5b387a2 --- /dev/null +++ b/bankid/src/main/res/navigation/nav_bankid.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bankid/src/main/res/values/strings.xml b/bankid/src/main/res/values/strings.xml new file mode 100644 index 0000000..680d604 --- /dev/null +++ b/bankid/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + BankID + Оберіть свій банк + Для авторизації за допомогою BankID, оберіть свій банк та увійдіть до системи інтернет-банкінгу. Авторизація не передбачає жодного доступу до фінансової інформації. + Пошук + 🖐 + На жаль, сталася помилка. + Перелік банків недоступний. Спробуйте трошки пізніше. + \ No newline at end of file diff --git a/bankid/src/test/java/ua/gov/diia/bankid/rules/MainDispatcherRule.kt b/bankid/src/test/java/ua/gov/diia/bankid/rules/MainDispatcherRule.kt new file mode 100644 index 0000000..4283421 --- /dev/null +++ b/bankid/src/test/java/ua/gov/diia/bankid/rules/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.bankid.rules + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/bankid/src/test/java/ua/gov/diia/bankid/ui/VerificationMethodBankIdTest.kt b/bankid/src/test/java/ua/gov/diia/bankid/ui/VerificationMethodBankIdTest.kt new file mode 100644 index 0000000..48b4167 --- /dev/null +++ b/bankid/src/test/java/ua/gov/diia/bankid/ui/VerificationMethodBankIdTest.kt @@ -0,0 +1,46 @@ +package ua.gov.diia.bankid.ui + +import android.os.BaseBundle +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.anyOrNull +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import ua.gov.diia.bankid.BankIdConst +import ua.gov.diia.bankid.R +import ua.gov.diia.verification.ui.VerificationSchema +import java.util.UUID + +class VerificationMethodBankIdTest { + + private lateinit var verificationMethod: VerificationMethodBankId + + @Before + fun before() { + verificationMethod = VerificationMethodBankId() + val bundleMock = Mockito.mock(BaseBundle::class.java) + Mockito.doNothing().`when`(bundleMock).putString(any(), anyOrNull()) + } + + @Test + fun getName() { + Assert.assertEquals(BankIdConst.METHOD_BANK_ID, verificationMethod.name) + } + + @Test + fun getVerificationRequest() = runTest { + val schema = VerificationSchema.AUTHORIZATION + val processId = UUID.randomUUID().toString() + val request = verificationMethod.getVerificationRequest( + verificationSchema = schema, + processId = processId, + ) + Assert.assertFalse(request.shouldLaunchUrl) + Assert.assertNull(request.url) + Assert.assertNotNull(request.navRequest) + val navDirection = checkNotNull(request.navRequest).getNavDirection(1, "") + Assert.assertEquals(R.id.action_global_destination_bankSelection, navDirection.actionId) + } +} \ No newline at end of file diff --git a/bankid/src/test/java/ua/gov/diia/bankid/ui/auth/BankAuthVMTest.kt b/bankid/src/test/java/ua/gov/diia/bankid/ui/auth/BankAuthVMTest.kt new file mode 100644 index 0000000..dc1e1cd --- /dev/null +++ b/bankid/src/test/java/ua/gov/diia/bankid/ui/auth/BankAuthVMTest.kt @@ -0,0 +1,62 @@ +package ua.gov.diia.bankid.ui.auth + +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import okhttp3.HttpUrl +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.verification.model.VerificationFlowResult + +@RunWith(MockitoJUnitRunner::class) +class BankAuthVMTest { + + private lateinit var viewModel: BankAuthVM + + @Before + fun setUp() { + viewModel = BankAuthVM() + viewModel.doInit( + bankCode = "test" + ) + } + + @Test + fun `parse auth code`() = runTest { + val url = HttpUrl.Builder() + .scheme("https") + .host("test") + .addQueryParameter(BankAuthConst.CODE, "1234") + .build() + .toString() + viewModel.navigation.test { + viewModel.parseAuthCode(url) + val navRequest = awaitItem() as BankAuthVM.Navigation.CompleteAuth + val data = (navRequest.data as VerificationFlowResult.CompleteVerificationStep) + Assert.assertEquals("test", data.bankCode) + Assert.assertEquals("1234", data.requestId) + } + } + + @Test + fun `navigate back`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK)) + Assert.assertEquals(BaseNavigation.Back, awaitItem()) + } + } + + @Test + fun `loading state`() = runTest { + Assert.assertTrue(viewModel.uiData.value.progressLoadState) + viewModel.onUIAction(UIAction(BankAuthConst.PROGRESS_INACTIVE)) + Assert.assertFalse(viewModel.uiData.value.progressLoadState) + viewModel.onUIAction(UIAction(BankAuthConst.PROGRESS_ACTIVE)) + Assert.assertTrue(viewModel.uiData.value.progressLoadState) + } +} \ No newline at end of file diff --git a/bankid/src/test/java/ua/gov/diia/bankid/ui/selection/BankSelectionVMTest.kt b/bankid/src/test/java/ua/gov/diia/bankid/ui/selection/BankSelectionVMTest.kt new file mode 100644 index 0000000..b3ad742 --- /dev/null +++ b/bankid/src/test/java/ua/gov/diia/bankid/ui/selection/BankSelectionVMTest.kt @@ -0,0 +1,183 @@ +package ua.gov.diia.bankid.ui.selection + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.bankid.BankIdConst +import ua.gov.diia.bankid.model.AuthBank +import ua.gov.diia.bankid.model.AuthBanks +import ua.gov.diia.bankid.model.BankSelectionRequest +import ua.gov.diia.bankid.network.ApiBankId +import ua.gov.diia.bankid.rules.MainDispatcherRule +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.organism.list.PlainListWithSearchOrganismData +import ua.gov.diia.verification.model.VerificationUrl +import ua.gov.diia.verification.network.ApiVerification +import ua.gov.diia.verification.ui.VerificationSchema + +@RunWith(MockitoJUnitRunner::class) +class BankSelectionVMTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Mock + lateinit var apiBankId: ApiBankId + + @Mock + lateinit var apiVerification: ApiVerification + + @Mock + lateinit var errorHandling: WithErrorHandlingOnFlow + + @Mock + lateinit var retryLastAction: WithRetryLastAction + + private lateinit var viewModel: BankSelectionVM + + @Before + fun setUp() { + viewModel = BankSelectionVM( + apiBankId = apiBankId, + apiVerification = apiVerification, + errorHandling = errorHandling, + retryLastAction = retryLastAction + ) + viewModel.doInit( + data = BankSelectionRequest( + schema = VerificationSchema.AUTHORIZATION, + processId = "12931", + verificationMethodCode = BankIdConst.METHOD_BANK_ID + ) + ) + } + + @Test + fun `banks list`() = runTest { + val banks = listOf( + AuthBank( + id = "12e4", + logoUrl = null, + name = "Test bank", + workable = true + ), AuthBank( + id = "11rr4", + logoUrl = null, + name = null, + workable = false, + ) + ) + val bankList = AuthBanks(value = banks) + whenever(apiBankId.getBanksList()).thenReturn(bankList) + viewModel.loadBanks() + viewModel.contentLoaded.first { it.second } + val data = viewModel.bodyData.toList() + val item = data.firstNotNullOf { it as? PlainListWithSearchOrganismData } + val bank = item.fullList.itemsList.first() + Assert.assertEquals(banks[0].id, bank.id) + Assert.assertEquals(banks[0].name, (bank.label as UiText.DynamicString).value) + } + + @Test + fun `navigate back`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK)) + Assert.assertEquals(BaseNavigation.Back, awaitItem()) + } + viewModel.navigation.test { + val action = DataActionWrapper(UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK) + viewModel.onUIAction(UIAction(UIActionKeysCompose.TITLE_GROUP_MLC, action = action)) + Assert.assertEquals(BaseNavigation.Back, awaitItem()) + } + viewModel.navigation.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.TITLE_GROUP_MLC)) + expectNoEvents() + } + } + + @Test + fun `navigate to bank`() = runTest { + val sampleBank = AuthBank( + id = "12e4", + logoUrl = null, + name = "Test bank", + workable = true + ) + val sampleAuthUrl = "https://test" + val sampleToken = "1424r" + val bankList = AuthBanks(value = listOf(sampleBank)) + whenever(apiBankId.getBanksList()).thenReturn(bankList) + whenever(apiVerification.getAuthUrl(any(), any(), eq(sampleBank.id))) + .thenReturn(VerificationUrl(sampleAuthUrl, sampleToken, null)) + viewModel.loadBanks() + viewModel.contentLoaded.first { it.second } + viewModel.navigation.test { + viewModel.onUIAction( + UIAction( + UIActionKeysCompose.LIST_ITEM_GROUP_ORG, + data = sampleBank.id + ) + ) + val navRequest = awaitItem() as BankSelectionVM.Navigation.ToBankAuth + Assert.assertEquals(sampleAuthUrl, navRequest.data.authUrl) + Assert.assertEquals(sampleBank.id, navRequest.data.bankCode) + } + } + + @Test + fun `bank search`() = runTest { + val bankList = AuthBanks( + value = listOf( + AuthBank(id = "1", logoUrl = null, name = "Test 1", workable = true), + AuthBank(id = "1", logoUrl = null, name = "Test 2", workable = true), + AuthBank(id = "1", logoUrl = null, name = "Test 3", workable = true), + AuthBank(id = "1", logoUrl = null, name = "Test 4", workable = true) + ) + ) + val searchQuery = "3" + whenever(apiBankId.getBanksList()).thenReturn(bankList) + viewModel.loadBanks() + viewModel.contentLoaded.first { it.second } + val data = viewModel.bodyData.toList() + val item = data.firstNotNullOf { it as? PlainListWithSearchOrganismData } + Assert.assertEquals(4, item.displayedList.itemsList.size) + viewModel.onUIAction(UIAction(UIActionKeysCompose.SEARCH_INPUT, data = searchQuery)) + val uiData = viewModel.bodyData.firstNotNullOf { it as? PlainListWithSearchOrganismData } + Assert.assertEquals(1, uiData.displayedList.itemsList.size) + Assert.assertEquals( + searchQuery, + (uiData.searchData.searchFieldValue as UiText.DynamicString).value + ) + } + + @Test + fun `empty actions`() = runTest { + viewModel.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG)) + Assert.assertFalse(viewModel.progressIndicator.value) + + viewModel.navigation.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.TITLE_GROUP_MLC)) + expectNoEvents() + } + } +} diff --git a/biometric/.gitignore b/biometric/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/biometric/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/biometric/README.md b/biometric/README.md new file mode 100644 index 0000000..adcf33b --- /dev/null +++ b/biometric/README.md @@ -0,0 +1,43 @@ +# Description + +This is module responsible for biometric authorization. + +# How to install + +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':biometric') +``` + +2. Module requires next modules to work + +```groovy +implementation project(':core') +implementation project(':ui_base') +implementation project(':diia_storage') +``` + +3. Add next nav graphs to main navigation graph + +```xml + +``` + +4. The following action should be added into the root navigation graph + +```xml + + + + + +``` diff --git a/biometric/build.gradle b/biometric/build.gradle new file mode 100644 index 0000000..be1b4ce --- /dev/null +++ b/biometric/build.gradle @@ -0,0 +1,129 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + id 'androidx.navigation.safeargs.kotlin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.biometric' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation project(':core') + implementation project(':ui_base') + implementation project(':diia_storage') + implementation deps.fragment_ktx + implementation deps.appcompat + implementation deps.material + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + kapt deps.hilt_compiler + //navigation + implementation deps.navigation_fragment_ktx + implementation deps.navigation_ui_ktx + //Compose + implementation deps.activity_compose + implementation deps.compose_ui + implementation deps.compose_material + implementation deps.compose_ui_tooling + implementation deps.compose_ui_tooling_preview + implementation deps.compose_constraintlayout + //biometric + implementation deps.biometric + + //test + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.json + testImplementation deps.turbine + testImplementation deps.mockk_android + testImplementation deps.mockk_agent + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/biometric/consumer-rules.pro b/biometric/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/biometric/excludes.jacoco b/biometric/excludes.jacoco new file mode 100644 index 0000000..73b9908 --- /dev/null +++ b/biometric/excludes.jacoco @@ -0,0 +1,4 @@ +ua/gov/diia/biometric/ui/**/*F.* +ua/gov/diia/biometric/**/*$*.* +ua/gov/diia/biometric/ui/BiometricAuthPromptKt.* +ua/gov/diia/biometric/ui/**/compose/*.* \ No newline at end of file diff --git a/biometric/proguard-rules.pro b/biometric/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/biometric/src/main/AndroidManifest.xml b/biometric/src/main/AndroidManifest.xml new file mode 100644 index 0000000..0500f9a --- /dev/null +++ b/biometric/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/AndroidBiometric.kt b/biometric/src/main/java/ua/gov/diia/biometric/AndroidBiometric.kt new file mode 100644 index 0000000..b2a9aa9 --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/AndroidBiometric.kt @@ -0,0 +1,65 @@ +package ua.gov.diia.biometric + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.biometric.BiometricManager +import dagger.hilt.android.qualifiers.ApplicationContext +import ua.gov.diia.biometric.Biometric.Companion.FACE_IN_USE +import ua.gov.diia.biometric.Biometric.Companion.FINGERPRINT_IN_USE +import ua.gov.diia.biometric.Biometric.Companion.NONE_IN_USE +import ua.gov.diia.core.util.delegation.WithBuildConfig +import javax.inject.Inject + +class AndroidBiometric @Inject constructor( + @ApplicationContext private val context: Context, + private val withBuildConfig: WithBuildConfig, +) : Biometric { + + override fun isBiometricAuthAvailable(): Boolean { + return when (BiometricManager.from(context) + .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { + BiometricManager.BIOMETRIC_SUCCESS -> true + else -> false + } + } + + override fun hasFingerprint(): Boolean { + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) + } + + override fun hasFace(): Boolean { + return if (withBuildConfig.getSdkVersion() >= Build.VERSION_CODES.Q) { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) + } else { + false + } + } + + override fun getCurrentBiometricTypeInUse(): Int { + return if (isBiometricAuthAvailable()) { + val faceAvailable = hasFace() + val fingerprintAvailable = hasFingerprint() + if (faceAvailable && fingerprintAvailable) { + FACE_IN_USE + } else if (faceAvailable) { + FACE_IN_USE + } else if (fingerprintAvailable) { + FINGERPRINT_IN_USE + } else { + NONE_IN_USE + } + } else { + NONE_IN_USE + } + } + + override fun getPromptDescriptionString(): Int { + val type = getCurrentBiometricTypeInUse() + return if (type == FACE_IN_USE) { + R.string.use_face_id + } else { + R.string.use_touch_id + } + } +} \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/Biometric.kt b/biometric/src/main/java/ua/gov/diia/biometric/Biometric.kt new file mode 100644 index 0000000..fdce2f9 --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/Biometric.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.biometric + +interface Biometric { + + fun isBiometricAuthAvailable(): Boolean + + fun hasFingerprint(): Boolean + + fun hasFace(): Boolean + + fun getCurrentBiometricTypeInUse(): Int + + fun getPromptDescriptionString(): Int + + companion object { + + const val NONE_IN_USE = -1 + const val FINGERPRINT_IN_USE = 0 + const val FACE_IN_USE = 1 + } +} \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/di/BiometricModule.kt b/biometric/src/main/java/ua/gov/diia/biometric/di/BiometricModule.kt new file mode 100644 index 0000000..c5f8457 --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/di/BiometricModule.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.biometric.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.biometric.AndroidBiometric +import ua.gov.diia.biometric.Biometric +import ua.gov.diia.biometric.store.BiometricRepository +import ua.gov.diia.biometric.store.BiometricRepositoryImpl + +@Module +@InstallIn(SingletonComponent::class) +interface BiometricModule { + + @Binds + fun bindsBiometricRepository( + impl: BiometricRepositoryImpl, + ): BiometricRepository + + @Binds + fun bindsBiometric( + impl: AndroidBiometric, + ): Biometric +} \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/store/BiometricRepository.kt b/biometric/src/main/java/ua/gov/diia/biometric/store/BiometricRepository.kt new file mode 100644 index 0000000..94da321 --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/store/BiometricRepository.kt @@ -0,0 +1,9 @@ +package ua.gov.diia.biometric.store + +interface BiometricRepository { + + suspend fun enableBiometricAuth(enable: Boolean) + + suspend fun isBiometricAuthEnabled(): Boolean + +} \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/store/BiometricRepositoryImpl.kt b/biometric/src/main/java/ua/gov/diia/biometric/store/BiometricRepositoryImpl.kt new file mode 100644 index 0000000..1aca3fc --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/store/BiometricRepositoryImpl.kt @@ -0,0 +1,24 @@ +package ua.gov.diia.biometric.store + +import kotlinx.coroutines.withContext +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.diia_storage.DiiaStorage +import javax.inject.Inject +import javax.inject.Singleton + +class BiometricRepositoryImpl @Inject constructor( + private val diiaStorage: DiiaStorage, + private val dispatcherProvider: DispatcherProvider +): BiometricRepository { + + override suspend fun enableBiometricAuth(enable: Boolean) { + withContext(dispatcherProvider.work) { + diiaStorage.set(Preferences.UseTouchId, enable) + } + } + + override suspend fun isBiometricAuthEnabled(): Boolean = withContext(dispatcherProvider.work) { + diiaStorage.getBoolean(Preferences.UseTouchId, false) + } +} \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricAuthPrompt.kt b/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricAuthPrompt.kt new file mode 100644 index 0000000..8bc2333 --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricAuthPrompt.kt @@ -0,0 +1,38 @@ +package ua.gov.diia.biometric.ui + +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import ua.gov.diia.biometric.R + +fun Fragment.showBiometricAuthPrompt( + descriptionTextRes: Int, + callback: (Boolean) -> Unit, +) { + val promptCallback = object : BiometricPrompt.AuthenticationCallback() { + + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + super.onAuthenticationSucceeded(result) + callback(true) + } + + override fun onAuthenticationFailed() { + super.onAuthenticationFailed() + callback(false) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + callback(false) + } + } + val executor = ContextCompat.getMainExecutor(requireContext()) + val prompt = BiometricPrompt(this, executor, promptCallback) + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.touch_id_for_diia)) + .setDescription(getString(descriptionTextRes)) + .setNegativeButtonText(getString(R.string.cancel)) + .build() + + prompt.authenticate(promptInfo) +} \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricSetupF.kt b/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricSetupF.kt new file mode 100644 index 0000000..60ccb22 --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricSetupF.kt @@ -0,0 +1,84 @@ +package ua.gov.diia.biometric.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.biometric.ui.compose.BiometricSetupScreen +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.core.util.extensions.fragment.setNavigationResult +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import javax.inject.Inject + +@AndroidEntryPoint +class BiometricSetupF : Fragment() { + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private val viewModel: BiometricSetupVM by viewModels() + private val args: BiometricSetupFArgs by navArgs() + private var composeView: ComposeView? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + composeView?.setContent { + val uiDataElements = viewModel.uiData + + viewModel.apply { + navigation.collectAsEffect { navigation -> + when (navigation) { + is BiometricSetupVM.Navigation.CompletePinCreation -> { + completeFlow() + } + } + } + } + + BiometricSetupScreen( + data = uiDataElements, + onUIAction = { viewModel.onUIAction(it) }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun completeFlow() { + setNavigationResult( + arbitraryDestination = args.resultDestinationId, + key = args.resultKey, + data = args.pin + ) + findNavController().popBackStack(args.resultDestinationId, false) + } + +} \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricSetupVM.kt b/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricSetupVM.kt new file mode 100644 index 0000000..bdca072 --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/ui/BiometricSetupVM.kt @@ -0,0 +1,91 @@ +package ua.gov.diia.biometric.ui + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import ua.gov.diia.biometric.R +import ua.gov.diia.biometric.store.BiometricRepository +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.ui_base.components.atom.button.BtnPrimaryDefaultAtmData +import ua.gov.diia.ui_base.components.atom.button.BtnPlainAtmData +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose.BUTTON_ALTERNATIVE +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose.BUTTON_REGULAR +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import javax.inject.Inject + +@HiltViewModel +class BiometricSetupVM @Inject constructor( + private val biometricRepository: BiometricRepository, + private val errorHandling: WithErrorHandlingOnFlow, + private val retryLastAction: WithRetryLastAction +) : ViewModel(), + WithRetryLastAction by retryLastAction, + WithErrorHandlingOnFlow by errorHandling { + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _uiData = mutableStateListOf() + val uiData: SnapshotStateList = _uiData + + init { + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.StringResource(R.string.biometric_screen_title_text), + ) + ) + ) + _uiData.add(TextLabelMlcData(text = UiText.StringResource(R.string.biometric_screen_description_text))) + _uiData.add(BtnPrimaryDefaultAtmData(title = UiText.StringResource(R.string.biometric_screen_button_text))) + _uiData.add( + BtnPlainAtmData( + actionKey = BUTTON_ALTERNATIVE, + id = "", + title = UiText.StringResource(R.string.biometric_screen_alt_button_text), + interactionState = UIState.Interaction.Enabled + ) + ) + } + + private fun enableBiometric(enable: Boolean) { + executeActionOnFlow { + if (enable) { + biometricRepository.enableBiometricAuth(enable = true) + } + _navigation.emit(Navigation.CompletePinCreation) + } + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + BUTTON_REGULAR -> { + enableBiometric(true) + } + + BUTTON_ALTERNATIVE -> { + enableBiometric(false) + } + } + } + + sealed class Navigation : NavigationPath { + object CompletePinCreation : Navigation() + } +} \ No newline at end of file diff --git a/biometric/src/main/java/ua/gov/diia/biometric/ui/compose/BiometricSetupScreen.kt b/biometric/src/main/java/ua/gov/diia/biometric/ui/compose/BiometricSetupScreen.kt new file mode 100644 index 0000000..03b4095 --- /dev/null +++ b/biometric/src/main/java/ua/gov/diia/biometric/ui/compose/BiometricSetupScreen.kt @@ -0,0 +1,166 @@ +package ua.gov.diia.biometric.ui.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import ua.gov.diia.ui_base.R +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.atom.button.BtnPlainAtm +import ua.gov.diia.ui_base.components.atom.button.BtnPlainAtmData +import ua.gov.diia.ui_base.components.atom.button.BtnPrimaryDefaultAtm +import ua.gov.diia.ui_base.components.atom.button.BtnPrimaryDefaultAtmData +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlc +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrg +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData + +@Composable +fun BiometricSetupScreen( + modifier: Modifier = Modifier, + data: SnapshotStateList, + diiaResourceIconProvider: DiiaResourceIconProvider, + onUIAction: (UIAction) -> Unit +) { + ConstraintLayout( + modifier = modifier + .paint( + painterResource(id = R.drawable.bg_blue_yellow_gradient), + contentScale = ContentScale.FillBounds + ) + .fillMaxSize() + .safeDrawingPadding() + ) { + val (title, descriptionText, iconZone, button, altButton) = createRefs() + data.forEach { item -> + if (item is TopGroupOrgData) { + TopGroupOrg( + modifier = Modifier.constrainAs(title) { + start.linkTo(parent.start) + end.linkTo(parent.end) + top.linkTo(parent.top) + }, + data = item, + onUIAction = onUIAction, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + + if (item is TextLabelMlcData) { + TextLabelMlc( + modifier = modifier + .constrainAs(descriptionText) { + top.linkTo(title.bottom) + }, + data = item, + onUIAction = onUIAction + ) + } + + + Icon( + modifier = modifier + .size(height = 96.dp, width = 224.dp) + .constrainAs(createRef()) { + top.linkTo(descriptionText.bottom) + bottom.linkTo(button.top) + start.linkTo(parent.start) + end.linkTo(parent.end) + }, + painter = painterResource(id = R.drawable.ic_biometric_auth), + contentDescription = stringResource(id = R.string.accessibility_biometric_methods_icon), + ) + + if (item is BtnPrimaryDefaultAtmData) { + val accessabilityText = + stringResource(id = R.string.accessibility_biometric_methods_button) + BtnPrimaryDefaultAtm( + modifier = modifier + .constrainAs(button) { + start.linkTo(parent.start) + end.linkTo(parent.end) + bottom.linkTo(altButton.top) + } + .semantics(mergeDescendants = true) { + contentDescription = accessabilityText + }, + data = item, + onUIAction = onUIAction + ) + } + + if (item is BtnPlainAtmData) { + val accessabilityText = + stringResource(id = R.string.accessibility_biometric_methods_alt_button) + BtnPlainAtm( + modifier = modifier + .constrainAs(altButton) { + start.linkTo(parent.start) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom, margin = 16.dp) + } + .semantics(mergeDescendants = true) { + contentDescription = accessabilityText + }, + data = item, + onUIAction = onUIAction + ) + } + } + } +} + +@Composable +@Preview +fun BiometricSetupScreenPreview() { + val _uiData = remember { mutableStateListOf() } + val uiData: SnapshotStateList = _uiData + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.DynamicString("Дозвольте вхід за біометричними даними"), + ) + ) + ) + _uiData.add( + TextLabelMlcData( + text = UiText.DynamicString("Дозвольте Дії використовувати сканер відбитку пальця та/або розпізнавання обличчя для входу у застосунок.") + ) + ) + _uiData.add( + BtnPrimaryDefaultAtmData(title = UiText.DynamicString("Дозволити")) + ) + _uiData.add( + BtnPlainAtmData( + actionKey = UIActionKeysCompose.BUTTON_ALTERNATIVE, + id = "", + title = UiText.DynamicString("Дозволю пізніше"), + interactionState = UIState.Interaction.Enabled + ) + ) + BiometricSetupScreen( + data = uiData, + onUIAction = { }, + diiaResourceIconProvider = DiiaResourceIconProvider.forPreview() + ) +} \ No newline at end of file diff --git a/biometric/src/main/res/navigation/nav_biometric.xml b/biometric/src/main/res/navigation/nav_biometric.xml new file mode 100644 index 0000000..f37abed --- /dev/null +++ b/biometric/src/main/res/navigation/nav_biometric.xml @@ -0,0 +1,21 @@ + + + + + + + + + + \ No newline at end of file diff --git a/biometric/src/main/res/values/strings.xml b/biometric/src/main/res/values/strings.xml new file mode 100644 index 0000000..3fe4b43 --- /dev/null +++ b/biometric/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + + Вхід за біометричними даними + Прикладіть палець, щоб увійти + Відскануйте обличчя, щоб увійти + + Дозвольте вхід за біометричними даними + Дозвольте Дії використовувати сканер відбитку пальця та/або розпізнавання обличчя для входу у застосунок. + Дозволити + Дозволю пізніше + \ No newline at end of file diff --git a/biometric/src/test/java/ua/gov/diia/biometric/AndroidBiometricTest.kt b/biometric/src/test/java/ua/gov/diia/biometric/AndroidBiometricTest.kt new file mode 100644 index 0000000..4cca1c3 --- /dev/null +++ b/biometric/src/test/java/ua/gov/diia/biometric/AndroidBiometricTest.kt @@ -0,0 +1,182 @@ +package ua.gov.diia.biometric + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.biometric.BiometricManager +import io.mockk.* +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.biometric.Biometric.Companion.FACE_IN_USE +import ua.gov.diia.biometric.rules.MainDispatcherRule +import ua.gov.diia.core.util.delegation.WithBuildConfig + +@RunWith(MockitoJUnitRunner::class) +class AndroidBiometricTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + lateinit var androidBiometric: AndroidBiometric + + lateinit var context: Context + lateinit var withBuildConfig: WithBuildConfig + + @Before + fun setUp() { + context = mockk() + withBuildConfig = mockk() + androidBiometric = AndroidBiometric(context, withBuildConfig) + } + + fun prepareIsBiometricAuthAvailable() { + val biometricManager: BiometricManager = mockk() + every { biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) } returns BiometricManager.BIOMETRIC_SUCCESS + mockkStatic(BiometricManager::class) + every { BiometricManager.from(context) } returns biometricManager + } + @Test + fun `test isBiometricAuthAvailable returns true if biometric is strong `() { + runBlocking { + prepareIsBiometricAuthAvailable() + + assertTrue(androidBiometric.isBiometricAuthAvailable()) + } + } + @Test + fun `test isBiometricAuthAvailable returns false if biometric is not strong`() { + runBlocking { + val biometricManager: BiometricManager = mockk() + every { biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) } returns BiometricManager.BIOMETRIC_STATUS_UNKNOWN + mockkStatic(BiometricManager::class) + every { BiometricManager.from(context) } returns biometricManager + assertFalse(androidBiometric.isBiometricAuthAvailable()) + } + } + + @Test + fun `test hasFingerprint`() { + runBlocking { + val packageManager = mockk() + every { context.packageManager } returns packageManager + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) } returns true + + assertTrue(androidBiometric.hasFingerprint()) + verify(exactly = 1) { context.packageManager } + verify(exactly = 1) { packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) } + } + } + + @Test + fun `test hasFace`() { + runBlocking { + val packageManager = mockk() + every { context.packageManager } returns packageManager + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) } returns true + every { withBuildConfig.getSdkVersion() } returns Build.VERSION_CODES.Q + + assertTrue(androidBiometric.hasFace()) + verify(exactly = 1) { context.packageManager } + verify(exactly = 1) { packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) } + } + } + + @Test + fun `test hasFace return false if SDK is less than Q`() { + runBlocking { + val packageManager = mockk() + every { context.packageManager } returns packageManager + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) } returns true + every { withBuildConfig.getSdkVersion() } returns Build.VERSION_CODES.P + + assertFalse(androidBiometric.hasFace()) + verify(exactly = 0) { context.packageManager } + verify(exactly = 0) { packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) } + } + } + + @Test + fun `test getCurrentBiometricTypeInUse return FACE_IN_USE if face and fingerprint are available`() { + runBlocking { + prepareIsBiometricAuthAvailable() + + val packageManager = mockk() + every { context.packageManager } returns packageManager + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) } returns true + every { withBuildConfig.getSdkVersion() } returns Build.VERSION_CODES.Q + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) } returns true + + + assertEquals(FACE_IN_USE, androidBiometric.getCurrentBiometricTypeInUse()) + } + } + + @Test + fun `test getCurrentBiometricTypeInUse return FACE_IN_USE if only face is available`() { + runBlocking { + prepareIsBiometricAuthAvailable() + + val packageManager = mockk() + every { context.packageManager } returns packageManager + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) } returns true + every { withBuildConfig.getSdkVersion() } returns Build.VERSION_CODES.Q + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) } returns false + + + assertEquals(FACE_IN_USE, androidBiometric.getCurrentBiometricTypeInUse()) + } + } + + @Test + fun `test getCurrentBiometricTypeInUse return FINGERPRINT_IN_USE if only fingerprint is available`() { + runBlocking { + prepareIsBiometricAuthAvailable() + + val packageManager = mockk() + every { context.packageManager } returns packageManager + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) } returns false + every { withBuildConfig.getSdkVersion() } returns Build.VERSION_CODES.Q + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) } returns true + + + assertEquals(Biometric.FINGERPRINT_IN_USE, androidBiometric.getCurrentBiometricTypeInUse()) + } + } + + @Test + fun `test getCurrentBiometricTypeInUse return NONE_IN_USE if only face and fingerprint are not available`() { + runBlocking { + prepareIsBiometricAuthAvailable() + + val packageManager = mockk() + every { context.packageManager } returns packageManager + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FACE) } returns false + every { withBuildConfig.getSdkVersion() } returns Build.VERSION_CODES.Q + every { packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) } returns false + + assertEquals(Biometric.NONE_IN_USE, androidBiometric.getCurrentBiometricTypeInUse()) + } + } + + @Test + fun `test getCurrentBiometricTypeInUse return NONE_IN_USE if biometry is not available`() { + runBlocking { + val biometricManager: BiometricManager = mockk() + every { biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) } returns BiometricManager.BIOMETRIC_STATUS_UNKNOWN + mockkStatic(BiometricManager::class) + every { BiometricManager.from(context) } returns biometricManager + + assertEquals(Biometric.NONE_IN_USE, androidBiometric.getCurrentBiometricTypeInUse()) + } + } +} \ No newline at end of file diff --git a/biometric/src/test/java/ua/gov/diia/biometric/rules/MainDispatcherRule.kt b/biometric/src/test/java/ua/gov/diia/biometric/rules/MainDispatcherRule.kt new file mode 100644 index 0000000..4bb82d0 --- /dev/null +++ b/biometric/src/test/java/ua/gov/diia/biometric/rules/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.biometric.rules + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/biometric/src/test/java/ua/gov/diia/biometric/store/BiometricRepositoryImplTest.kt b/biometric/src/test/java/ua/gov/diia/biometric/store/BiometricRepositoryImplTest.kt new file mode 100644 index 0000000..7428a8c --- /dev/null +++ b/biometric/src/test/java/ua/gov/diia/biometric/store/BiometricRepositoryImplTest.kt @@ -0,0 +1,67 @@ +package ua.gov.diia.biometric.store + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.biometric.rules.MainDispatcherRule +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.diia_storage.DiiaStorage + +@RunWith(MockitoJUnitRunner::class) +class BiometricRepositoryImplTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Mock + lateinit var diiaStorage: DiiaStorage + + @Mock + lateinit var dispatcherProvider: DispatcherProvider + + lateinit var biometricRepositoryImpl: BiometricRepositoryImpl + + @Before + fun setUp() { + `when`(dispatcherProvider.work).thenReturn(UnconfinedTestDispatcher()) + + biometricRepositoryImpl = BiometricRepositoryImpl( + diiaStorage = diiaStorage, + dispatcherProvider = dispatcherProvider + ) + } + + @Test + fun `test isBiometricAuthEnabled get UseTouchId from storage `() = runTest { + runBlocking { + `when`(diiaStorage.getBoolean(Preferences.UseTouchId, false)).thenReturn(true) + + val result = biometricRepositoryImpl.isBiometricAuthEnabled() + + verify(diiaStorage).getBoolean(Preferences.UseTouchId, false) + assertTrue(result) + } + } + + @Test + fun `test enableBiometricAuth save UseTouchId value to storage `() = runTest { + runBlocking { + biometricRepositoryImpl.enableBiometricAuth(true) + + verify(diiaStorage).set(Preferences.UseTouchId, true) + } + } +} \ No newline at end of file diff --git a/biometric/src/test/java/ua/gov/diia/biometric/ui/BiometricSetupVMTest.kt b/biometric/src/test/java/ua/gov/diia/biometric/ui/BiometricSetupVMTest.kt new file mode 100644 index 0000000..3a3762d --- /dev/null +++ b/biometric/src/test/java/ua/gov/diia/biometric/ui/BiometricSetupVMTest.kt @@ -0,0 +1,88 @@ +package ua.gov.diia.biometric.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.biometric.rules.MainDispatcherRule +import ua.gov.diia.biometric.store.BiometricRepository +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.ui_base.components.atom.button.BtnPlainAtmData +import ua.gov.diia.ui_base.components.atom.button.BtnPrimaryDefaultAtmData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData + +@RunWith(MockitoJUnitRunner::class) +class BiometricSetupVMTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Mock + lateinit var biometricRepository: BiometricRepository + + @Mock + lateinit var errorHandling: WithErrorHandlingOnFlow + + @Mock + lateinit var retryLastAction: WithRetryLastAction + + lateinit var viewModel: BiometricSetupVM + + @Before + fun setUp() { + viewModel = BiometricSetupVM( + biometricRepository = biometricRepository, + errorHandling = errorHandling, + retryLastAction = retryLastAction, + ) + } + + @Test + fun `initial state`() = runTest { + val snapshot = viewModel.uiData.toList() + Assert.assertEquals(4, snapshot.size) + Assert.assertTrue(snapshot[0] is TopGroupOrgData) + Assert.assertTrue(snapshot[1] is TextLabelMlcData) + Assert.assertTrue(snapshot[2] is BtnPrimaryDefaultAtmData) + Assert.assertTrue(snapshot[3] is BtnPlainAtmData) + } + + @Test + fun `enable biometric auth`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.BUTTON_REGULAR)) + Assert.assertEquals(BiometricSetupVM.Navigation.CompletePinCreation, awaitItem()) + } + verify(biometricRepository).enableBiometricAuth(true) + } + + @Test + fun `do not enable biometric auth`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.BUTTON_ALTERNATIVE)) + Assert.assertEquals(BiometricSetupVM.Navigation.CompletePinCreation, awaitItem()) + } + verifyNoMoreInteractions(biometricRepository) + } + + @Test + fun `test no reaction on wrong action`() = runTest { + viewModel.onUIAction(UIAction(UIActionKeysCompose.BTN_PLAIN_ATM)) + verifyNoMoreInteractions(biometricRepository) + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..081181e --- /dev/null +++ b/build.gradle @@ -0,0 +1,49 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + repositories { + google() + jcenter() + mavenCentral() + maven { url 'https://jitpack.io' } + maven { url 'https://developer.huawei.com/repo/' } + } + dependencies { + classpath 'com.android.tools.build:gradle:7.3.1' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.20' + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.3.5" + classpath 'com.google.gms:google-services:4.3.14' + classpath 'com.google.firebase:firebase-crashlytics-gradle:2.9.2' + classpath 'com.huawei.agconnect:agcp:1.6.5.300' + classpath "org.jacoco:org.jacoco.core:0.8.8" + //hilt + classpath 'com.google.dagger:hilt-android-gradle-plugin:2.44' + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +plugins { + id 'com.azizutku.jacocoaggregatecoverageplugin' version '0.1.0' apply true +} + +allprojects { + repositories { + google() + jcenter() + mavenCentral() + maven { + url 'https://jitpack.io' + } + maven { url 'https://developer.huawei.com/repo/' } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} + +jacocoAggregateCoverage { + jacocoTestReportTask.set("testGplayDebugUnitTestCoverage") + configuredCustomReportsDirectory.set("jacoco-report") +} \ No newline at end of file diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..aab5683 --- /dev/null +++ b/core/README.md @@ -0,0 +1,11 @@ +# Description + +This is module provides base framework and utilities. + +# How to install + +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':core') +``` diff --git a/core/build.gradle b/core/build.gradle new file mode 100644 index 0000000..00dbe74 --- /dev/null +++ b/core/build.gradle @@ -0,0 +1,153 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'dagger.hilt.android.plugin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.core' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + // work-runtime-ktx 2.1.0 and above now requires Java 8 + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation deps.activity_ktx + implementation deps.fragment_ktx + implementation deps.legacy_support + implementation deps.appcompat + //lifecycle + implementation deps.lifecycle_extensions + implementation deps.lifecycle_livedata_ktx + implementation deps.lifecycle_viewmodel_ktx + //navigation + implementation deps.navigation_fragment_ktx + implementation deps.navigation_ui_ktx + //retrofit + implementation deps.retrofit + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + //Desugaring + coreLibraryDesugaring deps.desugar_jdk_libs + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + implementation deps.hilt_work + kapt deps.hilt_compiler + //viewpager + implementation deps.viewpager + //glide + implementation deps.glide + kapt deps.glide_compiler + //lottie + implementation deps.lottie + + implementation deps.better_link_movement_method + + //work + implementation deps.work_runtime_ktx + + //Compose + implementation deps.activity_compose + + //test + testImplementation deps.junit + testImplementation deps.mockk_android + testImplementation deps.mockk_agent + testImplementation deps.turbine + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.hamcrest_library + testImplementation deps.json + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} + +tasks.withType(Test) { + // https://github.com/mockk/mockk/issues/681 + jvmArgs("--add-opens", "java.base/java.time=ALL-UNNAMED") +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/core/consumer-rules.pro b/core/consumer-rules.pro new file mode 100644 index 0000000..1900b19 --- /dev/null +++ b/core/consumer-rules.pro @@ -0,0 +1,8 @@ +-keep enum * { *; } + +-keep public class ua.gov.diia.core.models.ContextMenuField +-keep public class ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +-keep public class ua.gov.diia.core.models.notification.pull.EmptySelection +-keep public class ua.gov.diia.core.models.SystemDialog +-keep public class ua.gov.diia.core.models.dialogs.TemplateDialogModel +-keep public class ua.gov.diia.core.models.ConsumableItem \ No newline at end of file diff --git a/core/excludes.jacoco b/core/excludes.jacoco new file mode 100644 index 0000000..2686001 --- /dev/null +++ b/core/excludes.jacoco @@ -0,0 +1,15 @@ +ua/gov/diia/core/**/*$*.* +ua/gov/diia/core/push/** +ua/gov/diia/core/util/system/** +ua/gov/diia/core/util/delegation/** +ua/gov/diia/core/util/extensions/** +ua/gov/diia/core/util/filter/** +ua/gov/diia/core/util/decorators/** +ua/gov/diia/core/util/file/** +ua/gov/diia/core/util/phone/** +ua/gov/diia/core/util/navigation/** +ua/gov/diia/core/util/event/EventObserverKt*.* +ua/gov/diia/core/util/GradientBuilder*.* +ua/gov/diia/core/util/CombinedLiveData*.* +ua/gov/diia/core/util/CombinedLiveData*.* + diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro new file mode 100644 index 0000000..b3f8586 --- /dev/null +++ b/core/proguard-rules.pro @@ -0,0 +1,29 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile +-keep enum * { *; } + +-keep public class ua.gov.diia.core.models.ContextMenuField +-keep public class ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +-keep public class ua.gov.diia.core.models.notification.pull.EmptySelection +-keep public class ua.gov.diia.core.models.SystemDialog +-keep public class ua.gov.diia.core.models.dialogs.TemplateDialogModel +-keep public class ua.gov.diia.core.models.ConsumableItem \ No newline at end of file diff --git a/core/src/androidTest/java/ua/gov/diia/core/ExampleInstrumentedTest.kt b/core/src/androidTest/java/ua/gov/diia/core/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..e2e2d74 --- /dev/null +++ b/core/src/androidTest/java/ua/gov/diia/core/ExampleInstrumentedTest.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = + InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("ua.gov.diia.core.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..2a78226 --- /dev/null +++ b/core/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/CoreConstants.kt b/core/src/main/java/ua/gov/diia/core/CoreConstants.kt new file mode 100644 index 0000000..8dcec09 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/CoreConstants.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.core + +object CoreConstants { + const val CHECK_SAFETY_NET = "CHECK_SAFETY_NET" +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/ExcludeFromJacocoGeneratedReport.kt b/core/src/main/java/ua/gov/diia/core/ExcludeFromJacocoGeneratedReport.kt new file mode 100644 index 0000000..a88eda2 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/ExcludeFromJacocoGeneratedReport.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.core + + +@Retention +@Target(AnnotationTarget.FUNCTION) +annotation class ExcludeFromJacocoGeneratedReport \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/controller/DeeplinkProcessor.kt b/core/src/main/java/ua/gov/diia/core/controller/DeeplinkProcessor.kt new file mode 100644 index 0000000..2fa4e06 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/controller/DeeplinkProcessor.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.core.controller + +import androidx.navigation.NavDirections +import ua.gov.diia.core.models.deeplink.DeepLinkAction + +interface DeeplinkProcessor { + /** + * Handle deeplink action from list of available action list + */ + suspend fun handleDeepLinkAction(action: DeepLinkAction): NavDirections? +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/controller/NotificationController.kt b/core/src/main/java/ua/gov/diia/core/controller/NotificationController.kt new file mode 100644 index 0000000..00bb53d --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/controller/NotificationController.kt @@ -0,0 +1,44 @@ +package ua.gov.diia.core.controller + +interface NotificationController { + + /** + * Mark notification as read + */ + suspend fun markAsRead(resId: String?) + + /** + * Reload notification data + */ + fun invalidateNotificationDataSource() + + /** + * Load initial notifications data from network + */ + suspend fun getNotificationsInitial() + + /** + * Check if push token is synced and restart loading if it is not synced + */ + fun checkPushTokenInSync() + + /** + * Collect amount of unreaded notifications and return value in callback + */ + suspend fun collectUnreadNotificationCounts(callback: (amount: Int) -> Unit) + + /** + * Turn on notification + */ + suspend fun allowNotifications() + + /** + * Turn off notification + */ + fun denyNotifications() + + /** + * @return true if notification is allowed + */ + suspend fun checkNotificationsRequested(): Boolean? +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/controller/PromoController.kt b/core/src/main/java/ua/gov/diia/core/controller/PromoController.kt new file mode 100644 index 0000000..9e0b925 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/controller/PromoController.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.core.controller + + +import ua.gov.diia.core.models.dialogs.TemplateDialogModelWithProcessCode + +interface PromoController { + /** + * Validate current promo code with one from API + */ + suspend fun checkPromo(callback: (template: TemplateDialogModelWithProcessCode) -> Unit) + + /** + * Subscribe for promo news + */ + suspend fun subscribeToBetaByCode(value: Int?) + + /** + * Save process code to the storage + */ + suspend fun updatePromoProcessCode(value: Int) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/data/data_source/file/PrivateFileDataSource.kt b/core/src/main/java/ua/gov/diia/core/data/data_source/file/PrivateFileDataSource.kt new file mode 100644 index 0000000..bc5370a --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/data/data_source/file/PrivateFileDataSource.kt @@ -0,0 +1,51 @@ +package ua.gov.diia.core.data.data_source.file + +import android.net.Uri +import java.io.File + +interface PrivateFileDataSource { + + /** + * Saves base64 file in the specified [directory] + * + * @param name file to save name + * @param directory save directory name + * @param base64File file in the base64 format + * @return content//: uri to the saved file source + */ + suspend fun save(name: String, directory: String, base64File: String): Uri + + /** + * Saves base64 file in the created "container" file by the Uri link + * + * @param containerUri link to the file container + * @param base64File file in the base64 format + * @return content//: uri to the saved file source + */ + suspend fun save(containerUri: Uri, base64File: String): Uri + + /** + * Deletes file by [name] in the specified [directory] + * + * @param name file to delete name + * @param directory file store directory + * @return true - if the file was deleted, otherwise - false + */ + suspend fun delete(name: String, directory: String): Boolean + + /** + * Retrieves the content//: uri from the [file] + * + * @param file to retrieve uri + * @return true - if the file exists and uri has been retrieved successfully, otherwise - false + */ + suspend fun getContentUri(file: File): Uri? + + /** + * Returns or creates (if not found) the sub directory based on the [name] + * in the app internal storage + * + * @param name of the sub directory + */ + suspend fun createDir(name: String) : File +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/data/data_source/network/api/ApiSettings.kt b/core/src/main/java/ua/gov/diia/core/data/data_source/network/api/ApiSettings.kt new file mode 100644 index 0000000..cb76ee5 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/data/data_source/network/api/ApiSettings.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.data.data_source.network.api + +import retrofit2.http.GET +import ua.gov.diia.core.models.appversion.AppSettingsInfo +import ua.gov.diia.core.network.annotation.Analytics + +interface ApiSettings { + + @Analytics("getAppSettingsInfo") + @GET("api/v1/settings") + suspend fun appSettingsInfo(): AppSettingsInfo + +} diff --git a/core/src/main/java/ua/gov/diia/core/data/data_source/network/api/notification/ApiNotificationsPublic.kt b/core/src/main/java/ua/gov/diia/core/data/data_source/network/api/notification/ApiNotificationsPublic.kt new file mode 100644 index 0000000..ec4fdec --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/data/data_source/network/api/notification/ApiNotificationsPublic.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core.data.data_source.network.api.notification + +import retrofit2.http.* +import ua.gov.diia.core.models.PushToken +import ua.gov.diia.core.models.notification.pull.message.NotificationFull +import ua.gov.diia.core.network.annotation.Analytics + +interface ApiNotificationsPublic { + + @Analytics("getMessageByID") + @GET("api/v3/notification/message/{messageId}") + suspend fun getMessage(@Path("messageId") messageId: String): NotificationFull + + @Analytics("sendUserDevicePushToken") + @POST("api/v1/notification/user-push-token") + suspend fun sendDeviceUserPushToken(@Body pushToken: PushToken) + + @Analytics("sendAppStatus") + @POST("api/v1/analytics/app-status") + suspend fun sendAppStatus(@HeaderMap headers: Map, @Body appStatus: ua.gov.diia.core.models.AppStatus) + + @Analytics("sendAppVersion") + @POST("api/v1/notification/app-version") + suspend fun sendAppVersion(@Body body: Any = Object()) +} diff --git a/core/src/main/java/ua/gov/diia/core/data/repository/DataRepository.kt b/core/src/main/java/ua/gov/diia/core/data/repository/DataRepository.kt new file mode 100644 index 0000000..19bfa3c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/data/repository/DataRepository.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.data.repository + +import kotlinx.coroutines.flow.Flow + +interface DataRepository { + + val data: Flow + + suspend fun load(): T + + suspend fun clear() +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/data/repository/SystemRepository.kt b/core/src/main/java/ua/gov/diia/core/data/repository/SystemRepository.kt new file mode 100644 index 0000000..2018655 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/data/repository/SystemRepository.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.core.data.repository + +interface SystemRepository { + + suspend fun getAppVersionCode(): Int? + suspend fun setAppVersionCode(code: Int) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/di/AppInfoProviderModule.kt b/core/src/main/java/ua/gov/diia/core/di/AppInfoProviderModule.kt new file mode 100644 index 0000000..bd259e1 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/di/AppInfoProviderModule.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import ua.gov.diia.core.util.system.application.ApplicationLauncher +import ua.gov.diia.core.util.system.application.ApplicationLauncherImpl +import ua.gov.diia.core.util.system.application.InstalledApplicationInfoProvider +import ua.gov.diia.core.util.system.application.InstalledApplicationInfoProviderImpl + +@Module +@InstallIn(ViewModelComponent::class) +interface AppInfoProviderModule { + + @Binds + fun bindInstalledAppInfoProvider( + impl: InstalledApplicationInfoProviderImpl + ): InstalledApplicationInfoProvider + + @Binds + fun bindApplicationLauncher( + impl: ApplicationLauncherImpl + ): ApplicationLauncher +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/di/DateProviderModule.kt b/core/src/main/java/ua/gov/diia/core/di/DateProviderModule.kt new file mode 100644 index 0000000..a50e037 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/di/DateProviderModule.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.core.util.date.CurrentDateProvider +import ua.gov.diia.core.util.date.CurrentDateProviderImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DateProviderModule { + + @Provides + @Singleton + fun provideDateProvider(): CurrentDateProvider = CurrentDateProviderImpl() +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/di/SystemServiceProviderModule.kt b/core/src/main/java/ua/gov/diia/core/di/SystemServiceProviderModule.kt new file mode 100644 index 0000000..35f0e2d --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/di/SystemServiceProviderModule.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import ua.gov.diia.core.util.system.service.SystemServiceProvider +import ua.gov.diia.core.util.system.service.SystemServiceProviderImpl + +@Module +@InstallIn(ViewModelComponent::class) +interface SystemServiceProviderModule { + + @Binds + fun bindServiceProvider( + impl: SystemServiceProviderImpl + ): SystemServiceProvider +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/di/WorkersModule.kt b/core/src/main/java/ua/gov/diia/core/di/WorkersModule.kt new file mode 100644 index 0000000..428b24a --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/di/WorkersModule.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.core.di + +import android.content.Context +import androidx.work.WorkManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ElementsIntoSet +import ua.gov.diia.core.util.work.CheckAppVersionUpdatedWork +import ua.gov.diia.core.util.work.DoApplicationSettingsProvisionWork +import ua.gov.diia.core.util.work.WorkScheduler + +@Module +@InstallIn(SingletonComponent::class) +object WorkersModule { + + @Provides + fun provideWorkManager( + @ApplicationContext appContext: Context + ): WorkManager = WorkManager.getInstance(appContext) + + @Provides + @ElementsIntoSet + fun provideWorkSchedulers(): Set<@JvmSuppressWildcards WorkScheduler> = setOf( + DoApplicationSettingsProvisionWork, + CheckAppVersionUpdatedWork, + ) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/di/actions/Annotations.kt b/core/src/main/java/ua/gov/diia/core/di/actions/Annotations.kt new file mode 100644 index 0000000..09bbbe0 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/di/actions/Annotations.kt @@ -0,0 +1,47 @@ +package ua.gov.diia.core.di.actions + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionLogout + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionDeeplink + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionAllowAuthorizedLinks + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionDocLoadingIndicator + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionConfirmDocumentRemoval + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionFocusOnDocument + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionSelectedMenuItem + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionLazy + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionNetworkState + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionNotificationRead + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionNotificationReceived \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/di/data_source/http/Annotations.kt b/core/src/main/java/ua/gov/diia/core/di/data_source/http/Annotations.kt new file mode 100644 index 0000000..106db85 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/di/data_source/http/Annotations.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.di.data_source.http + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class AuthorizedClient + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class UnauthorizedClient + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ProlongClient \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/di/fragment/HiltFragmentFactory.kt b/core/src/main/java/ua/gov/diia/core/di/fragment/HiltFragmentFactory.kt new file mode 100644 index 0000000..fbabf73 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/di/fragment/HiltFragmentFactory.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.core.di.fragment + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentFactory +import dagger.hilt.android.scopes.FragmentScoped +import javax.inject.Inject +import javax.inject.Provider + +@FragmentScoped +class HiltFragmentFactory @Inject constructor( + private val providerMap: Map, @JvmSuppressWildcards Provider> +) : FragmentFactory() { + + override fun instantiate(classLoader: ClassLoader, className: String): Fragment { + val fragmentClass = loadFragmentClass(classLoader, className) + + val creator = providerMap[fragmentClass] ?: providerMap.entries.firstOrNull { + fragmentClass.isAssignableFrom(it.key) + }?.value + + return creator?.get() ?: super.instantiate(classLoader, className) + } +} diff --git a/core/src/main/java/ua/gov/diia/core/di/fragment/HiltNavHostFragment.kt b/core/src/main/java/ua/gov/diia/core/di/fragment/HiltNavHostFragment.kt new file mode 100644 index 0000000..12a293b --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/di/fragment/HiltNavHostFragment.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.di.fragment + +import android.content.Context +import androidx.navigation.fragment.NavHostFragment +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class HiltNavHostFragment : NavHostFragment(){ + + @Inject + lateinit var hiltFragmentFactory: HiltFragmentFactory + + override fun onAttach(context: Context) { + super.onAttach(context) + childFragmentManager.fragmentFactory = hiltFragmentFactory + } +} diff --git a/core/src/main/java/ua/gov/diia/core/models/ActionDataLazy.kt b/core/src/main/java/ua/gov/diia/core/models/ActionDataLazy.kt new file mode 100644 index 0000000..697dc9c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/ActionDataLazy.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.core.models + +data class ActionDataLazy( + val hard: String, + val hardMap: Map +) diff --git a/core/src/main/java/ua/gov/diia/core/models/AppStatus.kt b/core/src/main/java/ua/gov/diia/core/models/AppStatus.kt new file mode 100644 index 0000000..8b3f0cb --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/AppStatus.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AppStatus( + @Json(name = "lastActivityDate") + val lastActivityDate : String?, + @Json(name = "lastDocumentUpdate") + val lastDocumentUpdate : String? = null +) diff --git a/core/src/main/java/ua/gov/diia/core/models/ConsumableItem.kt b/core/src/main/java/ua/gov/diia/core/models/ConsumableItem.kt new file mode 100644 index 0000000..4d1e675 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/ConsumableItem.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.core.models + +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize + +@Keep +@Parcelize +data class ConsumableItem( + val item: Parcelable, + var isConsumed: Boolean = false +) : Parcelable { + + fun isNotConsumed() = !isConsumed + + inline fun consumeEvent(action: (T) -> Unit) { + if (!isConsumed && item is T) { + isConsumed = true + action.invoke(item) + } + } + +} + +@Parcelize +class ConsumableEvent(var isConsumed: Boolean = false) : Parcelable { + + fun consumeEvent(action: () -> Unit) { + if (!isConsumed) { + isConsumed = true + action.invoke() + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/ConsumableString.kt b/core/src/main/java/ua/gov/diia/core/models/ConsumableString.kt new file mode 100644 index 0000000..909de74 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/ConsumableString.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.core.models + +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize + +@Keep +@Parcelize +data class ConsumableString( + val item: String?, + var isConsumed: Boolean = false +) : Parcelable { + + fun isNotConsumed() = !isConsumed + + fun consumeEvent(action: (String) -> Unit) { + if (!isConsumed) { + isConsumed = true + action.invoke(item ?: "") + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/ContextMenuField.kt b/core/src/main/java/ua/gov/diia/core/models/ContextMenuField.kt new file mode 100644 index 0000000..9370406 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/ContextMenuField.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models + +import android.content.Context +import android.os.Parcelable +import androidx.annotation.ColorRes +import ua.gov.diia.core.R + +interface ContextMenuField : Parcelable { + + fun getActionType() : String + + fun getSubType(): String? + + fun getDisplayName(c: Context): String + + @ColorRes + fun getTintColor(): Int = R.color.colorPrimary +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/DiiaError.kt b/core/src/main/java/ua/gov/diia/core/models/DiiaError.kt new file mode 100644 index 0000000..c3a17b4 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/DiiaError.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +enum class ErrorType { + SERVER_ERROR, NETWORK_ERROR, AUTHORIZATION_ERROR, APP_OUTDATED, DEVICE_ROOTED, APP_CLONE, ALREADY_AUTHORIZED +} + +@Parcelize +data class DiiaError(val urlPath: String, val exception: Exception?, val errorType: ErrorType): + Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/ITN.kt b/core/src/main/java/ua/gov/diia/core/models/ITN.kt new file mode 100644 index 0000000..783fd81 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/ITN.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.core.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ITN( + @Json(name = "birthDay") + val birthDay: String?, + @Json(name = "currentDate") + val currentDate: String?, + @Json(name = "expirationDate") + val expirationDate: String?, + @Json(name = "fName") + val fName: String?, + @Json(name = "itn") + val itn: String?, + @Json(name = "lName") + val lName: String?, + @Json(name = "mName") + val mName: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/PushToken.kt b/core/src/main/java/ua/gov/diia/core/models/PushToken.kt new file mode 100644 index 0000000..cbd2934 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/PushToken.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PushToken( + @Json(name = "pushToken") + val pushToken: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/RefreshToken.kt b/core/src/main/java/ua/gov/diia/core/models/RefreshToken.kt new file mode 100644 index 0000000..e3270ee --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/RefreshToken.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +@Parcelize +data class RefreshToken( + @Json(name = "token") + val token: String?, + @Json(name = "template") + val template: TemplateDialogModel?, +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/SingleDeeplinkProcessor.kt b/core/src/main/java/ua/gov/diia/core/models/SingleDeeplinkProcessor.kt new file mode 100644 index 0000000..43eb3a6 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/SingleDeeplinkProcessor.kt @@ -0,0 +1,9 @@ +package ua.gov.diia.core.models + +import androidx.navigation.NavDirections +import ua.gov.diia.core.models.deeplink.DeepLinkAction + +interface SingleDeeplinkProcessor { + fun isHandled(action: DeepLinkAction): Boolean + suspend fun handleDeepLinkAction(action: DeepLinkAction): NavDirections? +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/SuccessResponse.kt b/core/src/main/java/ua/gov/diia/core/models/SuccessResponse.kt new file mode 100644 index 0000000..2a95c0f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/SuccessResponse.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.core.models + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SuccessResponse( + val success: Boolean +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/SystemDialog.kt b/core/src/main/java/ua/gov/diia/core/models/SystemDialog.kt new file mode 100644 index 0000000..929992f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/SystemDialog.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models + +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize + +@Keep +@Parcelize +data class SystemDialog( + val title: String?, + val message: String?, + val positiveButtonTitle: String?, + val negativeButtonTitle: String? = null, + val cancelable: Boolean = false +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/TextWithParameters.kt b/core/src/main/java/ua/gov/diia/core/models/TextWithParameters.kt new file mode 100644 index 0000000..4ad952f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/TextWithParameters.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common.message.TextParameter + +@JsonClass(generateAdapter = true) +@Parcelize +data class TextWithParameters( + @Json(name = "parameters") + val parameters: List?, + @Json(name = "text") + val text: String? +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/Token.kt b/core/src/main/java/ua/gov/diia/core/models/Token.kt new file mode 100644 index 0000000..1335f20 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/Token.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +@Parcelize +data class Token( + @Json(name = "token") + val token: String, + @Json(name = "template") + val template: TemplateDialogModel?, +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/TokenData.kt b/core/src/main/java/ua/gov/diia/core/models/TokenData.kt new file mode 100644 index 0000000..0167229 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/TokenData.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.core.models + +import java.util.* + +data class TokenData(val token: String, val tokenExp: Date) { + + val isEmptyToken: Boolean + get() = token == EMPTY_TOKEN + + fun isExpired(tokenLeeway: Long): Boolean{ + val now = Date() + val leewayDate = Date(tokenExp.time - tokenLeeway * SEC) + val expValid = now.before(leewayDate) + return !expValid + } + + companion object { + const val EMPTY_TOKEN = "empty_token" + const val SEC = 1_000 + const val EXP = "exp" + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/UserType.kt b/core/src/main/java/ua/gov/diia/core/models/UserType.kt new file mode 100644 index 0000000..f4e5d82 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/UserType.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.core.models + +enum class UserType { + PRIMARY_USER, SERVICE_USER +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/acquirer/AcquirerLinkType.kt b/core/src/main/java/ua/gov/diia/core/models/acquirer/AcquirerLinkType.kt new file mode 100644 index 0000000..6a789b8 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/acquirer/AcquirerLinkType.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.core.models.acquirer + +enum class AcquirerLinkType { + static, dynamic +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/acquirer/AcquirerServiceType.kt b/core/src/main/java/ua/gov/diia/core/models/acquirer/AcquirerServiceType.kt new file mode 100644 index 0000000..e528a4d --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/acquirer/AcquirerServiceType.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.core.models.acquirer + +enum class AcquirerServiceType(val id: String) { + DOCUMENT_GENERATION_BARCODE("documentsGeneration"), + DOCUMENT_GENERATION_LINK("documentsGeneration"), + IDENTITY_CHECK("identityCheck"), + FILE_HASH_SIGNING("hashedFilesSigningDiiaId"), + DIIA_ID_AUTH("authDiiaId"), + PAY_ADMINISTRATIVE_FEE("administrativeFees"), + UNKNOWN(""), +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/appversion/AppSettingsInfo.kt b/core/src/main/java/ua/gov/diia/core/models/appversion/AppSettingsInfo.kt new file mode 100644 index 0000000..247b10a --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/appversion/AppSettingsInfo.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.models.appversion + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AppSettingsInfo( + @Json(name = "needActions") + val actions: List? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/auth/Auth.kt b/core/src/main/java/ua/gov/diia/core/models/auth/Auth.kt new file mode 100644 index 0000000..717697f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/auth/Auth.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.models.auth + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class Auth( + @Json(name = "authUrl") + val authUrl: String +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/auth/AuthV3.kt b/core/src/main/java/ua/gov/diia/core/models/auth/AuthV3.kt new file mode 100644 index 0000000..1a8b52c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/auth/AuthV3.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.auth + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class AuthV3( + @Json(name = "authUrl") + val authUrl: String, + @Json(name = "fld") + val fld: Fld? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/auth/FaceRecoConfig.kt b/core/src/main/java/ua/gov/diia/core/models/auth/FaceRecoConfig.kt new file mode 100644 index 0000000..93a825b --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/auth/FaceRecoConfig.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.models.auth + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class FaceRecoConfig( + @Json(name = "fld") + val fld: Fld? +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/auth/Fld.kt b/core/src/main/java/ua/gov/diia/core/models/auth/Fld.kt new file mode 100644 index 0000000..477b17f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/auth/Fld.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.auth + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class Fld( + @Json(name = "version") + val version: String, + @Json(name = "config") + val config: String?, +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/LoadActionData.kt b/core/src/main/java/ua/gov/diia/core/models/common/LoadActionData.kt new file mode 100644 index 0000000..f0c3e03 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/LoadActionData.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.core.models.common + +abstract class LoadActionData { + abstract val icon: String? + abstract val name: String? + abstract val isLoading: Boolean + abstract val isEnabled: Boolean + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LoadActionData) return false + + if (icon != other.icon) return false + if (name != other.name) return false + if (isLoading != other.isLoading) return false + if (isEnabled != other.isEnabled) return false + + return true + } + + override fun hashCode(): Int { + var result = icon?.hashCode() ?: 0 + result = 31 * result + (name?.hashCode() ?: 0) + result = 31 * result + isLoading.hashCode() + result = 31 * result + isEnabled.hashCode() + return result + } +} diff --git a/core/src/main/java/ua/gov/diia/core/models/common/NavigationPanel.kt b/core/src/main/java/ua/gov/diia/core/models/common/NavigationPanel.kt new file mode 100644 index 0000000..8d13451 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/NavigationPanel.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.core.models.common + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.models.common.menu.ContextMenuItem + +@JsonClass(generateAdapter = true) +data class NavigationPanel( + @Json(name = "header") + val header: String?, + @Json(name = "contextMenu") + val contextMenu: List?, +) { + val menu: Array + get() = contextMenu?.toTypedArray() ?: arrayOf() +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/menu/ContextMenuItem.kt b/core/src/main/java/ua/gov/diia/core/models/common/menu/ContextMenuItem.kt new file mode 100644 index 0000000..a2ea6e3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/menu/ContextMenuItem.kt @@ -0,0 +1,29 @@ +package ua.gov.diia.core.models.common.menu + +import android.content.Context +import android.os.Parcelable +import androidx.annotation.Keep +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.ContextMenuField + +@Keep +@Parcelize +@JsonClass(generateAdapter = true) +data class ContextMenuItem( + @Json(name = "type") + val type: String, + @Json(name = "name") + val name: String, + @Json(name = "code") + val code: String?, +) : Parcelable, ContextMenuField { + + override fun getActionType() = type + + override fun getSubType(): String? = code + + override fun getDisplayName(c: Context) = name + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/message/AttentionMessage.kt b/core/src/main/java/ua/gov/diia/core/models/common/message/AttentionMessage.kt new file mode 100644 index 0000000..696afe6 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/message/AttentionMessage.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.models.common.message + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class AttentionMessage( + @Json(name = "icon") + val icon: String?, + @Json(name = "title") + val title: String?, + @Json(name = "text") + val text: String?, + @Json(name = "parameters") + val parameters: List?, +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/message/AttentionMessageParameterized.kt b/core/src/main/java/ua/gov/diia/core/models/common/message/AttentionMessageParameterized.kt new file mode 100644 index 0000000..4cbacb1 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/message/AttentionMessageParameterized.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.core.models.common.message + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common.message.TextParameter + +@Parcelize +@JsonClass(generateAdapter = true) +data class AttentionMessageParameterized( + @Json(name = "icon") + val icon: String?, + @Json(name = "parameters") + val parameters: List?, + @Json(name = "text") + val text: String?, + @Json(name = "title") + val title: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/message/StatusMessage.kt b/core/src/main/java/ua/gov/diia/core/models/common/message/StatusMessage.kt new file mode 100644 index 0000000..b07bbe3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/message/StatusMessage.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.core.models.common.message + +import android.os.Parcelable +import com.squareup.moshi.Json +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.TextWithParameters + +@Parcelize +data class StatusMessage( + @Json(name = "icon") + val icon: String?, + @Json(name = "text") + val text: String?, + @Json(name = "title") + val title: String?, + @Json(name = "textWithParameters") + val textWithParameters: TextWithParameters?, + @Json(name = "subtitle") + val subtitle: String?, + @Json(name = "parameters") + val parameters: List? +): Parcelable diff --git a/core/src/main/java/ua/gov/diia/core/models/common/message/StatusMessageParameterized.kt b/core/src/main/java/ua/gov/diia/core/models/common/message/StatusMessageParameterized.kt new file mode 100644 index 0000000..d8f5b50 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/message/StatusMessageParameterized.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.common.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.message.TextParameter + +@JsonClass(generateAdapter = true) +data class StatusMessageParameterized( + @Json(name = "icon") + val icon: String?, + @Json(name = "parameters") + val parameters: List?, + @Json(name = "text") + val text: String?, + @Json(name = "title") + val title: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/message/StubMessage.kt b/core/src/main/java/ua/gov/diia/core/models/common/message/StubMessage.kt new file mode 100644 index 0000000..e0074af --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/message/StubMessage.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.common.message + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class StubMessage( + @Json(name = "icon") + val icon: String?, + @Json(name = "text") + val text: String?, + @Json(name = "canRepeat") + val canRepeat: Boolean?, + @Json(name = "title") + val title: String?, + @Json(name = "description") + val description: String? +) diff --git a/core/src/main/java/ua/gov/diia/core/models/common/message/StubMessageParameterized.kt b/core/src/main/java/ua/gov/diia/core/models/common/message/StubMessageParameterized.kt new file mode 100644 index 0000000..789a295 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/message/StubMessageParameterized.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.models.common.message + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.message.TextParameter +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class StubMessageParameterized( + @Json(name = "icon") + val icon: String?, + @Json(name = "text") + val text: String?, + @Json(name = "parameters") + val parameters: List? +) +: Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/message/TextParameter.kt b/core/src/main/java/ua/gov/diia/core/models/common/message/TextParameter.kt new file mode 100644 index 0000000..08b050b --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/message/TextParameter.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.core.models.common.message + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class TextParameter( + @Json(name = "data") + val data: Data?, + @Json(name = "type") + val type: String? +) : Parcelable { + @Parcelize + @JsonClass(generateAdapter = true) + data class Data( + @Json(name = "alt") + val alt: String?, + @Json(name = "name") + val name: String?, + @Json(name = "resource") + val resource: String? + ) : Parcelable +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/template_dialogs/DynamicDialogData.kt b/core/src/main/java/ua/gov/diia/core/models/common/template_dialogs/DynamicDialogData.kt new file mode 100644 index 0000000..ed32eb3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/template_dialogs/DynamicDialogData.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.models.common.template_dialogs + +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize + +@Keep +@Parcelize +data class DynamicDialogData( + val title: String?, + val message: String?, + val positiveButtonTitle: String?, + val negativeButtonTitle: String?, + val cancelable: Boolean = false, +) : Parcelable { + + val showMessage: Boolean + get() = message != null +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common/template_dialogs/SystemDialogData.kt b/core/src/main/java/ua/gov/diia/core/models/common/template_dialogs/SystemDialogData.kt new file mode 100644 index 0000000..93a1b38 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common/template_dialogs/SystemDialogData.kt @@ -0,0 +1,33 @@ +package ua.gov.diia.core.models.common.template_dialogs + +import android.os.Parcelable +import androidx.annotation.Keep +import androidx.annotation.StringRes +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.util.extensions.isResourceValid + +@Keep +@Parcelize +data class SystemDialogData( + @StringRes val title: Int?, + @StringRes val message: Int?, + @StringRes val positiveButtonTitle: Int?, + @StringRes val negativeButtonTitle: Int? = null, + val cancelable: Boolean = false, + @StringRes val rationale: Int? = null, + @StringRes val rationaleTitle: Int? = null +) : Parcelable { + + val showMessage: Boolean + get() = message != null + + val showTwoButtonsGroup: Boolean + get() = negativeButtonTitle != null + + val showOneButtonGroup: Boolean + get() = negativeButtonTitle == null + + val enableRationale: Boolean + get() = rationale.isResourceValid() && rationaleTitle.isResourceValid() + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/NavigationBarMlcl.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/NavigationBarMlcl.kt new file mode 100644 index 0000000..69ff30a --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/NavigationBarMlcl.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.common_compose + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common.menu.ContextMenuItem + +@Parcelize +@JsonClass(generateAdapter = true) +data class NavigationBarMlcl( + @Json(name = "label") + val label: String, + @Json(name = "ellipseMenu") + val ellipseMenu: List?, +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/button/BtnPlainIconAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/button/BtnPlainIconAtm.kt new file mode 100644 index 0000000..af3b6c9 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/button/BtnPlainIconAtm.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.common_compose.atm.button + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class BtnPlainIconAtm( + @Json(name = "icon") + val icon: String, + @Json(name = "label") + val label: String, + @Json(name = "state") + val state: String?, + @Json(name = "action") + val action: Action?, +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/button/PlayerBtnAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/button/PlayerBtnAtm.kt new file mode 100644 index 0000000..cd96f2d --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/button/PlayerBtnAtm.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.models.common_compose.atm.button + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PlayerBtnAtm( + @Json(name = "icon") + val icon: String, + @Json(name = "type") + val type: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/chip/ChipStatusAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/chip/ChipStatusAtm.kt new file mode 100644 index 0000000..a4ed78c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/chip/ChipStatusAtm.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core.models.common_compose.atm.chip + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class ChipStatusAtm( + @Json(name = "code") + val code: String?, + @Json(name = "name") + val name: String?, + @Json(name = "type") + val type: String? +) : Parcelable + +enum class Type(val id: String) { + SUCCESS("success"), + PENDING("pending"), + FAIL("fail"), + NEUTRAL("neutral"); +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/BadgeCounterAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/BadgeCounterAtm.kt new file mode 100644 index 0000000..09b6129 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/BadgeCounterAtm.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.core.models.common_compose.atm.icon + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class BadgeCounterAtm( + @Json(name = "count") + val count: Int +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/DoubleIconAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/DoubleIconAtm.kt new file mode 100644 index 0000000..952cf25 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/DoubleIconAtm.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.common_compose.atm.icon + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class DoubleIconAtm( + @Json(name = "accessibilityDescription") + val accessibilityDescription: String?, + @Json(name = "action") + val action: Action?, + @Json(name = "code") + val code: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/IconAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/IconAtm.kt new file mode 100644 index 0000000..2abd2f1 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/IconAtm.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.core.models.common_compose.atm.icon + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.general.Action + +@Parcelize +@JsonClass(generateAdapter = true) +data class IconAtm( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "accessibilityDescription") + val accessibilityDescription: String? = null, + @Json(name = "action") + val action: Action?, + @Json(name = "code") + val code: String +) : Parcelable { + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/SmallIconAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/SmallIconAtm.kt new file mode 100644 index 0000000..083a9ae --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/icon/SmallIconAtm.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.core.models.common_compose.atm.icon + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.general.Action + +@Parcelize +@JsonClass(generateAdapter = true) +data class SmallIconAtm( + @Json(name = "accessibilityDescription") + val accessibilityDescription: String?, + @Json(name = "action") + val action: Action?, + @Json(name = "code") + val code: String +): Parcelable{ + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/indicators/DotNavigationAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/indicators/DotNavigationAtm.kt new file mode 100644 index 0000000..d16d986 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/indicators/DotNavigationAtm.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.core.models.common_compose.atm.indicators + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DotNavigationAtm( + @Json(name = "count") + val count: Int +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/media/ArticlePicAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/media/ArticlePicAtm.kt new file mode 100644 index 0000000..2ef2f0c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/media/ArticlePicAtm.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.core.models.common_compose.atm.media + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ArticlePicAtm( + @Json(name = "image") + val image: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/text/SectionTitleAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/text/SectionTitleAtm.kt new file mode 100644 index 0000000..96b4ca5 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/text/SectionTitleAtm.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.core.models.common_compose.atm.text + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class SectionTitleAtm( + @Json(name = "label") + val label: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/text/TickerAtm.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/text/TickerAtm.kt new file mode 100644 index 0000000..38e4308 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/atm/text/TickerAtm.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.core.models.common_compose.atm.text + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.general.Action + +@Parcelize +@JsonClass(generateAdapter = true) +data class TickerAtm( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "action") + val action: Action? = null, + @Json(name = "type") + val type: TickerType, + @Json(name = "usage") + val usage: String?, + @Json(name = "value") + val value: String +) : Parcelable { + enum class TickerType { + warning, positive, neutral, informative; + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/general/Action.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/Action.kt new file mode 100644 index 0000000..a59a8f1 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/Action.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.common_compose.general + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class Action( + @Json(name = "type") + val type: String, + @Json(name = "subtype") + val subtype: String?, + @Json(name = "resource") + val resource: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/general/Body.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/Body.kt new file mode 100644 index 0000000..b0eff4f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/Body.kt @@ -0,0 +1,48 @@ +package ua.gov.diia.core.models.common_compose.general + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.mlc.text.TextLabelContainerMlc +import ua.gov.diia.core.models.common_compose.atm.text.SectionTitleAtm +import ua.gov.diia.core.models.common_compose.mlc.card.ImageCardMlc +import ua.gov.diia.core.models.common_compose.mlc.card.WhiteCardMlc +import ua.gov.diia.core.models.common_compose.org.button.BtnIconRoundedGroupOrg +import ua.gov.diia.core.models.common_compose.org.carousel.ArticlePicCarouselOrg +import ua.gov.diia.core.models.common_compose.org.carousel.HalvedCardCarouselOrg +import ua.gov.diia.core.models.common_compose.org.carousel.SmallNotificationCarouselOrg +import ua.gov.diia.core.models.common_compose.org.carousel.VerticalCardCarouselOrg +import ua.gov.diia.core.models.common_compose.org.header.MediaTitleOrg +import ua.gov.diia.core.models.common_compose.org.list.ListItemGroupOrg +import ua.gov.diia.core.models.notification.pull.message.ArticlePicAtm +import ua.gov.diia.core.models.notification.pull.message.ArticleVideoMlc + +@JsonClass(generateAdapter = true) +data class Body( + @Json(name = "whiteCardMlc") + val whiteCardMlc: WhiteCardMlc?, + @Json(name = "btnIconRoundedGroupOrg") + val btnIconRoundedGroupOrg: BtnIconRoundedGroupOrg?, + @Json(name = "halvedCardCarouselOrg") + val halvedCardCarouselOrg: HalvedCardCarouselOrg?, + @Json(name = "imageCardMlc") + val imageCardMlc: ImageCardMlc?, + @Json(name = "listItemGroupOrg") + val listItemGroupOrg: ListItemGroupOrg?, + @Json(name = "sectionTitleAtm") + val sectionTitleAtm: SectionTitleAtm?, + @Json(name = "smallNotificationCarouselOrg") + val smallNotificationCarouselOrg: SmallNotificationCarouselOrg?, + @Json(name = "verticalCardCarouselOrg") + val verticalCardCarouselOrg: VerticalCardCarouselOrg?, + @Json(name = "mediaTitleOrg") + val mediaTitleOrg: MediaTitleOrg?, + @Json(name = "articlePicCarouselOrg") + val articlePicCarouselOrg: ArticlePicCarouselOrg?, + @Json(name = "textLabelContainerMlc") + val textLabelContainerMlc: TextLabelContainerMlc?, + @Json(name = "articlePicAtm") + val articlePicAtm: ArticlePicAtm?, + @Json(name = "articleVideoMlc") + val articleVideoMlc: ArticleVideoMlc?, + ) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/general/BottomGroup.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/BottomGroup.kt new file mode 100644 index 0000000..0531126 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/BottomGroup.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.models.common_compose.general + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.org.list.ListItemGroupOrg + +@JsonClass(generateAdapter = true) +data class BottomGroup( + @Json(name = "listItemGroupOrg") + val listItemGroupOrg: ListItemGroupOrg? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/general/ButtonStates.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/ButtonStates.kt new file mode 100644 index 0000000..e16d62b --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/ButtonStates.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.models.common_compose.general + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +@Suppress("EnumEntryName") +enum class ButtonStates : Parcelable { + enabled, disabled, invisible; +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/general/DiiaResponse.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/DiiaResponse.kt new file mode 100644 index 0000000..058301f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/DiiaResponse.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.core.models.common_compose.general + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class DiiaResponse( + @Json(name = "body") + val body: List?, + @Json(name = "processCode") + val processCode: String?, + @Json(name = "template") + val template: TemplateDialogModel?, + @Json(name = "topGroup") + val topGroup: List?, + @Json(name = "bottomGroup") + val bottomGroup: List?, +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/general/TopGroup.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/TopGroup.kt new file mode 100644 index 0000000..785831f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/general/TopGroup.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.common_compose.general + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.NavigationBarMlcl +import ua.gov.diia.core.models.common_compose.org.header.TopGroupOrg + +@JsonClass(generateAdapter = true) +data class TopGroup( + @Json(name = "topGroupOrg") + val topGroupOrg: TopGroupOrg?, + @Json(name = "navigationPanelMlc") + val navigationBarMlcl: NavigationBarMlcl? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/button/BtnIconRoundedMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/button/BtnIconRoundedMlc.kt new file mode 100644 index 0000000..5ea6c8e --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/button/BtnIconRoundedMlc.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.common_compose.mlc.button + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class BtnIconRoundedMlc( + @Json(name = "action") + val action: Action?, + @Json(name = "icon") + val icon: String, + @Json(name = "label") + val label: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/BlackCardMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/BlackCardMlc.kt new file mode 100644 index 0000000..3652a55 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/BlackCardMlc.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core.models.common_compose.mlc.card + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.atm.icon.DoubleIconAtm +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm +import ua.gov.diia.core.models.common_compose.atm.icon.SmallIconAtm +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class BlackCardMlc( + @Json(name = "action") + val action: Action?, + @Json(name = "doubleIconAtm") + val doubleIconAtm: DoubleIconAtm?, + @Json(name = "iconAtm") + val iconAtm: IconAtm?, + @Json(name = "label") + val label: String?, + @Json(name = "smallIconAtm") + val smallIconAtm: SmallIconAtm?, + @Json(name = "title") + val title: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/HalvedCardMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/HalvedCardMlc.kt new file mode 100644 index 0000000..ce48d51 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/HalvedCardMlc.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.core.models.common_compose.mlc.card + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class HalvedCardMlc( + @Json(name = "id") + val id: String?, + @Json(name = "title") + val title: String, + @Json(name = "image") + val image: String, + @Json(name = "label") + val label: String, + @Json(name = "action") + val action: Action?, +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/IconCardMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/IconCardMlc.kt new file mode 100644 index 0000000..dd9671f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/IconCardMlc.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.common_compose.mlc.card + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class IconCardMlc( + @Json(name = "action") + val action: Action?, + @Json(name = "iconLeft") + val iconLeft: String, + @Json(name = "label") + val label: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/ImageCardMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/ImageCardMlc.kt new file mode 100644 index 0000000..4885cfd --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/ImageCardMlc.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.common_compose.mlc.card + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class ImageCardMlc( + @Json(name = "action") + val action: Action?, + @Json(name = "iconRight") + val iconRight: String, + @Json(name = "image") + val image: String, + @Json(name = "label") + val label: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/SmallNotificationMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/SmallNotificationMlc.kt new file mode 100644 index 0000000..37b5e81 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/SmallNotificationMlc.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.common_compose.mlc.card + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class SmallNotificationMlc( + @Json(name = "id") + val id: String, + @Json(name = "label") + val label: String, + @Json(name = "text") + val text: String, + @Json(name = "action") + val action: Action?, +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/VerticalCardMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/VerticalCardMlc.kt new file mode 100644 index 0000000..9b3db94 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/VerticalCardMlc.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.core.models.common_compose.mlc.card + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.atm.icon.BadgeCounterAtm +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class VerticalCardMlc( + @Json(name = "action") + val action: Action?, + @Json(name = "badgeCounterAtm") + val badgeCounterAtm: BadgeCounterAtm, + @Json(name = "id") + val id: String?, + @Json(name = "image") + val image: String, + @Json(name = "title") + val title: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/WhiteCardMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/WhiteCardMlc.kt new file mode 100644 index 0000000..96717cc --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/card/WhiteCardMlc.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core.models.common_compose.mlc.card + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.atm.icon.DoubleIconAtm +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm +import ua.gov.diia.core.models.common_compose.atm.icon.SmallIconAtm +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class WhiteCardMlc( + @Json(name = "action") + val action: Action?, + @Json(name = "doubleIconAtm") + val doubleIconAtm: DoubleIconAtm?, + @Json(name = "iconAtm") + val iconAtm: IconAtm?, + @Json(name = "label") + val label: String?, + @Json(name = "smallIconAtm") + val smallIconAtm: SmallIconAtm?, + @Json(name = "title") + val title: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/header/TitleGroupMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/header/TitleGroupMlc.kt new file mode 100644 index 0000000..ff5aa33 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/header/TitleGroupMlc.kt @@ -0,0 +1,36 @@ +package ua.gov.diia.core.models.common_compose.mlc.header + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class TitleGroupMlc( + @Json(name = "heroText") + val heroText: String, + @Json(name = "label") + val label: String?, + @Json(name = "leftNavIcon") + val leftNavIcon: LeftNavIcon?, + @Json(name = "mediumIconRight") + val mediumIconRight: MediumIconRight? +) { + @JsonClass(generateAdapter = true) + data class MediumIconRight( + @Json(name = "action") + val action: Action?, + @Json(name = "code") + val code: String + ) + + @JsonClass(generateAdapter = true) + data class LeftNavIcon( + @Json(name = "accessibilityDescription") + val accessibilityDescription: String?, + @Json(name = "action") + val action: Action?, + @Json(name = "code") + val code: String + ) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/list/ListItemMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/list/ListItemMlc.kt new file mode 100644 index 0000000..a62b70f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/list/ListItemMlc.kt @@ -0,0 +1,39 @@ +package ua.gov.diia.core.models.common_compose.mlc.list + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Action + +@JsonClass(generateAdapter = true) +data class ListItemMlc( + @Json(name = "action") + val action: Action?, + @Json(name = "description") + val description: String?, + @Json(name = "iconLeft") + val iconLeft: IconLeft?, + @Json(name = "iconRight") + val iconRight: IconRight?, + @Json(name = "id") + val id: String?, + @Json(name = "label") + val label: String, + @Json(name = "logoLeft") + val logoLeft: String?, + @Json(name = "state") + val state: String? +) { + @JsonClass(generateAdapter = true) + data class IconRight( + @Json(name = "code") + val code: String? + ) + + @JsonClass(generateAdapter = true) + data class IconLeft( + @Json(name = "code") + val code: String? + ) + +} diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/media/ArticleVideoMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/media/ArticleVideoMlc.kt new file mode 100644 index 0000000..a52d7f6 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/media/ArticleVideoMlc.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.core.models.common_compose.mlc.media + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.atm.button.PlayerBtnAtm + +@JsonClass(generateAdapter = true) +data class ArticleVideoMlc( + @Json(name = "playerBtnAtm") + val playerBtnAtm: PlayerBtnAtm?, + @Json(name = "source") + val source: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/text/SmallEmojiPanelMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/text/SmallEmojiPanelMlc.kt new file mode 100644 index 0000000..730add7 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/text/SmallEmojiPanelMlc.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.common_compose.mlc.text + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class SmallEmojiPanelMlc( + @Json(name = "label") + val label: String?, + @Json(name = "icon") + val icon: IconAtm?, +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/text/TextLabelContainerMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/text/TextLabelContainerMlc.kt new file mode 100644 index 0000000..79569d8 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/mlc/text/TextLabelContainerMlc.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.common_compose.mlc.text + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +typealias TextParameterApi = ua.gov.diia.core.models.common.message.TextParameter + +@JsonClass(generateAdapter = true) +data class TextLabelContainerMlc( + @Json(name = "label") + val label: String?, + @Json(name = "text") + val text: String?, + @Json(name = "parameters") + val parameters: List? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/button/BtnIconRoundedGroupOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/button/BtnIconRoundedGroupOrg.kt new file mode 100644 index 0000000..59a53e1 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/button/BtnIconRoundedGroupOrg.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.common_compose.org.button + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.mlc.button.BtnIconRoundedMlc + +@JsonClass(generateAdapter = true) +data class BtnIconRoundedGroupOrg( + @Json(name = "items") + val items: List +) { + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "btnIconRoundedMlc") + val btnIconRoundedMlc: BtnIconRoundedMlc + ) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/ArticlePicCarouselOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/ArticlePicCarouselOrg.kt new file mode 100644 index 0000000..a946922 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/ArticlePicCarouselOrg.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.core.models.common_compose.org.carousel + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.atm.indicators.DotNavigationAtm +import ua.gov.diia.core.models.common_compose.atm.media.ArticlePicAtm +import ua.gov.diia.core.models.common_compose.mlc.media.ArticleVideoMlc + +@JsonClass(generateAdapter = true) +data class ArticlePicCarouselOrg( + @Json(name = "dotNavigationAtm") + val dotNavigationAtm: DotNavigationAtm, + @Json(name = "items") + val items: List +) { + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "articlePicAtm") + val articlePicAtm: ArticlePicAtm?, + @Json(name = "articleVideoMlc") + val articleVideoMlc: ArticleVideoMlc? + ) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/HalvedCardCarouselOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/HalvedCardCarouselOrg.kt new file mode 100644 index 0000000..338e261 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/HalvedCardCarouselOrg.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core.models.common_compose.org.carousel + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.atm.indicators.DotNavigationAtm +import ua.gov.diia.core.models.common_compose.mlc.card.HalvedCardMlc +import ua.gov.diia.core.models.common_compose.mlc.card.IconCardMlc + +@JsonClass(generateAdapter = true) +data class HalvedCardCarouselOrg( + @Json(name = "dotNavigationAtm") + val dotNavigationAtm: DotNavigationAtm, + @Json(name = "items") + val items: List +) { + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "halvedCardMlc") + val halvedCardMlc: HalvedCardMlc?, + @Json(name = "iconCardMlc") + val iconCardMlc: IconCardMlc? + ) +} + diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/SmallNotificationCarouselOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/SmallNotificationCarouselOrg.kt new file mode 100644 index 0000000..6798071 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/SmallNotificationCarouselOrg.kt @@ -0,0 +1,24 @@ +package ua.gov.diia.core.models.common_compose.org.carousel + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.atm.indicators.DotNavigationAtm +import ua.gov.diia.core.models.common_compose.mlc.card.IconCardMlc +import ua.gov.diia.core.models.common_compose.mlc.card.SmallNotificationMlc + +@JsonClass(generateAdapter = true) +data class SmallNotificationCarouselOrg( + @Json(name = "dotNavigationAtm") + val dotNavigationAtm: DotNavigationAtm, + @Json(name = "items") + val items: List +) { + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "smallNotificationMlc") + val smallNotificationMlc: SmallNotificationMlc?, + @Json(name = "iconCardMlc") + val iconCardMlc: IconCardMlc? + ) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/VerticalCardCarouselOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/VerticalCardCarouselOrg.kt new file mode 100644 index 0000000..5d0d295 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/carousel/VerticalCardCarouselOrg.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.models.common_compose.org.carousel + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.mlc.card.VerticalCardMlc + +@JsonClass(generateAdapter = true) +data class VerticalCardCarouselOrg( + @Json(name = "items") + val items: List +) { + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "verticalCardMlc") + val verticalCardMlc: VerticalCardMlc + ) +} + diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/ChipTabsOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/ChipTabsOrg.kt new file mode 100644 index 0000000..804f1be --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/ChipTabsOrg.kt @@ -0,0 +1,24 @@ +package ua.gov.diia.core.models.common_compose.org.header + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ChipTabsOrg( + @Json(name = "items") + val items: List, + @Json(name = "label") + val label: String?, + @Json(name = "preselectedCode") + val preselectedCode: String +) { + @JsonClass(generateAdapter = true) + data class Item( + @Json(name = "code") + val code: String?, + @Json(name = "count") + val count: String, + @Json(name = "label") + val label: String + ) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/MediaTitleOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/MediaTitleOrg.kt new file mode 100644 index 0000000..b94ba42 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/MediaTitleOrg.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.common_compose.org.header + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.atm.button.BtnPlainIconAtm + +@JsonClass(generateAdapter = true) +data class MediaTitleOrg( + @Json(name = "btnPlainIconAtm") + val btnPlainIconAtm: BtnPlainIconAtm, + @Json(name = "secondaryLabel") + val secondaryLabel: String, + @Json(name = "title") + val title: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/NavigationPanelMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/NavigationPanelMlc.kt new file mode 100644 index 0000000..cb26f89 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/NavigationPanelMlc.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.core.models.common_compose.org.header + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.menu.ContextMenuItem + +@JsonClass(generateAdapter = true) +data class NavigationPanelMlc( + @Json(name = "ellipseMenu") + val ellipseMenu: List?, + @Json(name = "label") + val label: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/TopGroupOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/TopGroupOrg.kt new file mode 100644 index 0000000..85c3e80 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/header/TopGroupOrg.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.common_compose.org.header + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.mlc.header.TitleGroupMlc + +@JsonClass(generateAdapter = true) +data class TopGroupOrg( + @Json(name = "chipTabsOrg") + val chipTabsOrg: ChipTabsOrg?, + @Json(name = "navigationPanelMlc") + val navigationPanelMlc: NavigationPanelMlc?, + @Json(name = "titleGroupMlc") + val titleGroupMlc: TitleGroupMlc?, +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/org/list/ListItemGroupOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/list/ListItemGroupOrg.kt new file mode 100644 index 0000000..3541d16 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/org/list/ListItemGroupOrg.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.core.models.common_compose.org.list + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.mlc.list.ListItemMlc + +@JsonClass(generateAdapter = true) +data class ListItemGroupOrg( + @Json(name = "items") + val items: List, + @Json(name = "title?") + val title: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/Action.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/Action.kt new file mode 100644 index 0000000..eafb2f3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/Action.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.core.models.common_compose.table + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class Action( + @Json(name = "resource") + val resource: String?, + @Json(name = "subtype") + val subtype: String?, + @Json(name = "type") + val type: String +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/HeadingWithSubtitlesMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/HeadingWithSubtitlesMlc.kt new file mode 100644 index 0000000..05279bb --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/HeadingWithSubtitlesMlc.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.common_compose.table + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class HeadingWithSubtitlesMlc( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "subtitles") + val subtitles: List?, + @Json(name = "value") + val value: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/Item.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/Item.kt new file mode 100644 index 0000000..02fae45 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/Item.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core.models.common_compose.table + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.android.parcel.Parcelize +import ua.gov.diia.core.models.common_compose.mlc.text.SmallEmojiPanelMlc + +@Parcelize +@JsonClass(generateAdapter = true) +data class Item( + @Json(name = "docTableItemHorizontalLongerMlc") + val docTableItemHorizontalLongerMlc: TableItemHorizontalMlc? = null, + @Json(name = "docTableItemHorizontalMlc") + val docTableItemHorizontalMlc: TableItemHorizontalMlc? = null, + @Json(name = "tableItemHorizontalMlc") + val tableItemHorizontalMlc: TableItemHorizontalMlc? = null, + @Json(name = "tableItemPrimaryMlc") + val tableItemPrimaryMlc: TableItemPrimaryMlc? = null, + @Json(name = "tableItemVerticalMlc") + val tableItemVerticalMlc: TableItemVerticalMlc? = null, + @Json(name = "smallEmojiPanelMlc") + val smallEmojiPanelMlc: SmallEmojiPanelMlc? = null, +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemHorizontalMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemHorizontalMlc.kt new file mode 100644 index 0000000..a5f9fc2 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemHorizontalMlc.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.core.models.common_compose.table + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableItemHorizontalMlc( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "icon") + val icon: IconAtm? = null, + @Json(name = "label") + val label: String? = null, + @Json(name = "secondaryLabel") + val secondaryLabel: String? = null, + @Json(name = "secondaryValue") + val secondaryValue: String? = null, + @Json(name = "supportingValue") + val supportingValue: String? = null, + @Json(name = "value") + val value: String? = null, + @Json(name = "valueImage") + val valueImage: String? = null, +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemPrimaryMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemPrimaryMlc.kt new file mode 100644 index 0000000..2229450 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemPrimaryMlc.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.core.models.common_compose.table + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableItemPrimaryMlc( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "icon") + val icon: IconAtm?, + @Json(name = "label") + val label: String, + @Json(name = "value") + val value: String +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemVerticalMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemVerticalMlc.kt new file mode 100644 index 0000000..d96465e --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableItemVerticalMlc.kt @@ -0,0 +1,32 @@ +package ua.gov.diia.core.models.common_compose.table + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableItemVerticalMlc( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "icon") + val icon: IconAtm? = null, + @Json(name = "label") + val label: String? = null, + @Json(name = "secondaryLabel") + val secondaryLabel: String? = null, + @Json(name = "secondaryValue") + val secondaryValue: String? = null, + @Json(name = "supportingValue") + val supportingValue: String? = null, + @Json(name = "value") + val value: String? = null, + @Json(name = "valueIcons") + val valueIcons: List? = null, + @Json(name = "valueImage") + val valueImage: String? = null, + @Json(name = "valueImages") + val valueImages: List? = null +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableMainHeadingMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableMainHeadingMlc.kt new file mode 100644 index 0000000..95bd914 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableMainHeadingMlc.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.models.common_compose.table + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableMainHeadingMlc( + @Json(name = "icon") + val icon: IconAtm?, + @Json(name = "label") + val label: String, + @Json(name = "description") + val description: String? +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableSecondaryHeadingMlc.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableSecondaryHeadingMlc.kt new file mode 100644 index 0000000..ff8fd52 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/TableSecondaryHeadingMlc.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.models.common_compose.table + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableSecondaryHeadingMlc( + @Json(name = "icon") + val icon: IconAtm?, + @Json(name = "label") + val label: String, + @Json(name = "description") + val description: String? +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/ValueIcon.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/ValueIcon.kt new file mode 100644 index 0000000..9d934aa --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/ValueIcon.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.common_compose.table + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class ValueIcon( + @Json(name = "code") + val code: String?, + @Json(name = "description") + val description: String? +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockOrg/TableBlockOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockOrg/TableBlockOrg.kt new file mode 100644 index 0000000..7bb19b3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockOrg/TableBlockOrg.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.core.models.common_compose.table.tableBlockOrg + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.table.Item +import ua.gov.diia.core.models.common_compose.table.TableMainHeadingMlc +import ua.gov.diia.core.models.common_compose.table.TableSecondaryHeadingMlc + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableBlockOrg( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "items") + val items: List? = null, + @Json(name = "tableMainHeadingMlc") + val tableMainHeadingMlc: TableMainHeadingMlc? = null, + @Json(name = "tableSecondaryHeadingMlc") + val tableSecondaryHeadingMlc: TableSecondaryHeadingMlc? = null, +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockPlaneOrg/TableBlockPlaneOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockPlaneOrg/TableBlockPlaneOrg.kt new file mode 100644 index 0000000..6efaeed --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockPlaneOrg/TableBlockPlaneOrg.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.core.models.common_compose.table.tableBlockPlaneOrg + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.table.Item +import ua.gov.diia.core.models.common_compose.table.TableMainHeadingMlc +import ua.gov.diia.core.models.common_compose.table.TableSecondaryHeadingMlc + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableBlockPlaneOrg( + @Json(name = "items") + val items: List? = null, + @Json(name = "tableMainHeadingMlc") + val tableMainHeadingMlc: TableMainHeadingMlc? = null, + @Json(name = "tableSecondaryHeadingMlc") + val tableSecondaryHeadingMlc: TableSecondaryHeadingMlc? = null, +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockTwoColumnsOrg/TableBlockTwoColumnsOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockTwoColumnsOrg/TableBlockTwoColumnsOrg.kt new file mode 100644 index 0000000..a6c8e8b --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockTwoColumnsOrg/TableBlockTwoColumnsOrg.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsOrg + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.table.HeadingWithSubtitlesMlc +import ua.gov.diia.core.models.common_compose.table.Item + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableBlockTwoColumnsOrg( + @Json(name = "headingWithSubtitlesMlc") + val headingWithSubtitlesMlc: HeadingWithSubtitlesMlc?, + @Json(name = "items") + val items: List?, + @Json(name = "photo") + val photo: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockTwoColumnsPlaneOrg/TableBlockTwoColumnsPlaneOrg.kt b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockTwoColumnsPlaneOrg/TableBlockTwoColumnsPlaneOrg.kt new file mode 100644 index 0000000..ce588d8 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/common_compose/table/tableBlockTwoColumnsPlaneOrg/TableBlockTwoColumnsPlaneOrg.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsPlaneOrg + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.table.HeadingWithSubtitlesMlc +import ua.gov.diia.core.models.common_compose.table.Item + + +@Parcelize +@JsonClass(generateAdapter = true) +data class TableBlockTwoColumnsPlaneOrg( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "items") + val items: List? = null, + @Json(name = "photo") + val photo: String? = null, + @Json(name = "headingWithSubtitlesMlc") + val headingWithSubtitlesMlc: HeadingWithSubtitlesMlc? = null +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/deeplink/DeepLinkAction.kt b/core/src/main/java/ua/gov/diia/core/models/deeplink/DeepLinkAction.kt new file mode 100644 index 0000000..9ad2ff8 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/deeplink/DeepLinkAction.kt @@ -0,0 +1,47 @@ +package ua.gov.diia.core.models.deeplink + +import ua.gov.diia.core.models.acquirer.AcquirerLinkType +import ua.gov.diia.core.models.acquirer.AcquirerServiceType +import ua.gov.diia.core.models.proper_user.VerifyArgs + +sealed class DeepLinkAction + +interface AnyScreenDeepLinkAction + +class DeepLinkActionAcquire( + val otp: String, + val serviceType: AcquirerServiceType, + val linkType: AcquirerLinkType = AcquirerLinkType.dynamic +) : DeepLinkAction(), AnyScreenDeepLinkAction + +class DeepLinkActionCheckDoc( + val docName: String, val docId: String, val otp: String, val uri: String, +) : DeepLinkAction() + +class DeepLinkActionViewMessage( + val needAuth: Boolean, + val resourceId: String, + val notificationId: String +) : DeepLinkAction() + +class DeepLinkActionStartFlow( + val flowId: String, + val resId: String, + val resType: String? = null, +) : DeepLinkAction() + +class DeepLinkActionOpenNotify( + val flowType: String, + val flowSubType: String, + val resId: String +) : DeepLinkAction() + +class DeepLinkActionProperUserVerify( + val args: VerifyArgs +) : DeepLinkAction() + +class DeepLinkActionViewDocument( + val notificationId: String?, + val resourceId: String?, + val documentType: String +) : DeepLinkAction() diff --git a/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogButton.kt b/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogButton.kt new file mode 100644 index 0000000..65e3819 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogButton.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.models.dialogs + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class TemplateDialogButton( + @Json(name = "name") + val name: String? = null, + @Json(name = "icon") + val icon: String? = null, + @Json(name = "action") + val action: String, + @Json(name = "link") + val link: String? = null +): Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogData.kt b/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogData.kt new file mode 100644 index 0000000..1b8bdee --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogData.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.core.models.dialogs + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class TemplateDialogData( + @Json(name = "icon") + val icon: String?, + @Json(name = "title") + val title: String, + @Json(name = "description") + val description: String? = null, + @Json(name = "mainButton") + val mainButton: TemplateDialogButton, + @Json(name = "alternativeButton") + val alternativeButton: TemplateDialogButton? = null, +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogModel.kt b/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogModel.kt new file mode 100644 index 0000000..85f8c45 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogModel.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.core.models.dialogs + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class TemplateDialogModel( + @Json(name = "key?") + val key: String?, + @Json(name = "type") + val type: String, + @Json(name = "isClosable") + val isClosable: Boolean, + @Json(name = "data") + val data: TemplateDialogData, +) : Parcelable { + + fun setKey(key: String) = copy(key = key) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogModelWithProcessCode.kt b/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogModelWithProcessCode.kt new file mode 100644 index 0000000..0c00c02 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/dialogs/TemplateDialogModelWithProcessCode.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.models.dialogs + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +class TemplateDialogModelWithProcessCode( + @Json(name = "processCode") + val processCode: Int?, + @Json(name = "template") + val template: TemplateDialogModel +) diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/EmptySelection.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/EmptySelection.kt new file mode 100644 index 0000000..7c20260 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/EmptySelection.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.core.models.notification.pull + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +class EmptySelection : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/PullNotificationItemSelection.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/PullNotificationItemSelection.kt new file mode 100644 index 0000000..29bf893 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/PullNotificationItemSelection.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.models.notification.pull + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PullNotificationItemSelection( + val notificationId: String? = null, + val resourceId: String?, + val resourceType: String, + val resourceSubtype: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Action.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Action.kt new file mode 100644 index 0000000..bd18c9b --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Action.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Action( + @Json(name = "resource") + val resource: String?, + @Json(name = "subtype") + val subtype: String?, + @Json(name = "type") + val type: MessageActions? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ArticlePicAtm.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ArticlePicAtm.kt new file mode 100644 index 0000000..1564bec --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ArticlePicAtm.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ArticlePicAtm( + @Json(name = "image") + val image: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ArticleVideoMlc.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ArticleVideoMlc.kt new file mode 100644 index 0000000..2d89c9d --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ArticleVideoMlc.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ArticleVideoMlc( + @Json(name = "source") + val source: String?, + @Json(name = "playerBtnAtm") + val playerBtnAtm: PlayerBtnAtm? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/AuthorizedNotificationData.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/AuthorizedNotificationData.kt new file mode 100644 index 0000000..042c7cb --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/AuthorizedNotificationData.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.models.notification.pull.message + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class AuthorizedNotificationData( + @Json(name = "notification") + val notification: Notification +) diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Data.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Data.kt new file mode 100644 index 0000000..e44b437 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Data.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Data( + @Json(name = "alt") + val alt: String?, + @Json(name = "name") + val name: String?, + @Json(name = "resource") + val resource: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Item.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Item.kt new file mode 100644 index 0000000..2bec581 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Item.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Item( + @Json(name = "action") + val action: Action?, + @Json(name = "description") + val description: String?, + @Json(name = "iconLeft") + val iconLeft: String?, + @Json(name = "iconRight") + val iconRight: String?, + @Json(name = "label") + val label: String?, + @Json(name = "logoLeft") + val logoLeft: String?, + @Json(name = "state") + val state: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/LeftNavIcon.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/LeftNavIcon.kt new file mode 100644 index 0000000..7b08a19 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/LeftNavIcon.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class LeftNavIcon( + @Json(name = "action") + val action: Action?, + @Json(name = "code") + val code: String?, + @Json(name = "accessibilityDescription") + val accessibilityDescription: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ListItemGroupOrg.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ListItemGroupOrg.kt new file mode 100644 index 0000000..857556c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/ListItemGroupOrg.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ListItemGroupOrg( + @Json(name = "items") + val items: List?, + @Json(name = "title") + val title: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MediumIconRight.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MediumIconRight.kt new file mode 100644 index 0000000..3585fe0 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MediumIconRight.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class MediumIconRight( + @Json(name = "action") + val action: Action?, + @Json(name = "code") + val code: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MessageActions.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MessageActions.kt new file mode 100644 index 0000000..6c4b640 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MessageActions.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.core.models.notification.pull.message + +@Suppress("EnumEntryName") +enum class MessageActions { + downloadLink, externalLink, internalLink, logout, default; +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MessageTypes.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MessageTypes.kt new file mode 100644 index 0000000..2eec322 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/MessageTypes.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.core.models.notification.pull.message + + +@Suppress("EnumEntryName") +enum class MessageTypes { + text, image, video, internalArrowedLink, separator, externalArrowedLink, downloadArrowedLink; +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Notification.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Notification.kt new file mode 100644 index 0000000..0976f40 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/Notification.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.models.notification.pull.message + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Notification( + @Json(name = "title") + val title: String, + @Json(name = "body") + val body: List, +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/NotificationFull.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/NotificationFull.kt new file mode 100644 index 0000000..addcd41 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/NotificationFull.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common_compose.general.Body +import ua.gov.diia.core.models.common_compose.general.BottomGroup +import ua.gov.diia.core.models.common_compose.general.TopGroup + +@JsonClass(generateAdapter = true) +data class NotificationFull( + @Json(name = "body") + val body: List?, + @Json(name = "bottomGroup") + val bottomGroup: List?, + @Json(name = "topGroup") + val topGroup: List? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/NotificationMessageBody.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/NotificationMessageBody.kt new file mode 100644 index 0000000..6c8cba0 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/NotificationMessageBody.kt @@ -0,0 +1,41 @@ +package ua.gov.diia.core.models.notification.pull.message + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.message.TextParameter + +@JsonClass(generateAdapter = true) +data class NotificationMessagesBody( + @Json(name = "type") + val type: MessageTypes, + @Json(name = "data") + val data: MessageData? +) + +@JsonClass(generateAdapter = true) +data class MessageData( + @Json(name = "text") + val text: String?, + @Json(name = "parameters") + val parameters: List?, + @Json(name = "image") + val image: String?, + @Json(name = "link") + val link: String?, + @Json(name = "action") + val action: MessageActions?, + @Json(name = "statementLoading") + var statementLoading: Boolean? = false +) { + val textVisibility: Boolean + get() = text != null && parameters == null + + val parametersVisibility: Boolean + get() = text != null && parameters != null + + val imageVisibility: Boolean + get() { + val img = image ?: "" + return img.isNotEmpty() + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/PlayerBtnAtm.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/PlayerBtnAtm.kt new file mode 100644 index 0000000..665829a --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/PlayerBtnAtm.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PlayerBtnAtm( + @Json(name = "icon") + val icon: String?, + @Json(name = "type") + val type: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/TextLabelContainerMlc.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/TextLabelContainerMlc.kt new file mode 100644 index 0000000..ec66b0e --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/TextLabelContainerMlc.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.message.TextParameter + +@JsonClass(generateAdapter = true) +data class TextLabelContainerMlc( + @Json(name = "parameters") + val parameters: List, + @Json(name = "text") + val text: String?, + @Json(name = "label") + val label: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/TitleGroupMlc.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/TitleGroupMlc.kt new file mode 100644 index 0000000..0c22fae --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/TitleGroupMlc.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.core.models.notification.pull.message + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class TitleGroupMlc( + @Json(name = "heroText") + val heroText: String?, + @Json(name = "mediumIconRight") + val mediumIconRight: MediumIconRight?, + @Json(name = "label") + val label: String?, + @Json(name = "leftNavIcon") + val leftNavIcon: LeftNavIcon? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/UnauthorizedNotificationMessage.kt b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/UnauthorizedNotificationMessage.kt new file mode 100644 index 0000000..852372c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/pull/message/UnauthorizedNotificationMessage.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.models.notification.pull.message + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UnauthorizedNotificationMessage( + @Json(name = "message") + val message: Notification +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/push/PushAction.kt b/core/src/main/java/ua/gov/diia/core/models/notification/push/PushAction.kt new file mode 100644 index 0000000..d6e34a6 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/push/PushAction.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.core.models.notification.push + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PushAction( + @Json(name = "resourceId") + val resourceId: String?, + @Json(name = "type") + val type: String, + @Json(name = "subtype") + val subtype: String? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/notification/push/PushNotification.kt b/core/src/main/java/ua/gov/diia/core/models/notification/push/PushNotification.kt new file mode 100644 index 0000000..888b72e --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/notification/push/PushNotification.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.core.models.notification.push + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PushNotification( + @Json(name = "action") + val action: PushAction, + @Json(name = "needAuth") + val needAuth: Boolean?, + @Json(name = "notificationId") + var notificationId: String?, + @Json(name = "shortText") + val shortText: String?, + @Json(name = "title") + val title: String?, + @Json(name = "unread") + val unread: Int? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/proper_user/VerifyArgs.kt b/core/src/main/java/ua/gov/diia/core/models/proper_user/VerifyArgs.kt new file mode 100644 index 0000000..459cecf --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/proper_user/VerifyArgs.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.core.models.proper_user + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class VerifyArgs( + val otp: String, + val type: Type +) : Parcelable { + enum class Type { + SHARE, CANCEL + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/Chip.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/Chip.kt new file mode 100644 index 0000000..eb445ee --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/Chip.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.rating_service + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class Chip( + @Json(name = "chips") + val chips: List?, + @Json(name = "description") + val description: String?, + @Json(name = "label") + val label: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/Chips.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/Chips.kt new file mode 100644 index 0000000..cc8fc0c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/Chips.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.core.models.rating_service + + +import android.os.Parcelable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.R + +@Parcelize +@JsonClass(generateAdapter = true) +data class Chips( + @Json(name = "code") + val code: String?, + @Json(name = "name") + val name: String?, + + var selectedChip: Boolean = false +) : Parcelable { + + val chipsNameColor: Int + @ColorRes + get() = if (selectedChip) R.color.white else R.color.black + + val chipsBackground: Int + @DrawableRes + get() = if (selectedChip) R.drawable.chips_selected else R.drawable.chips_unselected +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/Comment.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/Comment.kt new file mode 100644 index 0000000..f4b659b --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/Comment.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.rating_service + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class Comment( + @Json(name = "hint") + val hint: String?, + @Json(name = "label") + val label: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/Rating.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/Rating.kt new file mode 100644 index 0000000..f0bc3d6 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/Rating.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.rating_service + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class Rating( + @Json(name = "items") + val items: List?, + @Json(name = "label") + val label: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingFormByInitiative.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingFormByInitiative.kt new file mode 100644 index 0000000..2de7a8b --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingFormByInitiative.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.models.rating_service + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class RatingFormByInitiative( + @Json(name = "processCode") + val processCode: Long?, + @Json(name = "ratingForm") + val ratingForm: RatingFormModel?, + @Json(name = "template") + val template: TemplateDialogModel? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingFormModel.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingFormModel.kt new file mode 100644 index 0000000..872cfde --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingFormModel.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.core.models.rating_service + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) + data class RatingFormModel( + @Json(name = "resourceId") + val resourceId: String?, + @Json(name = "key") + val key: String?, + @Json(name = "comment") + val comment: Comment?, + @Json(name = "formCode") + val formCode: String?, + @Json(name = "mainButton") + val mainButton: String?, + @Json(name = "rating") + val rating: Rating?, + @Json(name = "title") + val title: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingItem.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingItem.kt new file mode 100644 index 0000000..eba22d4 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingItem.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.models.rating_service + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class RatingItem( + @Json(name = "chip") + val chip: Chip?, + @Json(name = "emoji") + val emoji: String?, + @Json(name = "rate") + val rate: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingRequest.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingRequest.kt new file mode 100644 index 0000000..3291998 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingRequest.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.core.models.rating_service + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class RatingRequest( + @Json(name = "resourceId") + val resourceId: String?, + @Json(name = "comment") + val comment: String?, + @Json(name = "completingTimeMs") + val completingTimeMs: Long?, + @Json(name = "isClosed") + val isClosed: Boolean? = false, + @Json(name = "rating") + val rating: String?, + @Json(name = "selectedChips") + val selectedChips: List?, + @Json(name = "ratingType") + val ratingType: String?, + @Json(name = "screenCode") + val screenCode: String?, + @Json(name = "formCode") + val formCode: String? +) : Parcelable \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingResult.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingResult.kt new file mode 100644 index 0000000..18a16cf --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/RatingResult.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.models.rating_service + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class RatingResult( + val item: RatingRequest?, + val key: String +): Parcelable diff --git a/core/src/main/java/ua/gov/diia/core/models/rating_service/SentRatingResponse.kt b/core/src/main/java/ua/gov/diia/core/models/rating_service/SentRatingResponse.kt new file mode 100644 index 0000000..be326e2 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/rating_service/SentRatingResponse.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.core.models.rating_service + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class SentRatingResponse( + @Json(name = "processCode") + val processCode: Long?, + @Json(name = "template") + val template: TemplateDialogModel? +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/models/share/ShareByteArr.kt b/core/src/main/java/ua/gov/diia/core/models/share/ShareByteArr.kt new file mode 100644 index 0000000..55ae38e --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/models/share/ShareByteArr.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.core.models.share + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class ShareByteArr( + @Json(name = "fileName") + val fileName: String, + @Json(name = "byteArray") + val byteArray: ByteArray +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ShareByteArr + + if (fileName != other.fileName) return false + if (!byteArray.contentEquals(other.byteArray)) return false + + return true + } + + override fun hashCode(): Int { + var result = fileName.hashCode() + result = 31 * result + byteArray.contentHashCode() + return result + } +} diff --git a/core/src/main/java/ua/gov/diia/core/network/Http.kt b/core/src/main/java/ua/gov/diia/core/network/Http.kt new file mode 100644 index 0000000..06fbce9 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/network/Http.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.core.network + +object Http { + const val HTTP_200 = 200 + const val HTTP_201 = 201 + const val HTTP_400 = 400 + const val HTTP_401 = 401 + const val HTTP_404 = 404 + const val HTTP_403 = 403 + const val HTTP_422 = 422 + const val HTTP_500 = 500 + const val HTTP_503 = 503 + + const val HTTP_1010 = 1010 + const val HTTP_1011 = 1011 + const val HTTP_1012 = 1012 + const val HTTP_1013 = 1013 + const val HTTP_1014 = 1014 + const val HTTP_1015 = 1015 + const val HTTP_1016 = 1016 + const val HTTP_1017 = 1017 + + + const val COVID_CERT_IN_PROGRESS_STATUS = 2019 + const val NEED_UPDATE_STATUS = 5555 +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/network/annotation/Analytics.kt b/core/src/main/java/ua/gov/diia/core/network/annotation/Analytics.kt new file mode 100644 index 0000000..1a8669a --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/network/annotation/Analytics.kt @@ -0,0 +1,3 @@ +package ua.gov.diia.core.network.annotation + +annotation class Analytics(val action: String) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/network/apis/ApiAuth.kt b/core/src/main/java/ua/gov/diia/core/network/apis/ApiAuth.kt new file mode 100644 index 0000000..7c7c7a2 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/network/apis/ApiAuth.kt @@ -0,0 +1,50 @@ +package ua.gov.diia.core.network.apis + +import retrofit2.http.* +import ua.gov.diia.core.models.RefreshToken +import ua.gov.diia.core.models.Token +import ua.gov.diia.core.models.auth.FaceRecoConfig +import ua.gov.diia.core.network.annotation.Analytics + +interface ApiAuth { + + @Analytics("facerecoConfig") + @GET("api/v1/auth/photoid/fld") + suspend fun getFaceRecoConfig( + @Query("isLowRamDevice") isLowRamDevice: Boolean, + ): FaceRecoConfig + + @Analytics("getTestToken") + @POST("api/v1/auth/test/{requestId}/token") + suspend fun getTestToken( + @Path("requestId") requestID: String?, + @QueryMap userInfo: Map + ): Token + + @Analytics("refreshToken") + @POST("api/v2/auth/token/refresh") + suspend fun refreshToken(@Header("Authorization") token: String?): RefreshToken + + + @Analytics("logout") + @POST("api/v2/auth/token/logout") + suspend fun logout( + @Header("Authorization") token: String, + @Header("mobile_uid") mobileUid: String + ) + + @Analytics("logoutServiceUser") + @POST("api/v1/auth/acquirer/branch/offer/token/logout") + suspend fun logoutServiceUser( + @Header("Authorization") token: String, + @Header("mobile_uid") mobileUid: String + ) + + @Analytics("tempToken") + @GET("api/v1/auth/acquirer/branch/offer/{uuid}/token") + suspend fun getServiceAccountToken( + @Path("uuid") uuid: String, + @Header("mobile_uid") mobileUid: String, + ): Token + +} diff --git a/core/src/main/java/ua/gov/diia/core/network/connectivity/ConnectivityObserver.kt b/core/src/main/java/ua/gov/diia/core/network/connectivity/ConnectivityObserver.kt new file mode 100644 index 0000000..fae4582 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/network/connectivity/ConnectivityObserver.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.network.connectivity + +import kotlinx.coroutines.flow.Flow + +interface ConnectivityObserver { + + val isAvailable: Boolean + + fun observe(): Flow +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/push/BasePushNotificationAction.kt b/core/src/main/java/ua/gov/diia/core/push/BasePushNotificationAction.kt new file mode 100644 index 0000000..1e136f0 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/push/BasePushNotificationAction.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.core.push + +import androidx.navigation.NavDirections +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection + +abstract class BasePushNotificationAction(val id: String) { + abstract fun getNavigationDirection(item: PullNotificationItemSelection): NavDirections? +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/ui/dynamicdialog/ActionsConst.kt b/core/src/main/java/ua/gov/diia/core/ui/dynamicdialog/ActionsConst.kt new file mode 100644 index 0000000..bc3ff9c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/ui/dynamicdialog/ActionsConst.kt @@ -0,0 +1,116 @@ +package ua.gov.diia.core.ui.dynamicdialog + +object ActionsConst { + + //diia custom actions + const val DIALOG_ACTION_CODE_CLOSE = "ua.gov.diia.app.close" + const val GENERAL_RETRY = "ua.gov.diia.app.retry" + const val ERROR_DIALOG_DEAL_WITH_IT = "ua.gov.diia.app.deal_with_it" + const val DIALOG_ACTION_ENABLE_CLICKS = "ua.gov.diia.app.enable_clicks" + + + const val ACTION_PAYMENT_COMPLETED = "payment_completed" + const val ACTION_PAYMENT_POSTCONDITION = "payment_postcondition" + const val ACTION_NAVIGATE_BACK = "back" + const val ACTION_CRIMINAL_CERT = "criminalRecordCertificate" + const val ACTION_SIGNATURE_GENERATED = "signature_generated" + const val ACTION_SIGNATURE_PASS_APPROVED = "signature_pass_approved" + const val DIALOG_ACTION_CODE_SKIP = "skip" + const val DIALOG_ACTION_CODE_PROLONG = "authMethods" + const val DIALOG_DEAL_WITH_IT = "ok" + const val DIALOG_ACTION_REFRESH = "refresh" + const val DIALOG_ACTION_EXIT = "exit" + const val DIALOG_ACTION_CONFIRM = "confirm" + const val DIALOG_ACTION_EXIT_CONFIRM = "exitConfirm" + const val DIALOG_ACTION_CLOSE = "close" + const val DIALOG_ACTION_PAY = "toPay" + const val DIALOG_ACTION_SHARE = "share" + const val DIALOG_ACTION_SIGN_AGAIN = "signing" + const val DIALOG_ACTION_SHARING = "sharing" + const val DIALOG_ACTION_CANCEL = "cancel" + const val DIALOG_ACTION_CODE_LOGOUT = "logout" + const val DIALOG_ACTION_CODE_REPEAT = "repeat" + const val DIALOG_ACTION_REMOVE_SIGNATURE = "removeSignature" + const val DIALOG_ACTION_RESUME = "resume" + const val RESULT_KEY_DOWNLOAD_PDF = "RESULT_KEY_DOWNLOAD_PDF" + const val DIALOG_ACTION_GET_MILITARY_ID = "getMilitaryId" + const val DIALOG_ACTION_CODE_DELETE = "delete" + const val DIALOG_ACTION_RESIDENCE_CERT_ORDER = "residenceCert" + const val DIALOG_ACTION_RESIDENCE_CERT_STATUS = "residenceCertStatus" + const val DIALOG_ACTION_RESIDENCE_CERT_ORDER_CHILD = "residenceCertChildren" + const val DIALOG_ACTION_RESIDENCE_CERT_STATUS_CHILD = "residenceCertChildrenStatus" + const val DIALOG_ACTION_CRIMINAL_RECORD_CERTIFICATE = "criminalRecordCertificate" + const val DIALOG_ACTION_PUBLIC_SERVICES = "publicServices" + const val DIALOG_ACTION_PREV_SCREEN = "previousScreen" + const val DIALOG_ACTION_OPEN_EXTERNAL_LINK = "externalLink" + + const val DIALOG_ACTION_CANCEL_APPLICATION = "cancelApplication" + const val DIALOG_ACTION_CHANGE_SELECTION = "changeSelection" + const val DIALOG_ACTION_BACK = "back" + const val DIALOG_ACTION_OPEN_BIRTH_CERT = "birthCertificate" + + + //proper user + const val DIALOG_ACTION_PROPER_USER_CANCEL_APPLICATION = "cancelApplication" + const val DIALOG_ACTION_PROPER_USER_APPLICATION_CANCELED = "properUserCanceling" + const val DIALOG_ACTION_PROPER_USER_APPLICATION_SIGNED = "properUser" + + const val KEY_GLOBAL_PROCESSING = + "ua.gov.diia.app.ui.fragments.dialogs.dynamicdialog.action_global_processing" + + //Template dialog result actions + const val FRAGMENT_USER_ACTION_RESULT_KEY = "fragment_template_action_result" + const val DIALOG_USER_ACTION_RESULT_KEY = "dialog_template_action_result" + + //system dialog actions + const val SYSTEM_DIALOG_POSITIVE = "SYSTEM_DIALOG_POSITIVE" + const val SYSTEM_DIALOG_SINGLE_POSITIVE = "SYSTEM_DIALOG_SINGLE_POSITIVE" + const val SYSTEM_DIALOG_NEGATIVE = "SYSTEM_DIALOG_NEGATIVE" + + //action item selection + const val ACTION_ITEM_SELECTED = "SELECTED_ACTION_ITEM" + + //context menu + const val FAQ_CATEGORY = "faqCategory" + const val SUPPORT_SERVICE = "supportServiceScreen" + const val CREATE_CONTENT = "createContent" + const val TIPS = "tips" + const val COMMUNITY_CONTACTS = "communityContacts" + const val FUND_DETAILS = "fundDetails" + const val RATING = "rating" + const val TYPE_USER_INITIATIVE = "userInitiative" + const val RATING_TYPE_REQUESTED = "byRequest" + const val DOCUMENTS_CODE = "document" + + //nav const + const val RESULT_KEY_NAVIGATION = "RESULT_KEY_NAVIGATION" + const val RESULT_KEY_NAV_TO_ADD_BIRTH_CERT = "RESULT_KEY_NAV_TO_ADD_BIRTH_CERT" + const val RESULT_KEY_NAV_TO_ADD_COVID_CERT = "RESULT_KEY_NAV_TO_ADD_COVID_CERT" + const val RESULT_KEY_NAV_TO_ADD_CHILD_COVID_CERT = "RESULT_KEY_NAV_TO_ADD_CHILD_COVID_CERT" + const val RESULT_KEY_NAV_TO_ADD_PROPER_USER = "RESULT_KEY_NAV_TO_ADD_PROPER_USER" + const val RESULT_KEY_NAV_TO_RESIDENCE_PERMIT_PERMANENT = "RESULT_KEY_NAV_TO_RESIDENCE_PERMIT_PERMANENT" + const val RESULT_KEY_NAV_TO_RESIDENCE_PERMIT_TEMPORARY = "RESULT_KEY_NAV_TO_RESIDENCE_PERMIT_TEMPORARY" + const val RESULT_KEY_NAV_TO_MILITARY_BOND = "RESULT_KEY_NAV_TO_MILITARY_BOND" + const val RESULT_KEY_NAV_TO_VEHICLE_RE_REGISTRATION = "RESULT_KEY_NAV_TO_VEHICLE_RE_REGISTRATION" + const val RESULT_KEY_REMOVE_MILITARY_BOND = "RESULT_KEY_REMOVE_MILITARY_BOND" + const val RESULT_KEY_NAV_TO_HOUSING_CERTIFICATES = "RESULT_KEY_NAV_TO_HOUSING_CERTIFICATES" + const val RESULT_KEY_NAV_TO_HC_FOUNDING_REQUEST = "RESULT_KEY_NAV_TO_HC_FOUNDING_REQUEST" + + + const val RESULT_KEY_OPEN_LINK = "RESULT_KEY_OPEN_LINK" + const val RESULT_KEY_NAV_TO_IDP_EDIT_ADDRESS = "RESULT_KEY_NAV_TO_IDP_EDIT_ADDRESS" + const val RESULT_KEY_NAV_TO_IDP_CERT_CANCEL = "RESULT_KEY_NAV_TO_IDP_CERT_CANCEL" + const val RESULT_KEY_RATING_SERVICE = "rating" + const val RESULT_KEY_RATE_DOCUMENT = "rate_document" + const val RESULT_KEY_REMOVE_DOCUMENT = "remove_document" + const val RESULT_KEY_VERIFICATION_CODE = "verification_code" + const val RESULT_KEY_QR_CODE = "verification_code_qr" + const val RESULT_KEY_EAN13_CODE = "verification_code_ean13" + + const val RESULT_KEY_UPDATE_DOCUMENT = "update_document" + + const val RESULT_KEY_NAV_TO_PACKAGE_STATUS_REFRESH = "refresh" + + const val DEEP_LINK_ACTION = "deep_link_action" + +} diff --git a/core/src/main/java/ua/gov/diia/core/util/CombinedLiveData.kt b/core/src/main/java/ua/gov/diia/core/util/CombinedLiveData.kt new file mode 100644 index 0000000..6e56f4c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/CombinedLiveData.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.util + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData + +class CombinedLiveData(vararg liveDatas: LiveData<*>, + private val combine: (datas: List) -> R) : MediatorLiveData() { + + private val datas: MutableList = MutableList(liveDatas.size) { null } + + init { + for(i in liveDatas.indices){ + super.addSource(liveDatas[i]) { + datas[i] = it + value = combine(datas) + } + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/CommonConst.kt b/core/src/main/java/ua/gov/diia/core/util/CommonConst.kt new file mode 100644 index 0000000..56cb347 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/CommonConst.kt @@ -0,0 +1,9 @@ +package ua.gov.diia.core.util + +object CommonConst { + const val DIIA_HOST = "ua.gov.diia.app" + + const val BUILD_TYPE_RELEASE = "release" + const val BUILD_TYPE_STAGE = "stage" + const val BUILD_TYPE_DEBUG = "debug" +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/DateFormats.kt b/core/src/main/java/ua/gov/diia/core/util/DateFormats.kt new file mode 100644 index 0000000..e63296e --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/DateFormats.kt @@ -0,0 +1,33 @@ +package ua.gov.diia.core.util + +import android.annotation.SuppressLint +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.* + +@SuppressLint("SimpleDateFormat") +object DateFormats { + + private const val ISO8601 = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + val iso8601: SimpleDateFormat = SimpleDateFormat(ISO8601, Locale.UK) + + val uaDateFormat: SimpleDateFormat = SimpleDateFormat("dd.MM.yyyy") + val fopDateFormat: SimpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale("UK")) + val penaltiesFormat: SimpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale("UK")) + val debtsFormat: SimpleDateFormat = SimpleDateFormat("dd MMMM yyyy", Locale("UK")) + val criminalCertFileFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd", Locale("UK")) + + @Throws(ParseException::class) + fun iso8601ToLocalCalendar(iso8601string: String): Calendar { + val calendar = Calendar.getInstance() + val dateFormat = SimpleDateFormat(ISO8601) + dateFormat.timeZone = TimeZone.getTimeZone("UTC") + val date = dateFormat.parse(iso8601string) + calendar.time = date!! + return calendar + } + + init { + iso8601.timeZone = TimeZone.getTimeZone("UTC") + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/DispatcherProvider.kt b/core/src/main/java/ua/gov/diia/core/util/DispatcherProvider.kt new file mode 100644 index 0000000..b623e58 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/DispatcherProvider.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.core.util + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import javax.inject.Inject + +interface DispatcherProvider { + + fun ioDispatcher(): CoroutineDispatcher + + val main: CoroutineDispatcher + + val work: CoroutineDispatcher +} + +class DiiaDispatcherProvider @Inject constructor() : DispatcherProvider { + + override fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO + + override val main: CoroutineDispatcher + get() = Dispatchers.Main + + override val work: CoroutineDispatcher + get() = Dispatchers.Default + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/Exception.kt b/core/src/main/java/ua/gov/diia/core/util/Exception.kt new file mode 100644 index 0000000..85d396a --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/Exception.kt @@ -0,0 +1,9 @@ +package ua.gov.diia.core.util + +import ua.gov.diia.core.BuildConfig + +fun throwExceptionInDebug(message: String) { + if (BuildConfig.BUILD_TYPE == CommonConst.BUILD_TYPE_DEBUG) { + throw Exception(message) + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/alert/ClientAlertDialogsFactory.kt b/core/src/main/java/ua/gov/diia/core/util/alert/ClientAlertDialogsFactory.kt new file mode 100644 index 0000000..bfbdbeb --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/alert/ClientAlertDialogsFactory.kt @@ -0,0 +1,84 @@ +package ua.gov.diia.core.util.alert + +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst + +interface ClientAlertDialogsFactory { + + /** + * For debug purposes to open verify user person at any point of app + */ + fun userVerifySuggestion(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun nfcCardNotSupported(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun nfcResidenceCardNotSupported(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun nfcScanFailed( + e: Exception, + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY + ): TemplateDialogModel + + fun codeScanFailed( + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY + ): TemplateDialogModel + + fun nfcScanFailedV2( + e: Exception, + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY, + closable: Boolean = true + ): TemplateDialogModel + + fun alertNoInternet(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun alertVerificationFailed(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun unknownErrorAlert( + closable: Boolean, + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY, + e: Exception + ): TemplateDialogModel + + fun userPhotoIdTryCountReached( + closable: Boolean, + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY + ): TemplateDialogModel + + fun getUnsupportedOptionDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun getUnsupportedNFCDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun getNoVerificationMethodsDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun getResetSignaturePasswordDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun showMockGeo(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun expiredOtp(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun showAlertAfterInvalidPin(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun showAlertAfterConfirmPin(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun getUnsupportedGLEDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun getCancelOfficialPollCreationDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun getCancelledOfficialPollCreationDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun getDeletePollDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun nfcEnableDialog(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun alertNoOfflineMap(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun locationNotAvailable(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun failedToDownloadMap(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun failedToSendRating(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun failedToSendReportPoint(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel + + fun failedToSendReportShelter(key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY): TemplateDialogModel +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/datasource/DataSourceOwner.kt b/core/src/main/java/ua/gov/diia/core/util/datasource/DataSourceOwner.kt new file mode 100644 index 0000000..84a4338 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/datasource/DataSourceOwner.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.core.util.datasource + +interface DataSourceOwner { + + fun invalidateDataSource() +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/date/CurrentDateProvider.kt b/core/src/main/java/ua/gov/diia/core/util/date/CurrentDateProvider.kt new file mode 100644 index 0000000..d93a296 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/date/CurrentDateProvider.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.core.util.date + +import ua.gov.diia.core.util.extensions.date_time.getCurrentDateUtc +import java.util.Date + + +interface CurrentDateProvider { + + fun getDate(): Date +} + +internal class CurrentDateProviderImpl : CurrentDateProvider { + + override fun getDate(): Date { + return getCurrentDateUtc() + } +} diff --git a/core/src/main/java/ua/gov/diia/core/util/decorators/ListDelimiterDecorator.kt b/core/src/main/java/ua/gov/diia/core/util/decorators/ListDelimiterDecorator.kt new file mode 100644 index 0000000..6437aff --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/decorators/ListDelimiterDecorator.kt @@ -0,0 +1,58 @@ +package ua.gov.diia.core.util.decorators + +import android.content.Context +import android.graphics.Canvas +import androidx.annotation.DrawableRes +import androidx.recyclerview.widget.RecyclerView +import ua.gov.diia.core.util.extensions.context.getDrawableSafe + +class ListDelimiterDecorator( + context: Context, + @DrawableRes dividerRes: Int, + private val ignorePadding: Boolean = true, + private val includeTopAge: Boolean = true, + private val includeBottomAge: Boolean = true +) : RecyclerView.ItemDecoration() { + + private val dividerBottom = context.getDrawableSafe(dividerRes) + private val dividerTop = context.getDrawableSafe(dividerRes) + + override fun onDraw(c: Canvas, parent: RecyclerView, state: RecyclerView.State) { + + val dividerBottomHeight = dividerBottom?.intrinsicHeight ?: 0 + val dividerTopHeight = dividerTop?.intrinsicHeight ?: 0 + + for (i in 0 until parent.childCount) { + val child = parent.getChildAt(i) + + val left = if (ignorePadding) child.left else child.left + child.paddingStart + val right = if (ignorePadding) child.right else child.right - child.paddingEnd + + val isFirstItem = i == 0 + val isLastItem = i == parent.childCount - 1 + + if (isFirstItem) { + if (includeTopAge) { + val tTop = child.top + val tBottom = tTop + dividerTopHeight + + dividerTop?.setBounds(left, tTop, right, tBottom) + dividerTop?.draw(c) + } + } + + val bTop = child.bottom - dividerBottomHeight + val bBottom = child.bottom + + if (!isLastItem) { + dividerBottom?.setBounds(left, bTop, right, bBottom) + dividerBottom?.draw(c) + } else { + if (includeBottomAge) { + dividerBottom?.setBounds(left, bTop, right, bBottom) + dividerBottom?.draw(c) + } + } + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/deeplink/DeepLinkActionFactory.kt b/core/src/main/java/ua/gov/diia/core/util/deeplink/DeepLinkActionFactory.kt new file mode 100644 index 0000000..0ab5aaf --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/deeplink/DeepLinkActionFactory.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.util.deeplink + +import ua.gov.diia.core.models.deeplink.* +import ua.gov.diia.core.models.notification.push.PushNotification + +interface DeepLinkActionFactory { + + fun buildDeepLinkAction(path: String): DeepLinkAction + + fun buildPathFromPushNotification(pushNotification: PushNotification): String + +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithAppConfig.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithAppConfig.kt new file mode 100644 index 0000000..5449d64 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithAppConfig.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.core.util.delegation + +interface WithAppConfig { + + fun getAppPolicyUrl(): String + fun getAboutUrl(): String +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithBuildConfig.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithBuildConfig.kt new file mode 100644 index 0000000..3d7a725 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithBuildConfig.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.core.util.delegation + +interface WithBuildConfig { + fun getApplicationId(): String + fun getServerUrl(): String + fun getBankIdHost(): String + fun getBankIdClientId(): String + fun getBankIdCallbackUrl(): String + fun getTokenLeeway(): Long + fun getSign(): String + fun getDGCVerificationBaseUrl(): String + fun getVersionCode(): Int + fun getVersionName(): String + fun getSdkVersion(): Int + fun getBuildType(): String +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithContextMenu.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithContextMenu.kt new file mode 100644 index 0000000..4581ce8 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithContextMenu.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.core.util.delegation + +import androidx.lifecycle.LiveData +import androidx.navigation.NavDirections +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.util.event.UiDataEvent + +interface WithContextMenu { + + val openContextMenu: LiveData>> + + val showContextMenu: LiveData + + val faqNavDirection: NavDirections? + + fun openContextMenu() + + fun setContextMenu(contextMenu: Array?) + + fun getMenu(): Array? +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithCrashlytics.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithCrashlytics.kt new file mode 100644 index 0000000..5245cc7 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithCrashlytics.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.core.util.delegation + +interface WithCrashlytics { + fun sendNonFatalError(e: Throwable) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithDeeplinkHandling.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithDeeplinkHandling.kt new file mode 100644 index 0000000..20d96ec --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithDeeplinkHandling.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.util.delegation + +import kotlinx.coroutines.flow.StateFlow +import ua.gov.diia.core.models.deeplink.DeepLinkAction +import ua.gov.diia.core.models.notification.push.PushNotification +import ua.gov.diia.core.util.event.UiDataEvent + + +interface WithDeeplinkHandling { + + val deeplinkFlow: StateFlow?> + + suspend fun emitDeeplink(event: UiDataEvent) + + fun buildDeepLinkAction(path: String): DeepLinkAction + + fun buildPathFromPushNotification(notification: PushNotification): String +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithErrorHandling.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithErrorHandling.kt new file mode 100644 index 0000000..646e9fa --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithErrorHandling.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.core.util.delegation + +import androidx.lifecycle.LiveData +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.event.UiDataEvent + +interface WithTemplateDialog { + + val showTemplateDialog: LiveData> + + fun showTemplateDialog( + templateDialog: TemplateDialogModel, + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY + ) +} + +interface WithErrorHandling : WithTemplateDialog { + + fun consumeException( + exception: Exception, + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY, + needRetry: Boolean = true + ) + + fun resetErrorCounter() +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithErrorHandlingOnFlow.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithErrorHandlingOnFlow.kt new file mode 100644 index 0000000..7619a8c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithErrorHandlingOnFlow.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.core.util.delegation + +import kotlinx.coroutines.flow.SharedFlow +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.event.UiDataEvent + +interface WithTemplateDialogOnFlow { + + val showTemplateDialog: SharedFlow> + + fun showTemplateDialog( + templateDialog: TemplateDialogModel, + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY + ) +} + +interface WithErrorHandlingOnFlow : WithTemplateDialogOnFlow { + + fun consumeException( + exception: Exception, + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY, + needRetry: Boolean = true + ) + + fun resetErrorCounter() +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithPermission.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithPermission.kt new file mode 100644 index 0000000..67b57d3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithPermission.kt @@ -0,0 +1,141 @@ +package ua.gov.diia.core.util.delegation + +import android.Manifest +import android.os.Build +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LiveData +import ua.gov.diia.core.R +import ua.gov.diia.core.models.common.template_dialogs.SystemDialogData +import ua.gov.diia.core.util.event.UiEvent + +enum class Permission { + CAMERA, + LOCATION, + STORAGE_READ, + STORAGE_WRITE, + POST_NOTIFICATIONS; + + val value: Array + get() = when (this) { + CAMERA -> arrayOf(Manifest.permission.CAMERA) + LOCATION -> arrayOf( + Manifest.permission.ACCESS_COARSE_LOCATION, + Manifest.permission.ACCESS_FINE_LOCATION + ) + STORAGE_READ -> arrayOf("Manifest.permission.READ_EXTERNAL_STORAGE") + STORAGE_WRITE -> arrayOf("Manifest.permission.WRITE_EXTERNAL_STORAGE") + POST_NOTIFICATIONS -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + arrayOf(Manifest.permission.POST_NOTIFICATIONS) + } else { + arrayOf("android.permission.POST_NOTIFICATIONS") + } + } + } + + val permissionDialog: SystemDialogData + get() = SystemDialogData( + title = dialogTitle, + message = dialogText, + positiveButtonTitle = R.string.allow, + negativeButtonTitle = R.string.deny, + cancelable = false, + rationale = rationale, + rationaleTitle = rationaleTitle + ) + + val rationaleDialog: SystemDialogData + get() = SystemDialogData( + title = rationaleDialogTitle, + message = rationalDialogText, + positiveButtonTitle = R.string.open_settings, + negativeButtonTitle = R.string.close, + cancelable = false, + rationale = rationale, + rationaleTitle = rationaleTitle + ) + + private val dialogTitle: Int + @StringRes + get() = when (this) { + CAMERA -> R.string.camera_permission_request_title + LOCATION -> R.string.location_permission_request_title + STORAGE_READ, + STORAGE_WRITE -> R.string.file_permission_request_title + + POST_NOTIFICATIONS -> R.string.notifications_permission_request_title + } + + private val rationaleDialogTitle: Int + @StringRes + get() = when (this) { + CAMERA -> R.string.camera_permission_request_title_rational + LOCATION -> R.string.location_permission_request_title + STORAGE_READ, + STORAGE_WRITE -> R.string.file_permission_request_title + + POST_NOTIFICATIONS -> R.string.notifications_permission_request_title + } + + private val dialogText: Int + @StringRes + get() = when (this) { + CAMERA -> R.string.camera_permission_request + LOCATION -> R.string.location_permission_request + STORAGE_READ, + STORAGE_WRITE -> R.string.file_permission_request + + POST_NOTIFICATIONS -> R.string.notifications_permission_request + } + + private val rationalDialogText: Int + @StringRes + get() = when (this) { + CAMERA -> R.string.camera_permission_request_rational + LOCATION -> R.string.location_permission_request + STORAGE_READ, + STORAGE_WRITE -> R.string.file_permission_request + + POST_NOTIFICATIONS -> R.string.notifications_permission_request + } + + private val rationaleTitle: Int? + @StringRes + get() = when (this) { + CAMERA -> null + LOCATION -> R.string.location_permission_rationale_title + STORAGE_READ, + STORAGE_WRITE -> R.string.file_permission_rationale_title + + POST_NOTIFICATIONS -> null + } + + private val rationale: Int? + @StringRes + get() = when (this) { + CAMERA -> R.string.camera_permission_rationale + LOCATION -> R.string.location_permission_rationale + STORAGE_READ, + STORAGE_WRITE -> R.string.file_permission_rationale + + POST_NOTIFICATIONS -> null + } +} + +interface WithPermission : DefaultLifecycleObserver { + + val doOnCameraPermissionGrantedEvent: LiveData + + val doOnGeoPermissionGrantedEvent: LiveData + + val doOnPostNotificationPermissionGrantedEvent: LiveData + + val doOnStoragePermissionGrantedEvent: LiveData + + val doOnPermissionDeniedEvent: LiveData + + fun T.approvePermission(permission: Permission) + +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithPushHandling.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithPushHandling.kt new file mode 100644 index 0000000..ca24ae3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithPushHandling.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.util.delegation + +import kotlinx.coroutines.flow.Flow +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection + + +interface WithPushHandling { + + val notificationConsumedEvent: Flow + + suspend fun consumePush(notification: PullNotificationItemSelection) +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithPushNotification.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithPushNotification.kt new file mode 100644 index 0000000..2a60139 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithPushNotification.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.util.delegation + +import kotlinx.coroutines.flow.Flow +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.util.event.UiDataEvent + +interface WithPushNotification { + + val notificationConsumedEvent: Flow> + + suspend fun consumePush(consumablePushItem: ConsumableItem) + + suspend fun markNotificationAsRead(resId: String) +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithRatingDialog.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithRatingDialog.kt new file mode 100644 index 0000000..257c4fe --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithRatingDialog.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.core.util.delegation + +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import ua.gov.diia.core.models.rating_service.RatingFormModel +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.event.UiDataEvent + +interface WithRatingDialog { + + val showRatingDialog: LiveData> + + val showRatingDialogByUserInitiative: LiveData> + + val sendingRatingResult: LiveData + + fun showRatingDialog( + ratingDialog: RatingFormModel, + key: String = ActionsConst.RESULT_KEY_RATING_SERVICE + ) + + fun T.sendRating( + ratingRequest: RatingRequest, + category: String, + serviceCode: String + ) where T : ViewModel, T : WithErrorHandling, T : WithRetryLastAction + + fun T.getRating( + category: String, + serviceCode: String + ) where T : ViewModel, T : WithErrorHandling, T : WithRetryLastAction + +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithRatingDialogOnFlow.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithRatingDialogOnFlow.kt new file mode 100644 index 0000000..f7e8b30 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithRatingDialogOnFlow.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.core.util.delegation + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.SharedFlow +import ua.gov.diia.core.models.rating_service.RatingFormModel +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.event.UiDataEvent + +interface WithRatingDialogOnFlow { + + val showRatingDialog: SharedFlow> + + val showRatingDialogByUserInitiative: SharedFlow> + + val sendingRatingResult: SharedFlow + + fun showRatingDialog( + ratingDialog: RatingFormModel, + key: String = ActionsConst.RESULT_KEY_RATING_SERVICE + ) + + fun T.sendRating( + ratingRequest: RatingRequest, + category: String, + serviceCode: String + ) where T : ViewModel, T : WithErrorHandlingOnFlow, T : WithRetryLastAction + + fun T.getRating( + category: String, + serviceCode: String + ) where T : ViewModel, T : WithErrorHandlingOnFlow, T : WithRetryLastAction + +} diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/WithRetryLastAction.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/WithRetryLastAction.kt new file mode 100644 index 0000000..f342f33 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/WithRetryLastAction.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.util.delegation + + +interface WithRetryLastAction { + + fun retryLastAction() + + fun setLastAction(action: () -> Unit) + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/download_files/WithDownloadFiles.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/download_files/WithDownloadFiles.kt new file mode 100644 index 0000000..4484ca1 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/download_files/WithDownloadFiles.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.core.util.delegation.download_files + +import android.net.Uri +import androidx.fragment.app.Fragment +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LiveData + +sealed class DownloadFilesResult { + data class Success(val uris: List) : DownloadFilesResult() + data class Failed(val exception: Exception) : DownloadFilesResult() +} + +interface WithDownloadFiles { + + suspend fun T.downloadFiles(request: Request): DownloadFilesResult + + val saving: LiveData +} + +interface WithDownloadFile : DefaultLifecycleObserver { + + fun T.downloadFile(request: Request) + + val saving: LiveData +} + diff --git a/core/src/main/java/ua/gov/diia/core/util/delegation/download_files/base64/DownloadableBase64File.kt b/core/src/main/java/ua/gov/diia/core/util/delegation/download_files/base64/DownloadableBase64File.kt new file mode 100644 index 0000000..181f458 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/delegation/download_files/base64/DownloadableBase64File.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.core.util.delegation.download_files.base64 + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DownloadableBase64File( + @Json(name = "file") + val file: String, + @Json(name = "name") + val name: String, + @Json(name = "mimeType") + val mimeType: String +) \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/event/EventObserver.kt b/core/src/main/java/ua/gov/diia/core/util/event/EventObserver.kt new file mode 100644 index 0000000..cc2adf4 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/event/EventObserver.kt @@ -0,0 +1,72 @@ +package ua.gov.diia.core.util.event + +import androidx.annotation.MainThread +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer + +class UiEventObserver(private val onEventUnhandledContent: () -> Unit) : Observer { + + override fun onChanged(event: UiEvent) { + if (event.notHandedYet) { + onEventUnhandledContent() + event.handle() + } + } +} + +open class UiDataEventObserver(private val onEventUnhandledContent: (T) -> Unit) : + Observer> { + + override fun onChanged(event: UiDataEvent) { + event.getContentIfNotHandled()?.let { value -> + onEventUnhandledContent(value) + } + } +} + + +/** + * Adds the given [onChanged] lambda as an observer within the lifespan of the given + * [owner] and returns a reference to observer. + * + * The events are dispatched on the main thread. If LiveData already has data + * set, it will be delivered to the onChanged but if data set was handled before nothing happens + * and dispatcher will wait to the new data set. + * + * The observer will only receive events if the owner is in [Lifecycle.State.STARTED] + * or [Lifecycle.State.RESUMED] state (active). + * + * If the owner moves to the [Lifecycle.State.DESTROYED] state, the observer will + * automatically be removed. + * + * When data changes while the [owner] is not active, it will not receive any updates. + * If it becomes active again, it will receive the last available data automatically. + * + * LiveData keeps a strong reference to the observer and the owner as long as the + * given LifecycleOwner is not destroyed. When it is destroyed, LiveData removes references to + * the observer and the owner. + * + * If the given owner is already in [Lifecycle.State.DESTROYED] state, LiveData + * ignores the call. + */ +@MainThread +inline fun LiveData.observeUiEvent( + owner: LifecycleOwner, + crossinline onChanged: () -> Unit +): UiEventObserver { + val wrappedObserver = UiEventObserver { onChanged.invoke() } + observe(owner, wrappedObserver as Observer) + return wrappedObserver +} + +@MainThread +inline fun LiveData>.observeUiDataEvent( + owner: LifecycleOwner, + crossinline onChanged: (T) -> Unit +): UiDataEventObserver { + val wrappedObserver = UiDataEventObserver { t -> onChanged.invoke(t) } + observe(owner, wrappedObserver as Observer>) + return wrappedObserver +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/event/UiEvent.kt b/core/src/main/java/ua/gov/diia/core/util/event/UiEvent.kt new file mode 100644 index 0000000..d2de4c0 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/event/UiEvent.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.core.util.event + + +open class UiEvent { + + var hasBeenHandled = false + protected set + + val notHandedYet + get() = !hasBeenHandled + + + fun handle() { + hasBeenHandled = true + } +} + +open class UiDataEvent(private val content: T) : UiEvent() { + + fun getContentIfNotHandled(): T? { + return if (hasBeenHandled) { + null + } else { + hasBeenHandled = true + content + } + } + + fun peekContent(): T = content + + override fun toString(): String { + return "UiDataEvent(content=$content)" + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/ErrorHandlingExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/ErrorHandlingExt.kt new file mode 100644 index 0000000..c9c3bf3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/ErrorHandlingExt.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.util.extensions + +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.TimeoutException + + +fun Exception.noInternetException() = + this is SocketTimeoutException || this is TimeoutException || this is UnknownHostException || this is ConnectException \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/PadingExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/PadingExt.kt new file mode 100644 index 0000000..d3bdd12 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/PadingExt.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.core.util.extensions + +import android.app.Activity +import android.app.PendingIntent +import android.content.Context +import android.os.Build +import android.view.WindowManager +import dagger.hilt.android.internal.managers.FragmentComponentManager + +fun addFlagKeepScreen(context: Context) { + (FragmentComponentManager.findActivity(context) as Activity). + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) +} + +fun clearFlagKeepScreen(context: Context) { + (FragmentComponentManager.findActivity(context) as Activity). + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) +} + + +fun getPendingFlags(): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } +} + diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/ResourceValidation.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/ResourceValidation.kt new file mode 100644 index 0000000..277bf6d --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/ResourceValidation.kt @@ -0,0 +1,33 @@ +package ua.gov.diia.core.util.extensions + +inline fun Int?.validateResource(invalid: () -> Unit = {}, valid: (res: Int) -> Unit) { + if (this != null && this != -1 && this != 0 && this != 0x0) { + valid.invoke(this) + } else { + invalid.invoke() + } +} + +fun Int?.isResourceValid(): Boolean = this != null && this != -1 && this != 0 && this != 0x0 + +inline fun String?.validateString(invalid: () -> Unit = {}, valid: (string: String) -> Unit) { + val validationString = this?.trim() + if (validationString.isNullOrEmpty()) { + invalid.invoke() + } else { + valid.invoke(validationString) + } +} + +fun String?.isStringValid(): Boolean { + val validationString = this?.trim() + return !validationString.isNullOrEmpty() +} + +inline fun List?.validateList(invalid: () -> Unit = {}, valid: (List) -> Unit) { + if (isNullOrEmpty()) { + invalid.invoke() + } else { + valid.invoke(this) + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/ShareExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/ShareExt.kt new file mode 100644 index 0000000..aaa65a3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/ShareExt.kt @@ -0,0 +1,29 @@ +package ua.gov.diia.core.util.extensions + +import ua.gov.diia.core.models.share.ShareByteArr +import java.io.BufferedInputStream +import java.io.ByteArrayOutputStream +import java.net.URL + +fun getByteArrFromUrl(imgUrl: String) = getByteArrFromUrl(getFileNameFromUrl(imgUrl), imgUrl) + +fun getByteArrFromUrl(name: String, imgUrl: String): ShareByteArr { + val url = URL(imgUrl) + url.openStream().use { inp -> + BufferedInputStream(inp).use { bis -> + ByteArrayOutputStream(1024).use { fos -> + val data = ByteArray(1024) + var count: Int + while (bis.read(data, 0, 1024).also { count = it } != -1) { + fos.write(data, 0, count) + } + return ShareByteArr(name, fos.toByteArray()) + } + } + } +} + +fun getFileNameFromUrl(imgUrl: String, fileName: String? = null): String { + return fileName ?: imgUrl.substringAfterLast("/") +} + diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/activity/ActivityWindowExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/activity/ActivityWindowExt.kt new file mode 100644 index 0000000..6ab9701 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/activity/ActivityWindowExt.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.util.extensions.activity + +import android.app.Activity + +fun Activity.setWindowBrightness(userDefaultBrightness: Boolean = false) { + var userBrightness: Float = -1F + window.attributes = window.attributes.apply { + if (userDefaultBrightness) screenBrightness = userBrightness else { + userBrightness = screenBrightness + screenBrightness = 0.8f + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextAppPackageInfoExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextAppPackageInfoExt.kt new file mode 100644 index 0000000..0a00d4d --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextAppPackageInfoExt.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.core.util.extensions.context + +import android.content.Context +import android.content.pm.PackageManager + +fun Context.isChromeBrowserExist(): Boolean = try { + packageManager?.getPackageInfo( + "com.android.chrome", + 0 + ) + ?.applicationInfo?.enabled + ?: false +} catch (e: PackageManager.NameNotFoundException) { + false +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextGlobalAppControlExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextGlobalAppControlExt.kt new file mode 100644 index 0000000..105bae8 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextGlobalAppControlExt.kt @@ -0,0 +1,38 @@ +package ua.gov.diia.core.util.extensions.context + +import android.app.ActivityManager +import android.content.Context +import android.os.Build +import android.os.VibrationEffect +import android.os.Vibrator +import ua.gov.diia.core.util.CommonConst + +fun Context.vibrate(millis: Long = 500L) { + val v = getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator ?: return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + v.vibrate( + VibrationEffect.createOneShot( + millis, + VibrationEffect.DEFAULT_AMPLITUDE + ) + ) + } else { + @Suppress("DEPRECATION") + v.vibrate(millis) + } +} + + +fun Context.isDiiaAppRunning(): Boolean { + val activityManager = + getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val processDetails = activityManager.runningAppProcesses + if (processDetails != null) { + for (info in processDetails) { + if (info.processName == CommonConst.DIIA_HOST) { + return info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND || info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE + } + } + } + return false +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextResourcesExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextResourcesExt.kt new file mode 100644 index 0000000..f8a39f9 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextResourcesExt.kt @@ -0,0 +1,56 @@ +package ua.gov.diia.core.util.extensions.context + +import android.content.Context +import android.content.res.Resources +import android.graphics.drawable.Drawable +import android.util.TypedValue +import androidx.annotation.ColorRes +import androidx.annotation.DimenRes +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.core.content.ContextCompat +import androidx.core.content.res.ResourcesCompat +import kotlin.math.roundToInt + +fun Context.getMarginFromDimenId(@DimenRes dimension: Int) = + resources.getDimension(dimension).roundToInt() + +fun Context.dpToPx(dip: Float): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dip, + this.resources.displayMetrics + ).toInt() +} + +fun Context.getDrawableSafe(@DrawableRes drawableId: Int?): Drawable? = try { + ContextCompat.getDrawable(this, drawableId!!) +} catch (e: Exception) { + null +} + +/** + * Wrapper for [ResourcesCompat] to prevent building long method signature every time. + * Also this call compatible with all android versions. + */ +fun Context.getColorCompat(@ColorRes id: Int, colorTheme : Resources.Theme = theme): Int { + return ResourcesCompat.getColor(resources, id, colorTheme) +} + +fun Context.getColorCompatSafe(@ColorRes id: Int?): Int? = try { + ResourcesCompat.getColor(resources, id!!, theme) +} catch (e: Exception) { + null +} + +fun Context.getStringSafe(@StringRes res: Int?): String = try { + getString(res!!) +} catch (e: Exception) { + "" +} + +fun Context.getDimensionPixelSizeSafe(@DimenRes res: Int?): Int = try { + resources.getDimensionPixelSize(res!!) +} catch (e: Exception) { + 0 +} diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextServicesExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextServicesExt.kt new file mode 100644 index 0000000..a2b5e6c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/context/ContextServicesExt.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.core.util.extensions.context + +import android.app.DownloadManager +import android.content.ClipboardManager +import android.content.Context +import android.content.Context.AUDIO_SERVICE +import android.media.AudioManager +import android.nfc.NfcManager +import android.view.inputmethod.InputMethodManager +import androidx.core.content.ContextCompat + +val Context.serviceClipboard: ClipboardManager? + get() = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + +val Context.serviceInput: InputMethodManager? + get() = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager + +val Context.serviceNfc: NfcManager? + get() = getSystemService(Context.NFC_SERVICE) as? NfcManager + +val Context.serviceDownloadManager: DownloadManager + get() = ContextCompat.getSystemService(this, DownloadManager::class.java)!! + +val Context.audioManager: AudioManager + get() = applicationContext.getSystemService(AUDIO_SERVICE) as AudioManager \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/data/DoubleExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/data/DoubleExt.kt new file mode 100644 index 0000000..25e0b09 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/data/DoubleExt.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.core.util.extensions.data + +fun Double.toCurrency() = "%.2f".format(this).replace(",", ".") + +fun Double.toCurrencyUah() = "%.2f грн".format(this).replace(",", ".") + +fun Double.percent(p: Double) = this / 100 * p + +fun String.toAmount() = filter { it.isDigit() || it == ','}.replace(",", ".").toDouble() + +fun String.toPrice() = filter { it.isDigit() }.toIntOrNull() \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/data/NumberExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/data/NumberExt.kt new file mode 100644 index 0000000..ebe2279 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/data/NumberExt.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.core.util.extensions.data + +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols + +private val decimalFormatSymbols = DecimalFormatSymbols().apply { + groupingSeparator = ' ' +} +private val formatter = DecimalFormat("###,###,###", decimalFormatSymbols) + +fun Int?.formatWithSpaces(): String { + return this?.let { + return try { + formatter.format(it) + } catch (e: java.lang.IllegalArgumentException) { + "" + } + } ?: "" +} diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/date_time/DateTimeExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/date_time/DateTimeExt.kt new file mode 100644 index 0000000..6305e3f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/date_time/DateTimeExt.kt @@ -0,0 +1,165 @@ +package ua.gov.diia.core.util.extensions.date_time + +import ua.gov.diia.core.util.DateFormats +import java.text.ParseException +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.OffsetDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Calendar +import java.util.Date +import java.util.Locale + +fun getUTCDate(date: String): Date? { + return try { + val parsedDate = LocalDateTime.parse(date, DateTimeFormatter.ISO_DATE_TIME) + .atOffset(ZoneOffset.UTC) + .toInstant() + Date.from(parsedDate) + } catch (e: java.lang.Exception) { + return null + } +} + +fun getUTCDateUA(date: String): Date? { + return try { + val dateProduct = LocalDate.parse( + date, + DateTimeFormatter.ofPattern("dd.MM.yyyy") + ) + Date.from(dateProduct.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } catch (e: java.lang.Exception) { + return null + } +} + +fun getCurrentDateUtc(): Date { + return try { + val utc: OffsetDateTime = OffsetDateTime.now(ZoneOffset.UTC) + Date.from(utc.toInstant()) + } catch (e: ParseException) { + Date() + } +} + +fun Date.toLocalDate(): LocalDate? = try { + this.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDate() +} catch (e: Exception) { + null +} + +fun Date.toLocalDateTime(): LocalDateTime? = try { + this.toInstant() + .atZone(ZoneId.systemDefault()) + .toLocalDateTime() +} catch (e: Exception) { + null +} + +fun LocalDate.toEpochMillis(): Long { + val localTime = LocalTime.of(0, 0) + val zonedDateTime = ZonedDateTime.of(this, localTime, ZoneOffset.UTC) + return zonedDateTime.toEpochSecond() * 1000 +} + +fun LocalDate.toEpochSecond(): Long { + val localTime = LocalTime.of(0, 0) + val zonedDateTime = ZonedDateTime.of(this, localTime, ZoneOffset.UTC) + return zonedDateTime.toEpochSecond() +} + +fun LocalDate.toSimpleDisplayFormat(): String = try { + val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + this.format(formatter) +} catch (e: Exception) { + "" +} + +fun LocalDateTime.toSimpleDisplayFormatWithTime(): String = try { + val formatter = DateTimeFormatter.ofPattern("HH:mm | dd.MM.yyyy") + this.format(formatter) +} catch (e: Exception) { + "" +} + +fun LocalDateTime.toDefaultDisplayFormat(): String = try { + val formatter = DateTimeFormatter.ofPattern("d MMMM yyyy 'о' HH:mm", Locale("uk")) + this.format(formatter) +} catch (e: Exception) { + "" +} + +fun LocalDate.toServerSendFormat(): String = try { + val formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd") + this.format(formatter) +} catch (e: Exception) { + "" +} + +fun toServerSendFormat(date: LocalDate, time: LocalTime): String = try { + val dateTime = ZonedDateTime.of(date, time, ZoneId.systemDefault()) + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSZ") + dateTime.format(formatter) +} catch (e: Exception) { + "" +} + +fun toServerSendFormat(dateInMillis: Long): String? { + return try { + DateFormats.iso8601.format(Date(dateInMillis)) + } catch (e: Exception) { + null + } +} + +fun LocalDate.toDisplayTimeFormat(): String = try { + val formatter = DateTimeFormatter.ofPattern("HH:mm") + this.format(formatter) +} catch (e: Exception) { + "" +} + +fun toShortDateFormat(date: String): String = try { + val inputFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + val outputFormatter = DateTimeFormatter.ofPattern("dd-MM-yyyy") + val result = LocalDate.parse(date, inputFormatter) + outputFormatter.format(result) +} catch (e: Exception) { + "" +} + +fun getCurrentDate(): String { + val sdf = SimpleDateFormat("dd-MM-yyyy") + return sdf.format(Date()) +} + +fun getLocalDateTime(): LocalDateTime{ + val c = Calendar.getInstance() + + val year = c.get(Calendar.YEAR) + val month = c.get(Calendar.MONTH) + val day = c.get(Calendar.DAY_OF_MONTH) + + val hour = c.get(Calendar.HOUR_OF_DAY) + val minute = c.get(Calendar.MINUTE) + + return LocalDateTime.of(year, month+1, day, hour, minute) +} + +fun getLocalDate(): LocalDate{ + val c = Calendar.getInstance() + + val year = c.get(Calendar.YEAR) + val month = c.get(Calendar.MONTH) + val day = c.get(Calendar.DAY_OF_MONTH) + + return LocalDate.of(year, month+1, day) +} + diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/date_time/DisplayFormatExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/date_time/DisplayFormatExt.kt new file mode 100644 index 0000000..e78f95f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/date_time/DisplayFormatExt.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.util.extensions.date_time + +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +fun LocalDate.toDisplayFormat(format: FormatStyle = FormatStyle.MEDIUM): String{ + val formatter = DateTimeFormatter.ofLocalizedDate(format) + return format(formatter) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentActionsExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentActionsExt.kt new file mode 100644 index 0000000..8360f0c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentActionsExt.kt @@ -0,0 +1,40 @@ +package ua.gov.diia.core.util.extensions.fragment + +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import androidx.core.net.toUri +import androidx.fragment.app.Fragment +import ua.gov.diia.core.util.delegation.WithCrashlytics + +fun Fragment.openLink(link: String, withCrashlytics: WithCrashlytics) { + if (link.startsWith("https:")){ + val uri = try { + link.toUri() + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + null + } + val intent = Intent(Intent.ACTION_VIEW).apply { + data = uri + } + requireContext().startActivity(intent) + } +} + +fun Fragment.openPlayMarket(withCrashlytics: WithCrashlytics) { + val uri = Uri.parse("market://details?id=ua.gov.diia.app") + val goToMarket = Intent(Intent.ACTION_VIEW, uri).apply { + addFlags( + Intent.FLAG_ACTIVITY_NO_HISTORY + or Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET + or Intent.FLAG_ACTIVITY_MULTIPLE_TASK + ) + } + try { + startActivity(goToMarket) + } catch (e: ActivityNotFoundException) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("http://play.google.com/store/apps/details?id=ua.gov.diia.app"))) + withCrashlytics.sendNonFatalError(e) + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentNavigationExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentNavigationExt.kt new file mode 100644 index 0000000..25a3366 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentNavigationExt.kt @@ -0,0 +1,52 @@ +package ua.gov.diia.core.util.extensions.fragment + +import android.app.Activity +import androidx.activity.OnBackPressedCallback +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.navigation.NavController +import androidx.navigation.NavDirections +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.findNavController + +fun Fragment.findNavControllerById(@IdRes id: Int): NavController { + var parent = parentFragment + while (parent != null) { + if (parent is NavHostFragment && parent.id == id) { + return parent.navController + } + parent = parent.parentFragment + } + throw RuntimeException("NavController with specified id not found") +} + +/** + * Simplified version to get the current fragment destination id from navigation graph. + */ +val Fragment.currentDestinationId: Int? + get() = findNavController().currentDestination?.id + +val Fragment.previousDestinationId: Int? + get() = findNavController().previousBackStackEntry?.destination?.id + +fun Fragment.doOnSystemBackPressed(todo: () -> Unit) { + activity?.onBackPressedDispatcher?.addCallback( + viewLifecycleOwner, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + todo.invoke() + } + }) +} + +fun Fragment.navigate( + destination: NavDirections, + navController: NavController = findNavController() +) = with(navController) { + currentDestination + ?.getAction(destination.actionId) + ?.let { navigate(destination) } +} + +fun Activity.collapseApp(){ + moveTaskToBack(false); +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentResultExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentResultExt.kt new file mode 100644 index 0000000..09cefd0 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentResultExt.kt @@ -0,0 +1,161 @@ +package ua.gov.diia.core.util.extensions.fragment + +import androidx.annotation.IdRes +import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavBackStackEntry +import androidx.navigation.NavController +import androidx.navigation.NavGraph +import androidx.navigation.fragment.findNavController +import ua.gov.diia.core.models.ConsumableString +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.throwExceptionInDebug +import java.io.Serializable + +////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////// +////////////////////// Register for fragments navigation result ////////////////// +////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////// + +inline fun Fragment.registerForNavigationResult( + key: String, + crossinline resultEvent: (T) -> Unit +) { + findNavController() + .currentBackStackEntry + ?.savedStateHandle + ?.getLiveData(key) + ?.observe(viewLifecycleOwner) { + validateClassType(it) + resultEvent.invoke(it) + } +} + +inline fun Fragment.registerForNavigationResultOnce( + key: String, + crossinline resultEvent: (T) -> Unit +) { + findNavController() + .currentBackStackEntry + ?.savedStateHandle + ?.getLiveData(key) + ?.observe(viewLifecycleOwner) { + validateClassType(it) + clearResultCallback(key) + resultEvent.invoke(it) + } +} + +fun Fragment.clearResultCallback(key: String) { + findNavController() + .currentBackStackEntry + ?.savedStateHandle + ?.remove(key) +} + +////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////// +/////////////////////// Register for the template dialog events ////////////////// +////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////// + +inline fun Fragment.registerForTemplateDialogNavResult( + key: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY, + crossinline resultEvent: (String) -> Unit +) { + registerForNavigationResult(key) { event -> + event.consumeEvent { action -> resultEvent.invoke(action) } + } +} + +////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////// +///////////////////// Register for navigation result from /////////////////// +///////////////////// pushTop fragments and alertDialogs //////////////////// +////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////// + +inline fun Fragment.registerForDialogNavigationResultOnce( + key: String, + crossinline resultEvent: (T) -> Unit +) { + @IdRes val currentDestinationId = currentDestinationId ?: return + val navBackStackEntry = findNavController().getBackStackEntry(currentDestinationId) + + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && navBackStackEntry.savedStateHandle.contains(key)) { + val result = navBackStackEntry.savedStateHandle.get(key) + result?.run { resultEvent.invoke(this) } + + //remove live data to prevent the resultEvent() trigger every time when the app + //moves into the ON_RESUME state + navBackStackEntry.savedStateHandle.remove(key) + } + } + + //add observer to the fragment back stack + navBackStackEntry.lifecycle.addObserver(observer) + +} + + +////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////// +/////////////////////////// Set navigation result ///////////// ////////////////// +////////////////////////////////////////////////////////////////////////////////// +////////////////////////////////////////////////////////////////////////////////// + +fun Fragment.setNavigationResult( + result: T, + key: String, + navController: NavController = findNavController() +) { + navController.previousBackStackEntry?.savedStateHandle?.set(key, result) +} + +/** + * Send result back using the [NavController] [NavBackStackEntry] back stack and [SavedStateHandle]. + * + * By default it sends the [data] to the previous destination on the navigation back stack using + * the [key] or do nothing if less than two destinations on the stack. + * + * NOTE: if we're want to pop back stack to some arbitrary destination after sending result + * (not to the previous one) we should find that destination [NavBackStackEntry] because + * [NavController.getPreviousBackStackEntry] will not send result to the right destination. + * + * @param arbitraryDestination any destination [IdRes] in the [NavGraph] to which we want send data back + * @param key the key to receive data via result callbacks + * @param data the result data to send to the caller + */ +fun Fragment.setNavigationResult( + @IdRes arbitraryDestination: Int? = null, + key: String, + data: T, + navController: NavController = findNavController() +) { + //fetch backStackEntry as per the data destination + val backStackEntry = with(navController) { + if (arbitraryDestination != null) { + getBackStackEntry(arbitraryDestination) + } else { + previousBackStackEntry + } + } + + backStackEntry + ?.savedStateHandle + ?.set(key, data) +} + +fun validateClassType(it: Any) { + if (it is Serializable) { + if (it is java.lang.String || it is java.lang.Boolean) { + //in some cases we receive java String here. Ignore this case + return + } + throwExceptionInDebug("Serializable is not supported. Please use Parcelable") + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentSendPdfExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentSendPdfExt.kt new file mode 100644 index 0000000..24c51db --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentSendPdfExt.kt @@ -0,0 +1,43 @@ +package ua.gov.diia.core.util.extensions.fragment + +import android.content.Intent +import androidx.core.content.FileProvider +import androidx.fragment.app.Fragment +import ua.gov.diia.core.util.file.AndroidInternalFileManager + +fun Fragment.sendImage(byte: ByteArray, fileName: String, applicationId: String) { + val fileManager = AndroidInternalFileManager(requireContext(), "docs") + fileManager.saveFile(fileName, byte) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/*" + putExtra( + Intent.EXTRA_STREAM, FileProvider.getUriForFile( + requireContext(), + applicationId, + fileManager.getFile(fileName), + ) + ) + //display name + //putExtra(Intent.EXTRA_SUBJECT, "") + } + startActivity(Intent.createChooser(intent, null)) +} + +fun Fragment.sendPdf(pdfInBytes: ByteArray, fileName: String, applicationId: String) { + val fileManager = AndroidInternalFileManager(requireContext(), "docs") + fileManager.saveFile(fileName, pdfInBytes) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra( + Intent.EXTRA_STREAM, FileProvider.getUriForFile( + requireContext(), + applicationId, + fileManager.getFile(fileName) + ) + ) + putExtra(Intent.EXTRA_SUBJECT, fileName) + } + startActivity(intent) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentTimeSelectionExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentTimeSelectionExt.kt new file mode 100644 index 0000000..1f53c7f --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentTimeSelectionExt.kt @@ -0,0 +1,33 @@ +package ua.gov.diia.core.util.extensions.fragment + +import android.app.TimePickerDialog +import androidx.fragment.app.Fragment +import kotlinx.coroutines.suspendCancellableCoroutine +import java.time.LocalTime +import kotlin.coroutines.resume + +suspend fun Fragment.awaitForSelectedTime(): LocalTime = suspendCancellableCoroutine { cont -> + + val listener: TimePickerDialog.OnTimeSetListener = + TimePickerDialog.OnTimeSetListener { _, hour, minute -> + val time = LocalTime.of(hour, minute) + if (cont.isActive) cont.resume(time) + } + + val currentTime = LocalTime.now() + val timePicker = TimePickerDialog( + requireContext(), + listener, + currentTime.hour, + currentTime.minute, + true + ) + + timePicker.show() + + cont.invokeOnCancellation { + if (timePicker.isShowing) { + timePicker.dismiss() + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentWindowControllExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentWindowControllExt.kt new file mode 100644 index 0000000..0ebb89e --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/fragment/FragmentWindowControllExt.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.core.util.extensions.fragment + +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.fragment.app.Fragment +import ua.gov.diia.core.util.extensions.context.serviceInput + +fun Fragment.setDarkStatusBarIcons() { + val decor: View = requireActivity().window.decorView + var flags = decor.systemUiVisibility + flags = flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + decor.systemUiVisibility = flags +} + +fun Fragment.setLightStatusBarIcons() { + val decor: View = requireActivity().window.decorView + var flags = decor.systemUiVisibility + flags = flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + decor.systemUiVisibility = flags +} + +fun Fragment.hideKeyboard() { + val windowToken = view?.windowToken ?: return + context?.serviceInput?.hideSoftInputFromWindow(windowToken, 0) +} + +fun Fragment.showKeyboard(requestView: View?) { + requestView?.requestFocus() + context?.serviceInput?.toggleSoftInput(InputMethodManager.SHOW_FORCED, 0) +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleEventsExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleEventsExt.kt new file mode 100644 index 0000000..f20958e --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleEventsExt.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.core.util.extensions.lifecycle + +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent + +inline fun UiDataEvent.consumeEvent(todo: (T) -> Unit){ + getContentIfNotHandled()?.let { data -> + handle() + todo(data) + } +} + +inline fun UiEvent.consumeEvent(todo: () -> Unit){ + if(notHandedYet){ + handle() + todo.invoke() + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleExt.kt new file mode 100644 index 0000000..7f0a8f4 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleExt.kt @@ -0,0 +1,38 @@ +package ua.gov.diia.core.util.extensions.lifecycle + +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.* + +val ViewGroup.lifecycleScope: LifecycleCoroutineScope? + get() = findViewTreeLifecycleOwner()?.lifecycleScope + +val View.lifecycleScope: LifecycleCoroutineScope? + get() = findViewTreeLifecycleOwner()?.lifecycleScope + +inline fun MutableLiveData.asLiveData(): LiveData = this + +fun LiveData.combineWith(liveData: LiveData): LiveData { + val result = MediatorLiveData() + result.addSource(this) { value -> + result.value = value + } + result.addSource(liveData) { value -> + result.value = value + } + return result +} + +fun LiveData.combineWith( + liveData: LiveData, + block: (T?, K?) -> R +): LiveData { + val result = MediatorLiveData() + result.addSource(this) { + result.value = block(this.value, liveData.value) + } + result.addSource(liveData) { + result.value = block(this.value, liveData.value) + } + return result +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/extensions/vm/ViewModelActionExecutionExt.kt b/core/src/main/java/ua/gov/diia/core/util/extensions/vm/ViewModelActionExecutionExt.kt new file mode 100644 index 0000000..0758cf5 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/extensions/vm/ViewModelActionExecutionExt.kt @@ -0,0 +1,84 @@ +package ua.gov.diia.core.util.extensions.vm + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction + +fun T.executeAction( + progressIndicator: MutableLiveData? = null, + contentLoadedIndicator: MutableLiveData? = null, + errorIndicator: MutableLiveData? = null, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + templateKey: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY, + action: suspend CoroutineScope.() -> Unit +) where T : ViewModel, T : WithErrorHandling, T : WithRetryLastAction { + progressIndicator?.postValue(true) + contentLoadedIndicator?.postValue(false) + errorIndicator?.postValue(false) + viewModelScope.launch(dispatcher) { + try { + action.invoke(this) + contentLoadedIndicator?.postValue(true) + resetErrorCounter() + } catch (e: Exception) { + setLastAction { + executeAction( + progressIndicator, + contentLoadedIndicator, + errorIndicator, + dispatcher, + templateKey, + action + ) + } + consumeException(e, templateKey) + errorIndicator?.postValue(true) + } finally { + progressIndicator?.postValue(false) + } + } +} + +fun T.executeActionOnFlow( + progressIndicator: MutableStateFlow? = null, + contentLoadedIndicator: MutableStateFlow? = null, + errorIndicator: MutableStateFlow? = null, + dispatcher: CoroutineDispatcher = Dispatchers.Main, + templateKey: String = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY, + action: suspend CoroutineScope.() -> Unit +) where T : ViewModel, T : WithErrorHandlingOnFlow, T : WithRetryLastAction { + progressIndicator?.value = true + contentLoadedIndicator?.value = false + errorIndicator?.value = false + viewModelScope.launch(dispatcher) { + try { + action.invoke(this) + contentLoadedIndicator?.value = true + resetErrorCounter() + } catch (e: Exception) { + setLastAction { + executeActionOnFlow( + progressIndicator, + contentLoadedIndicator, + errorIndicator, + dispatcher, + templateKey, + action + ) + } + consumeException(e, templateKey) + errorIndicator?.value = true + } finally { + progressIndicator?.value = false + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/file/AndroidInternalFileManager.kt b/core/src/main/java/ua/gov/diia/core/util/file/AndroidInternalFileManager.kt new file mode 100644 index 0000000..05ccf48 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/file/AndroidInternalFileManager.kt @@ -0,0 +1,63 @@ +package ua.gov.diia.core.util.file + +import android.content.Context +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.InputStream + +open class AndroidInternalFileManager( + private val context: Context, + private val subDir: String, +) : FileManager { + + override fun saveFile(filename: String, data: ByteArray): File { + val file = File(getDir(), filename) + file.writeBytes(data) + return file + } + + override fun getFile(filename: String): File { + return File(getDir(), filename) + } + + override fun readFileData(filename: String): ByteArray { + val file = File(getDir(), filename) + val size = file.length().toInt() + val bytes = ByteArray(size) + val buf = BufferedInputStream(FileInputStream(file)) + buf.read(bytes, 0, bytes.size) + buf.close() + return bytes + } + + override fun deleteFile(filename: String) { + File(getDir(), filename).delete() + } + + override fun clearPath() { + getDir().deleteRecursively() + } + + override fun getDir(): File { + val appDir: File = context.filesDir + val subDir = File(appDir, subDir) + if (!subDir.exists()) subDir.mkdir() + return subDir + } + + override fun readAssetsBytes(asset: String): ByteArray { + var inputStream: InputStream? = null + try { + inputStream = context.assets.open(asset) + val size = inputStream.available() + val buffer = ByteArray(size) + if (size != inputStream.read(buffer)) { + throw IllegalStateException() + } + return buffer + } finally { + inputStream?.close() + } + } +} diff --git a/core/src/main/java/ua/gov/diia/core/util/file/FileManager.kt b/core/src/main/java/ua/gov/diia/core/util/file/FileManager.kt new file mode 100644 index 0000000..f90779a --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/file/FileManager.kt @@ -0,0 +1,42 @@ +package ua.gov.diia.core.util.file + +import java.io.File + +interface FileManager { + + /** + * Save file to working dir + */ + fun saveFile(filename: String, data: ByteArray): File + + /** + * Get file from current working dir + */ + fun getFile(filename: String): File + + /** + * Read file from working dir to byte array + */ + fun readFileData(filename: String): ByteArray + + /** + * Remove file from working dir + */ + fun deleteFile(filename: String) + + /** + * Clear all files from current working dir + */ + fun clearPath() + + /** + * Return current working dir + */ + fun getDir(): File + + /** + * Read file as byte array from assets + */ + fun readAssetsBytes(asset: String): ByteArray + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/filter/DecimalDigitsInputFilter.kt b/core/src/main/java/ua/gov/diia/core/util/filter/DecimalDigitsInputFilter.kt new file mode 100644 index 0000000..df3f561 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/filter/DecimalDigitsInputFilter.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.core.util.filter + +import android.text.InputFilter +import android.text.Spanned +import java.util.regex.Pattern + +class DecimalDigitsInputFilter(digitsBeforeZero: Int, digitsAfterZero: Int) : InputFilter { + + private val pattern = + Pattern.compile("(\\d{0,$digitsBeforeZero})|(\\d{0,$digitsBeforeZero}\\.\\d{0,$digitsAfterZero})") + + override fun filter( + source: CharSequence, + start: Int, + end: Int, + dest: Spanned, + dstart: Int, + dend: Int + ): CharSequence? { + return if (source.isEmpty()) { + if (pattern.matcher(dest.removeRange(dstart, dend)).matches()) { + null + } else { + dest.subSequence(dstart, dend) + } + } else { + if (pattern.matcher(dest.replaceRange(dstart, dend, source)).matches()) { + null + } else { + "" + } + } + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/filter/MoneyValueFilter.kt b/core/src/main/java/ua/gov/diia/core/util/filter/MoneyValueFilter.kt new file mode 100644 index 0000000..accb909 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/filter/MoneyValueFilter.kt @@ -0,0 +1,113 @@ +package ua.gov.diia.core.util.filter + +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.method.DigitsKeyListener + + +class MoneyValueFilter( + private val digits: Int = 2, + private val digitsSigned: Int = 7 +) : DigitsKeyListener(false, true) { + + override fun filter( + source: CharSequence, start: Int, end: Int, + dest: Spanned, dstart: Int, dend: Int + ): CharSequence { + var sourceValue = source + + if (digits == 0) { + if (source.length > 1) { + if (source.any { it in "," }) { + sourceValue = SpannableString(source.toString().replace(",", "")) + } + if (source.any { it in "." }) { + sourceValue = SpannableString(source.toString().replace(".", "")) + } + } else { + if (source.any { it in listOf(',', '.') }) { + return "" + } + } + } else { + if (source.any { it in "," }) { + sourceValue = SpannableString(source.toString().replace(",", ".")) + } + } + + var startValue = start + var endValue = end + val out = super.filter(sourceValue, startValue, endValue, dest, dstart, dend) + + // if changed, replace the source + if (out != null) { + sourceValue = out + startValue = 0 + endValue = out.length + } + val len = endValue - startValue + + // if deleting, source is empty + // and deleting can't break anything + if (len == 0) { + return sourceValue + } + + if (dest.any { it in "." }) { + val signed = filterSigned(dest.toString()) + val decimalPlace = filterDecimalPlace(dest.toString()) + return if (signed.length >= digitsSigned) { + if (dstart == digitsSigned + 1 || dstart == digitsSigned + 2) { + SpannableStringBuilder( + sourceValue, + startValue, + endValue + ) + } else { + "" + } + } else { + if (source != ".") { + if (dstart <= signed.length) { + SpannableStringBuilder( + sourceValue, + startValue, + endValue + ) + } else { + if (decimalPlace.length >= digits) { + "" + } else { + SpannableStringBuilder( + sourceValue, + startValue, + endValue + ) + } + } + } else { + "" + } + } + } else { + return if (dest.length >= digitsSigned && source != ".") { + "" + } else { + SpannableStringBuilder( + sourceValue, + startValue, + endValue + ) + } + } + } + + private fun filterSigned(input: String): String { + return input.substring(0, input.indexOf(".")) + } + + private fun filterDecimalPlace(input: String): String { + return input.substring(input.indexOf(".") + 1, input.length) + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/html/HtmlGenerator.kt b/core/src/main/java/ua/gov/diia/core/util/html/HtmlGenerator.kt new file mode 100644 index 0000000..3bd4c79 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/html/HtmlGenerator.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.core.util.html + +fun convertToLink(url: String, name: String): String { + return "$name" +} +fun convertToPhone(tel:String, name:String):String{ + return "$name" +} + +fun convertToMail(mail:String, name:String):String{ + return "$name" +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/navigation/KeepStateNavigator.kt b/core/src/main/java/ua/gov/diia/core/util/navigation/KeepStateNavigator.kt new file mode 100644 index 0000000..2a58999 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/navigation/KeepStateNavigator.kt @@ -0,0 +1,82 @@ +package ua.gov.diia.core.util.navigation + +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.navigation.NavDestination +import androidx.navigation.NavOptions +import androidx.navigation.Navigator +import androidx.navigation.fragment.FragmentNavigator + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class KeepViewHierarchyInMemory + +@Navigator.Name("keep_state_fragment") +class KeepStateNavigator( + private val context: Context, + private val manager: FragmentManager, + private val containerId: Int, + private val onTransactionComplete: (Class) -> Unit, +) : FragmentNavigator(context, manager, containerId) { + + override fun navigate( + destination: Destination, + args: Bundle?, + navOptions: NavOptions?, + navigatorExtras: Navigator.Extras? + ): NavDestination? { + + val tag = destination.id.toString() + val transaction = manager.beginTransaction() + + var initialNavigate = false + val currentFragment = manager.primaryNavigationFragment + + if (currentFragment != null) { + if (currentFragment::class.java.isAnnotationPresent(KeepViewHierarchyInMemory::class.java)) { + transaction.hide(currentFragment) + } else { + transaction.detach(currentFragment) + } + } else { + initialNavigate = true + } + + + var fragment = manager.findFragmentByTag(tag) + if (fragment == null) { + val className = destination.className + fragment = manager.fragmentFactory.instantiate(context.classLoader, className) + transaction.add(containerId, fragment, tag) + } else { + if (fragment::class.java.isAnnotationPresent(KeepViewHierarchyInMemory::class.java)) { + transaction.show(fragment) + } else { + transaction.attach(fragment) + } + } + fragment.arguments = args + + if (currentFragment?.javaClass == fragment.javaClass) { + return null + } + + transaction.apply { + setPrimaryNavigationFragment(fragment) + setReorderingAllowed(true) + runOnCommit { + onTransactionComplete(fragment::class.java) + } + commit() + } + + return if (initialNavigate) { + destination + } else { + null + } + } + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/phone/PhoneNumberExt.kt b/core/src/main/java/ua/gov/diia/core/util/phone/PhoneNumberExt.kt new file mode 100644 index 0000000..493e0e1 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/phone/PhoneNumberExt.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.core.util.phone + +fun String.removePhoneCodeIfNeed(): String { + val startIndex = when { + startsWith("0") -> 1 + startsWith("80") -> 2 + startsWith("380") -> 3 + startsWith("+380") -> 4 + else -> 0 + } + return if (startIndex > 0) { + substring(startIndex) + } else { + this + } +} + +const val RAW_PHONE_NUMBER_PREFIX = "380" +const val PHONE_NUMBER_VALIDATION_PATTERN = + "^38(039|050|063|066|067|068|073|091|092|093|094|095|096|097|098|099)\\d{7}\$" \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/settings_action/SettingsActionExecutor.kt b/core/src/main/java/ua/gov/diia/core/util/settings_action/SettingsActionExecutor.kt new file mode 100644 index 0000000..3916851 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/settings_action/SettingsActionExecutor.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.core.util.settings_action + + +interface SettingsActionExecutor { + + val actionKey: String + + suspend fun executeAction() + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/system/application/ApplicationLauncher.kt b/core/src/main/java/ua/gov/diia/core/util/system/application/ApplicationLauncher.kt new file mode 100644 index 0000000..10a229c --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/system/application/ApplicationLauncher.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.core.util.system.application + +import android.content.Context +import android.content.Intent +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK +import android.net.Uri +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +interface ApplicationLauncher { + + fun launch(uri: String) +} + +class ApplicationLauncherImpl @Inject constructor( + @ApplicationContext private val context: Context +) : ApplicationLauncher { + + override fun launch(uri: String) { + val launcher = Intent().apply { + action = Intent.ACTION_VIEW + data = Uri.parse(uri) + flags = FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(launcher) + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/system/application/InstalledApplicationInfoProvider.kt b/core/src/main/java/ua/gov/diia/core/util/system/application/InstalledApplicationInfoProvider.kt new file mode 100644 index 0000000..b6668c2 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/system/application/InstalledApplicationInfoProvider.kt @@ -0,0 +1,47 @@ +package ua.gov.diia.core.util.system.application + +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.drawable.Drawable +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +data class ApplicationLauncherInfo( + val packageName: String, + val applicationName: String, + val appIcon: Drawable +) + +interface InstalledApplicationInfoProvider { + + fun applicationExists(packageName: String): Boolean + + fun getApplicationDetails(packageName: String): ApplicationLauncherInfo? +} + +class InstalledApplicationInfoProviderImpl @Inject constructor( + @ApplicationContext private val context: Context +) : InstalledApplicationInfoProvider { + + private val packageManager: PackageManager = context.packageManager + + override fun applicationExists(packageName: String): Boolean = + try { + packageManager.getPackageInfo(packageName, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } + + override fun getApplicationDetails(packageName: String): ApplicationLauncherInfo? = + if (applicationExists(packageName)) { + val ai = packageManager.getApplicationInfo(packageName, 0) + ApplicationLauncherInfo( + packageName = packageName, + applicationName = packageManager.getApplicationLabel(ai).toString(), + appIcon = ai.loadIcon(packageManager) + ) + } else { + null + } +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/system/service/SystemServiceProvider.kt b/core/src/main/java/ua/gov/diia/core/util/system/service/SystemServiceProvider.kt new file mode 100644 index 0000000..1af1026 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/system/service/SystemServiceProvider.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.core.util.system.service + +interface SystemServiceProvider { + + fun isNfcServiceAvailable(): Boolean +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/system/service/SystemServiceProviderImpl.kt b/core/src/main/java/ua/gov/diia/core/util/system/service/SystemServiceProviderImpl.kt new file mode 100644 index 0000000..8679245 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/system/service/SystemServiceProviderImpl.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.core.util.system.service + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import ua.gov.diia.core.util.extensions.context.serviceNfc +import javax.inject.Inject + +class SystemServiceProviderImpl @Inject constructor( + @ApplicationContext private val context: Context +) : SystemServiceProvider { + + override fun isNfcServiceAvailable(): Boolean = context.serviceNfc?.defaultAdapter != null +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/work/CheckAppVersionUpdatedWork.kt b/core/src/main/java/ua/gov/diia/core/util/work/CheckAppVersionUpdatedWork.kt new file mode 100644 index 0000000..1613e80 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/work/CheckAppVersionUpdatedWork.kt @@ -0,0 +1,69 @@ +package ua.gov.diia.core.util.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import ua.gov.diia.core.data.data_source.network.api.notification.ApiNotificationsPublic +import ua.gov.diia.core.data.repository.SystemRepository +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.core.util.delegation.WithBuildConfig +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit + +@HiltWorker +class CheckAppVersionUpdatedWork @AssistedInject constructor( + @UnauthorizedClient private val apiNotificationsPublic: ApiNotificationsPublic, + private val systemRepository: SystemRepository, + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val buildConfig: WithBuildConfig +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = if (runAttemptCount > RETRY_COUNT) { + Result.failure() + } else { + try { + val appVersionCode = systemRepository.getAppVersionCode() + val currentAppVersionCode = buildConfig.getVersionCode() + if (appVersionCode == null || currentAppVersionCode > appVersionCode) { + apiNotificationsPublic.sendAppVersion() + } + systemRepository.setAppVersionCode(currentAppVersionCode) + Result.success() + } catch (e: UnknownHostException) { + Result.retry() + } catch (e: Exception) { + Result.failure() + } + } + + companion object : WorkScheduler { + private const val RETRY_COUNT = 3 + private const val VERSION_CHECK_WORK_DELAY = 5L + + override fun enqueue(workManager: WorkManager) { + val workConstraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val checkAppVersionWork = OneTimeWorkRequest + .Builder(CheckAppVersionUpdatedWork::class.java) + .setConstraints(workConstraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + VERSION_CHECK_WORK_DELAY, + TimeUnit.SECONDS + ) + .build() + workManager.enqueue(checkAppVersionWork) + } + } + +} \ No newline at end of file diff --git a/core/src/main/java/ua/gov/diia/core/util/work/DoApplicationSettingsProvisionWork.kt b/core/src/main/java/ua/gov/diia/core/util/work/DoApplicationSettingsProvisionWork.kt new file mode 100644 index 0000000..d098a32 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/work/DoApplicationSettingsProvisionWork.kt @@ -0,0 +1,78 @@ +package ua.gov.diia.core.util.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import ua.gov.diia.core.data.data_source.network.api.ApiSettings +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.core.util.extensions.context.isDiiaAppRunning +import ua.gov.diia.core.util.settings_action.SettingsActionExecutor +import java.util.concurrent.TimeUnit + +/** + * Trigger other workers including updating of push notification token + */ +@HiltWorker +class DoApplicationSettingsProvisionWork @AssistedInject constructor( + @UnauthorizedClient private val apiSettings: ApiSettings, + private val settingsActionExecutor: Set<@JvmSuppressWildcards SettingsActionExecutor>, + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result = try { + if (!appContext.isDiiaAppRunning()) { + Result.failure() + } else { + val settings = apiSettings.appSettingsInfo() + processActions(settings.actions) + Result.success() + } + } catch (e: Exception) { + Result.retry() + } + + private suspend fun processActions(actions: List?) { + if (actions == null) return + settingsActionExecutor.forEach { exec -> + if (actions.contains(exec.actionKey)) { + exec.executeAction() + } + } + } + + companion object : WorkScheduler { + private const val VERSION_CHECK_WORK_DELAY = 5L + + override fun enqueue(workManager: WorkManager) { + val workConstraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val checkVersionWork = OneTimeWorkRequest + .Builder(DoApplicationSettingsProvisionWork::class.java) + .setConstraints(workConstraints) + .setBackoffCriteria( + BackoffPolicy.LINEAR, + VERSION_CHECK_WORK_DELAY, + TimeUnit.SECONDS + ).build() + + workManager.enqueueUniqueWork( + "checkVersionWork", + ExistingWorkPolicy.REPLACE, + checkVersionWork + ) + } + } + +} diff --git a/core/src/main/java/ua/gov/diia/core/util/work/WorkScheduler.kt b/core/src/main/java/ua/gov/diia/core/util/work/WorkScheduler.kt new file mode 100644 index 0000000..b580cb3 --- /dev/null +++ b/core/src/main/java/ua/gov/diia/core/util/work/WorkScheduler.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.core.util.work + +import androidx.work.WorkManager + +interface WorkScheduler { + + fun enqueue(workManager: WorkManager) +} \ No newline at end of file diff --git a/core/src/main/res/color/outlined_button_text.xml b/core/src/main/res/color/outlined_button_text.xml new file mode 100644 index 0000000..f21eb07 --- /dev/null +++ b/core/src/main/res/color/outlined_button_text.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/color/text_clickable.xml b/core/src/main/res/color/text_clickable.xml new file mode 100644 index 0000000..b98fb97 --- /dev/null +++ b/core/src/main/res/color/text_clickable.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/back_penalties_paid.xml b/core/src/main/res/drawable/back_penalties_paid.xml new file mode 100644 index 0000000..9381b20 --- /dev/null +++ b/core/src/main/res/drawable/back_penalties_paid.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/back_penalty_card.xml b/core/src/main/res/drawable/back_penalty_card.xml new file mode 100644 index 0000000..a7d9fd1 --- /dev/null +++ b/core/src/main/res/drawable/back_penalty_card.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/back_white_round.xml b/core/src/main/res/drawable/back_white_round.xml new file mode 100644 index 0000000..a7d9fd1 --- /dev/null +++ b/core/src/main/res/drawable/back_white_round.xml @@ -0,0 +1,10 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/bg_radius_8.xml b/core/src/main/res/drawable/bg_radius_8.xml new file mode 100644 index 0000000..9aeb645 --- /dev/null +++ b/core/src/main/res/drawable/bg_radius_8.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/black_button_enabled.xml b/core/src/main/res/drawable/black_button_enabled.xml new file mode 100644 index 0000000..7c7be37 --- /dev/null +++ b/core/src/main/res/drawable/black_button_enabled.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/black_disable_button.xml b/core/src/main/res/drawable/black_disable_button.xml new file mode 100644 index 0000000..6ce6fd5 --- /dev/null +++ b/core/src/main/res/drawable/black_disable_button.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/black_enable_button.xml b/core/src/main/res/drawable/black_enable_button.xml new file mode 100644 index 0000000..cc78833 --- /dev/null +++ b/core/src/main/res/drawable/black_enable_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/button_buy_card.xml b/core/src/main/res/drawable/button_buy_card.xml new file mode 100644 index 0000000..4333300 --- /dev/null +++ b/core/src/main/res/drawable/button_buy_card.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/chips_selected.xml b/core/src/main/res/drawable/chips_selected.xml new file mode 100644 index 0000000..006d0ed --- /dev/null +++ b/core/src/main/res/drawable/chips_selected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/chips_unselected.xml b/core/src/main/res/drawable/chips_unselected.xml new file mode 100644 index 0000000..219c8fa --- /dev/null +++ b/core/src/main/res/drawable/chips_unselected.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/delimiter_gradient.xml b/core/src/main/res/drawable/delimiter_gradient.xml new file mode 100644 index 0000000..e2381c7 --- /dev/null +++ b/core/src/main/res/drawable/delimiter_gradient.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/diia_circular_progress.xml b/core/src/main/res/drawable/diia_circular_progress.xml new file mode 100644 index 0000000..7b4e607 --- /dev/null +++ b/core/src/main/res/drawable/diia_circular_progress.xml @@ -0,0 +1,21 @@ + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/divider.xml b/core/src/main/res/drawable/divider.xml new file mode 100644 index 0000000..aea4769 --- /dev/null +++ b/core/src/main/res/drawable/divider.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/doc_shadow.png b/core/src/main/res/drawable/doc_shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..3b3afb5381b0d01aba389477212fbb75565bf585 GIT binary patch literal 5980 zcmX|Fdpy(o|Ce-1ol+!(rpYCf8M$n3$xw*qHgY$%5@U0}cRiumj>=`_Qsgc*_ggq( z6Ek0@q{;>XfdiIz?43fKZdk!kfdOh>K7ro8Y7qfJynO)yeWQpVFVtP%5IJvO z%pI%&aH+8cD0j!l0EpDOp?)LC#P{}{Yf*S#yC`dWRMcG*)CXu}cvL?E#sdiO4e^qT z2=K=S!y*iT`?xUPn7<4L%I%Yc+%*6i^BKxH-MA@d5{UPe(^AvEgo0@5%IQGWAX-pe zjmxTX8tM>jusReBxqL|-0#k>=)FE>J{Q(V+%IV{M&@fxL*?*bwW(L69At6C9FgQFs zTrFHvEf9|ZL!eM7SX~3Gp>c^vaVaX}hpH6gmt-;}7sWOTDjk}pUigLWx&=7_j}H#b%SQSH0~dn>Y;U@}p`uO6rgcnw*-hr6UB!b|uEP ze;ZX7ZeHIHtiLdnF*~fuHR_;P&AqO)FA0|6M21uRg>S8Ou_l*{$DVi1 zv;+IJ^$G&)W;Ghm@L@*Ny9|D5xxHXsS_C2G#f}4vM~{hBXWsqu<4=&7HsHF8Ld;jq zg!QkqV;PVt<(tbEl5p?5D2LSu37kS3TIj~Nv@w?_rSKyY{6z2sXFY)3TCkf!E_UNG z6Vs<(rWAetO@&YRBseMFL}#AU^2ib6Y`JWqEjv$qUAd^`RG=L|Y`LiQkIF#F*fowB zlxM`dVM~7RZ(K&70TdWS{bf-WA`apUTBZqdvX3rYTv+SPJe~tceCbcON?6CeSTAO! zCnJ}2Y@q6>wX}!DzHV+h3Vutf(yNoE!9?#<7B>eYkQwW{Dupj?YGmFusPVz*Uv`3; zHUA+6ylHxVUAd6{!6a*g-E`*zVzTCf-gUo; z(qB$pmtD?f&9Nr|H!=P>rR8EhpY*P}c|H8a7cSyY(1e1R8Y0ed9TQpeZgUn@P09y3 zZR6ICWBOvz+^MA=ni0;d)#q^>tE(t;`fS|%?KlS)MQ{BffG1o*y&`h)oCF_;)j9jo z8Zj2?ksBw=RlwU(rvv^)KIl01(&BrOf)80>C73k*btHb~sAQMOl?5vr8p z-dJV}sh9iq93jg?QL+M}64S!a-4)TFt(-JkblZElZx3`+#4pNZO!d3ytZN_2jz~I~ zgwHMxy*GE9=sp*|eeAxpks33 zaOJ8WRyN3n59!?i_==8nqdc3kXYYOlc~)emG9Qw~BWV~+&V)>q!dB3t1djXeu$ zGf8j;;dUbH%`nE)>aW@GyEX4!#UT8c{E%LL@AIZT+ptE(33!+q_mYf>{u^P zsEBk$tRc(oL&v&q%pFqU+CmoSlC!@Rg%sKcnr zfyqf(3B|L8qLL}E#HzD!p1E61QSwx4aBmB|vRtgcYURrH2hNDm@?}<($ zI0(L-{OJ=(HWBNA+6Fy&z!*(Y9>?7QLY{M$gAp59IKF(I#FE`^Z?Y3P7U}(&)?cu5 zt%`tnnr@M4Z*6&*Y4t(%OQ@&6zUYO1_f$WURdtMNF36{Wk(|HzkaLyF&Kde-J4N~W zCm#rqC8)O<Tkfirou9kvk<>x_ECD9mQe5mq=eY)H2shI0Mn%phh@*m)cWI zU24V*tel|tw2PBb6~HtWLY^ z{OEG#=F7VRDM}lhJ+AX96iF}I^@Yh~hY4%gesv}x-R%T{6poKk-IRCO7y?{>(fAh4 zw_7u`#4*XQ$a-0}ADyC%2=F_R;*{u4KKMLQQrcr9E2GFUxf4IanOm9=tbDvnj}u)A zkrA13%Z$%3q-hZc`*sx>v9H8!mRS1HV{|7SNScQk>(Fx&05u9HiiVo3RZuoPCa2C@xJ2DkGU62h{0k{6l*WP3*sbHE9${(eskc_(Y~) z34x7?u1oF8ho?G?s2?UH&8f_%;eJ@-@>sRLY%;z2bI~wTNV@F^kE1&od+E!D=P<2O zq?p9jd0zUc@5t~@MAuAF`y&Z(`~$-wjY^BA^^HC(7P4N#08_WJ3Z zu@Udd{?O9s50UQr8%EluYczjS2ppQc*vqQDw6~lJx-Y zktcIWYAzw&uf_5*ce>slXu}EUs5DS7RTfX38l_?mx7WniY0otimWQ07ROq;TtuNvI zLHf{D>U2@Jgy(BI@;hHGfLScfX^d=k!eb{m2jQAn& zpLL0Ml_jo0ubS_jU)c#>#+iNvce!lJ^8+lx7(JI;e(?4FHKv~WdZwww0F-i2Qy#B! zAg|quTOSsT9aqpTN{cWJND+fP;D)EOJ(m8V7?AKpN`Gy#FY}6H&*xfSuGCmG+sVL1 zgvfV$>p2bMF9AhE?B!AM*sJ#cLK_Os>^CIZ!qr`Aaf0y4)dbwzc1bM!|im;r)B}tbvJ(Iy% zXT---#ZC#QD`TemL#@$pEA0VB>;^k8J4Gp`xldHXWgA0LEg#-7+WqIeqkrF?*F;>R zQ&l_tm{^OsSt5~P$yuhHpD-*KZ=_`f^S}i%l*H7{k8k|&cJ?~=(VQ~xnwTJU;ETAH zrbkNVSRhq>qrOMxlk*@rQ z54V=!H(s>3#&+wQV9G;mtD_O9V00XyKop|6nviXZ{HjXXa_MaPBnkxw0$G#hEw}3j zel%@M-6Ggu?WiHiu`{0RozchvWQj&YEvVEU6XYjr+|jWKX!nSMdxfBp*dh0u z%I6`CN@l`oB+sT<_kLWh2b`=4@`No~gw8AFdq2@Fc9p7rOGj}TK35fkZ^baKQKf*G zgjM2PZZ@Y3#J__CUUmf_mdU{J984Rple*fLgFInr{vV$<#aTU9fY#o$p(d}oTA>9M z#Xu%ikrez-I8ZSdLtR(0-bRXyXp6rRzc_UF@}zu%!;(q6ys@pY#3O!*y^>>nAZrS~ zM8i!rz-cL-2+Fngh!k5WGz*d!ZO6ruJ{ta_Ia}d7M4ZprAF39GhE32S3mzfipHKXl z7!esaWk@#cUBkz|GN|o6-nV}PKj}0`ND6THJ3LdO-%F*c69 zU|yT#Wa^8OPl%><)S3dRjh7tj;K5N6^`JIhqX?ZH2d1MFWSBKyaK_U-5nFA1YJzGy zYTj9D(yoZFI5f!%l{7iLd6w)o^0l_y^Z^}U4h6-`7_DB63%~Q+&+c)(%GypOD=I`r zY{W6(+tSetVxwqB#nw{0et(N>+aVt0Ya|>4?8pHj&j-82#XL#K6^os;g}_QKx!-b&MLRG-7;)6k|^$#1LC&Qzbh39eZId( zg@En1>${@G*!?T&;MWyRjOY+o;pgH?eAfsx+lmiY3Q}qEDU)tz`W+KVsx6{Qsg;v# zyRk}N(=w|!M|V`>70ZVmk482M&FhlV?mAe~wzV1-Es;M%#`92|GN5t63STDAWUD54 zIlNz|>nYrr7CV!asmb}%@(xRC+$10DJggLz--`iKjD5k;fS>R7p?qh>PjHuIbp zPSZ)*v$1CF4p;L*w@&Kt#oC+xN+M5cM9b8x4Mm$*5#DgC=trKAq?S+G7lv~yVl+Y+0^yVt}nWb$vvN0($%Gz%EX&| z?VR%PuDKBdiB6GAx;hB=Go^5AjC)}2CB5HeHL(-puhvP@TpD7WzzO*Yh-Je43B3aV z-~iR^m%H#=<-mj{|9NFF%;UfTAs*IyMi)N6D88`M-5Kiu)!c28#{^e2xcQyDZV8t-Y1?zLF)U>ClyYVHewQDUKi6zeDc98eQlk>o z04z=_o!fwxqC(v}N^8LL>+tx{9BNVml!$pmNX~IC_$I6LGM*g=GKOv~JuKquv`)gN zjGlrZyaIzkn74i-8}5m($&q2bdI6Ti9wM;*OCkPF?v#rkf#}lb{7t;tSMB-~J%n9C zC9c4BM-6-37R&9!XVvv1G$_<&Xw>XdMckF0fsObH8h<>SaL08n-<-#8g z<=1HBncs7S6wcTwezCdtQnS0$ziHB%o^YRNm*<@|7e*tnSc%? J}SC3{q1U<4! zvxBOX1cY65tWhn~Bax?2vb9BBJ)1sq{(RoM5EoTUM--;s-u!fk%&UH1cUek>hE3>V z{nfhdg<6-^kgXoq+BxsR!TEp7E$+c5!^=~W?Ks`KmHr~U(kN2kS`MU)*WmO|#+9b=|ec7J57Wx1(TPdL#V&W**nJwf}PNCKs8@}0FEQXVAE z&^@;sL)(2pj@9GYYqPh;hV$`OYX~hPz~j`RQZ~}{u3}mT(s+$W-x@wSFPc9DL8c?$ z9nD~9VbbG6Gc(K=B89h@(RcopQu?8iG7&&=BU)fa{bz24CTm9WP6-%gVR0WnJ^oA` zTA0WTy_Y*^6|I9Q-t+%w>rbrJ3%h`~OKT&kc|unG@d_vS^aB+QYbxCiTaCSh>VLsq zu`hczPxUq>#;5w3?5ggP?xIgvw1-evcXGF9E%}vO;5E%amh5tJ&V5d0;HQGq#i*qp zZJ9kYbr9#R6Hk1?>PP9%Di$-unJf`09(CO1QqD|y;02dEjj(uhW|n1J4R#Q@4a429 zyozQwpO}K%3j*<%!Y-BCbq&|}E_dOIseEhQJL_WkG0`cFnHWCY3j>!14=xP4Hiyi6 z4pih}hNPcQg=v2arw&)!__B9OV%m@Mj=(nPU%ps`+6fFpsLf$(%{^frcGxE9KkzHRCL55lqER{$yxCT?HPLS(HuqU>RJbRydl`_KWZ8BhV!xO$Wwu{@$Vr2-u*H_V`t;ZM@?2=YGCiEU(*6)}gUrIdq{IWW7#`)=DG-EEcrIWuQko^?7TQZ4h z8kdMOiV{?(PVb%H|7Ihwc#t#nd?n$=c4bUqpoLy{C4>KP6DXfbVbDIgNpzQ_~A<-pF^BIdBZ7;kmGxXBe4ci4TG9K)UX-N*#+Nv}Lfmry%wL}}ei + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/green_radiobutton.xml b/core/src/main/res/drawable/green_radiobutton.xml new file mode 100644 index 0000000..8ceaf49 --- /dev/null +++ b/core/src/main/res/drawable/green_radiobutton.xml @@ -0,0 +1,18 @@ + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/ic_add_item.xml b/core/src/main/res/drawable/ic_add_item.xml new file mode 100644 index 0000000..44035ed --- /dev/null +++ b/core/src/main/res/drawable/ic_add_item.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/src/main/res/drawable/ic_arrow.xml b/core/src/main/res/drawable/ic_arrow.xml new file mode 100644 index 0000000..0d5c035 --- /dev/null +++ b/core/src/main/res/drawable/ic_arrow.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/src/main/res/drawable/ic_arrow_disabled.xml b/core/src/main/res/drawable/ic_arrow_disabled.xml new file mode 100644 index 0000000..9c84a2b --- /dev/null +++ b/core/src/main/res/drawable/ic_arrow_disabled.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/ic_arrow_forward_white.xml b/core/src/main/res/drawable/ic_arrow_forward_white.xml new file mode 100644 index 0000000..045d385 --- /dev/null +++ b/core/src/main/res/drawable/ic_arrow_forward_white.xml @@ -0,0 +1,5 @@ + + + diff --git a/core/src/main/res/drawable/ic_arrow_top.xml b/core/src/main/res/drawable/ic_arrow_top.xml new file mode 100644 index 0000000..b1a707b --- /dev/null +++ b/core/src/main/res/drawable/ic_arrow_top.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/src/main/res/drawable/ic_b_back.xml b/core/src/main/res/drawable/ic_b_back.xml new file mode 100644 index 0000000..c8eb7a0 --- /dev/null +++ b/core/src/main/res/drawable/ic_b_back.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/core/src/main/res/drawable/ic_b_back_bold.xml b/core/src/main/res/drawable/ic_b_back_bold.xml new file mode 100644 index 0000000..aebee54 --- /dev/null +++ b/core/src/main/res/drawable/ic_b_back_bold.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/src/main/res/drawable/ic_b_back_bold_white.xml b/core/src/main/res/drawable/ic_b_back_bold_white.xml new file mode 100644 index 0000000..d366f8f --- /dev/null +++ b/core/src/main/res/drawable/ic_b_back_bold_white.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/ic_badge.xml b/core/src/main/res/drawable/ic_badge.xml new file mode 100644 index 0000000..9efbd23 --- /dev/null +++ b/core/src/main/res/drawable/ic_badge.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/ic_check_for_btn.xml b/core/src/main/res/drawable/ic_check_for_btn.xml new file mode 100644 index 0000000..36953ea --- /dev/null +++ b/core/src/main/res/drawable/ic_check_for_btn.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/src/main/res/drawable/ic_checkbox_green_cempty19.png b/core/src/main/res/drawable/ic_checkbox_green_cempty19.png new file mode 100644 index 0000000000000000000000000000000000000000..7da5895a93bca0061e1d504804c1c18156e0ab83 GIT binary patch literal 444 zcmV;t0Ym{(FkEt%o6PR*USwe$fM?(U zOu-|~7hnl4!9{3e6qw7wu|%6d;YnS%E_ef8>Ukw6ui&TVGC97gxTfedB*>3O;0Bz5 zFV>8qzGozJ@X2f!7V~v0v3ogd!#nr}o|?<`2e^*4IMO)N!hgX-n&^2M-tv`H<2C>``h4qIQ%qW=YrAzMWC^aUr?HLsGMVnZEq#6qf`cQN0 z51+P@f~!3U`Z``xkj?#NuG)vz9o*yVco9qNq4KA!gIH=iciTGly}Skw<&wSmZLU|_ zoZU%-V)LpX!A9rpa}1t<=WNR*_u1YBM>_tKsEH_l|yT8DnJX-B$iW)Xh mIF~D*S0()A`3pbk#71IUxZ_v=0000 + + diff --git a/core/src/main/res/drawable/ic_close_green_light.xml b/core/src/main/res/drawable/ic_close_green_light.xml new file mode 100644 index 0000000..cf5ba12 --- /dev/null +++ b/core/src/main/res/drawable/ic_close_green_light.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/core/src/main/res/drawable/ic_details_three_dots.xml b/core/src/main/res/drawable/ic_details_three_dots.xml new file mode 100644 index 0000000..417ad5b --- /dev/null +++ b/core/src/main/res/drawable/ic_details_three_dots.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/core/src/main/res/drawable/ic_dl_menu.xml b/core/src/main/res/drawable/ic_dl_menu.xml new file mode 100644 index 0000000..05250da --- /dev/null +++ b/core/src/main/res/drawable/ic_dl_menu.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/core/src/main/res/drawable/ic_empty_ellipse.xml b/core/src/main/res/drawable/ic_empty_ellipse.xml new file mode 100644 index 0000000..77b4b60 --- /dev/null +++ b/core/src/main/res/drawable/ic_empty_ellipse.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/ic_full_ellipse.xml b/core/src/main/res/drawable/ic_full_ellipse.xml new file mode 100644 index 0000000..55f5444 --- /dev/null +++ b/core/src/main/res/drawable/ic_full_ellipse.xml @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/ic_logo_diia.xml b/core/src/main/res/drawable/ic_logo_diia.xml new file mode 100644 index 0000000..2e583d1 --- /dev/null +++ b/core/src/main/res/drawable/ic_logo_diia.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/core/src/main/res/drawable/ic_logo_diia_gerb.png b/core/src/main/res/drawable/ic_logo_diia_gerb.png new file mode 100644 index 0000000000000000000000000000000000000000..355b59dbf0d265d6069c10b9abeca6b39f3875a0 GIT binary patch literal 2478 zcmV;f2~qZmP)j;UEAey8| zSxNftdk>Ei_(LQ?fFLA0LmbAMrkW<2Mw*l%hP*B`We9`I!2)CbloA{#vwuhsxx=7f4tuv^7k1ReD6X z8FQJbkl+rUG-fflc#k*@EF_wcDSjU)RDJ4PkaKkJL0v1q!08$kBkTqGVUC1V`wmcO zg!RK$z&G(BN5ZFVp-GMra?IjTNS_35V~XDw3XXpMVbZxt#m8K1bgF>~>422wx({y2HZV_J5eVm6_N5S=+m}l*f1|H)S!-AZV zHHHNV{%DNEjBy4+lO1wAQcPZq$&|b?;-Jt^xLp{DKE?JW7OjOvrca@LKC&V63c)L^ zd{}K{aGS%-bM1qxZ_GPK9N09aMgeHDrV;HD&N|9%k%k9Rw-nSA)gxxP&7SyEkyT2c$uvGHZW(@^i#JdyRDG=SlRT6cnkfZt#25kD=G4``ytezO5C`5r za-@S+HidMSIG*sm=bFJn+8`adNz1-VK@qx4Q>^KdLX?_Ubd>S6!}V=pD!e{O0Jr8@ zmySG!?WoL5ooiI_!#>X?)bgQCF2)PKQoX}1;=p4ZBMnq% zjcT%XaC?m$dAG4f)4vtc_hu2&Hx?+2>Oz51|%DcI_DH!ANIHq3Aap0{5MYu8d z6HnU`JjqLQze2j8`1xf$cFzkdQ=&FEdag4~GG)rv>$Pjo!u|a{@;t|8v%$;Di)$YW z?M-t3DjfL=Z-aqC*qnoG>lC zLQ{)>dwbg|GoR0~SS*mxcH!a2K)%K83;y0gS$yT-eKKhuZ_f$ZqwM|3wJ!jOKWFKE zDbwZ-pFe-Dd1M0lT5C|jLi+aYTajfMG!;@);npPkJe4Z2Q{fX7L24Q;j2r`k1Qpt! zLh$Yn`~99Ah(epdvJCBMnpd~21yx_KR;$pnrP+Ni;YiToSBd+nxc)4zsZ5Rm!SAEw zq-PFZZEnbev&z9LS5L0qf9m~7g4!Tq60`}-$Qhx1?c>Lzh>(nQ*ur{CM}okgkP%pW zqz5<^*9vZvcoGUk9B}nymS-XDIJELYhCgB`dXU7&@5e^>n#|Pdnk)u#_>T!k&kz$XgoT49%;e zwaA~KpZ2?+@9a!#V#q_gySuB+5>31$&fD#F`F*KAS%Rg_Ag63$hdn36P)V z5PA$H4>$pAzGmi{grm?zbGD-F+VCikwCjhtU!Zzj|0;s2U!!YNbtn>`<9THp7Z(>b zkAz_~vD60g23f;ou+}3eU##(f_`!4FH1)lZ)RL9x$JV6sldkV*?^lJXBkQKZDJZ9z zVL{Qv(gf4JNn6AdZv<7=rHLm&uof{r$-X}~T9KkQFzW;@8P&s8NMY#N|T=4i88O&2w8M^1Vpk1eHj$zf= znRfo<(@Z6+lNJ9>PC}MNLH4>8a?&hfWrxk{YoO566-!aeNHg_(w7i+#^P1~VI z9oer~6EidQ?lBDHFEfewfJ+NUVHhk;oAqK)@K03CZ=!s{4V|C5gde-##cGH`GoeF4 zq5Y}d4#R0I;Q955{8f10jXt(+m*e8&*>!$lfPtR~g-WxA)Ff_~HME5Vh8M^w<-0VE zXo)l|OuaRRg?ynn6liSDF)AFJDwm5Mv*L$#&wU!DkOn?L4y{!~L!lX`aurzMw8?et zNxuE)R1KS>9~Nj#io)*)g~mkRgbLb`;n4mOI`AG4uXQn=pXC2v|Gt2K?8bcyp84!Q z1c{lr7{R8P!%*8p{UL~4kW0L;z@}*SBwmQhF60Xm&f)$qhXOr5$n=zRJwL?%2mSwF z!i5FeCNxJCQQ$w5+M^v@&Cw6ax+bE + + + + diff --git a/core/src/main/res/drawable/ic_radiobutton_green_checked19.png b/core/src/main/res/drawable/ic_radiobutton_green_checked19.png new file mode 100644 index 0000000000000000000000000000000000000000..8ea90b668fae459f25da5d75b89f3414f44d795b GIT binary patch literal 676 zcmV;V0$crwP)V}-*mrjh7uFM#UB1~0Uo>sR0Ck4Jz$tavjuc=Bb#4j*}rT9 z?8n(V`stdMd)R6gc<>aMuY+|i2<)2&n?R`^bgeEAhs-=k*gmv60rY~N5R6~bKmAgM z_CO;h$0cnK2;dT1Ow3NtiBI1Lza;-&1}LVd!bG!RNz#%>a*k)1xbHR)=vxcQ^szh` zjUC{u`^R1Z=oOiXX(ytPhD8Hu8npUa%$sL=L4HfTSk+m9-9jFY>YFJjF@{(k3!e379bmzC*>A7qUCtE@3Y^{e`;0nalnB!q(4ouD>($jeXG6n7JPWes~hSN|mYG1L|lZscGxTo9&HP?C++yL4t%Qqv^ zk-oB(tLfV!(@HSIECb6^ur?1#{c$c>H}8z#Q^sQYA%uRU+i;Wb$fx3pS~erFIbdyX zvwk+ED~)o>aAGD*ahFxOJK&)0r;yi~(JY@sC3kqKq}-Pq4e%dtc>BGH> + + + + + + + + + + + + diff --git a/core/src/main/res/drawable/ic_view.xml b/core/src/main/res/drawable/ic_view.xml new file mode 100644 index 0000000..2235908 --- /dev/null +++ b/core/src/main/res/drawable/ic_view.xml @@ -0,0 +1,12 @@ + + + + diff --git a/core/src/main/res/drawable/line_button_back.xml b/core/src/main/res/drawable/line_button_back.xml new file mode 100644 index 0000000..c450076 --- /dev/null +++ b/core/src/main/res/drawable/line_button_back.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_black_back.xml b/core/src/main/res/drawable/line_button_black_back.xml new file mode 100644 index 0000000..6822716 --- /dev/null +++ b/core/src/main/res/drawable/line_button_black_back.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_black_back_disabled.xml b/core/src/main/res/drawable/line_button_black_back_disabled.xml new file mode 100644 index 0000000..08c2822 --- /dev/null +++ b/core/src/main/res/drawable/line_button_black_back_disabled.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_black_back_focused.xml b/core/src/main/res/drawable/line_button_black_back_focused.xml new file mode 100644 index 0000000..3ff2c49 --- /dev/null +++ b/core/src/main/res/drawable/line_button_black_back_focused.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_black_select.xml b/core/src/main/res/drawable/line_button_black_select.xml new file mode 100644 index 0000000..92c4d96 --- /dev/null +++ b/core/src/main/res/drawable/line_button_black_select.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_green.xml b/core/src/main/res/drawable/line_button_green.xml new file mode 100644 index 0000000..a53f94a --- /dev/null +++ b/core/src/main/res/drawable/line_button_green.xml @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_green_select.xml b/core/src/main/res/drawable/line_button_green_select.xml new file mode 100644 index 0000000..cfd4988 --- /dev/null +++ b/core/src/main/res/drawable/line_button_green_select.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_pressed_round.xml b/core/src/main/res/drawable/line_button_pressed_round.xml new file mode 100644 index 0000000..e9ea89b --- /dev/null +++ b/core/src/main/res/drawable/line_button_pressed_round.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_select.xml b/core/src/main/res/drawable/line_button_select.xml new file mode 100644 index 0000000..580e96c --- /dev/null +++ b/core/src/main/res/drawable/line_button_select.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_white.xml b/core/src/main/res/drawable/line_button_white.xml new file mode 100644 index 0000000..c0c1699 --- /dev/null +++ b/core/src/main/res/drawable/line_button_white.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_white_disabled.xml b/core/src/main/res/drawable/line_button_white_disabled.xml new file mode 100644 index 0000000..b763dcc --- /dev/null +++ b/core/src/main/res/drawable/line_button_white_disabled.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_white_focused.xml b/core/src/main/res/drawable/line_button_white_focused.xml new file mode 100644 index 0000000..62919fe --- /dev/null +++ b/core/src/main/res/drawable/line_button_white_focused.xml @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/line_button_white_select.xml b/core/src/main/res/drawable/line_button_white_select.xml new file mode 100644 index 0000000..3bde003 --- /dev/null +++ b/core/src/main/res/drawable/line_button_white_select.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/outlined_button_black.xml b/core/src/main/res/drawable/outlined_button_black.xml new file mode 100644 index 0000000..95340c1 --- /dev/null +++ b/core/src/main/res/drawable/outlined_button_black.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/outlined_button_black_disabled.xml b/core/src/main/res/drawable/outlined_button_black_disabled.xml new file mode 100644 index 0000000..d2d34a7 --- /dev/null +++ b/core/src/main/res/drawable/outlined_button_black_disabled.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/outlined_button_black_focused.xml b/core/src/main/res/drawable/outlined_button_black_focused.xml new file mode 100644 index 0000000..13d672f --- /dev/null +++ b/core/src/main/res/drawable/outlined_button_black_focused.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/outlined_button_black_selector.xml b/core/src/main/res/drawable/outlined_button_black_selector.xml new file mode 100644 index 0000000..956e531 --- /dev/null +++ b/core/src/main/res/drawable/outlined_button_black_selector.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/outlined_card.xml b/core/src/main/res/drawable/outlined_card.xml new file mode 100644 index 0000000..1e66d0e --- /dev/null +++ b/core/src/main/res/drawable/outlined_card.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/rounded_bottom_dialog.xml b/core/src/main/res/drawable/rounded_bottom_dialog.xml new file mode 100644 index 0000000..665c7cf --- /dev/null +++ b/core/src/main/res/drawable/rounded_bottom_dialog.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/selector_text_black.xml b/core/src/main/res/drawable/selector_text_black.xml new file mode 100644 index 0000000..f21eb07 --- /dev/null +++ b/core/src/main/res/drawable/selector_text_black.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/service_card_shadow.png b/core/src/main/res/drawable/service_card_shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..e696739ec7681f7b507f58bda22eecc3346854fa GIT binary patch literal 3008 zcmbVO2~-p37M_3$5kV>nR6#q~AZ!7{W`KZHoy-gnAqz=>KtWJ>E}&9S zR8$V~Dk`hU1C@t}QrzG@5M)uT;)V+QsWLh?iNy=Vgg!%1V>o8SRo;g#47K zv%u|w<4b&k?!|)5yp-va)4E^fbhFKya6gDUbGG@*Lq#`T-0jYv}?7k1yiV#{&hHaz0r*10K8T^qCu^F-#E@s zpPId^$*#(B>vj+0_7-2$EB(6bsFsl|i>iRi;+oe<@3~)f4x+MLH$8_XT+<*&O&&SP z-)!mbXX49>uMPX5cp*~FQk=%=pLRJF{I>y`AgozgR53t!8(aoxCf~KK;KyV z#Q_HnnE}A$CW%m_7712zV7ZhGY5PmoNEH|w0G6%LC?GfjRTIQ$m_)`U_E*;u2@-@$ z3}gsEfr5vIOWfm?=&E=xAsin8vk~Ho<%DG#4n`nF)eu1=jg+Z48ZL2^mxGC5{bLB=8~dPX(TF>1JXHE7GeBD z#Ih-oP>!Fo>v%5gic1Vvs}&pyB{nve9BW6GE5j(5S=bbiMxoJ27=omVlc^yMNv5*? zz~GFkV5LN%mdIrUEh8kBN2|F+OzBt&QpE(VOf}vntYH)lq@YmAptht@AOcU|6w%7a z(c}nBK_gKqDpRX4EOi2_2$!qns&M&VP)|JmT>`AN0>MO%Pim1$CsL@?E-_dc;|=*F zS|yBApcFq;C689Zs7nkc(^^}Ng2PjykXo)3%H@$C2IccXGJ!@V(+CR%5G;{ti&*qg z2mi`-$60cAdnjR zpI`*$gvymt2rF44g~CvZLKa3OOf-_ilSj&xSYk|_-KYwIfWw!m)Q}8D`OaJ-RyA27 zK{!+~JygsRGe}}kY)7Ko)955Njmadz_ACaBh}rf`w)jK5vmB1r&Q=>gW^+UiV;uj@ z3bC0qCW6@xL>MRuLg;Lgn92~7P^y@Q*dY)?6;H5vD<#;Q21S0%s#S$xj3A8_ilOaE z3>J$W6Ikbbm^AyB9XyM7pP%nP^!x8VybMEgJKXqzl@w=-# z(_}-Y{9K#FC+A{`Pc=mrIu4}b`CO0q@71t?HkoDtDIf)<>l6G0$ z*U{AONnIb2{i1w+Bn@tx1l$$OwD?|GAMTODpl7(Ya!wj-#GREYA15^7{WhAu$u8^2 z-k$$(3LxJcXaL$UiuWA(RTOP4a}xsceS2mEK+cvs_I8pz>)Pt~X7i7()CZz>d-yH# zHQ-0g$`8b=%7Y7sXOz8J&S=R=l>s3B*SJF-!aGwRrQ{u{oNbdR@(tJ^9;v$`Fp=Qe zJoorxp{7Tcfen)fH*N3wd3ObVx%$DKn$!4PcTNE1sx{y4?XCA*U}d@{x_Rr~CqVy3 zvvta%(w#eI?-g{!Ag-^CYxExoONt-$qKrTa#dvm|&VFVA($hRLBy*msiORN1?G@$Y zZ4b0J_MV!{>P35Yw{s$S!NBzef&VBU?9P45jJ%Z-5B(?q-jg{U%Tv&({yTzSLgIo* zHl(&=#o0sVQMi?9BA9mdm%;9);b*dts<+>i?k`^xBH9_8QNOAB>yFm;?16HcHD!-i z5Zl)ZOzyN=kba{Hd0dWv=2~+2y~jVFU&@Myf*Mls5j9Y)_l>|h8kD^)VW-DEpt-ts zwL0lRM9{2zxTCzn&9~Rieg*YlQRH@y#(K1Mps_A+^~|pNRrrX~f0xJm)P{sSiF=v2 z5XGH`wi*1rpiGRno%Sr)dtXYyoQ{q?dX0wXdwtz{cl(_kFh!$}1&G4d_r%;kw{ND& zfR6q2sv+IPrMpQ}OU#kiMV_p)WeF`c$FJ8Q{O}Vk6~4?E#hL)iS9Hh)x_EynV7^y) z@^^|rzgI1_Pnvzfx#xP;3Fu-B>GAzrDwn$i z?c8AGB&i#idUDQ&ChbX7ejnG;9AL@?E@~OP$tj`VE!_ka_?B literal 0 HcmV?d00001 diff --git a/core/src/main/res/drawable/shape_card_notification_dot.xml b/core/src/main/res/drawable/shape_card_notification_dot.xml new file mode 100644 index 0000000..e4bc702 --- /dev/null +++ b/core/src/main/res/drawable/shape_card_notification_dot.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/shape_outlined_box_primary.xml b/core/src/main/res/drawable/shape_outlined_box_primary.xml new file mode 100644 index 0000000..c860f01 --- /dev/null +++ b/core/src/main/res/drawable/shape_outlined_box_primary.xml @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/shape_status_message.xml b/core/src/main/res/drawable/shape_status_message.xml new file mode 100644 index 0000000..0a9bb86 --- /dev/null +++ b/core/src/main/res/drawable/shape_status_message.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/shape_white_card.xml b/core/src/main/res/drawable/shape_white_card.xml new file mode 100644 index 0000000..570d121 --- /dev/null +++ b/core/src/main/res/drawable/shape_white_card.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/core/src/main/res/drawable/thumb_selector.xml b/core/src/main/res/drawable/thumb_selector.xml new file mode 100644 index 0000000..15c974c --- /dev/null +++ b/core/src/main/res/drawable/thumb_selector.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/core/src/main/res/drawable/track_selector.xml b/core/src/main/res/drawable/track_selector.xml new file mode 100644 index 0000000..de53e6b --- /dev/null +++ b/core/src/main/res/drawable/track_selector.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml new file mode 100644 index 0000000..9f65d04 --- /dev/null +++ b/core/src/main/res/values/colors.xml @@ -0,0 +1,125 @@ + + + #C5D9E9 + #69747d + #000000 + + #CADDEB + #E2ECF4 + #80E2ECF4 + + + #19BE6F + #FFD600 + #CA2F28 + #D1D1D1 + #FFFFFF + #66FFFFFF + #80FFFFFF + #B3FFFFFF + #50FFFFFF + #99FFFFFF + #ECF2F7 + #000000 + #1A000000 + #25000000 + #33000000 + #4D000000 + #66000000 + #80000000 + #CC000000 + #B3000000 + #282828 + #3B3E4F + #65C680 + #BAD6B8 + #A4C5C3 + #D1E1E1 + #B9BBD2 + #EEEEF4 + #DEC7D8 + #E83C3C + #BAF8C4 + #D5E4EF + #4DFFFFFF + #99000000 + #E1E8EE + #CBD4DC + + //docs + #E9DBCE + #BFECB4 + #DDDBE7 + #BADAEC + #74B4D8 + #DBEBEB + #B6D7D7 + #DBECF1 + #B7D8E2 + #FFF7F0 + #FFE4CB + #D7E3F3 + #ECF2F7 + + #FFF495 + #8BBFF0 + #947C12 + + #B0C6E6 + #F0F6F9 + #D7E3F3 + #74B4D8 + #BADAEC + #B0C6E7 + #FAEB64 + #FAEB64 + #E9DBCE + #F4EDE7 + #E9DBCE + #BCB9D0 + #F2DECB + #B6D7D7 + #DB7C76 + #BFECB4 + #FFF7CF + #FFE4CB + #C6D4CA + #DBE1F1 + #B5C2E6 + #F9FCFF + + + #60000000 + #ffafed44 + #B6D7D7 + + #65C680 + #FF9975 + + #65C680 + + #4DD4D4D4 + #2F7BF5 + + #E5F6E1 + + #80C5D9E9 + #33E2ECF4 + + #CED9E9 + + + + + #93BEFA + #91ADF7 + #D7A5E1 + #F3B8C0 + #F0DFBD + #FFFFFFFF + #B4C6D3 + + #E2F2FE + #FAEB64 + #ECECEC + diff --git a/core/src/main/res/values/dimens.xml b/core/src/main/res/values/dimens.xml new file mode 100644 index 0000000..1b89ca2 --- /dev/null +++ b/core/src/main/res/values/dimens.xml @@ -0,0 +1,65 @@ + + + //sp + 9sp + 12sp + 14sp + 16sp + 32sp + + //dp + 2dp + 4dp + 8dp + 10dp + 12dp + 14dp + 16dp + 20dp + 24dp + 28dp + 32dp + 36dp + 40dp + 48dp + 60dp + 120dp + 19dp + 3dp + 8dp + 28dp + 24dp + 40dp + 68dp + 78dp + 88dp + 96dp + 3dp + 8dp + 16dp + 5dp + 1dp + 4dp + 48dp + + //sp + 4sp + 12sp + + //int + + //int + + + 9dp + 18dp + 1dp + 2dp + @dimen/xtwolarge + 7dp + 9sp + 18dp + 48dp + + + \ No newline at end of file diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml new file mode 100644 index 0000000..e9db958 --- /dev/null +++ b/core/src/main/res/values/strings.xml @@ -0,0 +1,123 @@ + + + Спробувати ще раз + Try again + + Спробувати ще + Оновити дані + На жаль, сталася помилка + Немає інтернету. Перевірте з’єднання та спробуйте ще раз + Закінчився термін очікування відповіді + Зрозуміло + Запит не зареєстровано у Дії + Запитувач не зареєстрований в Дії. Щоб захистити ваші персональні дані, ми надсилаємо цифрові копії документів лище тим, хто зареєструвався у Дії. + Повернутись назад + + За вашим запитом не знайдено жодного документа + Сервіс недоступний + + Запит містить відсутні в Дії документи + Термін дії QR-коду закінчився + + + Платіж не виконано + Сума до сплати змінилася + Заяву не надіслано + Повернутись до документів + Кількість спроб вичерпано. Спробуйте, будь ласка, пізніше + + Дозволити + Налаштування + Ні, дякую + Закрити + + Це потрібно для перевірки документів за QR-кодом. Також щоб порівняти ваше фото з наявними в реєстрах, при підтвердженні особи чи підписанні документів. Дія не зберігає ці фото. + Щоб продовжити, надайте Дії доступ до камери. Це можна зробити в Налаштуваннях. + Застосунку потрібен доступ до камери пристрою + Потрібен доступ до камери + Перейти до налаштувань + Детальніше про доступ до камери тут + Дія може запитувати доступ до камери з метою перевірки документів чи даних користувача за допомогою сканування QR-коду, створення віддаленого кваліфікованого електронного підпису, ідентифікації особи (шляхом порівняння фото з даними Єдиного демографічного реєстру) та використання кваліфікованого віддаленого електронного підпису. Камера не використовується для будь-яких інших цілей. Цією згодою ви підтверджуєте такі дії. + + + Надати «Дії» одноразовий доступ до геолокації? + Надайте доступ Дії до геолокації, для підтвердження місця перебування. Таким чином, Дія перевірить достовірність адреси, зазначеної як поточна. Геолокація не використовуватиметься для будь-яких інших цілей. + Детальніше про доступ до геолокації тут + Дія може запитувати доступ до геолокації з метою перевірки достовірність адреси, зазначеної як поточна. ВАЖЛИВО!!! Геолокація не використовуватиметься для будь-яких інших цілей. + + + Хочете отримувати push-сповіщення від Дії? + Щоб зручніше отримувати державні послуги в Дії, надайте дозвіл на відправку push-сповіщень. Застосунок сповіщатиме про призначені штрафи, судові засідання і рішення, запити кредитної історії, нові оптування та статуси замовлених послуг. + + + Вас уже авторизовано у застосунку. + Для авторизації іншим методом, необхідно спочатку вийти із застосунку. + Ні, залишитися у застосунку + + + Надайте Дії доступ до файлового сховища + Це потрібно для збереження квитанцій, декларацій та заяв, а також для додання фото при створенні петицій. + Читати детальніше про доступ до сховища + Надайте Дії доступ до зовнішнього файлового сховища. Це потрібно для зберігання копій документів, що підтверджує надання електронних послуг, а також для завантаження фотографій під час створення електронних петицій до місцевих органів влади. Ми використовуємо цей доступ лише для цих цілей і нікому їх не передаємо без вашої згоди. Цією згодою ви підтверджуєте такі дії. + + + Придумайте код з %s цифр + Повторіть код з %s цифр + Цей код ви будете вводити для входу у застосунок Дія. + Цей код ви будете вводити для підписання документів за допомогою Дія.Підпис. + Переконайтеся, що не помилилися і пам\'ятаєте код для входу. + Впевніться, що не помилилися і пам\'ятаєте код для Дія.Підпис. + Щоб впевнитися, що це ви змінюєте код для входу + Код для входу + Новий код з 4 цифр + Повторіть код з 4 цифр + Код для Дія.Підпис + Не пам\'ятаю код для входу + Не пам\'ятаю код для Дія.Підпис + + Вийти + + + Перевірку не\nпройдено + Спробуйте підтвердити особу користувача через свій інтернет-банкінг + Чому так + Чому так? + Новий реєстр водійських посвідчень був створений у 2013 році. З 9,5 млн власників водійських посвідчень фото відображалися тільки у 2,5 млн.\n\nНашій IT-команді вдалося отримати якісні дані із демографічного реєстру та надати можливість 6 млн українцям отримати електронні документи.\n\nМи будемо й надалі робити все, щоб якість даних та їх безпека в реєстрах зростали кожного дня. Якщо ваші посвідчення не відобразилася, ви отримаєте поради, як це виправити. + Якісний реєстр свідоцтв було створено тільки у 2013 році.\n\nТому водії, які придбали транспортний засіб до 2013 року, але не здійснили інші реєстраційні дії після (наприклад, реєстрація газової установки, зміна кольору авто та інше), можуть не побачити власних документів.\n\nЯкщо ваше свідоцтво про реєстрацію транспортного засобу не відображається у застосунку, ви отримаєте поради, як це виправити. + + Зчитування номеру документа + Зчитати + + Авторизуватися + Забули код для входу? + Пройдіть повторну авторизацію у застосунку + + Скасувати + Так + Ні + + ⚡ Увага + + Всі зупинені (+%1$d) + Спробуйте, будь ласка, пізніше + + Перевірте з’єднання та спробуйте ще раз + + Дія хоче відкрити %s + «%s» не встановлений на вашому пристрої + Хочете вийти із застосунку? + Для входу буде потрібна повторна авторизація + Вийти + Залишитися + Відкрити + Скасувати + + браузер + Номер пристрою скопійовано + Завантажити архів з витягом + + Спробувати ще + 0123456789123456789 + Залишилось символів: %d + + \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/LiveDataTestUtil.kt b/core/src/test/java/ua/gov/diia/core/LiveDataTestUtil.kt new file mode 100644 index 0000000..a7f474b --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/LiveDataTestUtil.kt @@ -0,0 +1,55 @@ +package ua.gov.diia.core + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * Gets the value of a [LiveData] or waits for it to have one, with a timeout. + * + * Use this extension from host-side (JVM) tests. It's recommended to use it alongside + * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously. + */ +fun LiveData.getOrAwaitValue( + time: Long = 2, + timeUnit: TimeUnit = TimeUnit.SECONDS, + afterObserve: () -> Unit = {} +): T { + var data: T? = null + val latch = CountDownLatch(1) + val observer = object : Observer { + override fun onChanged(value: T?) { + data = value + latch.countDown() + this@getOrAwaitValue.removeObserver(this) + } + } + this.observeForever(observer) + + afterObserve.invoke() + + // Don't wait indefinitely if the LiveData is not set. + if (!latch.await(time, timeUnit)) { + this.removeObserver(observer) + throw TimeoutException("LiveData value was never set.") + } + + @Suppress("UNCHECKED_CAST") + return data as T +} + +/** + * Observes a [LiveData] until the `block` is done executing. + */ +suspend fun LiveData.observeForTesting(block: suspend () -> Unit) { + + val observer = Observer { } + try { + observeForever(observer) + block() + } finally { + removeObserver(observer) + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/models/ConsumableEventTest.kt b/core/src/test/java/ua/gov/diia/core/models/ConsumableEventTest.kt new file mode 100644 index 0000000..17a8670 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/models/ConsumableEventTest.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.core.models + +import org.junit.Assert +import org.junit.Test + +class ConsumableEventTest { + @Test + fun `item should be consumed after call consumeEvent`() { + val item = ConsumableEvent() + Assert.assertEquals( + item.isConsumed, + false + ) + item.consumeEvent { } + Assert.assertEquals( + item.isConsumed, + true + ) + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/models/ConsumableItemTest.kt b/core/src/test/java/ua/gov/diia/core/models/ConsumableItemTest.kt new file mode 100644 index 0000000..c491071 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/models/ConsumableItemTest.kt @@ -0,0 +1,39 @@ +package ua.gov.diia.core.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import org.junit.Assert +import org.junit.Test + +class ConsumableItemTest { + @Test + fun `item should not be consumed by default`() { + Assert.assertEquals( + ConsumableItem(TestParcelable()).isNotConsumed(), + true + ) + } + @Test + fun `item should be consumed if set true`() { + Assert.assertEquals( + ConsumableItem(TestParcelable(), true).isNotConsumed(), + false + ) + } + @Test + fun `item should be consumed after call consumeEvent`() { + val item = ConsumableItem(TestParcelable()) + Assert.assertEquals( + item.isNotConsumed(), + true + ) + item.consumeEvent { } + Assert.assertEquals( + item.isNotConsumed(), + false + ) + } +} + +@Parcelize +private data class TestParcelable(val param: String? = null): Parcelable \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/models/ConsumableStringTest.kt b/core/src/test/java/ua/gov/diia/core/models/ConsumableStringTest.kt new file mode 100644 index 0000000..29b1278 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/models/ConsumableStringTest.kt @@ -0,0 +1,36 @@ +package ua.gov.diia.core.models + +import org.junit.Assert +import org.junit.Test + +class ConsumableStringTest { + @Test + fun `item should not be consumed by default`() { + Assert.assertEquals( + ConsumableString("").isNotConsumed(), + true + ) + } + + @Test + fun `item should be consumed if set true`() { + Assert.assertEquals( + ConsumableString("", true).isNotConsumed(), + false + ) + } + + @Test + fun `item should be consumed after call consumeEvent`() { + val item = ConsumableString("") + Assert.assertEquals( + item.isNotConsumed(), + true + ) + item.consumeEvent { } + Assert.assertEquals( + item.isNotConsumed(), + false + ) + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/rules/MainDispatcherRule.kt b/core/src/test/java/ua/gov/diia/core/rules/MainDispatcherRule.kt new file mode 100644 index 0000000..ba97fbd --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/rules/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.core.rules + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/core/src/test/java/ua/gov/diia/core/util/DateFormatsTest.kt b/core/src/test/java/ua/gov/diia/core/util/DateFormatsTest.kt new file mode 100644 index 0000000..6f52650 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/DateFormatsTest.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.core.util + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.text.ParseException +import java.util.Calendar +import java.util.TimeZone + +class DateFormatsTest { + + @Test + fun iso8601ToLocalCalendar_validInput_returnsCalendar() { + val iso8601String = "2022-01-01T12:34:56.789Z" + + val utcCalendar = Calendar.getInstance(TimeZone.getTimeZone("UTC")) + utcCalendar.set(2022, Calendar.JANUARY, 1, 12, 34, 56) + utcCalendar.set(Calendar.MILLISECOND, 789) + + val resultCalendar = DateFormats.iso8601ToLocalCalendar(iso8601String) + + assertEquals(utcCalendar.time, resultCalendar.time) + } + + @Test(expected = ParseException::class) + fun `iso8601ToLocalCalendar throws ParseException if input is invalid`() { + val iso8601String = "invalid_date" + + DateFormats.iso8601ToLocalCalendar(iso8601String) + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/DiiaDispatcherProviderTest.kt b/core/src/test/java/ua/gov/diia/core/util/DiiaDispatcherProviderTest.kt new file mode 100644 index 0000000..e474b56 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/DiiaDispatcherProviderTest.kt @@ -0,0 +1,42 @@ +package ua.gov.diia.core.util + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.Dispatchers +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import ua.gov.diia.core.rules.MainDispatcherRule + +class DiiaDispatcherProviderTest { + + private lateinit var dispatcherProvider: DiiaDispatcherProvider + + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Before + fun setup() { + dispatcherProvider = DiiaDispatcherProvider() + } + + @Test + fun `ioDispatcher should return Dispatchers IO`() { + assertEquals(Dispatchers.IO, dispatcherProvider.ioDispatcher()) + } + + @Test + fun `main should return Dispatchers Main`() { + assertEquals(Dispatchers.Main, dispatcherProvider.main) + } + + @Test + fun `work should return Dispatchers Default`() { + assertEquals(Dispatchers.Default, dispatcherProvider.work) + } + +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/date/CurrentDateProviderTest.kt b/core/src/test/java/ua/gov/diia/core/util/date/CurrentDateProviderTest.kt new file mode 100644 index 0000000..194d0cd --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/date/CurrentDateProviderTest.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.core.util.date + +import io.mockk.every +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Assert +import org.junit.Test +import ua.gov.diia.core.util.extensions.date_time.getCurrentDateUtc +import java.util.Date + +class CurrentDateProviderTest { + + @Test + fun `test get current date`() { + mockkStatic(::getCurrentDateUtc) + val date = Date() + every { getCurrentDateUtc() } returns date + Assert.assertEquals(date, CurrentDateProviderImpl().getDate()) + } + + @After + fun after(){ + unmockkStatic(::getCurrentDateUtc) + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/event/UiDataEventObserverTest.kt b/core/src/test/java/ua/gov/diia/core/util/event/UiDataEventObserverTest.kt new file mode 100644 index 0000000..bf28c26 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/event/UiDataEventObserverTest.kt @@ -0,0 +1,38 @@ +package ua.gov.diia.core.util.event + +import io.mockk.* +import org.junit.After +import org.junit.Test + +private fun onEventUnhandledContent(data: String) = run { } + +class UiDataEventObserverTest { + + @Test + fun `UiEventObserver onchange`() { + val data = "data" + mockkStatic(::onEventUnhandledContent) + justRun { onEventUnhandledContent(data) } + val observer = UiDataEventObserver(::onEventUnhandledContent) + observer.onChanged(UiDataEvent(data)) + verify { onEventUnhandledContent(data) } + unmockkStatic(::onEventUnhandledContent) + } + + @Test + fun `UiEventObserver onchange not called if was handled`() { + val data = "data" + mockkStatic(::onEventUnhandledContent) + justRun { onEventUnhandledContent(data) } + val observer = UiDataEventObserver(::onEventUnhandledContent) + val event = UiDataEvent(data) + event.handle() + observer.onChanged(event) + verify(exactly = 0) { onEventUnhandledContent(data) } + unmockkStatic(::onEventUnhandledContent) + } + @After + fun after() { + clearAllMocks() + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/event/UiDataEventTest.kt b/core/src/test/java/ua/gov/diia/core/util/event/UiDataEventTest.kt new file mode 100644 index 0000000..9ff3f44 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/event/UiDataEventTest.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.core.util.event + +import io.mockk.clearAllMocks +import org.junit.After +import org.junit.Assert +import org.junit.Test + +class UiDataEventTest { + + @Test + fun `get content if not handled`() { + val data = "data" + val event = UiDataEvent(data) + + Assert.assertEquals(data, event.peekContent()) + Assert.assertEquals("UiDataEvent(content=$data)", event.toString()) + Assert.assertEquals( + event.getContentIfNotHandled(), + data + ) + Assert.assertNull(event.getContentIfNotHandled()) + } + + @After + fun after() { + clearAllMocks() + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/event/UiEventObserverTest.kt b/core/src/test/java/ua/gov/diia/core/util/event/UiEventObserverTest.kt new file mode 100644 index 0000000..6f0afb2 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/event/UiEventObserverTest.kt @@ -0,0 +1,36 @@ +package ua.gov.diia.core.util.event + +import io.mockk.* +import org.junit.After +import org.junit.Test + +private fun onEventUnhandledContent() = run { } + +class UiEventObserverTest { + + @Test + fun `UiEventObserver onchange`() { + mockkStatic(::onEventUnhandledContent) + justRun { onEventUnhandledContent() } + val observer = UiEventObserver(::onEventUnhandledContent) + observer.onChanged(UiEvent()) + verify { onEventUnhandledContent() } + unmockkStatic(::onEventUnhandledContent) + } + + @Test + fun `UiEventObserver onchange not call if event was processed`() { + mockkStatic(::onEventUnhandledContent) + justRun { onEventUnhandledContent() } + val observer = UiEventObserver(::onEventUnhandledContent) + val event = UiEvent() + event.handle() + observer.onChanged(event) + verify(exactly = 0) { onEventUnhandledContent() } + unmockkStatic(::onEventUnhandledContent) + } + @After + fun after() { + clearAllMocks() + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/event/UiEventTest.kt b/core/src/test/java/ua/gov/diia/core/util/event/UiEventTest.kt new file mode 100644 index 0000000..0aee357 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/event/UiEventTest.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.core.util.event + +import io.mockk.clearAllMocks +import org.junit.After +import org.junit.Assert +import org.junit.Test + +class UiEventTest { + + @Test + fun `get content is handled`() { + val event = UiEvent() + Assert.assertEquals(event.notHandedYet, true) + Assert.assertEquals(event.hasBeenHandled, false) + event.handle() + Assert.assertEquals(event.notHandedYet, false) + Assert.assertEquals(event.hasBeenHandled, true) + } + @After + fun after() { + clearAllMocks() + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/extensions/ErrorHandlingExtTest.kt b/core/src/test/java/ua/gov/diia/core/util/extensions/ErrorHandlingExtTest.kt new file mode 100644 index 0000000..5943d26 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/extensions/ErrorHandlingExtTest.kt @@ -0,0 +1,64 @@ +package ua.gov.diia.core.util.extensions + +import org.junit.Assert +import org.junit.Test +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.util.concurrent.TimeoutException + +class ErrorHandlingExtTest { + @Test + fun `SocketTimeoutException should be recognized as no internet`() { + val exception = SocketTimeoutException() + Assert.assertEquals( + true, + exception.noInternetException() + ) + } + + @Test + fun `TimeoutException should be recognized as no internet`() { + val exception = TimeoutException() + Assert.assertEquals( + true, + exception.noInternetException() + ) + } + + @Test + fun `UnknownHostException should be recognized as no internet`() { + val exception = UnknownHostException() + Assert.assertEquals( + true, + exception.noInternetException() + ) + } + + @Test + fun `ConnectException should be recognized as no internet`() { + val exception = ConnectException() + Assert.assertEquals( + true, + exception.noInternetException() + ) + } + + @Test + fun `NullPointerException should not be recognized as no internet`() { + val exception = NullPointerException() + Assert.assertEquals( + false, + exception.noInternetException() + ) + } + + @Test + fun `IllegalArgumentException should not be recognized as no internet`() { + val exception = IllegalArgumentException() + Assert.assertEquals( + false, + exception.noInternetException() + ) + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/extensions/date_time/DateUtilsExtTest.kt b/core/src/test/java/ua/gov/diia/core/util/extensions/date_time/DateUtilsExtTest.kt new file mode 100644 index 0000000..082ba72 --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/extensions/date_time/DateUtilsExtTest.kt @@ -0,0 +1,67 @@ +package ua.gov.diia.core.util.extensions.date_time + +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockkStatic +import org.junit.After +import org.junit.Assert +import org.junit.Test +import java.time.Instant +import java.time.LocalDateTime +import java.time.Month +import java.time.ZoneId +import java.time.ZoneOffset +import java.util.* + +class DateUtilsExtTest { + + @Test + fun `getUTCDate should return null when given an invalid date`() { + val invalidDate = "not a date" + val result = getUTCDate(invalidDate) + Assert.assertNull(result) + } + + @Test + fun `getUTCDate should return the correct date when given a valid date string`() { + val dateString = "2022-03-22T12:00:00Z" + val expectedDate = Date.from(Instant.parse(dateString)) + val result = getUTCDate(dateString) + Assert.assertEquals(expectedDate, result) + } + + @Test + fun `getCurrentDateUtc should return the current date and time in UTC`() { + val now = LocalDateTime.now(ZoneOffset.UTC) + val expectedDate = Date.from(now.toInstant(ZoneOffset.UTC)) + val result = getCurrentDateUtc() + Assert.assertEquals(expectedDate, result) + } + + @Test + fun `toLocalDateTime should return the correct LocalDateTime when given a valid date`() { + mockkStatic(ZoneId::systemDefault) + every { ZoneId.systemDefault() } returns ZoneId.of("UTC") + + val date = Date.from(Instant.parse("2022-03-22T12:00:00Z")) + val expectedLocalDateTime = + LocalDateTime.of(2022, Month.MARCH, 22, 12, 0, 0) + + val result = date.toLocalDateTime() + Assert.assertEquals(expectedLocalDateTime, result) + } + + @Test + fun `toLocalDateTime should return null when throw error`() { + mockkStatic(ZoneId::systemDefault) + every { ZoneId.systemDefault() } throws java.lang.RuntimeException() + + val invalidDate = Date.from(Instant.EPOCH) + val result = invalidDate.toLocalDateTime() + Assert.assertNull(result) + } + @After + fun after() { + clearAllMocks() + } +} diff --git a/core/src/test/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleEventsExtTest.kt b/core/src/test/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleEventsExtTest.kt new file mode 100644 index 0000000..bb769ef --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/extensions/lifecycle/LifecycleEventsExtTest.kt @@ -0,0 +1,58 @@ +package ua.gov.diia.core.util.extensions.lifecycle + +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert +import org.junit.Test +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent + +class LifecycleEventsExtTest { + + @Test + fun `consumeEvent should call the provided function with correct parameter`() { + val testEvent = UiDataEvent("test") + + val todo = mockk<(String) -> Unit>(relaxed = true) + testEvent.consumeEvent(todo) + + verify { todo.invoke("test") } + + Assert.assertEquals(true, testEvent.hasBeenHandled) + } + + @Test + fun `UiDataEvent consumeEvent should not call the provided function when the event has already been handled`() { + val testEvent = UiDataEvent("test") + testEvent.handle() + val todo = mockk<(String) -> Unit>(relaxed = true) + + testEvent.consumeEvent(todo) + + verify(exactly = 0) { todo.invoke(any()) } + Assert.assertEquals(true, testEvent.hasBeenHandled) + } + + @Test + fun `UiEvent consumeEvent should call the provided function when the event has not been handled yet`() { + val testEvent = UiEvent() + val todo = mockk<() -> Unit>(relaxed = true) + + testEvent.consumeEvent(todo) + + verify { todo.invoke() } + Assert.assertEquals(false, testEvent.notHandedYet) + } + + @Test + fun `UiEvent consumeEvent should not call the provided function when the event has already been handled`() { + val testEvent = UiEvent() + testEvent.handle() + + val todo = mockk<() -> Unit>(relaxed = true) + testEvent.consumeEvent(todo) + + verify(exactly = 0) { todo.invoke() } + Assert.assertEquals(false, testEvent.notHandedYet) + } +} diff --git a/core/src/test/java/ua/gov/diia/core/util/html/HtmlGeneratorTest.kt b/core/src/test/java/ua/gov/diia/core/util/html/HtmlGeneratorTest.kt new file mode 100644 index 0000000..77cb0cf --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/html/HtmlGeneratorTest.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.core.util.html + +import org.junit.Assert +import org.junit.Test + +class HtmlGeneratorTest { + @Test + fun `convertToLink generate link html tag`() { + Assert.assertEquals( + convertToLink("url", "name"), + "name" + ) + } + + @Test + fun `convertToPhone generate phone html tag`() { + Assert.assertEquals( + convertToPhone( + "tel", + "name" + ), "name" + ) + } + + @Test + fun `convertToMail generate mail html tag`() { + Assert.assertEquals( + convertToMail( + "mail", + "name" + ), "name" + ) + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/work/CheckAppVersionUpdatedWorkTest.kt b/core/src/test/java/ua/gov/diia/core/util/work/CheckAppVersionUpdatedWorkTest.kt new file mode 100644 index 0000000..8ca56bb --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/work/CheckAppVersionUpdatedWorkTest.kt @@ -0,0 +1,84 @@ +package ua.gov.diia.core.util.work + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coJustRun +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import ua.gov.diia.core.data.data_source.network.api.notification.ApiNotificationsPublic +import ua.gov.diia.core.data.repository.SystemRepository +import ua.gov.diia.core.util.delegation.WithBuildConfig +import java.net.UnknownHostException + +class CheckAppVersionUpdatedWorkTest { + + lateinit var context: Context + lateinit var apiNotifications: ApiNotificationsPublic + lateinit var systemRepository: SystemRepository + lateinit var workerParams: WorkerParameters + lateinit var buildConfig: WithBuildConfig + + lateinit var checkAppVersionUpdatedWork: CheckAppVersionUpdatedWork + + @Before + fun before() { + context = mockk(relaxed = true) + apiNotifications = mockk(relaxed = true) + systemRepository = mockk(relaxed = true) + workerParams = mockk(relaxed = true) + buildConfig = mockk(relaxed = true) + + checkAppVersionUpdatedWork = CheckAppVersionUpdatedWork( + apiNotifications, + systemRepository, + context, + workerParams, + buildConfig + ) + } + + @Test + fun `should return failure if runAttemptCount more than 3`() = runTest { + every { workerParams.runAttemptCount } returns 4 + Assert.assertEquals( + ListenableWorker.Result.failure(), + checkAppVersionUpdatedWork.doWork(), + ) + } + + @Test + fun `should return retry if there no internet connection`() = runTest { + every { workerParams.runAttemptCount } returns 0 + coEvery { systemRepository.getAppVersionCode() } returns null + every { buildConfig.getVersionCode() } returns 0 + coEvery { apiNotifications.sendAppVersion(any()) } throws UnknownHostException() + Assert.assertEquals( + ListenableWorker.Result.retry(), + checkAppVersionUpdatedWork.doWork() + ) + } + + @Test + fun `should return success`() = runTest { + every { workerParams.runAttemptCount } returns 0 + coJustRun { systemRepository.setAppVersionCode(any()) } + coEvery { systemRepository.getAppVersionCode() } returns 1 + every { buildConfig.getVersionCode() } returns 2 + Assert.assertEquals( + ListenableWorker.Result.success(), + checkAppVersionUpdatedWork.doWork() + ) + } + @After + fun after() { + clearAllMocks() + } +} \ No newline at end of file diff --git a/core/src/test/java/ua/gov/diia/core/util/work/DoApplicationSettingsProvisionWorkTest.kt b/core/src/test/java/ua/gov/diia/core/util/work/DoApplicationSettingsProvisionWorkTest.kt new file mode 100644 index 0000000..944aa7c --- /dev/null +++ b/core/src/test/java/ua/gov/diia/core/util/work/DoApplicationSettingsProvisionWorkTest.kt @@ -0,0 +1,116 @@ +package ua.gov.diia.core.util.work + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import ua.gov.diia.core.data.data_source.network.api.ApiSettings +import ua.gov.diia.core.models.appversion.AppSettingsInfo +import ua.gov.diia.core.util.extensions.context.isDiiaAppRunning +import ua.gov.diia.core.util.settings_action.SettingsActionExecutor +import java.net.UnknownHostException + +class DoApplicationSettingsProvisionWorkTest { + + lateinit var context: Context + + lateinit var apiSettings: ApiSettings + lateinit var settingsActionExecutor: MutableSet<@JvmSuppressWildcards SettingsActionExecutor> + + lateinit var workerParams: WorkerParameters + + lateinit var doApplicationSettingsProvisionWork: DoApplicationSettingsProvisionWork + + @Before + fun before() { + context = mockk(relaxed = true) + apiSettings = mockk(relaxed = true) + workerParams = mockk(relaxed = true) + settingsActionExecutor = mutableSetOf() + + doApplicationSettingsProvisionWork = DoApplicationSettingsProvisionWork( + apiSettings, + settingsActionExecutor, + context, + workerParams, + ) + } + + @Test + fun `should return failure if diia app is running`() = runTest { + mockkStatic(Context::isDiiaAppRunning) + every { context.isDiiaAppRunning() } returns false + Assert.assertEquals( + ListenableWorker.Result.failure(), + doApplicationSettingsProvisionWork.doWork(), + ) + } + + @Test + fun `should return retry if there no internet connection`() = runTest { + mockkStatic(Context::isDiiaAppRunning) + every { context.isDiiaAppRunning() } returns true + coEvery { apiSettings.appSettingsInfo() } throws UnknownHostException() + Assert.assertEquals( + ListenableWorker.Result.retry(), + doApplicationSettingsProvisionWork.doWork() + ) + } + + @Test + fun `should return success and call appSettingsInfo`() = runTest { + mockkStatic(Context::isDiiaAppRunning) + every { context.isDiiaAppRunning() } returns true + val appSettingsInfo = mockk(relaxed = true) + coEvery { apiSettings.appSettingsInfo() } returns appSettingsInfo + every { appSettingsInfo.actions } returns null + + Assert.assertEquals( + ListenableWorker.Result.success(), + doApplicationSettingsProvisionWork.doWork() + ) + coVerify { apiSettings.appSettingsInfo() } + } + + + @Test + fun `should execute executor if its key in action list`() = runTest { + val executorOneKey = "executor1" + val executor1 = mockk(relaxed = true) + every { executor1.actionKey } returns executorOneKey + val executor2 = mockk(relaxed = true) + every { executor2.actionKey } returns "executor2" + settingsActionExecutor.add(executor1) + settingsActionExecutor.add(executor2) + + mockkStatic(Context::isDiiaAppRunning) + every { context.isDiiaAppRunning() } returns true + val appSettingsInfo = mockk(relaxed = true) + coEvery { apiSettings.appSettingsInfo() } returns appSettingsInfo + val actions = mutableListOf() + actions.add(executorOneKey) + every { appSettingsInfo.actions } returns actions + + Assert.assertEquals( + ListenableWorker.Result.success(), + doApplicationSettingsProvisionWork.doWork() + ) + coVerify(exactly = 1) { executor1.executeAction() } + coVerify(exactly = 0) { executor2.executeAction() } + } + + @After + fun after() { + clearAllMocks() + } +} \ No newline at end of file diff --git a/dependencies.gradle b/dependencies.gradle new file mode 100644 index 0000000..781e068 --- /dev/null +++ b/dependencies.gradle @@ -0,0 +1,139 @@ +ext { + deps = [ + activity_ktx : 'androidx.activity:activity-ktx:1.6.1', + fragment_ktx : 'androidx.fragment:fragment-ktx:1.5.4', + legacy_support : 'androidx.legacy:legacy-support-v4:1.0.0', + appcompat : 'androidx.appcompat:appcompat:1.4.1', + constraintlayout : 'androidx.constraintlayout:constraintlayout:2.0.4', + cardview : "androidx.cardview:cardview:1.0.0", + + //video + exoplayer_core : 'com.google.android.exoplayer:exoplayer-core:2.17.1', + exoplayer_ui : 'com.google.android.exoplayer:exoplayer-ui:2.17.1', + exoplayer_hls : 'com.google.android.exoplayer:exoplayer-hls:2.17.1', + + light_compressor : 'com.github.AbedElazizShe:LightCompressor:1.1.1', + //kotlin + core_ktx : 'androidx.core:core-ktx:1.9.0', + //constraint + constraint_layout : 'androidx.constraintlayout:constraintlayout:2.0.4', + //lifecycle + lifecycle_extensions : "androidx.lifecycle:lifecycle-extensions:2.2.0", + lifecycle_livedata_ktx : "androidx.lifecycle:lifecycle-livedata-ktx:2.6.1", + lifecycle_viewmodel_ktx : "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1", + //work + work_runtime_ktx : "androidx.work:work-runtime-ktx:2.7.1", + //navigation + navigation_fragment_ktx : "androidx.navigation:navigation-fragment-ktx:2.3.5", + navigation_ui_ktx : "androidx.navigation:navigation-ui-ktx:2.3.5", + //hilt + hilt_android : "com.google.dagger:hilt-android:2.44", + hilt_android_compiler : "com.google.dagger:hilt-android-compiler:2.44", + hilt_navigation_fragment : 'androidx.hilt:hilt-navigation-fragment:1.0.0', + hilt_work : 'androidx.hilt:hilt-work:1.0.0', + hilt_compiler : 'androidx.hilt:hilt-compiler:1.0.0', + //recycler + recyclerview : "androidx.recyclerview:recyclerview:1.2.1", + //viewpager + viewpager : "androidx.viewpager2:viewpager2:1.1.0-alpha01", + //glide + glide : "com.github.bumptech.glide:glide:4.15.1", + glide_compiler : "com.github.bumptech.glide:compiler:4.15.1", + glide_compose : "com.github.bumptech.glide:compose:1.0.0-beta01", + glide_okhttp : "com.github.bumptech.glide:okhttp3-integration:4.11.0", + //material + material : "com.google.android.material:material:1.9.0", + //coroutine + kotlinx_coroutines_android : "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0", + kotlinx_coroutines_core : "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.1", + //retrofit + retrofit : "com.squareup.retrofit2:retrofit:2.9.0", + //okhttp + okhttp : 'com.squareup.okhttp3:okhttp:3.12.2', + okhttp_logging_interceptor : "com.squareup.okhttp3:logging-interceptor:3.12.2", + retrofit_kotlin_coroutines_adapter : 'com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2', + retrofit_gson_converter : 'com.squareup.retrofit2:converter-gson:2.9.0', + //QR + zxing : 'com.google.zxing:core:3.3.1', + //biometric + biometric : "androidx.biometric:biometric:1.1.0", + // Moshi + moshi : "com.squareup.moshi:moshi:1.14.0", + moshi_adapters : "com.squareup.moshi:moshi-adapters:1.14.0", + moshi_kotlin : "com.squareup.moshi:moshi-kotlin:1.14.0", + retrofit_moshi_converter : "com.squareup.retrofit2:converter-moshi:2.9.0", + moshi_codegen : "com.squareup.moshi:moshi-kotlin-codegen:1.14.0", + //multidex + multidex : "androidx.multidex:multidex:2.0.1", + //Firebase SDK + gplay_firebase_bom : 'com.google.firebase:firebase-bom:31.0.2', + gplay_firebase_appcheck_playintegrity : 'com.google.firebase:firebase-appcheck-playintegrity', + gplay_firebase_analytics : 'com.google.firebase:firebase-analytics:21.2.0', + gplay_firebase_crashlytics : 'com.google.firebase:firebase-crashlytics:18.3.1', + gplay_firebase_messaging : 'com.google.firebase:firebase-messaging:23.1.0', + //GSM + gplay_services_auth : 'com.google.android.gms:play-services-auth:20.0.1', + gplay_services_auth_api_phone : 'com.google.android.gms:play-services-auth-api-phone:18.0.1', + //Huawei SDK + huawei_push : 'com.huawei.hms:push:6.7.0.300', + huawei_analytics : 'com.huawei.hms:hianalytics:6.8.0.300', + huawei_agconnect_crash : 'com.huawei.agconnect:agconnect-crash:1.6.4.300', + huawei_hwid : 'com.huawei.hms:hwid:6.4.0.301', + huawei_location : 'com.huawei.hms:location:6.7.0.300', + huawei_safetydetect : 'com.huawei.hms:safetydetect:6.4.0.301', + //datastore preferences + datastore_preferences : "androidx.datastore:datastore-preferences:1.0.0", + //security + gplay_integrity : "com.google.android.play:integrity:1.0.2", + //noinspection GradleDependency + security_crypto : "androidx.security:security-crypto:1.0.0", + mlkit_vision_common : 'com.google.mlkit:vision-common:17.2.1', + //ui + lottie : 'com.airbnb.android:lottie:6.2.0', + lottie_compose : 'com.airbnb.android:lottie-compose:6.1.0', + flexbox : 'com.google.android:flexbox:2.0.1', + shortcut_badger : "me.leolin:ShortcutBadger:1.1.22@aar", + better_link_movement_method : 'me.saket:better-link-movement-method:2.2.0', + browser : "androidx.browser:browser:1.3.0", + paging_runtime_ktx : "androidx.paging:paging-runtime-ktx:3.2.0-alpha03", + + jmrtd : 'org.jmrtd:jmrtd:0.7.34', + scuba_sc_android : 'net.sf.scuba:scuba-sc-android:0.0.23', + //Desugaring + desugar_jdk_libs : 'com.android.tools:desugar_jdk_libs:1.1.5', + //test + junit : 'junit:junit:4.13.2', + mockito_inline : 'org.mockito:mockito-inline:2.8.47', + mockito_kotlin : 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0', + mockito_core : 'org.mockito:mockito-core:4.0.0', + kotlinx_coroutines_test : 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1', + androidx_core_testing : 'androidx.arch.core:core-testing:2.1.0', + hamcrest_library : 'org.hamcrest:hamcrest-library:2.2', + mockwebserver : 'com.squareup.okhttp3:mockwebserver:4.9.0', + json : 'org.json:json:20220320', + android_junit : 'androidx.test.ext:junit:1.1.3', + android_espresso_core : 'androidx.test.espresso:espresso-core:3.4.0', + turbine : 'app.cash.turbine:turbine:1.0.0', + mockk : "io.mockk:mockk:1.13.2", + mockk_android : "io.mockk:mockk-android:1.13.2", + mockk_agent : "io.mockk:mockk-agent:1.13.2", + paging_test : "androidx.paging:paging-testing:3.2.0-alpha03", + //Compose + activity_compose : 'androidx.activity:activity-compose:1.7.1', + compose_bom : 'androidx.compose:compose-bom:2023.05.01', + compose_ui : 'androidx.compose.ui:ui', + ui_graphics : 'androidx.compose.ui:ui-graphics', + compose_material : 'androidx.compose.material3:material3', + compose_ui_tooling_preview : "androidx.compose.ui:ui-tooling-preview:1.3.2", + compose_ui_tooling : "androidx.compose.ui:ui-tooling:1.3.2", + compose_ui_util : "androidx.compose.ui:ui-util:1.3.2", + compose_constraintlayout : "androidx.constraintlayout:constraintlayout-compose:1.0.1", + accompanist_pager : "com.google.accompanist:accompanist-pager:0.23.1", + paging_compose : "androidx.paging:paging-compose:3.3.0-alpha02", + compose_reorderable : "org.burnoutcrew.composereorderable:reorderable:0.9.6", + //Room + room_runtime : 'androidx.room:room-runtime:2.5.2', + room_ktx : 'androidx.room:room-ktx:2.5.2', + room_compiler : "androidx.room:room-compiler:2.5.2" + ] +} \ No newline at end of file diff --git a/diia_storage/.gitignore b/diia_storage/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/diia_storage/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/diia_storage/build.gradle b/diia_storage/build.gradle new file mode 100644 index 0000000..2de7471 --- /dev/null +++ b/diia_storage/build.gradle @@ -0,0 +1,111 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'androidx.navigation.safeargs.kotlin' + id 'kotlin-kapt' + id 'kotlin-android' + id 'kotlin-parcelize' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.diia_storage' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + + buildFeatures { + dataBinding = true + } +} + +dependencies { + implementation deps.security_crypto + + implementation project(':core') + implementation deps.moshi + + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + implementation deps.hilt_work + kapt deps.hilt_compiler + //datastore preferences + implementation deps.datastore_preferences + + testImplementation deps.junit + testImplementation deps.mockk_android + testImplementation deps.mockk_agent + testImplementation deps.turbine + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.hamcrest_library + testImplementation deps.mockwebserver + testImplementation deps.json +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/diia_storage/consumer-rules.pro b/diia_storage/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/diia_storage/excludes.jacoco b/diia_storage/excludes.jacoco new file mode 100644 index 0000000..cabce06 --- /dev/null +++ b/diia_storage/excludes.jacoco @@ -0,0 +1,8 @@ +ua/gov/diia/diia_storage/**/*$*.* +ua/gov/diia/diia_storage/DiiaStorage.* +ua/gov/diia/diia_storage/EncryptedAndroidKeyValueStore.* +ua/gov/diia/diia_storage/AndroidBase64Wrapper.* +ua/gov/diia/diia_storage/SecureDiiaStorage.* +ua/gov/diia/diia_storage/PreferenceConfiguration.* +ua/gov/diia/diia_storage/store/datasource/DataSourceDataResult.* +ua/gov/diia/diia_storage/store/datasource/preferences/KotlinStoreImpl.* \ No newline at end of file diff --git a/diia_storage/proguard-rules.pro b/diia_storage/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/diia_storage/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/diia_storage/src/main/AndroidManifest.xml b/diia_storage/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/diia_storage/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/AndroidBase64Wrapper.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/AndroidBase64Wrapper.kt new file mode 100644 index 0000000..c6e09b9 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/AndroidBase64Wrapper.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.diia_storage + +class AndroidBase64Wrapper: Base64Wrapper { + override fun encode(data: ByteArray): ByteArray { + return android.util.Base64.encode(data, android.util.Base64.NO_WRAP) + } + + override fun decode(data: ByteArray): ByteArray { + return android.util.Base64.decode(data, android.util.Base64.NO_WRAP) + } +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/AndroidKeyValueStore.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/AndroidKeyValueStore.kt new file mode 100644 index 0000000..474148a --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/AndroidKeyValueStore.kt @@ -0,0 +1,74 @@ +package ua.gov.diia.diia_storage + +import android.annotation.SuppressLint +import android.content.SharedPreferences +import ua.gov.diia.diia_storage.model.BaseSecuredKeyValueStore +import ua.gov.diia.diia_storage.model.PreferenceKey + +abstract class AndroidKeyValueStore : BaseSecuredKeyValueStore() { + + abstract fun getSharedPreferences(): SharedPreferences + + override fun getBoolean(key: PreferenceKey, defValue: Boolean): Boolean { + scopeCheck(key) + return getSharedPreferences().getBoolean(key.name, defValue) + } + + override fun getInt(key: PreferenceKey, defValue: Int): Int { + scopeCheck(key) + return getSharedPreferences().getInt(key.name, defValue) + } + + override fun getFloat(key: PreferenceKey, defValue: Float): Float { + scopeCheck(key) + return getSharedPreferences().getFloat(key.name, defValue) + } + + override fun getLong(key: PreferenceKey, defValue: Long): Long { + scopeCheck(key) + return getSharedPreferences().getLong(key.name, defValue) + } + + override fun getString(key: PreferenceKey, defValue: String): String { + scopeCheck(key) + return getSharedPreferences().getString(key.name, defValue) ?: defValue + } + + override fun delete(key: PreferenceKey) { + getSharedPreferences().edit().remove(key.name).apply() + } + + override fun set(key: PreferenceKey, value: Any) { + scopeCheck(key) + when (key.dataType) { + Boolean::class.java -> { + getSharedPreferences().edit().putBoolean(key.name, value as Boolean).apply() + } + Int::class.java -> { + getSharedPreferences().edit().putInt(key.name, value as Int).apply() + } + Float::class.java -> { + getSharedPreferences().edit().putFloat(key.name, value as Float).apply() + } + Long::class.java -> { + getSharedPreferences().edit().putLong(key.name, value as Long).apply() + } + String::class.java -> { + getSharedPreferences().edit().putString(key.name, value as String).apply() + } + else -> { + getSharedPreferences().edit().putString(key.name, value.toString()).apply() + } + } + } + + override fun containsKey(key: PreferenceKey): Boolean { + return getSharedPreferences().contains(key.name) + } + + @SuppressLint("ApplySharedPref") + override fun clear() { + val preferences = getSharedPreferences() + preferences.edit().clear().commit() + } +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/Base64Wrapper.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/Base64Wrapper.kt new file mode 100644 index 0000000..95c97a2 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/Base64Wrapper.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.diia_storage + +interface Base64Wrapper { + + fun encode(data: ByteArray): ByteArray + + fun decode(data: ByteArray): ByteArray +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/CommonPreferenceKeys.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/CommonPreferenceKeys.kt new file mode 100644 index 0000000..7c7585e --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/CommonPreferenceKeys.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.diia_storage + +import ua.gov.diia.diia_storage.CommonPreferenceKeys.CommonScopes.AUTH_SCOPE +import ua.gov.diia.diia_storage.model.PreferenceKey + +object CommonPreferenceKeys { + + object CommonScopes { + const val HASH_SCOPE = "hash" + const val AUTH_SCOPE = "auth" + } + + open class HashKey(name: String) : + PreferenceKey(name, CommonScopes.HASH_SCOPE, String::class.java) + + open class AuthKey(name: String, dataType: Class<*>) : + PreferenceKey(name, AUTH_SCOPE, dataType) + + object LastActivityDate : AuthKey("last_activity_date", String::class.java) + object LastDocumentUpdate : AuthKey("last_document_update", String::class.java) + object CurrentAppVersion : AuthKey("current_version_code", Int::class.java) + + object Token : AuthKey("token", String::class.java) + object UUID : AuthKey("uuid", String::class.java) + object IsFirst : AuthKey("is_first", Boolean::class.java) + object IsPassed : AuthKey("is_passed", Boolean::class.java) +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/DiiaStorage.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/DiiaStorage.kt new file mode 100644 index 0000000..f208cc5 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/DiiaStorage.kt @@ -0,0 +1,94 @@ +package ua.gov.diia.diia_storage + +import ua.gov.diia.diia_storage.model.BaseSecuredKeyValueStore +import ua.gov.diia.diia_storage.model.KeyValueStore +import ua.gov.diia.diia_storage.model.PreferenceKey +import java.util.UUID + +abstract class DiiaStorage: KeyValueStore, PinStore, MobileUidStore { + + abstract fun isFirstLaunch(): Boolean + + abstract fun isPassed(): Boolean + + abstract fun markAppLaunched() + + abstract fun markAppPassed() + + abstract suspend fun userAuthorized(userPin: String) + + abstract suspend fun userChangePin(oldPin: String, newPin: String) + + abstract fun userLogOut() + + protected abstract val currentKeyValueStore: BaseSecuredKeyValueStore + protected abstract var pinStorage: PinStore + + override fun getMobileUuid(): String { + val key = CommonPreferenceKeys.UUID + val uuid = getKeyValueStoreForKey(key).getString( + CommonPreferenceKeys.UUID, + KeyValueStore.DEFAULT_STRING_VALUE, + ) + return if (uuid == KeyValueStore.DEFAULT_STRING_VALUE) { + val newUuid = UUID.randomUUID().toString() + getKeyValueStoreForKey(key).set(CommonPreferenceKeys.UUID, newUuid) + newUuid + } else { + uuid + } + } + + override suspend fun savePin(pin: String) { + pinStorage.savePin(pin) + } + + override suspend fun isPinValid(pinInput: String): Boolean { + return pinStorage.isPinValid(pinInput) + } + + override fun pinPresent(): Boolean { + return pinStorage.pinPresent() + } + + override fun getString(key: PreferenceKey, defValue: String): String { + return getKeyValueStoreForKey(key).getString(key, defValue) + } + + override fun getBoolean(key: PreferenceKey, defValue: Boolean): Boolean { + return getKeyValueStoreForKey(key).getBoolean(key, defValue) + } + + override fun getInt(key: PreferenceKey, defValue: Int): Int { + return getKeyValueStoreForKey(key).getInt(key, defValue) + } + + override fun getFloat(key: PreferenceKey, defValue: Float): Float { + return getKeyValueStoreForKey(key).getFloat(key, defValue) + } + + override fun getLong(key: PreferenceKey, defValue: Long): Long { + return getKeyValueStoreForKey(key).getLong(key, defValue) + } + + override fun set(key: PreferenceKey, value: Any) { + getKeyValueStoreForKey(key).set(key, value) + } + + override fun get(key: PreferenceKey, default: Any?): Any? { + return getKeyValueStoreForKey(key).get(key, default) + } + + override fun containsKey(key: PreferenceKey): Boolean { + return getKeyValueStoreForKey(key).containsKey(key) + } + + override fun clear() { + currentKeyValueStore.clear() + } + + protected open fun getKeyValueStoreForKey(key: PreferenceKey): KeyValueStore { + return currentKeyValueStore + } + +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/EncryptedAndroidKeyValueStore.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/EncryptedAndroidKeyValueStore.kt new file mode 100644 index 0000000..2481e0f --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/EncryptedAndroidKeyValueStore.kt @@ -0,0 +1,64 @@ +package ua.gov.diia.diia_storage + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import ua.gov.diia.diia_storage.model.KeyValueStore + +class EncryptedAndroidKeyValueStore( + private val context: Context, + private val configuration: PreferenceConfiguration, +) : AndroidKeyValueStore(), PinStore { + + private var sharedPreferences: SharedPreferences + + init { + sharedPreferences = buildKeyValue() + } + + private fun buildKeyValue(): SharedPreferences { + val masterKeyAlias: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + return EncryptedSharedPreferences.create( + configuration.preferenceName, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + override fun getSharedPreferences(): SharedPreferences = sharedPreferences + + override fun allowedScopes() = configuration.allowedScopes + + override suspend fun savePin(pin: String) { + sharedPreferences.edit().putString(PIN, pin).apply() + } + + override suspend fun isPinValid(pinInput: String): Boolean { + if (sharedPreferences.contains(PIN)) { + val pin = sharedPreferences.getString(PIN, KeyValueStore.DEFAULT_STRING_VALUE) + return pin == pinInput + } + return false + } + + override fun pinPresent(): Boolean { + return sharedPreferences.contains(PIN) + } + + override fun clear() { + context.getSharedPreferences( + configuration.preferenceName, + Context.MODE_PRIVATE + ).edit().clear().apply() + sharedPreferences = buildKeyValue() + set(CommonPreferenceKeys.IsFirst, false) + } + + companion object { + const val PIN = "pin" + } + +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/MobileUidStore.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/MobileUidStore.kt new file mode 100644 index 0000000..bd409b4 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/MobileUidStore.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.diia_storage + +interface MobileUidStore { + + fun getMobileUuid(): String +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/PinStore.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/PinStore.kt new file mode 100644 index 0000000..bc7d73b --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/PinStore.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.diia_storage + +interface PinStore { + + suspend fun savePin(pin: String) + + suspend fun isPinValid(pinInput: String): Boolean + + fun pinPresent(): Boolean + + fun clear() + +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/PreferenceConfiguration.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/PreferenceConfiguration.kt new file mode 100644 index 0000000..cd7a481 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/PreferenceConfiguration.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.diia_storage + +data class PreferenceConfiguration( + private val _preferenceName: String, + val allowedScopes: Set, + val preferenceNamePrefix: String = "", +) { + val preferenceName: String + get() = preferenceNamePrefix + _preferenceName +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/SecureDiiaStorage.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/SecureDiiaStorage.kt new file mode 100644 index 0000000..73cd573 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/SecureDiiaStorage.kt @@ -0,0 +1,51 @@ +package ua.gov.diia.diia_storage + +import android.content.Context +import ua.gov.diia.diia_storage.model.BaseSecuredKeyValueStore +import ua.gov.diia.diia_storage.model.PreferenceKey + +class SecureDiiaStorage( + context: Context, + persistentStorageConfiguration: PreferenceConfiguration, +) : DiiaStorage() { + + private val keyValueStore = EncryptedAndroidKeyValueStore( + context, + persistentStorageConfiguration + ) + + override val currentKeyValueStore: BaseSecuredKeyValueStore = keyValueStore + override var pinStorage: PinStore = currentKeyValueStore as PinStore + + override fun delete(key: PreferenceKey) { + keyValueStore.delete(key) + } + + override suspend fun userAuthorized(userPin: String) { + savePin(userPin) + } + + override fun userLogOut() { + currentKeyValueStore.clear() + } + + override suspend fun userChangePin(oldPin: String, newPin: String) { + savePin(newPin) + } + + override fun isFirstLaunch(): Boolean { + return currentKeyValueStore.getBoolean(CommonPreferenceKeys.IsFirst, true) + } + + override fun markAppLaunched() { + currentKeyValueStore.set(CommonPreferenceKeys.IsFirst, false) + } + + override fun isPassed(): Boolean { + return currentKeyValueStore.getBoolean(CommonPreferenceKeys.IsPassed, false) + } + + override fun markAppPassed() { + currentKeyValueStore.set(CommonPreferenceKeys.IsPassed, true) + } +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/di/Base64WrapperModule.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/di/Base64WrapperModule.kt new file mode 100644 index 0000000..0d7e269 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/di/Base64WrapperModule.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.diia_storage.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.diia_storage.AndroidBase64Wrapper +import ua.gov.diia.diia_storage.Base64Wrapper + +@Module +@InstallIn(SingletonComponent::class) +object Base64WrapperModule { + + @Provides + fun provideBase64Wrapper(): Base64Wrapper = AndroidBase64Wrapper() + +} \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/di/PreferenceStorage.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/di/PreferenceStorage.kt new file mode 100644 index 0000000..43d3838 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/di/PreferenceStorage.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.diia_storage.di + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.diia_storage.store.datasource.preferences.KotlinStoreImpl +import ua.gov.diia.diia_storage.store.datasource.preferences.PreferenceDataSource +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PreferenceStorage { + + @Provides + @Singleton + fun providePreferenceStorage( + @ApplicationContext appContext: Context + ): PreferenceDataSource = KotlinStoreImpl(appContext) +} \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/BaseSecuredKeyValueStore.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/BaseSecuredKeyValueStore.kt new file mode 100644 index 0000000..b73ba08 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/BaseSecuredKeyValueStore.kt @@ -0,0 +1,87 @@ +package ua.gov.diia.diia_storage.model + +import ua.gov.diia.diia_storage.model.KeyValueStore.Companion.DEFAULT_STRING_VALUE + +abstract class BaseSecuredKeyValueStore : KeyValueStore { + + abstract fun allowedScopes(): Set + + + override fun get(key: PreferenceKey, default: Any?): Any? { + scopeCheck(key) + return when (key.dataType) { + Boolean::class.java -> { + if (containsKey(key) || default != null) { + return if (default == null) { + getBoolean(key, false) + } else { + getBoolean(key, default as Boolean) + } + } else { + null + } + } + Int::class.java -> { + if (containsKey(key) || default != null) { + return if (default == null) { + getInt(key, 0) + } else { + getInt(key, default as Int) + } + } else { + null + } + } + Float::class.java -> { + if (containsKey(key) || default != null) { + return if (default == null) { + getFloat(key, 0.0f) + } else { + getFloat(key, default as Float) + } + } else { + null + } + } + Long::class.java -> { + if (containsKey(key) || default != null) { + return if (default == null) { + getLong(key, 0L) + } else { + getLong(key, default as Long) + } + } else { + null + } + } + String::class.java -> { + if (containsKey(key) || default != null) { + return if (default == null) { + getString(key, DEFAULT_STRING_VALUE) + } else { + getString(key, default as String) + } + } else { + null + } + } + else -> { + if (containsKey(key) || default != null) { + return if (default == null) { + getString(key, DEFAULT_STRING_VALUE) + } else { + getString(key, default as String) + } + } else { + null + } + } + } + } + + protected open fun scopeCheck(key: PreferenceKey) { + if (key.scope !in allowedScopes()) { + throw IllegalAccessException("Store does not have scope : ${key.scope} for key ${key.name}") + } + } +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/KeyValueStore.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/KeyValueStore.kt new file mode 100644 index 0000000..d80f62a --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/KeyValueStore.kt @@ -0,0 +1,29 @@ +package ua.gov.diia.diia_storage.model + +interface KeyValueStore { + + fun getString(key: PreferenceKey, defValue: String): String + + fun getBoolean(key: PreferenceKey, defValue: Boolean): Boolean + + fun getInt(key: PreferenceKey, defValue: Int): Int + + fun getFloat(key: PreferenceKey, defValue: Float): Float + + fun getLong(key: PreferenceKey, defValue: Long): Long + + fun set(key: PreferenceKey, value: Any) + + fun get(key: PreferenceKey, default: Any? = null): Any? + + fun containsKey(key: PreferenceKey): Boolean + + fun delete(key: PreferenceKey) + + fun clear() + + companion object { + const val DEFAULT_STRING_VALUE = "PREF_DEF" + } +} + diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/PreferenceKey.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/PreferenceKey.kt new file mode 100644 index 0000000..a0f9e42 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/model/PreferenceKey.kt @@ -0,0 +1,3 @@ +package ua.gov.diia.diia_storage.model + +abstract class PreferenceKey(val name: String, val scope: String, val dataType: Class<*>) diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/AbstractKeyValueDataSource.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/AbstractKeyValueDataSource.kt new file mode 100644 index 0000000..d712320 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/AbstractKeyValueDataSource.kt @@ -0,0 +1,29 @@ +package ua.gov.diia.diia_storage.store + +import com.squareup.moshi.JsonAdapter +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.model.KeyValueStore +import ua.gov.diia.diia_storage.model.PreferenceKey + +abstract class AbstractKeyValueDataSource(val store: KeyValueStore, val withCrashlytics: WithCrashlytics) { + + protected abstract val preferenceKey: PreferenceKey + protected abstract val jsonAdapter: JsonAdapter + + open fun saveDataToStore(data: T) { + store.set(preferenceKey, jsonAdapter.toJson(data)) + } + + suspend fun loadData(): T? { + if (store.containsKey(preferenceKey)) { + try { + return jsonAdapter.fromJson(store.getString(preferenceKey, Preferences.DEF)) + } catch (e: Exception) { + store.set(preferenceKey, "") + withCrashlytics.sendNonFatalError(e) + } + } + return null + } + +} \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/Preferences.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/Preferences.kt new file mode 100644 index 0000000..1ea0e7b --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/Preferences.kt @@ -0,0 +1,69 @@ +package ua.gov.diia.diia_storage.store + +import ua.gov.diia.diia_storage.model.PreferenceKey + +object Preferences { + + object Settings { + const val NAME_DIIA = "DIIA_PREF" + } + + object Scopes { + const val AUTH_SCOPE = "auth" + const val UPDATE_SCOPE = "update" + const val USER_SCOPE = "user" + const val PIN_SCOPE = "pin" + const val DOUBLE_CHECK = "double_check" + const val FAQS = "faqs" + const val FEATURES = "features" + const val USER_PREFERENCES = "user_preferences" + const val QUESTIONNAIRE = "questionnaire" + const val INVINCIBILITY_PREFERENCES = "invincibility_preferences" + } + + const val DEF = "PREF_DEF" + + open class DoubleCheck(name: String) : + PreferenceKey(name, Scopes.DOUBLE_CHECK, Boolean::class.java) + + object V1 : DoubleCheck("v1") + + + open class UserDataKey(name: String, dataType: Class<*>) : + PreferenceKey(name, Scopes.USER_SCOPE, dataType) + object UseTouchId : UserDataKey("use_touch_id", Boolean::class.java) + object DeepLinkFeatureKey : UserDataKey("bank_account_deep_link", String::class.java) + object Documents : UserDataKey("documents_cache", String::class.java) + object ITN : UserDataKey("itn", String::class.java) + object PublicServicesCategories : UserDataKey("public_services_categories", PublicServicesCategories::class.java) + object DiiaSign : UserDataKey("diia_sign", String::class.java) + object DiiaSignEcdsa : UserDataKey("diia_sign_ecdsa", String::class.java) + object DiiaSignPass : UserDataKey("diia_sign_pass", String::class.java) + object DiiaSignPassNonce : UserDataKey("diia_sign_pass_nonce", String::class.java) + object PromoProcessCode : UserDataKey("promoProcessCode", Int::class.java) + + open class PinKey(name: String, dataType: Class<*>) : + PreferenceKey(name, Scopes.PIN_SCOPE, dataType) + object PinTryCountGlobal : PinKey("pin_try_count_global", Int::class.java) + object PinTryCountSignature : PinKey("pin_try_count_signature", Int::class.java) + + open class FaqsKey(name: String, dataType: Class<*>): PreferenceKey(name, Scopes.FAQS, dataType) + object FaqsList: FaqsKey("faq_categories", String::class.java) + + open class FeaturesKey(name: String, dataType: Class<*>): PreferenceKey(name, + Scopes.FEATURES, dataType) + object Features: FeaturesKey("features", String::class.java) + + open class UserPreferenceKey(name: String, dataType: Class<*>): PreferenceKey(name, + Scopes.USER_PREFERENCES, dataType) + + open class InvincibilityKey(name: String, dataType: Class<*>): PreferenceKey(name, + Scopes.INVINCIBILITY_PREFERENCES, dataType) + object InvincibilityRegions : InvincibilityKey("invincibility_regions", InvincibilityRegions::class.java) + data class InvincibilityMapDownloadId(val downloadId: String) : InvincibilityKey(downloadId, Long::class.java) + + open class ContactsDataPreferenceKey(name: String, dataType: Class): PreferenceKey(name, Scopes.QUESTIONNAIRE, dataType) + object UserPhoneNumber : ContactsDataPreferenceKey("questionnaire_user_phone_number", String::class.java) + object UserEmail : ContactsDataPreferenceKey("questionnaire_user_email", String::class.java) + +} \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/DataSource.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/DataSource.kt new file mode 100644 index 0000000..98d584a --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/DataSource.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.diia_storage.store.datasource + +import kotlinx.coroutines.flow.Flow + +interface DataSource { + + val isDataLoading: Flow + + val data: Flow> + + fun invalidate() +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/DataSourceDataResult.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/DataSourceDataResult.kt new file mode 100644 index 0000000..1a8751f --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/DataSourceDataResult.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.diia_storage.store.datasource + +data class DataSourceDataResult( + val isSuccessful: Boolean, + val data: T?, + val exception: Exception?, +) { + + companion object { + fun failed(exception: Exception? = null): DataSourceDataResult { + return DataSourceDataResult(false, null, exception) + } + + fun successful(data: T): DataSourceDataResult { + return DataSourceDataResult(true, data, null) + } + } +} diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/itn/ItnDataRepository.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/itn/ItnDataRepository.kt new file mode 100644 index 0000000..a2c135c --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/itn/ItnDataRepository.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.diia_storage.store.datasource.itn + +import ua.gov.diia.core.models.ITN +import ua.gov.diia.diia_storage.store.datasource.DataSource + +interface ItnDataRepository : DataSource \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/preferences/KotlinStoreImpl.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/preferences/KotlinStoreImpl.kt new file mode 100644 index 0000000..8cb5b37 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/preferences/KotlinStoreImpl.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.diia_storage.store.datasource.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.firstOrNull + +class KotlinStoreImpl(private val appContext: Context) : PreferenceDataSource { + + private companion object { + const val PREFERENCES_STORAGE = "preferences_storage" + } + + private val Context.dataStore: DataStore by preferencesDataStore( + name = PREFERENCES_STORAGE + ) + + override suspend fun setBoolean(key: String, value: Boolean) { + val booleanKey = booleanPreferencesKey(key) + appContext.dataStore.edit { preferences -> + preferences[booleanKey] = value + } + } + + override suspend fun getBoolean(key: String): Boolean { + val booleanKey = booleanPreferencesKey(key) + return appContext.dataStore.data.firstOrNull() + ?.get(booleanKey) ?: false + } + +} \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/preferences/PreferenceDataSource.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/preferences/PreferenceDataSource.kt new file mode 100644 index 0000000..a3d2bc2 --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/datasource/preferences/PreferenceDataSource.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.diia_storage.store.datasource.preferences + +interface PreferenceDataSource { + + suspend fun setBoolean(key: String, value: Boolean) + + suspend fun getBoolean(key: String): Boolean +} \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepository.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepository.kt new file mode 100644 index 0000000..1fb608d --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepository.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.diia_storage.store.repository.authorization + +import ua.gov.diia.core.models.TokenData +import ua.gov.diia.core.models.UserType + +interface AuthorizationRepository { + + suspend fun getMobileUuid(): String + + suspend fun setMobileUuid(uuid: String) + + suspend fun isUserAuthorized(): Boolean + + suspend fun logoutUser() + + suspend fun setToken(token: String) + + suspend fun getToken(): String? + + suspend fun getTokenData(): TokenData + + suspend fun setIsServiceUser(isServiceUser: Boolean) + + suspend fun isServiceUser(): Boolean + + suspend fun getUserType(): UserType + +} \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepositoryImpl.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepositoryImpl.kt new file mode 100644 index 0000000..13513cd --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepositoryImpl.kt @@ -0,0 +1,85 @@ +package ua.gov.diia.diia_storage.store.repository.authorization + +import kotlinx.coroutines.withContext +import org.json.JSONObject +import ua.gov.diia.core.models.TokenData +import ua.gov.diia.core.models.TokenData.Companion.EMPTY_TOKEN +import ua.gov.diia.core.models.UserType +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.Base64Wrapper +import ua.gov.diia.diia_storage.CommonPreferenceKeys +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.store.datasource.preferences.PreferenceDataSource +import java.util.Date +import javax.inject.Inject + +class AuthorizationRepositoryImpl @Inject constructor( + private val preferenceDataSource: PreferenceDataSource, + private val diiaStorage: DiiaStorage, + private val dispatcherProvider: DispatcherProvider, + private val base64Wrapper: Base64Wrapper +) : AuthorizationRepository { + + private companion object { + const val DEF_VALUE_STRING = "def_string" + const val PREFERENCE_KEY_SERVICE_USER = "is_service_user" + } + + override suspend fun getUserType(): UserType = withContext(dispatcherProvider.work) { + if (isServiceUser()) UserType.SERVICE_USER else UserType.PRIMARY_USER + } + + override suspend fun setIsServiceUser(isServiceUser: Boolean) = + withContext(dispatcherProvider.work) { + preferenceDataSource.setBoolean(PREFERENCE_KEY_SERVICE_USER, isServiceUser) + } + + override suspend fun isServiceUser(): Boolean = withContext(dispatcherProvider.work) { + preferenceDataSource.getBoolean(PREFERENCE_KEY_SERVICE_USER) + } + + override suspend fun getMobileUuid(): String = withContext(dispatcherProvider.work) { + diiaStorage.getMobileUuid() + } + + override suspend fun setMobileUuid(uuid: String) { + withContext(dispatcherProvider.work) { + diiaStorage.set(CommonPreferenceKeys.UUID, uuid) + } + } + + override suspend fun isUserAuthorized(): Boolean = withContext(dispatcherProvider.work) { + diiaStorage.containsKey(CommonPreferenceKeys.Token) + } + + override suspend fun logoutUser() { + withContext(dispatcherProvider.work) { + diiaStorage.userLogOut() + } + } + + override suspend fun getTokenData(): TokenData = withContext(dispatcherProvider.work) { + val token = diiaStorage.getString(CommonPreferenceKeys.Token, DEF_VALUE_STRING) + if (token == DEF_VALUE_STRING) { + TokenData(EMPTY_TOKEN, Date()) + } else { + val tokenPayload = base64Wrapper.decode(token.split(".")[1].toByteArray()) + val tokenJson = JSONObject(String(tokenPayload)) + val expiration = Date(tokenJson.getLong(TokenData.EXP) * TokenData.SEC) + TokenData(token, expiration) + } + } + + override suspend fun getToken(): String? = withContext(dispatcherProvider.work) { + val token = diiaStorage.getString(CommonPreferenceKeys.Token, DEF_VALUE_STRING) + if (token != DEF_VALUE_STRING) token else null + } + + override suspend fun setToken(token: String) { + withContext(dispatcherProvider.work) { + diiaStorage.set(CommonPreferenceKeys.Token, token) + } + } + + +} \ No newline at end of file diff --git a/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/system/SystemRepositoryImpl.kt b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/system/SystemRepositoryImpl.kt new file mode 100644 index 0000000..c26572c --- /dev/null +++ b/diia_storage/src/main/java/ua/gov/diia/diia_storage/store/repository/system/SystemRepositoryImpl.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.diia_storage.store.repository.system + +import kotlinx.coroutines.withContext +import ua.gov.diia.core.data.repository.SystemRepository +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.CommonPreferenceKeys +import ua.gov.diia.diia_storage.DiiaStorage +import javax.inject.Inject + +class SystemRepositoryImpl @Inject constructor( + private val dispatcherProvider: DispatcherProvider, + private val diiaStorage: DiiaStorage +) : SystemRepository { + + override suspend fun getAppVersionCode(): Int? = withContext(dispatcherProvider.work) { + val code = diiaStorage.getInt(CommonPreferenceKeys.CurrentAppVersion, -1) + if (code == -1) null else code + } + + override suspend fun setAppVersionCode(code: Int) { + withContext(dispatcherProvider.work) { + diiaStorage.set(CommonPreferenceKeys.CurrentAppVersion, code) + } + } +} \ No newline at end of file diff --git a/diia_storage/src/test/java/ua/gov/diia/diia_storage/AndroidKeyValueStoreTest.kt b/diia_storage/src/test/java/ua/gov/diia/diia_storage/AndroidKeyValueStoreTest.kt new file mode 100644 index 0000000..52f57ef --- /dev/null +++ b/diia_storage/src/test/java/ua/gov/diia/diia_storage/AndroidKeyValueStoreTest.kt @@ -0,0 +1,318 @@ +package ua.gov.diia.diia_storage + +import android.content.SharedPreferences +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.diia_storage.model.PreferenceKey + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class AndroidKeyValueStoreTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var androidKeyValueStoreImpl: AndroidKeyValueStoreImpl + lateinit var sharedPreferences: SharedPreferences + lateinit var allowedScopes: Set + + @Before + fun before() { + sharedPreferences = mockk(relaxed = true) + allowedScopes = mutableSetOf("scope") + + androidKeyValueStoreImpl = AndroidKeyValueStoreImpl(sharedPreferences, allowedScopes) + } + + @Test + fun `test getBoolean get data from preferences if key is in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { sharedPreferences.getBoolean("key", true) } returns false + + assertFalse(androidKeyValueStoreImpl.getBoolean(key, true)) + + coVerify(exactly = 1) { sharedPreferences.getBoolean("key", true) } + } + + + @Test(expected = IllegalAccessException::class) + fun `test getBoolean not get data from preferences if key is not in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope2" + + androidKeyValueStoreImpl.getBoolean(key, true) + } + + @Test + fun `test getFloat get data from preferences if key is in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { sharedPreferences.getFloat("key", 0f) } returns 1f + + assertEquals(1f, androidKeyValueStoreImpl.getFloat(key, 0f)) + + coVerify(exactly = 1) { sharedPreferences.getFloat("key", 0f) } + } + + + @Test(expected = IllegalAccessException::class) + fun `test getFloat not get data from preferences if key is not in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope2" + + androidKeyValueStoreImpl.getFloat(key, 0f) + } + + @Test + fun `test getLong get data from preferences if key is in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { sharedPreferences.getLong("key", 0) } returns 1 + + assertEquals(1, androidKeyValueStoreImpl.getLong(key, 0)) + + coVerify(exactly = 1) { sharedPreferences.getLong("key", 0) } + } + + + @Test(expected = IllegalAccessException::class) + fun `test getLong not get data from preferences if key is not in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope2" + + androidKeyValueStoreImpl.getLong(key, 0) + } + + @Test + fun `test getString get data from preferences if key is in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { sharedPreferences.getString("key", "empty") } returns "data" + + assertEquals("data", androidKeyValueStoreImpl.getString(key, "empty")) + + coVerify(exactly = 1) { sharedPreferences.getString("key", "empty") } + } + + + @Test(expected = IllegalAccessException::class) + fun `test getString not get data from preferences if key is not in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope2" + + androidKeyValueStoreImpl.getString(key, "empty") + } + + @Test + fun `test getInt get data from preferences if key is in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { sharedPreferences.getInt("key", 0) } returns 1 + + assertEquals(1, androidKeyValueStoreImpl.getInt(key, 0)) + + coVerify(exactly = 1) { sharedPreferences.getInt("key", 0) } + } + + @Test(expected = IllegalAccessException::class) + fun `test getInt not get data from preferences if key is not in scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope2" + + androidKeyValueStoreImpl.getInt(key, 0) + } + + @Test + fun `test contains check datain shared preferences`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope2" + + androidKeyValueStoreImpl.containsKey(key) + verify(exactly = 1) { sharedPreferences.contains("key") } + } + + @Test + fun `test clear call clear function in shared preference`() = runTest { + val editor: SharedPreferences.Editor = mockk(relaxed = true) + every { sharedPreferences.edit() } returns editor + every { editor.clear() } returns editor + + androidKeyValueStoreImpl.clear() + verify(exactly = 1) { sharedPreferences.edit() } + verify(exactly = 1) { editor.clear() } + verify(exactly = 1) { editor.commit() } + } + + @Test(expected = IllegalAccessException::class) + fun `test set check scope`() = runTest { + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope2" + + androidKeyValueStoreImpl.set(key, "data") + } + + @Test + fun `test delete by ket in shared preference`() = runTest { + val editor: SharedPreferences.Editor = mockk(relaxed = true) + every { sharedPreferences.edit() } returns editor + every { editor.remove(any()) } returns editor + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + androidKeyValueStoreImpl.delete(key) + verify(exactly = 1) { sharedPreferences.edit() } + verify(exactly = 1) { editor.remove("key") } + verify(exactly = 1) { editor.apply() } + } + + @Test + fun `test set save data in integer put method corresponded put method in shared preference`() = runTest { + val editor: SharedPreferences.Editor = mockk(relaxed = true) + every { sharedPreferences.edit() } returns editor + every { editor.putBoolean(any(), any()) } returns editor + every { editor.putInt(any(), any()) } returns editor + every { editor.putFloat(any(), any()) } returns editor + every { editor.putLong(any(), any()) } returns editor + every { editor.putString(any(), any()) } returns editor + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + + every { key.dataType } returns Int::class.java + val intValue = 1 + androidKeyValueStoreImpl.set(key, intValue) + coVerify(exactly = 1) { sharedPreferences.edit() } + coVerify(exactly = 1) { editor.putInt("key", intValue) } + coVerify(exactly = 1) { editor.apply() } + } + + @Test + fun `test set save data in float put method corresponded put method in shared preference`() = runTest { + val editor: SharedPreferences.Editor = mockk(relaxed = true) + every { sharedPreferences.edit() } returns editor + every { editor.putFloat(any(), any()) } returns editor + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { key.dataType } returns Float::class.java + androidKeyValueStoreImpl.set(key, 1f) + coVerify(exactly = 1) { sharedPreferences.edit() } + coVerify(exactly = 1) { editor.putFloat("key", 1f) } + coVerify(exactly = 1) { editor.apply() } + } + + @Test + fun `test set save data in long put method corresponded put method in shared preference`() = runTest { + val editor: SharedPreferences.Editor = mockk(relaxed = true) + every { sharedPreferences.edit() } returns editor + every { editor.putLong(any(), any()) } returns editor + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { key.dataType } returns Long::class.java + androidKeyValueStoreImpl.set(key, 1L) + coVerify(exactly = 1) { sharedPreferences.edit() } + coVerify(exactly = 1) { editor.putLong("key", 1L) } + coVerify(exactly = 1) { editor.apply() } + + } + + @Test + fun `test set save data in boolean put method corresponded put method in shared preference`() = runTest { + val editor: SharedPreferences.Editor = mockk(relaxed = true) + every { sharedPreferences.edit() } returns editor + every { editor.putBoolean(any(), any()) } returns editor + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { key.dataType } returns Boolean::class.java + androidKeyValueStoreImpl.set(key, true) + coVerify(exactly = 1) { sharedPreferences.edit() } + coVerify(exactly = 1) { editor.putBoolean("key", true) } + coVerify(exactly = 1) { editor.apply() } + } + + @Test + fun `test set save data in string put method corresponded put method in shared preference`() = runTest { + val editor: SharedPreferences.Editor = mockk(relaxed = true) + every { sharedPreferences.edit() } returns editor + every { editor.putString(any(), any()) } returns editor + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { key.dataType } returns String::class.java + androidKeyValueStoreImpl.set(key, "data") + coVerify(exactly = 1) { sharedPreferences.edit() } + coVerify(exactly = 1) { editor.putString("key", "data") } + coVerify(exactly = 1) { editor.apply() } + + } + + @Test + fun `test set save data in other object put method corresponded put method in shared preference`() = runTest { + val editor: SharedPreferences.Editor = mockk(relaxed = true) + every { sharedPreferences.edit() } returns editor + every { editor.putString(any(), any()) } returns editor + val key: PreferenceKey = mockk(relaxed = true) + every { key.name } returns "key" + every { key.scope } returns "scope" + + every { key.dataType } returns Object::class.java + val obj = Object() + androidKeyValueStoreImpl.set(key, obj) + coVerify(exactly = 1) { sharedPreferences.edit() } + coVerify(exactly = 1) { editor.putString("key", obj.toString()) } + coVerify(exactly = 1) { editor.apply() } + } +} + +class AndroidKeyValueStoreImpl(val preferences: SharedPreferences, val allowedScopes: Set): AndroidKeyValueStore() { + override fun getSharedPreferences(): SharedPreferences { + return preferences + } + + override fun allowedScopes(): Set { + return allowedScopes + } + +} \ No newline at end of file diff --git a/diia_storage/src/test/java/ua/gov/diia/diia_storage/MainDispatcherRule.kt b/diia_storage/src/test/java/ua/gov/diia/diia_storage/MainDispatcherRule.kt new file mode 100644 index 0000000..64363eb --- /dev/null +++ b/diia_storage/src/test/java/ua/gov/diia/diia_storage/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.diia_storage + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/AbstractKeyValueDataSourceTest.kt b/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/AbstractKeyValueDataSourceTest.kt new file mode 100644 index 0000000..a686ad0 --- /dev/null +++ b/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/AbstractKeyValueDataSourceTest.kt @@ -0,0 +1,93 @@ +package ua.gov.diia.diia_storage.store + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.squareup.moshi.JsonAdapter +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.MainDispatcherRule +import ua.gov.diia.diia_storage.model.KeyValueStore +import ua.gov.diia.diia_storage.model.PreferenceKey + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class AbstractKeyValueDataSourceTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + + lateinit var preferenceKeyMock: PreferenceKey + lateinit var jsonAdapterMock: JsonAdapter + lateinit var store: KeyValueStore + lateinit var withCrashlytics: WithCrashlytics + lateinit var abstractKeyValueDataSource: AbstractKeyValueDataSource + + @Before + fun before() { + preferenceKeyMock = mockk(relaxed = true) + jsonAdapterMock = mockk(relaxed = true) + store = mockk(relaxed = true) + withCrashlytics = mockk(relaxed = true) + + abstractKeyValueDataSource = object : AbstractKeyValueDataSource(store, withCrashlytics) { + override val preferenceKey: PreferenceKey + get() = preferenceKeyMock + override val jsonAdapter: JsonAdapter + get() = jsonAdapterMock + } + } + + @Test + fun `test saveDataToStore set serialized data into storage`() = runTest { + every { jsonAdapterMock.toJson("data") } returns "value" + + abstractKeyValueDataSource.saveDataToStore("data") + + coVerify(exactly = 1) { store.set(preferenceKeyMock, "value") } + coVerify(exactly = 1) { jsonAdapterMock.toJson("data") } + } + + @Test + fun `test loadData from store and deserialize data`() = runTest { + every { store.containsKey(preferenceKeyMock) } returns true + every { store.getString(preferenceKeyMock, Preferences.DEF) } returns "data" + + abstractKeyValueDataSource.loadData() + + coVerify(exactly = 1) { store.getString(preferenceKeyMock, Preferences.DEF) } + coVerify(exactly = 1) { jsonAdapterMock.fromJson("data") } + } + + @Test + fun `test loadData handle error by setting empty data and call sendNonFatalError`() = runTest { + val error = RuntimeException("error") + every { store.containsKey(preferenceKeyMock) } returns true + every { store.getString(preferenceKeyMock, Preferences.DEF) } throws error + + abstractKeyValueDataSource.loadData() + + coVerify(exactly = 1) { store.set(preferenceKeyMock, "") } + coVerify(exactly = 1) { withCrashlytics.sendNonFatalError(error)} + } + + @Test + fun `test loadData returns null if store not contains data`() = runTest { + every { store.containsKey(preferenceKeyMock) } returns false + + assertNull(abstractKeyValueDataSource.loadData()) + } +} + diff --git a/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepositoryImplTest.kt b/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepositoryImplTest.kt new file mode 100644 index 0000000..1c3148d --- /dev/null +++ b/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/repository/authorization/AuthorizationRepositoryImplTest.kt @@ -0,0 +1,188 @@ +package ua.gov.diia.diia_storage.store.repository.authorization + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.models.TokenData +import ua.gov.diia.core.models.UserType +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.Base64Wrapper +import ua.gov.diia.diia_storage.CommonPreferenceKeys +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.MainDispatcherRule +import ua.gov.diia.diia_storage.store.datasource.preferences.PreferenceDataSource +import java.util.Date + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class AuthorizationRepositoryImplTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var authorizationRepositoryImpl: AuthorizationRepositoryImpl + + lateinit var preferenceDataSource: PreferenceDataSource + lateinit var diiaStorage: DiiaStorage + lateinit var dispatcherProvider: DispatcherProvider + lateinit var base64Wrapper: Base64Wrapper + + @Before + fun before() { + preferenceDataSource = mockk(relaxed = true) + diiaStorage = mockk(relaxed = true) + dispatcherProvider = mockk(relaxed = true) + base64Wrapper = mockk(relaxed = true) + + every { dispatcherProvider.work } returns UnconfinedTestDispatcher() + + authorizationRepositoryImpl = AuthorizationRepositoryImpl( + preferenceDataSource, + diiaStorage, + dispatcherProvider, + base64Wrapper + ) + } + + @Test + fun `setIsServiceUser set data with PREFERENCE_KEY_SERVICE_USER key`() = runTest { + authorizationRepositoryImpl.setIsServiceUser(true) + + coVerify(exactly = 1) { preferenceDataSource.setBoolean("is_service_user", true) } + } + + @Test + fun `isServiceUser get data with PREFERENCE_KEY_SERVICE_USER key`() = runTest { + + coEvery { preferenceDataSource.getBoolean("is_service_user") } returns true + + assertTrue(authorizationRepositoryImpl.isServiceUser()) + + coVerify(exactly = 1) { preferenceDataSource.getBoolean("is_service_user") } + } + + @Test + fun `getUserType get relevant user type`() = runTest { + + coEvery { preferenceDataSource.getBoolean("is_service_user") } returns true + assertEquals(UserType.SERVICE_USER, authorizationRepositoryImpl.getUserType()) + + coEvery { preferenceDataSource.getBoolean("is_service_user") } returns false + assertEquals(UserType.PRIMARY_USER, authorizationRepositoryImpl.getUserType()) + } + + @Test + fun `getMobileUuid load data from storage`() = runTest { + val mobileId = "mobile_id" + + coEvery { diiaStorage.getMobileUuid() } returns mobileId + val result = authorizationRepositoryImpl.getMobileUuid() + + coVerify(exactly = 1) { diiaStorage.getMobileUuid() } + assertEquals(mobileId, result) + } + + @Test + fun `setMobileUuid save id in storage`() = runTest { + val mobileId = "mobile_id" + + authorizationRepositoryImpl.setMobileUuid(mobileId) + + coVerify(exactly = 1) { diiaStorage.set(CommonPreferenceKeys.UUID, mobileId) } + } + + @Test + fun `isUserAuthorized check if storage has token`() = runTest { + coEvery { diiaStorage.containsKey(CommonPreferenceKeys.Token) } returns true + + assertTrue(authorizationRepositoryImpl.isUserAuthorized()) + + coVerify(exactly = 1) { diiaStorage.containsKey(CommonPreferenceKeys.Token) } + } + + @Test + fun `logoutUser trigger logout in storage`() = runTest { + authorizationRepositoryImpl.logoutUser() + + coVerify(exactly = 1) { diiaStorage.userLogOut() } + } + + @Test + fun `getTokenData returns empty token if token is not saved in storage`() = runTest { + every { + diiaStorage.getString( + CommonPreferenceKeys.Token, + "def_string" + ) + } returns "def_string" + val result = authorizationRepositoryImpl.getTokenData() + + assertEquals(TokenData.EMPTY_TOKEN, result.token) + } + + @Test + fun `getTokenData returns token decode and split`() = runTest { + val token = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9" + + "lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE2MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf" + + "36POk6yJV_adQssw5c" + val expectedTokenData = TokenData(token, Date(1616239022000)) + coEvery { diiaStorage.getString(CommonPreferenceKeys.Token, "def_string") } returns token + val byteArray = "{\n" + + " \"sub\": \"1234567890\",\n" + + " \"name\": \"John Doe\",\n" + + " \"iat\": 1516239022,\n" + + " \"exp\": 1616239022\n" + + "}" + coEvery { base64Wrapper.decode(token.split(".")[1].toByteArray()) } returns byteArray.toByteArray() + + val result = authorizationRepositoryImpl.getTokenData() + + coVerify(exactly = 1) { diiaStorage.getString(CommonPreferenceKeys.Token, "def_string") } + coVerify(exactly = 1) { base64Wrapper.decode(token.split(".")[1].toByteArray()) } + assertEquals(expectedTokenData, result) + } + + @Test + fun `getToken returns null if token is not saved in storage`() = runTest { + coEvery { diiaStorage.getString(CommonPreferenceKeys.Token, "def_string") } returns "def_string" + val result = authorizationRepositoryImpl.getToken() + + coVerify(exactly = 1) { diiaStorage.getString(CommonPreferenceKeys.Token, "def_string") } + assertNull(result) + } + + @Test + fun `getToken returns token from storage`() = runTest { + val token = "token" + coEvery { diiaStorage.getString(CommonPreferenceKeys.Token, "def_string") } returns token + val result = authorizationRepositoryImpl.getToken() + + coVerify(exactly = 1) { diiaStorage.getString(CommonPreferenceKeys.Token, "def_string") } + assertEquals(token, result) + } + + @Test + fun `setToken save toekn in storage`() = runTest { + val token = "token" + authorizationRepositoryImpl.setToken(token) + + coVerify(exactly = 1) { diiaStorage.set(CommonPreferenceKeys.Token, token) } + } +} \ No newline at end of file diff --git a/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/repository/system/SystemRepositoryImplTest.kt b/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/repository/system/SystemRepositoryImplTest.kt new file mode 100644 index 0000000..224812e --- /dev/null +++ b/diia_storage/src/test/java/ua/gov/diia/diia_storage/store/repository/system/SystemRepositoryImplTest.kt @@ -0,0 +1,70 @@ +package ua.gov.diia.diia_storage.store.repository.system + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.CommonPreferenceKeys +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.MainDispatcherRule + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class SystemRepositoryImplTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var diiaStorage: DiiaStorage + lateinit var dispatcherProvider: DispatcherProvider + lateinit var systemRepositoryImpl: SystemRepositoryImpl + + @Before + fun before() { + diiaStorage = mockk(relaxed = true) + dispatcherProvider = mockk(relaxed = true) + every { dispatcherProvider.work } returns UnconfinedTestDispatcher() + + systemRepositoryImpl = SystemRepositoryImpl(dispatcherProvider, diiaStorage) + } + + @Test + fun `getAppVersionCode returns null if code is -1`() = runTest { + every { diiaStorage.getInt(CommonPreferenceKeys.CurrentAppVersion, -1) } returns -1 + val appVersionCode = systemRepositoryImpl.getAppVersionCode() + + coVerify(exactly = 1) { diiaStorage.getInt(CommonPreferenceKeys.CurrentAppVersion, -1) } + assertNull(appVersionCode) + } + + @Test + fun `getAppVersionCode returns code if storage returns code`() = runTest { + every { diiaStorage.getInt(CommonPreferenceKeys.CurrentAppVersion, -1) } returns 10 + val appVersionCode = systemRepositoryImpl.getAppVersionCode() + + coVerify(exactly = 1) { diiaStorage.getInt(CommonPreferenceKeys.CurrentAppVersion, -1) } + assertEquals(10, appVersionCode) + } + + + @Test + fun `setAppVersionCode saves code into storage`() = runTest { + systemRepositoryImpl.setAppVersionCode(10) + + coVerify(exactly = 1) { diiaStorage.set(CommonPreferenceKeys.CurrentAppVersion, 10) } + } +} \ No newline at end of file diff --git a/doc_driver_license/.gitignore b/doc_driver_license/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/doc_driver_license/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/doc_driver_license/README.md b/doc_driver_license/README.md new file mode 100644 index 0000000..ba8f8f0 --- /dev/null +++ b/doc_driver_license/README.md @@ -0,0 +1,54 @@ +# Description + +This is module responsible for Driver license document implementation. + +# How to install + +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':doc_driver_license') +``` + +2. Module requires next modules to work + +```groovy +implementation project(path: ':ui_base') +implementation project(path: ':core') +implementation project(path: ':documents') +``` + +3. Add following field inside document api response + +```kotlin +@Parcelize +@JsonClass(generateAdapter = true) +data class Docs( + @Json(name = "driverLicense") + val driverLicense: DriverLicenseV2? + ) +``` +4. Implement response handling inside ApiDocumensWrapper on Enter point layer + +```kotlin + private suspend fun docsToDocumentWithMetadataList(docs: Docs): List { + var docsWithMetadata = mutableListOf() + + docs.driverLicense?.let { + docsWithMetadata.addAll(groupToDocumentsWithMetadata(it, docs)) + } + + return docsWithMetadata +} +``` +5. Provide DriverLicenseJsonAdapterDelegate in DI inside DocumentsModule on Enter point layer + +```kotlin +@Provides +@Singleton +fun provideDocDelegates(): List> { + return listOf( + DriverLicenceJsonAdapterDelegate() + ) +} +``` diff --git a/doc_driver_license/build.gradle b/doc_driver_license/build.gradle new file mode 100644 index 0000000..1dfbc51 --- /dev/null +++ b/doc_driver_license/build.gradle @@ -0,0 +1,121 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'kotlin-kapt' + id 'kotlin-android' + id 'kotlin-parcelize' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.doc_driver_license' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation deps.activity_ktx + implementation deps.fragment_ktx + implementation deps.appcompat + implementation project(path: ':ui_base') + implementation project(path: ':core') + implementation project(path: ':documents') + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + //Desugaring + coreLibraryDesugaring deps.desugar_jdk_libs + + //Compose + implementation deps.activity_compose + + //test + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.json +} +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/doc_driver_license/consumer-rules.pro b/doc_driver_license/consumer-rules.pro new file mode 100644 index 0000000..b38aa72 --- /dev/null +++ b/doc_driver_license/consumer-rules.pro @@ -0,0 +1 @@ +-keep public class ua.gov.diia.doc_driver_license.DriverLicenseV2 \ No newline at end of file diff --git a/doc_driver_license/excludes.jacoco b/doc_driver_license/excludes.jacoco new file mode 100644 index 0000000..41a7ce3 --- /dev/null +++ b/doc_driver_license/excludes.jacoco @@ -0,0 +1,5 @@ +ua/gov/diia/doc_driver_license/DriverLicenceJsonAdapterDelegate.* +ua/gov/diia/doc_driver_license/DriverLicenseFullInfoComposeMapper.* +ua/gov/diia/doc_driver_license/DriverLicenseV2.* +ua/gov/diia/doc_driver_license/DriverLicenseV2$Data.* +ua/gov/diia/doc_driver_license/DriverLicenseV2$Data$DocData.* \ No newline at end of file diff --git a/doc_driver_license/proguard-rules.pro b/doc_driver_license/proguard-rules.pro new file mode 100644 index 0000000..b38aa72 --- /dev/null +++ b/doc_driver_license/proguard-rules.pro @@ -0,0 +1 @@ +-keep public class ua.gov.diia.doc_driver_license.DriverLicenseV2 \ No newline at end of file diff --git a/doc_driver_license/src/androidTest/java/ua/gov/diia/doc_driver_license/ExampleInstrumentedTest.kt b/doc_driver_license/src/androidTest/java/ua/gov/diia/doc_driver_license/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..5e9d928 --- /dev/null +++ b/doc_driver_license/src/androidTest/java/ua/gov/diia/doc_driver_license/ExampleInstrumentedTest.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.doc_driver_license + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = + InstrumentationRegistry.getInstrumentation().targetContext + assertEquals( + "ua.gov.diia.doc_driver_license.test", + appContext.packageName + ) + } +} \ No newline at end of file diff --git a/doc_driver_license/src/main/AndroidManifest.xml b/doc_driver_license/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3b9f914 --- /dev/null +++ b/doc_driver_license/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenceJsonAdapterDelegate.kt b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenceJsonAdapterDelegate.kt new file mode 100644 index 0000000..ed22163 --- /dev/null +++ b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenceJsonAdapterDelegate.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.doc_driver_license + +import ua.gov.diia.documents.data.datasource.local.DocJsonAdapterDelegate + +class DriverLicenceJsonAdapterDelegate : DocJsonAdapterDelegate( + DriverLicenseV2.Data::class.java, + "driver_licence_v2" +) \ No newline at end of file diff --git a/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenceLocalizationChecker.kt b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenceLocalizationChecker.kt new file mode 100644 index 0000000..cba04b6 --- /dev/null +++ b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenceLocalizationChecker.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.doc_driver_license + +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.ui.BaseLocalizationChecker + +class DriverLicenceLocalizationChecker: BaseLocalizationChecker { + override fun checkLocalizationDocs( + doc: DiiaDocumentWithMetadata + ): String? { + if (doc.diiaDocument is DriverLicenseV2.Data) { + val document = doc.diiaDocument as DriverLicenseV2.Data + if (document.frontCard.ua == null) { + return DriverLicenseConst.NAME + } + } + return null + } +} \ No newline at end of file diff --git a/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseConst.kt b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseConst.kt new file mode 100644 index 0000000..0250845 --- /dev/null +++ b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseConst.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.doc_driver_license + +object DriverLicenseConst { + const val NAME = "driver-license" +} \ No newline at end of file diff --git a/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseFullInfoComposeMapper.kt b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseFullInfoComposeMapper.kt new file mode 100644 index 0000000..1dd1b61 --- /dev/null +++ b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseFullInfoComposeMapper.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.doc_driver_license + +import androidx.compose.runtime.snapshots.SnapshotStateList +import ua.gov.diia.documents.barcode.DocumentBarcodeResultLoading +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.documents.ui.fullinfo.BaseFullInfoComposeMapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addAllIfNotNull + +class DriverLicenseFullInfoComposeMapper( + docComposeMapper: DocumentComposeMapper +): BaseFullInfoComposeMapper, DocumentComposeMapper by docComposeMapper { + override fun mapDocToBody(document: DiiaDocument, bodyData: SnapshotStateList) { + if(document is DriverLicenseV2.Data) { + bodyData.addAllIfNotNull( + document.fullInfo?.find { it.docHeadingOrg != null }?.docHeadingOrg.toComposeDocHeadingOrg(), + document.fullInfo?.find { it.tickerAtm != null }?.tickerAtm.toComposeTickerAtm(), + toComposeContentTableOrg( + document.fullInfo?.mapNotNull { it.tableBlockTwoColumnsOrg }, + document.fullInfo?.mapNotNull { it.tableBlockOrg }, + document.photo?.image, + null + ), + toComposeDocCodeOrg( + DocumentBarcodeResultLoading(loading = true), + localizationType = LocalizationType.ua, + showToggle = true + ) + ) + } + } +} \ No newline at end of file diff --git a/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseV2.kt b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseV2.kt new file mode 100644 index 0000000..29a60f7 --- /dev/null +++ b/doc_driver_license/src/main/java/ua/gov/diia/doc_driver_license/DriverLicenseV2.kt @@ -0,0 +1,315 @@ +package ua.gov.diia.doc_driver_license + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import ua.gov.diia.documents.models.docgroups.BaseDocumentGroup +import ua.gov.diia.core.models.common_compose.atm.chip.ChipStatusAtm +import ua.gov.diia.core.network.Http +import ua.gov.diia.documents.models.* +import ua.gov.diia.core.models.common_compose.atm.text.TickerAtm +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata.Companion.LAST_DOC_ORDER +import ua.gov.diia.documents.models.docgroups.v2.* +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsPlaneOrg.TableBlockTwoColumnsPlaneOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockPlaneOrg.TableBlockPlaneOrg + +@Parcelize +@JsonClass(generateAdapter = true) +class DriverLicenseV2( + @Json(name = "data") + internal val data: List = listOf(), +) : BaseDocumentGroup() { + + override fun getItemType() = DriverLicenseConst.NAME + + @Parcelize + @JsonClass(generateAdapter = true) + data class Data( + @Json(name = "dataForDisplayingInOrderConfigurations") + val dataForDisplayingInOrderConfigurations: DataForDisplayingInOrderConfigurations?, + @Json(name = "content") + val content: List?, + @Json(name = "docData") + val docData: DocData?, + @Json(name = "docNumber") + val docNumber: String?, + @Json(name = "docStatus") + internal var docStatus: Int, + @Json(name = "frontCard") + val frontCard: FrontCard, + @Json(name = "fullInfo") + val fullInfo: List?, + @Json(name = "id") + override val id: String?, + @Json(name = "localization") + internal var localization: LocalizationType?, + @Json(name = "expirationDate") + internal val expirationDate: String = "", + @Json(name = "shareLocalization") + internal var shareLocalization: LocalizationType?, + @Json(name = "order") + internal var order: Int = LAST_DOC_ORDER + ) : DiiaDocument, DocumentWithPhoto, WithFrontCard { + + @Parcelize + @JsonClass(generateAdapter = true) + data class DocData( + @Json(name = "docName") + val docName: String?, + @Json(name = "birthday") + val birthday: String?, + @Json(name = "category") + val category: String?, + @Json(name = "fullName") + val fullName: String? + ) : Parcelable + + @IgnoredOnParcel + val photo = content?.find { it.code == "photo" } + + override fun docId() = id ?: "" + override fun getItemType() = DriverLicenseConst.NAME + override fun getDocColor() = R.color.purple_light + override fun getDocNum() = docNumber + override fun getStatus(): Int = docStatus + + fun setStatus(status: Int){ + docStatus = status + } + override fun getExpirationDateISO() = Preferences.DEF + override fun localization(): LocalizationType? = localization + + override fun setLocalization(code: LocalizationType) { + localization = code + } + + override fun getDocExpirationDate() = expirationDate + + override fun getWeight() = DocWeight.WEIGHT_4 + override fun getPhoto() = photo?.image + override fun getLightParcelable(): DiiaDocument { + throw IllegalStateException() + } + + override fun makeCopy() = this.copy() + override fun verificationCodesCount() = 2 + override fun getPersonName(): String { + return docData?.fullName ?: "" + } + + override fun getDisplayDate(): String { + return "" + } + + override fun getDocOrder() = order + override fun setNewOrder(newOrder: Int) { + order = newOrder + } + + override fun birthCertificateId() = "" + + override fun getDocName() = docData?.docName + + override fun getDocOrderDescription() = + this.dataForDisplayingInOrderConfigurations?.description + + override fun getDocOrderLabel() = this.dataForDisplayingInOrderConfigurations?.label + + override fun getTicker(): TickerAtm? { + return when (this.localization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.tickerAtm != null + }?.tickerAtm + + LocalizationType.eng -> this.frontCard.en?.find { + it.tickerAtm != null + }?.tickerAtm + + null -> { + when (shareLocalization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.tickerAtm != null + }?.tickerAtm + + LocalizationType.eng -> this.frontCard.en?.find { + it.tickerAtm != null + }?.tickerAtm + + else -> { + this.frontCard.ua?.find { + it.tickerAtm != null + }?.tickerAtm + } + } + } + } + } + + override fun getDocHeading(): DocHeadingOrg? { + return when (this.localization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.docHeadingOrg != null + }?.docHeadingOrg + + LocalizationType.eng -> this.frontCard.en?.find { + it.docHeadingOrg != null + }?.docHeadingOrg + + null -> { + when (shareLocalization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.docHeadingOrg != null + }?.docHeadingOrg + + LocalizationType.eng -> this.frontCard.en?.find { + it.docHeadingOrg != null + }?.docHeadingOrg + + else -> { + this.frontCard.ua?.find { + it.docHeadingOrg != null + }?.docHeadingOrg + } + } + } + } + } + + override fun getDocButtonHeading(): DocButtonHeadingOrg? { + return when (this.localization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.docButtonHeadingOrg != null + }?.docButtonHeadingOrg + + LocalizationType.eng -> this.frontCard.en?.find { + it.docButtonHeadingOrg != null + }?.docButtonHeadingOrg + + null -> { + when (shareLocalization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.docButtonHeadingOrg != null + }?.docButtonHeadingOrg + + LocalizationType.eng -> this.frontCard.en?.find { + it.docButtonHeadingOrg != null + }?.docButtonHeadingOrg + + else -> { + this.frontCard.ua?.find { + it.docButtonHeadingOrg != null + }?.docButtonHeadingOrg + } + } + } + } + } + + override fun getTableBlockTwoColumnsPlane(): List? { + return when (this.localization) { + LocalizationType.ua -> this.frontCard.ua?.mapNotNull { it.tableBlockTwoColumnsPlaneOrg } + LocalizationType.eng -> this.frontCard.en?.mapNotNull { it.tableBlockTwoColumnsPlaneOrg } + null -> { + when (shareLocalization) { + LocalizationType.ua -> this.frontCard.ua?.mapNotNull { it.tableBlockTwoColumnsPlaneOrg } + LocalizationType.eng -> this.frontCard.en?.mapNotNull { it.tableBlockTwoColumnsPlaneOrg } + else -> { + this.frontCard.ua?.mapNotNull { it.tableBlockTwoColumnsPlaneOrg } + + } + } + } + } + } + + override fun getTableBlockPlaneOrg(): List? { + return when (this.localization) { + LocalizationType.ua -> this.frontCard.ua?.mapNotNull { it.tableBlockPlaneOrg } + LocalizationType.eng -> this.frontCard.en?.mapNotNull { it.tableBlockPlaneOrg } + null -> { + when (shareLocalization) { + LocalizationType.ua -> this.frontCard.ua?.mapNotNull { it.tableBlockPlaneOrg } + LocalizationType.eng -> this.frontCard.en?.mapNotNull { it.tableBlockPlaneOrg } + else -> { + this.frontCard.ua?.mapNotNull { it.tableBlockPlaneOrg } + + } + } + } + } + } + + override fun getSubtitleLabel(): SubtitleLabelMlc? { + return when (this.localization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.subtitleLabelMlc != null + }?.subtitleLabelMlc + + LocalizationType.eng -> this.frontCard.en?.find { + it.subtitleLabelMlc != null + }?.subtitleLabelMlc + + null -> { + when (shareLocalization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.subtitleLabelMlc != null + }?.subtitleLabelMlc + + LocalizationType.eng -> this.frontCard.en?.find { + it.subtitleLabelMlc != null + }?.subtitleLabelMlc + + else -> { + this.frontCard.ua?.find { + it.subtitleLabelMlc != null + }?.subtitleLabelMlc + } + } + } + } + } + + override fun getChipStatus(): ChipStatusAtm? { + return when (this.localization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.chipStatusAtm != null + }?.chipStatusAtm + + LocalizationType.eng -> this.frontCard.en?.find { + it.chipStatusAtm != null + }?.chipStatusAtm + + null -> { + when (shareLocalization) { + LocalizationType.ua -> this.frontCard.ua?.find { + it.chipStatusAtm != null + }?.chipStatusAtm + + LocalizationType.eng -> this.frontCard.en?.find { + it.chipStatusAtm != null + }?.chipStatusAtm + + else -> { + this.frontCard.ua?.find { + it.chipStatusAtm != null + }?.chipStatusAtm + } + } + } + } + } + } + + override fun getData(): List = data + + companion object { + const val STATUS_OK = Http.HTTP_200 + const val STATUS_NO_PHOTO = Http.HTTP_1010 + const val STATUS_OUTDATED = Http.HTTP_1011 + const val STATUS_NEED_VERIFICATION = Http.HTTP_1012 + const val STATUS_PHOTO_NOT_VALID = Http.HTTP_1013 + const val STATUS_IS_INVALID = Http.HTTP_1016 + } +} \ No newline at end of file diff --git a/doc_driver_license/src/main/res/values/strings.xml b/doc_driver_license/src/main/res/values/strings.xml new file mode 100644 index 0000000..d4d85cf --- /dev/null +++ b/doc_driver_license/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Посвідчення\nводія + \ No newline at end of file diff --git a/doc_driver_license/src/test/java/ua/gov/diia/doc_driver_license/DriverLicenseLocalizationCheckerTest.kt b/doc_driver_license/src/test/java/ua/gov/diia/doc_driver_license/DriverLicenseLocalizationCheckerTest.kt new file mode 100644 index 0000000..71bb583 --- /dev/null +++ b/doc_driver_license/src/test/java/ua/gov/diia/doc_driver_license/DriverLicenseLocalizationCheckerTest.kt @@ -0,0 +1,55 @@ +package ua.gov.diia.doc_driver_license + +import org.junit.Test +import org.junit.Assert.* +import org.junit.Before +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.docgroups.v2.FrontCard + +class DriverLicenseLocalizationCheckerTest { + private lateinit var driverLicenceLocalizationChecker: DriverLicenceLocalizationChecker + private lateinit var mockDoc: DiiaDocumentWithMetadata + private lateinit var mockDriverLicenseData: DriverLicenseV2.Data + private lateinit var mockFrontCard: FrontCard + @Before + fun setUp() { + driverLicenceLocalizationChecker = DriverLicenceLocalizationChecker() + mockDoc = mock(DiiaDocumentWithMetadata::class.java) + mockDriverLicenseData = mock(DriverLicenseV2.Data::class.java) + mockFrontCard = mock(FrontCard::class.java) + } + + @Test + fun `should return NAME when ua is null`() { + `when`(mockDoc.diiaDocument).thenReturn(mockDriverLicenseData) + `when`(mockDriverLicenseData.frontCard).thenReturn(mockFrontCard) + `when`(mockFrontCard.ua).thenReturn(null) + + val result = driverLicenceLocalizationChecker.checkLocalizationDocs(mockDoc) + + assertEquals(DriverLicenseConst.NAME, result) + } + + @Test + fun `should return null when ua is not null`() { + `when`(mockDoc.diiaDocument).thenReturn(mockDriverLicenseData) + `when`(mockDriverLicenseData.frontCard).thenReturn(mockFrontCard) + `when`(mockFrontCard.ua).thenReturn(emptyList()) + + val result = driverLicenceLocalizationChecker.checkLocalizationDocs(mockDoc) + + assertNull(result) + } + + @Test + fun `should return null when document is not DriverLicenseV2 Data`() { + `when`(mockDoc.diiaDocument).thenReturn(mock(DiiaDocument::class.java)) + + val result = driverLicenceLocalizationChecker.checkLocalizationDocs(mockDoc) + + assertNull(result) + } +} \ No newline at end of file diff --git a/documents/.gitignore b/documents/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/documents/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/documents/README.md b/documents/README.md new file mode 100644 index 0000000..e9d2455 --- /dev/null +++ b/documents/README.md @@ -0,0 +1,168 @@ +# Description + +This is module responsible for documents and logic around it. + +# How to install +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':documents') +``` + +2. Module requires next modules to work +```groovy +implementation project(':core') +implementation project(':diia_storage') +implementation project(':web') +implementation project(path: ':ui_base') +``` + +3. nav_id file describe all ids that this module requires. Entry point should implement all those ids. + +`./src/main/res/values/nav_ids.xml` + +4. Enter point should implement next interfaces and provide them through Hilt DI: +`./src/main/java/ua/gov/diia/documents/data/datasource/local/DocumentsHelper.kt` +`./src/main/java/ua/gov/diia/documents/data/api/ApiDocuments.kt` +`./src/main/java/ua/gov/diia/documents/util/DocNameProvider.kt` +`./src/main/java/ua/gov/diia/documents/ui/WithPdfCertificate.kt` +`./src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryNavigationHelper.kt` +`./src/main/java/ua/gov/diia/documents/ui/WithRemoveDocument.kt` +`./src/main/java/ua/gov/diia/documents/ui/actions/DocActionsNavigationHandler.kt` +`./src/main/java/ua/gov/diia/documents/ui/DocumentComposeMapper.kt` + +5. To provide custom update behaviour for new document group implement next interface and provide it through Hilt DI: +`./src/main/java/ua/gov/diia/documents/data/datasource/local/DocGroupUpdateBehavior.kt` +6. To customize documents before saving to store implement next interface and provide it through Hilt DI: +`./src/main/java/ua/gov/diia/documents/data/datasource/local/DocumentsTransformation.kt` +7. To provide custom action before docs become published implement next interface and provide it through Hilt DI: +`./src/main/java/ua/gov/diia/documents/data/repository/BeforePublishAction.kt` + + +8. Add next nav graphs to root navigation graph +```xml + + + + +``` +9. The following actions should be added into the root navigation graph +```xml + + + + + + + + +``` +```xml + +``` +```xml + + + + +``` +```xml + + + +``` +10. Add next nav graphs to home_children navigation graph +```xml + +``` + +11. The following actions should be added into the home_children nav graph +```xml + +``` + + +## Add new document type + +1. Create new android library module. Module name should start with doc_ prefix + +2. Place document model and DocJsonAdapterDelegate implementation inside of it + +3. Add following field inside document api response on Enter point layer + +```kotlin +@Parcelize +@JsonClass(generateAdapter = true) +data class Docs( + @Json(name = "newDoc") + val newDoc: NewDoc? + ) +``` +4. Implement response handling inside ApiDocumensWrapper on Enter point layer + +```kotlin + private suspend fun docsToDocumentWithMetadataList(docs: Docs): List { + var docsWithMetadata = mutableListOf() + + docs.newDoc?.let { + docsWithMetadata.addAll(groupToDocumentsWithMetadata(it, docs)) + } + + return docsWithMetadata +} +``` +5. Provide DriverLicenseJsonAdapterDelegate in DI inside DocumentsModule on Enter point layer + +```kotlin +@Provides +@Singleton +fun provideDocDelegates(): List> { + return listOf( + NewDocJsonAdapterDelegate() + ) +} +``` diff --git a/documents/build.gradle b/documents/build.gradle new file mode 100644 index 0000000..c1d2caa --- /dev/null +++ b/documents/build.gradle @@ -0,0 +1,146 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + id 'androidx.navigation.safeargs.kotlin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.documents' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + lint { + disable 'MissingTranslation' + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation project(':core') + implementation project(':diia_storage') + implementation project(':web') + implementation project(path: ':ui_base') + implementation deps.activity_ktx + implementation deps.fragment_ktx + implementation deps.appcompat + //Compose + implementation deps.activity_compose + implementation deps.compose_ui + implementation deps.compose_material + debugImplementation deps.compose_ui_tooling + debugImplementation deps.compose_ui_tooling_preview + implementation deps.lottie_compose + //lifecycle + implementation deps.lifecycle_extensions + implementation deps.lifecycle_livedata_ktx + implementation deps.lifecycle_viewmodel_ktx + //navigation + implementation deps.navigation_fragment_ktx + implementation deps.navigation_ui_ktx + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + implementation deps.hilt_work + kapt deps.hilt_compiler + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + // ML + implementation deps.mlkit_vision_common + // barcode + implementation deps.zxing + //Desugaring + coreLibraryDesugaring deps.desugar_jdk_libs + //test + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.mockk_android + testImplementation deps.mockk_agent + testImplementation deps.turbine + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.json + testImplementation deps.turbine + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/documents/consumer-rules.pro b/documents/consumer-rules.pro new file mode 100644 index 0000000..c0f3486 --- /dev/null +++ b/documents/consumer-rules.pro @@ -0,0 +1,2 @@ +-keep public class ua.gov.diia.documents.models.ManualDocs +-keep public class ua.gov.diia.documents.models.docgroups.** {public *;} \ No newline at end of file diff --git a/documents/excludes.jacoco b/documents/excludes.jacoco new file mode 100644 index 0000000..827b589 --- /dev/null +++ b/documents/excludes.jacoco @@ -0,0 +1,12 @@ +ua/gov/diia/documents/ui/**/*F.* +ua/gov/diia/documents/**/*$*.* +ua/gov/diia/documents/**/Nav*Args.* +ua/gov/diia/documents/util/view/** +ua/gov/diia/documents/ui/BottomDoc* +ua/gov/diia/documents/ui/ToggleId* +ua/gov/diia/documents/ui/**/*FComposeArgs.* +ua/gov/diia/documents/barcode/** +ua/gov/diia/documents/data/datasource/local/DocJsonAdapterDelegate.* +ua/gov/diia/documents/**/compose/*.* +ua/gov/diia/documents/util/DocumentActionMapper.* +ua/gov/diia/documents/ui/actions/DocActionsProviderImpl.* \ No newline at end of file diff --git a/documents/proguard-rules.pro b/documents/proguard-rules.pro new file mode 100644 index 0000000..c0f3486 --- /dev/null +++ b/documents/proguard-rules.pro @@ -0,0 +1,2 @@ +-keep public class ua.gov.diia.documents.models.ManualDocs +-keep public class ua.gov.diia.documents.models.docgroups.** {public *;} \ No newline at end of file diff --git a/documents/src/androidTest/java/ua/gov/diia/documents/ExampleInstrumentedTest.kt b/documents/src/androidTest/java/ua/gov/diia/documents/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..65a47f5 --- /dev/null +++ b/documents/src/androidTest/java/ua/gov/diia/documents/ExampleInstrumentedTest.kt @@ -0,0 +1,25 @@ +package ua.gov.diia.documents + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = + InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("ua.gov.diia.documents.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/documents/src/main/AndroidManifest.xml b/documents/src/main/AndroidManifest.xml new file mode 100644 index 0000000..651505e --- /dev/null +++ b/documents/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcode.kt b/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcode.kt new file mode 100644 index 0000000..2be3f50 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcode.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.documents.barcode + +data class DocumentBarcode( + val data: DocumentBarcodeImageData +) + +interface DocumentBarcodeResult + +data class DocumentBarcodeSuccessfulLoadResult( + val shareQr: DocumentBarcode, + val shareEan13: DocumentBarcode?, + val shareEanCode: String?, + val position: Int, + val timerText: String?, + val timerTime: Int? +) : DocumentBarcodeResult + +data class DocumentBarcodeErrorLoadResult(val exception: Exception, val code: Int? = null) : + DocumentBarcodeResult + +data class DocumentBarcodeResultLoading(val loading: Boolean) : + DocumentBarcodeResult \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeFactory.kt b/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeFactory.kt new file mode 100644 index 0000000..c3bfd5a --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeFactory.kt @@ -0,0 +1,98 @@ +package ua.gov.diia.documents.barcode + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Color +import android.util.Base64 +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.oned.EAN13Writer +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import ua.gov.diia.ui_base.components.subatomic.icon.replaceWhiteWithTransparent +import java.util.stream.IntStream +import kotlin.math.roundToInt + +class DocumentBarcodeFactory( + private val qrSizePx: Int, + private val ean13CodeWidth: Int, + private val ean13CodeHeight: Int +) { + + private var qrBmp: Bitmap? = null + private var eanBmp: Bitmap? = null + + suspend fun buildBitmapQrCode(data: String) { + val code = QRCodeWriter().encode( + data, + BarcodeFormat.QR_CODE, + qrSizePx, + qrSizePx, + mapOf( + com.google.zxing.EncodeHintType.MARGIN to 0, + com.google.zxing.EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.H + ) + ) + this.qrBmp = qrToBitmap(code, qrSizePx) + } + + suspend fun buildBitmapEan13Code(data: String) { + val ean = EAN13Writer().encode(data) + this.eanBmp = ean13ToBitmap(ean, ean13CodeWidth, ean13CodeHeight) + } + + fun getQrCodeResult(): DocumentBarcode { + val qrBmp = this.qrBmp + checkNotNull(qrBmp) + return DocumentBarcode(qrBmp.toDocumentBarcodeImage()) + } + + fun getEan13CodeResult(): DocumentBarcode? { + val eanBmp = this.eanBmp ?: return null + return DocumentBarcode(eanBmp.toDocumentBarcodeImage()) + } + + fun parseBase64Barcode(data: String): DocumentBarcode { + val imageBytes = Base64.decode(data, Base64.DEFAULT) + val decodedImage = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size) + .replaceWhiteWithTransparent() + return DocumentBarcode(decodedImage.toDocumentBarcodeImage()) + } + + fun clearResults() { + eanBmp = null + qrBmp = null + } + + private fun ean13ToBitmap(code: BooleanArray, width: Int, height: Int): Bitmap { + val inputWidth = code.size + val outputWidth = inputWidth * (width.toFloat() / inputWidth.toFloat()).roundToInt() + val multiple = outputWidth / inputWidth + var inputX = 0 + var outputX = 0 + val bmp = Bitmap.createBitmap(outputWidth, height, Bitmap.Config.ARGB_8888) + while (inputX < inputWidth) { + if (code[inputX]) { + (0 until height).forEach { y -> + for (columnIndex in outputX until outputX + multiple) { + bmp.setPixel(columnIndex, y, Color.BLACK) + } + } + } + inputX++ + outputX += multiple + } + return bmp + } + + private fun qrToBitmap(code: BitMatrix, size: Int): Bitmap { + return Bitmap.createBitmap( + IntStream.range(0, size).flatMap { h -> + IntStream.range(0, size).map { w -> + if (code.get(w, h)) Color.BLACK else Color.TRANSPARENT + } + }.parallel().toArray(), + size, size, Bitmap.Config.ARGB_8888 + ) + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeImageData.kt b/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeImageData.kt new file mode 100644 index 0000000..a10bc21 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeImageData.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.documents.barcode + +import android.graphics.* +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.common.internal.ImageConvertUtils + +class DocumentBarcodeImageData(bitmap: Bitmap) { + + private val inputImage: InputImage + + private val width: Int + private val height: Int + + init { + this.inputImage = InputImage.fromBitmap(bitmap, 0) + this.width = bitmap.width + this.height = bitmap.height + } + + fun toAndroidBitmap(): Bitmap { + return inputImage.bitmapInternal ?: ImageConvertUtils.getInstance() + .getUpRightBitmap(this.toFirebaseMlInputImage()) + } + + private fun toFirebaseMlInputImage(): InputImage = this.inputImage + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeRepository.kt b/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeRepository.kt new file mode 100644 index 0000000..334106f --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/barcode/DocumentBarcodeRepository.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.documents.barcode + +import ua.gov.diia.documents.models.DiiaDocument + +interface DocumentBarcodeRepository { + suspend fun loadBarcode( + doc: DiiaDocument, + position: Int, + fullInfo: Boolean = false + ): DocumentBarcodeRepositoryResult +} + +data class DocumentBarcodeRepositoryResult( + val result: DocumentBarcodeResult, + val showToggle: Boolean +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/barcode/Extensions.kt b/documents/src/main/java/ua/gov/diia/documents/barcode/Extensions.kt new file mode 100644 index 0000000..595381b --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/barcode/Extensions.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.documents.barcode + +import android.graphics.Bitmap + + +fun Bitmap.toDocumentBarcodeImage(): DocumentBarcodeImageData { + return DocumentBarcodeImageData(this) +} diff --git a/documents/src/main/java/ua/gov/diia/documents/data/api/ApiDocuments.kt b/documents/src/main/java/ua/gov/diia/documents/data/api/ApiDocuments.kt new file mode 100644 index 0000000..52c9df7 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/api/ApiDocuments.kt @@ -0,0 +1,31 @@ +package ua.gov.diia.documents.data.api + +import ua.gov.diia.documents.models.* + +interface ApiDocuments { + + /** + * @return full list of documents + */ + suspend fun fetchDocuments(docTypes: Map): List + /** + * @return list of documents with specific types + */ + suspend fun fetchDocumentsWithTypes(docTypes: Map): DiiaDocumentsWithOrder + /** + * Set new document order + */ + suspend fun setDocumentsOrder(docOrder: DocumentsOrder) + /** + * Set new typed document order + */ + suspend fun setTypedDocumentsOrder(documentType: String, docOrder: TypeDefinedDocumentsOrder) + /** + * @return list of docs to add manually + */ + suspend fun getDocsManual(): ManualDocs + /** + * @return document by id + */ + suspend fun getDocumentById(type: String, id: String): UpdatedDoc +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/BrokenDocFilter.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/BrokenDocFilter.kt new file mode 100644 index 0000000..5b53d9f --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/BrokenDocFilter.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.documents.data.datasource.local + +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +interface BrokenDocFilter { + /** + * Filters existing documents and fills list of documents to remove + */ + fun filter( + docs: List, + existsId: MutableList, + removeList: MutableList + ) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/BrokenDocFilterImpl.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/BrokenDocFilterImpl.kt new file mode 100644 index 0000000..7b8979f --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/BrokenDocFilterImpl.kt @@ -0,0 +1,24 @@ +package ua.gov.diia.documents.data.datasource.local + +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import javax.inject.Inject + +class BrokenDocFilterImpl @Inject constructor(val documentsHelper: DocumentsHelper) : BrokenDocFilter { + override fun filter( + docs: List, + existsId: MutableList, + removeList: MutableList + ) { + docs.forEach { item -> + if (documentsHelper.isDocCanBeBroken(item.type)) { + val id = item.diiaDocument?.docId() + if (id == null || id == "") { + removeList.add(item) + } else { + existsId.add(id) + } + } + } + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DefaultDocGroupUpdateBehavior.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DefaultDocGroupUpdateBehavior.kt new file mode 100644 index 0000000..b4edb39 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DefaultDocGroupUpdateBehavior.kt @@ -0,0 +1,45 @@ +package ua.gov.diia.documents.data.datasource.local + +import ua.gov.diia.core.network.Http +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import javax.inject.Inject + +class DefaultDocGroupUpdateBehavior @Inject constructor(): DocGroupUpdateBehavior { + override fun canHandleType(type: String) = true + + override fun handleUpdate( + docType: String, + docValue: List, + status: Int, + docsToPersist: MutableList, + existsId: List + ) { + when (status) { + Http.HTTP_200, Http.HTTP_404 -> { + docsToPersist.removeAll { it.type == docType } + docsToPersist.addAll(docValue) + } + Http.COVID_CERT_IN_PROGRESS_STATUS -> { + docsToPersist.apply { + addAll(docValue.map { + it.copy(expirationDate = Preferences.DEF) + }) + } + } + else -> { + val docs = docsToPersist.filter { it.type == docType } + if (docs.isEmpty()) { + docsToPersist.addAll(docValue) + } else { + docs.forEach { + it.expirationDate = docValue.first().expirationDate + if (status != Http.HTTP_403) { + it.status = status + } + } + } + } + } + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocGroupUpdateBehavior.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocGroupUpdateBehavior.kt new file mode 100644 index 0000000..619f7d4 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocGroupUpdateBehavior.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.documents.data.datasource.local + +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +interface DocGroupUpdateBehavior { + + /** + * @return true if implementation can handle this type of document + */ + fun canHandleType(type: String): Boolean + + /** + * Handle update of existing document group + */ + fun handleUpdate( + docType: String, + docValue: List, + status: Int, + docsToPersist: MutableList, + existsId: List + ) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocJsonAdapterDelegate.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocJsonAdapterDelegate.kt new file mode 100644 index 0000000..4179877 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocJsonAdapterDelegate.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.documents.data.datasource.local + +import ua.gov.diia.documents.models.DiiaDocument + +open class DocJsonAdapterDelegate( + val subtype: Class, + val label: String +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocumentsTransformation.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocumentsTransformation.kt new file mode 100644 index 0000000..c326416 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/DocumentsTransformation.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.documents.data.datasource.local + +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +interface DocumentsTransformation { + + /** + * Transforms documents state + */ + fun transform(data: List) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/KeyValueDocumentsDataSource.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/KeyValueDocumentsDataSource.kt new file mode 100644 index 0000000..b7a0ca3 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/KeyValueDocumentsDataSource.kt @@ -0,0 +1,149 @@ +package ua.gov.diia.documents.data.datasource.local + +import com.squareup.moshi.JsonAdapter +import ua.gov.diia.core.network.Http +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.model.PreferenceKey +import ua.gov.diia.diia_storage.store.AbstractKeyValueDataSource +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata.Companion.LAST_DOC_ORDER +import ua.gov.diia.documents.util.datasource.DateCompareExpirationStrategy +import ua.gov.diia.documents.util.datasource.ExpirationStrategy + +class KeyValueDocumentsDataSource ( + override val jsonAdapter: JsonAdapter>, + diiaStorage: DiiaStorage, + private val docTransformations: List, + private val docTypesAvailableToUsers: Set, + private val expirationStrategy: ExpirationStrategy, + private val documentsHelper: DocumentsHelper, + private val docGroupUpdateBehaviors: List, + private val defaultDocGroupUpdateBehavior: DefaultDocGroupUpdateBehavior, + private val brokenDocFilter: BrokenDocFilter, + private val removeExpiredDocBehavior: RemoveExpiredDocBehavior, + withCrashlytics: WithCrashlytics +) : AbstractKeyValueDataSource>( + diiaStorage, + withCrashlytics +) { + + override val preferenceKey: PreferenceKey = Preferences.Documents + + suspend fun fetchDocuments(): List? { + return if (store.containsKey(preferenceKey)) { + val data = loadData() + + return documentsHelper.migrateDocuments(data, ::saveDataToStore) + } else null + } + + suspend fun updateData(data: List): DataSourceDataResult>? { + if (data.isEmpty()) return null + + val currentlyStoredDocuments = loadData().orEmpty() + val docsToPersist = currentlyStoredDocuments.toMutableList() + val existsId = mutableListOf() + val removeList = mutableListOf() + brokenDocFilter.filter(docsToPersist, existsId, removeList) + docsToPersist.removeAll(removeList) + data.groupBy { it.type }.forEach { updateDocGroup -> + val docType = updateDocGroup.key + val docValue = updateDocGroup.value + val behavior = + docGroupUpdateBehaviors.find { it.canHandleType(docType) } + ?: defaultDocGroupUpdateBehavior + behavior.handleUpdate( + docType = docType, + docValue = docValue, + status = updateDocGroup.value.first().status, + docsToPersist = docsToPersist, + existsId = existsId + ) + } + + processDataAndSaveToStore(docsToPersist) + return DataSourceDataResult.successful(docsToPersist) + } + + suspend fun processExpiredData(data: List): List { + removeExpiredDocBehavior.removeExpiredDocs(data, ::removeDocument) + if (expirationStrategy is DateCompareExpirationStrategy) { + expirationStrategy.reset() + } + var expiredData = data.filter { expirationStrategy.isExpired(it) } + + //handle case when new doc types added but client had not received it before + val availableDocTypes = data.map { it.type } + expiredData = + expiredData + docTypesAvailableToUsers.filter { it !in availableDocTypes } + .map { + DiiaDocumentWithMetadata( + status = Http.HTTP_404, + type = it, + timestamp = "", + expirationDate = Preferences.DEF, + diiaDocument = null, + order = LAST_DOC_ORDER + ) + } + + return expiredData + } + + suspend fun removeDocument(diiaDocument: DiiaDocument): DataSourceDataResult>? { + val currentData = loadData() ?: return null + with(currentData.filter { it.diiaDocument != diiaDocument }) { + saveDataToStore(this) + return DataSourceDataResult.successful(this) + } + } + + suspend fun updateDocument(document: DiiaDocument): DataSourceDataResult>? { + val currentData = loadData() + val newData = mutableListOf() + var updateData = false + currentData?.forEach { diiaDocument -> + if (diiaDocument.diiaDocument?.docId() == document.docId()) { + updateData = true + newData.add(diiaDocument.copy(diiaDocument = document)) + } else { + newData.add(diiaDocument) + } + } + return if (updateData) { + saveDataToStore(newData) + DataSourceDataResult.successful(newData) + } else null + } + + suspend fun replaceExpDateByType(documentTypes: List): DataSourceDataResult>? { + val docList = loadData() ?: return null + val dataToAdd = documentTypes.map { newType -> + DiiaDocumentWithMetadata( + status = documentsHelper.getExpiredDocStatus(newType), + type = newType, + timestamp = "", + expirationDate = Preferences.DEF, + diiaDocument = null, + order = LAST_DOC_ORDER + ) + } + with(docList + dataToAdd) { + saveDataToStore(this) + return DataSourceDataResult.successful(this) + } + } + + private fun processDataAndSaveToStore(data: List) { + docTransformations.forEach { transformation -> + transformation.transform(data) + } + saveDataToStore(data) + } + +} diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/RemoveExpiredDocBehavior.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/RemoveExpiredDocBehavior.kt new file mode 100644 index 0000000..dadb5fc --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/RemoveExpiredDocBehavior.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.documents.data.datasource.local + +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +interface RemoveExpiredDocBehavior { + /** + * Removing expired documents + */ + suspend fun removeExpiredDocs( + data: List, + remove: suspend (DiiaDocument) -> Unit + ) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/RemoveExpiredDocBehaviorImpl.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/RemoveExpiredDocBehaviorImpl.kt new file mode 100644 index 0000000..2becc3f --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/local/RemoveExpiredDocBehaviorImpl.kt @@ -0,0 +1,32 @@ +package ua.gov.diia.documents.data.datasource.local + +import ua.gov.diia.core.util.extensions.date_time.getUTCDate +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.core.util.date.CurrentDateProvider +import javax.inject.Inject + +class RemoveExpiredDocBehaviorImpl @Inject constructor(private val currentDateProvider: CurrentDateProvider, + private val documentsHelper: DocumentsHelper) : + RemoveExpiredDocBehavior { + override suspend fun removeExpiredDocs( + data: List, + remove: suspend (DiiaDocument) -> Unit + ) { + data.forEach { item -> + if (documentsHelper.isDocEligibleForDeletion(item.type)) { + val date = item.diiaDocument?.getExpirationDateISO() ?: return + if (date != Preferences.DEF && getUTCDate(date)?.before( + currentDateProvider.getDate() + ) == true + ) { + item.diiaDocument?.let { document -> + remove(document) + } + } + } + } + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/datasource/remote/NetworkDocumentsDataSource.kt b/documents/src/main/java/ua/gov/diia/documents/data/datasource/remote/NetworkDocumentsDataSource.kt new file mode 100644 index 0000000..7cea372 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/datasource/remote/NetworkDocumentsDataSource.kt @@ -0,0 +1,66 @@ +package ua.gov.diia.documents.data.datasource.remote + +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.documents.data.api.ApiDocuments +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DocumentsOrder +import ua.gov.diia.documents.models.FetchDocumentsResult +import ua.gov.diia.documents.models.TypeDefinedDocumentsOrder + +class NetworkDocumentsDataSource ( + private val apiDocs: ApiDocuments, + private val withCrashlytics: WithCrashlytics +) : WithCrashlytics by withCrashlytics { + + suspend fun fetchData(docTypes: Set): DataSourceDataResult> { + return try { + DataSourceDataResult.successful(apiDocs.fetchDocuments(docTypes.mapTypes())) + } catch (e: Exception) { + sendNonFatalError(e) + DataSourceDataResult.failed(e) + } + } + + /** + * Returns pair of requested documents and general documents order + */ + suspend fun fetchDocsWithTypes(docTypes: Set): FetchDocumentsResult { + return try { + val data = apiDocs.fetchDocumentsWithTypes(docTypes.mapTypes()) + FetchDocumentsResult( + documents = data.documents, + docOrder = data.docOrder + ) + } catch (e: Exception) { + FetchDocumentsResult(exception = e) + } + } + + suspend fun saveDocOrderForSpecificType( + documentType: String, + docOrder: TypeDefinedDocumentsOrder + ) { + try { + apiDocs.setTypedDocumentsOrder( + documentType = documentType, + docOrder = docOrder + ) + } catch (e: Exception) { + sendNonFatalError(e) + } + } + + suspend fun setDocumentsOrder(docOrder: DocumentsOrder) { + try { + apiDocs.setDocumentsOrder(docOrder = docOrder) + } catch (e: Exception) { + sendNonFatalError(e) + } + } + + private fun Collection.mapTypes(): Map { + return withIndex().associateBy({ "filter[${it.index}]" }, { it.value }) + } + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/repository/BeforePublishAction.kt b/documents/src/main/java/ua/gov/diia/documents/data/repository/BeforePublishAction.kt new file mode 100644 index 0000000..3032b8c --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/repository/BeforePublishAction.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.documents.data.repository + +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +interface BeforePublishAction { + /** + * perform some action before publish documents + */ + suspend fun perform(data: List) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/data/repository/DocumentsDataRepository.kt b/documents/src/main/java/ua/gov/diia/documents/data/repository/DocumentsDataRepository.kt new file mode 100644 index 0000000..c412136 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/repository/DocumentsDataRepository.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.documents.data.repository + +import ua.gov.diia.diia_storage.store.datasource.DataSource +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DocOrder +import ua.gov.diia.documents.models.TypeDefinedDocOrder + +interface DocumentsDataRepository : DataSource> { + + fun updateDocOrder(docTypeList: List) + + fun attachExternalDocument(document: DiiaDocumentWithMetadata) + + fun removeDocument(diiaDocument: DiiaDocument) + + fun updateDocument(diiaDocument: DiiaDocument) + + fun replaceExpDateByType(types: List) + + fun saveDocTypeOrder(docOrders: List) + + fun saveDocOrderForSpecificType( + docOrders: List, + docType: String + ) + + suspend fun getDocsByType(type: String): List? + + suspend fun loadLocalDocData(): List? + + suspend fun clear() + +} diff --git a/documents/src/main/java/ua/gov/diia/documents/data/repository/DocumentsDataRepositoryImpl.kt b/documents/src/main/java/ua/gov/diia/documents/data/repository/DocumentsDataRepositoryImpl.kt new file mode 100644 index 0000000..eb9d736 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/data/repository/DocumentsDataRepositoryImpl.kt @@ -0,0 +1,219 @@ +package ua.gov.diia.documents.data.repository + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.documents.data.datasource.local.KeyValueDocumentsDataSource +import ua.gov.diia.documents.data.datasource.remote.NetworkDocumentsDataSource +import ua.gov.diia.documents.models.* + +class DocumentsDataRepositoryImpl( + private val scope: CoroutineScope, + private val keyValueDataSource: KeyValueDocumentsDataSource, + private val networkDocumentsDataSource: NetworkDocumentsDataSource, + private val beforePublishActions: List, + private val docTypesAvailableToUsers: Set, + private val withCrashlytics: WithCrashlytics +) : DocumentsDataRepository, WithCrashlytics by withCrashlytics{ + + private val _isDataLoading = MutableStateFlow(false) + override val isDataLoading: Flow + get() = _isDataLoading + + private val _data = MutableSharedFlow>>( + replay = 1, + extraBufferCapacity = 0, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override val data: Flow>> + get() = _data + + private val baseDocumentList = listOf( + DiiaDocumentWithMetadata.DOC_ERROR + ) + + private var invalidateDocJob: Job? = null + + override fun invalidate() { + if (invalidateDocJob?.isActive == true) { + return + } + + invalidateDocJob = scope.launch { + _isDataLoading.value = true + val cachedDocuments = keyValueDataSource.fetchDocuments() + if (cachedDocuments != null) { + emitData(DataSourceDataResult.successful(cachedDocuments)) + _isDataLoading.value = false + + val result = processExpiredData(cachedDocuments) + if (!result.isNullOrEmpty()) { + emitData(DataSourceDataResult.successful(result)) + _isDataLoading.value = false + + } + + } else { + _isDataLoading.value = true + emitData(DataSourceDataResult.successful(baseDocumentList)) + val networkData = networkDocumentsDataSource.fetchData(docTypesAvailableToUsers) + if (networkData.isSuccessful) { + networkData.data?.let { + emitData(keyValueDataSource.updateData(it)) + } + } + _isDataLoading.value = false + } + } + } + + override fun updateDocOrder(docTypeList: List) { + scope.launch { updateOrder(docTypeList) } + } + + override fun attachExternalDocument(document: DiiaDocumentWithMetadata) { + scope.launch { + loadLocalDocData() + ?.filter { it.type == document.type && it.status == document.status && it.id != document.id } + ?.toMutableList() + ?.apply { add(document) } + ?.also { data -> emitData(keyValueDataSource.updateData(data)) } + } + } + + override fun removeDocument(diiaDocument: DiiaDocument) { + scope.launch { + emitData(keyValueDataSource.removeDocument(diiaDocument)) + } + } + + override fun updateDocument(diiaDocument: DiiaDocument) { + scope.launch { + emitData(keyValueDataSource.updateDocument(diiaDocument)) + } + } + + override fun replaceExpDateByType(types: List) { + scope.launch { + emitData(keyValueDataSource.replaceExpDateByType(types)) + } + } + + override suspend fun getDocsByType(type: String) = loadLocalDocData() + ?.filter { it.type == type } + ?.map { it.diiaDocument } + + override fun saveDocTypeOrder(docOrders: List) { + scope.launch { + if (docOrders.isEmpty()) return@launch + + val docsList = loadLocalDocData() + ?.filter { it.diiaDocument !is DocError } + ?: return@launch + + docOrders.forEach { docOrder -> + docsList.updateDocOrderByType(docOrder) + } + keyValueDataSource.updateData(docsList) + + networkDocumentsDataSource.setDocumentsOrder(DocumentsOrder(docOrders)) + } + } + + override fun saveDocOrderForSpecificType( + docOrders: List, + docType: String, + ) { + scope.launch { + if (docOrders.isEmpty()) return@launch + val docsList = loadLocalDocData() + ?.filter { it.diiaDocument != null && it.diiaDocument !is DocError } + ?: return@launch + + val documentsFilteredByType = docsList.filter { it.type == docType } + docOrders.forEach { docOrder -> + documentsFilteredByType + .find { it.diiaDocument?.getDocNum() == docOrder.docNumber } + ?.diiaDocument?.setNewOrder(docOrder.order) + } + + emitData(keyValueDataSource.updateData(docsList)) + + networkDocumentsDataSource.saveDocOrderForSpecificType( + documentType = docType, + docOrder = TypeDefinedDocumentsOrder(docOrders) + ) + } + } + + override suspend fun loadLocalDocData() = keyValueDataSource.loadData() + + override suspend fun clear() = emitData(DataSourceDataResult.successful(emptyList())) + + private fun List.updateDocOrderByType(docOrder: DocOrder) { + filter { it.type == docOrder.documentType }.forEach { it.order = docOrder.order } + } + + private suspend fun updateExpiredData(docTypes: Set): List { + val docFetchResult = networkDocumentsDataSource.fetchDocsWithTypes(docTypes) + if (docFetchResult.isSuccessful) { + updateOrder(docFetchResult.docOrder) + } + + return docFetchResult.documents + } + + private suspend fun emitData(dataResult: DataSourceDataResult>?) { + val data = dataResult?.data + if (dataResult?.isSuccessful == true && data != null) { + _data.tryEmit(DataSourceDataResult.successful(prepareDataToPublish(data))) + } + } + + private suspend fun prepareDataToPublish(data: List): List { + beforePublishActions.forEach { action -> + action.perform(data) + } + return baseDocumentList + data + } + + private suspend fun processExpiredData(data: List): List? { + with(keyValueDataSource) { + val expireData = processExpiredData(data) + val expiredList = expireData.map { it.type }.toSet() + if (expiredList.isNotEmpty()) { + updateData(updateExpiredData(expiredList)) + return loadLocalDocData() + } + } + return null + } + + private suspend fun updateOrder(docTypeList: List) { + try { + val data = loadLocalDocData() ?: return + var dataUpdated = false + data.filter { it.diiaDocument !is DocError } + .forEach { doc -> + val newOrder = docTypeList.indexOf(doc.type) + 1 + if (doc.order != newOrder) { + dataUpdated = true + doc.setNewOrder(newOrder) + } + } + + if (dataUpdated) { + keyValueDataSource.updateData(data) + } + } catch (e: Exception) { + sendNonFatalError(e) + } + } + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/di/Annotations.kt b/documents/src/main/java/ua/gov/diia/documents/di/Annotations.kt new file mode 100644 index 0000000..ddf3d28 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/di/Annotations.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.documents.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class DocTypesAvailableToUsers + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionUpdateDocument \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/di/DocumentDataSourceModule.kt b/documents/src/main/java/ua/gov/diia/documents/di/DocumentDataSourceModule.kt new file mode 100644 index 0000000..af0724e --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/di/DocumentDataSourceModule.kt @@ -0,0 +1,53 @@ +package ua.gov.diia.documents.di + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.adapters.PolymorphicJsonAdapterFactory +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.documents.data.datasource.local.DocJsonAdapterDelegate +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DocumentDataSourceModule { + + @Provides + @Singleton + fun provideDocumentJsonAdapter(docDelegates: List<@JvmSuppressWildcards DocJsonAdapterDelegate>): JsonAdapter> { + val moshi = Moshi.Builder().add( + PolymorphicJsonAdapterFactory.of(DiiaDocument::class.java, "__type") + .let { + return@let registerJsonAdapters(it, docDelegates) + } + ) + .add(KotlinJsonAdapterFactory()) + .build() + return moshi.adapter( + Types.newParameterizedType( + MutableList::class.java, + DiiaDocumentWithMetadata::class.java + ) + ) + } + + + private fun registerJsonAdapters( + factory: PolymorphicJsonAdapterFactory, + delegates: List> + ): PolymorphicJsonAdapterFactory { + return if (delegates.isEmpty()) factory + else { + val delegate = delegates.first() + val result = factory.withSubtype(delegate.subtype, delegate.label) + val newList = delegates.filter { it != delegate } + registerJsonAdapters(result, newList) + } + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/di/ExpirationStrategyModule.kt b/documents/src/main/java/ua/gov/diia/documents/di/ExpirationStrategyModule.kt new file mode 100644 index 0000000..fb01b6f --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/di/ExpirationStrategyModule.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.documents.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.core.util.date.CurrentDateProvider +import ua.gov.diia.documents.util.datasource.* +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ExpirationStrategyModule { + + @Provides + @Singleton + fun provideExpirationStrategy( + currentDateProvider: CurrentDateProvider, + ): ExpirationStrategy = DateCompareExpirationStrategy(currentDateProvider) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/helper/DocumentsHelper.kt b/documents/src/main/java/ua/gov/diia/documents/helper/DocumentsHelper.kt new file mode 100644 index 0000000..5828e55 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/helper/DocumentsHelper.kt @@ -0,0 +1,61 @@ +package ua.gov.diia.documents.helper + +import android.content.res.Resources +import android.os.Parcelable +import androidx.fragment.app.Fragment +import ua.gov.diia.core.models.rating_service.RatingFormModel +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.ui.DocVM +import ua.gov.diia.documents.ui.actions.DocActionsDFCompose +import ua.gov.diia.documents.ui.actions.DocActionsDFComposeArgs +import ua.gov.diia.ui_base.components.infrastructure.event.DocAction +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData + +interface DocumentsHelper { + + fun isDocCanBeBroken(docType: String): Boolean + + fun getExpiredDocStatus(docType: String): Int + + /** + * Leave empty if no migration needed + */ + suspend fun migrateDocuments( + data: List?, + shouldSaveData: (data: List) -> Unit): List? + + fun isDocEligibleForDeletion(docType: String): Boolean + + fun isDocumentValid(receivedDoc: DiiaDocumentWithMetadata): Boolean + + fun provideListOfDocumentsRequireUpdateOfExpirationDate(focusDocType: String): List? + + fun showVerificationButtons(document: Parcelable): Boolean + + fun isDocRequireGeneralMenuActions(doc: Parcelable): Boolean + + fun isDocRequireHousingMenuActions(doc: Parcelable): Boolean + + fun getStackHeader(fragment: Fragment, docType: String): String + + /** + * performs navigation to RatingService + */ + fun navigateToRatingService(fragment: Fragment, + viewModel: DocVM, + form: RatingFormModel, + isFromStack: Boolean = false + ) + + /** + * performs navigation to StackDocs + */ + fun navigateToStackDocs(fragment: Fragment, doc: DiiaDocument) + /** + * performs navigation to DocOrder + */ + fun navigateToDocOrder(fragment: Fragment) + fun handleAction(fragment: DocActionsDFCompose, action: DocAction, args: DocActionsDFComposeArgs) + fun provideActions(document: DiiaDocument, enableStackActions: Boolean, resources: Resources): List? +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/DiiaDocuments.kt b/documents/src/main/java/ua/gov/diia/documents/models/DiiaDocuments.kt new file mode 100644 index 0000000..4a70ec0 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/DiiaDocuments.kt @@ -0,0 +1,239 @@ +package ua.gov.diia.documents.models + +import android.os.Parcelable +import androidx.annotation.ColorRes +import androidx.annotation.Keep +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.chip.ChipStatusAtm +import ua.gov.diia.core.network.Http.HTTP_200 +import ua.gov.diia.documents.models.docgroups.* +import ua.gov.diia.documents.models.docgroups.v2.* +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.core.models.common_compose.atm.text.TickerAtm +import ua.gov.diia.core.models.common_compose.table.tableBlockPlaneOrg.TableBlockPlaneOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsPlaneOrg.TableBlockTwoColumnsPlaneOrg + +@Parcelize +@JsonClass(generateAdapter = true) +data class DiiaDocumentWithMetadata( + @Json(name = "document") val diiaDocument: DiiaDocument?, + @Json(name = "timestamp") internal val timestamp: String, + @Json(name = "expirationDate") internal var expirationDate: String, + @Json(name = "status") internal var status: Int, + @Json(name = "type") val type: String, + @Json(name = "order") internal var order: Int = LAST_DOC_ORDER, +) : Expiring, + WithTimestamp, + WithStatus, + WithId, + WithOrder, Parcelable { + + fun setStatus(status: Int) { + this.status = status + } + + fun setExpirationDate(expirationDate: String) { + this.expirationDate = expirationDate + } + + override val id: String + get() = diiaDocument?.id ?: Preferences.DEF + + override fun getStatus() = status + + override fun getTimestamp() = timestamp + + override fun getDocExpirationDate() = expirationDate + + override fun getDocOrder() = order + + + override fun setNewOrder(newOrder: Int) { + order = if (newOrder < 0) { + LAST_DOC_ORDER + } else { + newOrder + } + } + + companion object { + + const val LAST_DOC_ORDER = Int.MAX_VALUE - 1 + const val FIRST_DOC_ORDER = Int.MIN_VALUE + 1 + + private const val EMPTY_VALUE = "" + private const val ERROR_PLACEHOLDER = "error_placeholder" + + val DOC_ERROR = DiiaDocumentWithMetadata( + DocError(), + EMPTY_VALUE, + EMPTY_VALUE, + HTTP_200, + ERROR_PLACEHOLDER, + //should be after last doc + LAST_DOC_ORDER + 1 + ) + + } + +} + +interface WithOrder { + + fun getDocOrder(): Int + + fun setNewOrder(newOrder: Int) +} + +interface WithSeriesAndNumber { + + fun getSeries(): String + + fun getNumber(): String + +} + +interface DocumentWithPhoto { + + fun getPhoto(): String? + + fun setPhoto(photo: String?) {} + + fun getLightParcelable(): DiiaDocument +} + +interface WithIssueDate { + fun getIssueDate(): String? +} + +interface WithTaxpayerCard { + + fun getTaxpayerCard(): TaxPayerCard? + + fun setTaxpayerCard(taxpayerCard: TaxPayerCard) +} + +interface DiiaDocumentGroup : Expiring, + WithStatus, + WithTimestamp, Parcelable, + WithType, WithOrder { + + fun getData(): List +} + +@Keep +interface DiiaDocument : Expiring, + WithId, + WithDocNum, + WithDocName, + WithStatus, + WithWeight, + WithOrder, + Parcelable { + + fun docId() = String() + + @ColorRes + fun getDocColor(): Int + + fun getItemType(): String + + override fun equals(other: Any?): Boolean + + fun makeCopy(): DiiaDocument + + fun verificationCodesCount(): Int + + fun getPersonName(): String + + fun getDisplayDate(): String + + fun birthCertificateId(): String + + fun getExpirationDateISO(): String + + fun localization(): LocalizationType? + + fun setLocalization(code: LocalizationType) +} + +enum class LocalizationType { + ua, eng +} + +interface Expiring { + + fun getDocExpirationDate(): String +} + +interface WithTimestamp { + + fun getTimestamp(): String +} + +interface WithStatus { + + fun getStatus(): Int +} + +interface WithWeight { + + fun getWeight(): Int +} + +interface WithType { + + fun getItemType(): String +} + +interface WithId { + + val id: String? +} + +interface WithDocNum { + + fun getDocNum(): String? +} + +interface WithDocName { + + fun getDocName(): String? + + fun getDocOrderLabel(): String? + + fun getDocOrderDescription(): String? +} + +interface WithRegistrationPlace { + var currentRegistrationPlaceUA: String? +} + +interface WithQrCode { + val qrCode: String? + val qr: String? +} + +interface Passport: WithRegistrationPlace, WithTaxpayerCard { + + val recordNumber: String? + +} + +interface WithFrontCard { + fun getTicker(): TickerAtm? + + fun getDocHeading(): DocHeadingOrg? + + fun getDocButtonHeading(): DocButtonHeadingOrg? + + fun getTableBlockTwoColumnsPlane(): List? + + fun getTableBlockPlaneOrg(): List? + + fun getSubtitleLabel(): SubtitleLabelMlc? + + fun getChipStatus(): ChipStatusAtm? +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/DiiaDocumentsWithOrder.kt b/documents/src/main/java/ua/gov/diia/documents/models/DiiaDocumentsWithOrder.kt new file mode 100644 index 0000000..1e9778b --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/DiiaDocumentsWithOrder.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.documents.models + +data class DiiaDocumentsWithOrder( + val documents: List = emptyList(), + val docOrder: List = emptyList() +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/DocEmpty.kt b/documents/src/main/java/ua/gov/diia/documents/models/DocEmpty.kt new file mode 100644 index 0000000..7090fad --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/DocEmpty.kt @@ -0,0 +1,42 @@ +package ua.gov.diia.documents.models + +import kotlinx.parcelize.Parcelize +import ua.gov.diia.documents.R +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata.Companion.LAST_DOC_ORDER +import ua.gov.diia.core.network.Http.HTTP_200 +import ua.gov.diia.diia_storage.store.Preferences +import java.util.UUID + +@Parcelize +data class DocError( + val empty: String = DOCUMENT_ERROR, + val message: String = "", + override val id: String? = UUID.randomUUID().toString() +) : DiiaDocument { + + override fun docId() = id ?: "" + override fun getItemType() = DOCUMENT_ERROR + override fun getDocExpirationDate(): String = Preferences.DEF + override fun getExpirationDateISO(): String = Preferences.DEF + override fun getStatus() = HTTP_200 + override fun getWeight() = Int.MAX_VALUE + override fun getDocOrder() = LAST_DOC_ORDER + override fun setNewOrder(newOrder: Int) { + } + override fun getDocColor() = R.color.colorPrimary + override fun getDocNum() = id + override fun makeCopy(): DiiaDocument = this.copy() + override fun verificationCodesCount() = 1 + override fun getPersonName(): String = "" + override fun getDisplayDate(): String = "" + override fun birthCertificateId() = "" + override fun localization(): LocalizationType = LocalizationType.ua + override fun setLocalization(code: LocalizationType) {} + override fun getDocName() = "" + override fun getDocOrderDescription() = "" + override fun getDocOrderLabel() = "" + + companion object { + private const val DOCUMENT_ERROR = "doc_error" + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/DocOrder.kt b/documents/src/main/java/ua/gov/diia/documents/models/DocOrder.kt new file mode 100644 index 0000000..7aa8393 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/DocOrder.kt @@ -0,0 +1,35 @@ +package ua.gov.diia.documents.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DocumentsOrder( + @Json(name = "documentsOrder") + val documentsOrder: List +) + +@JsonClass(generateAdapter = true) +data class TypeDefinedDocumentsOrder( + @Json(name = "documentsOrder") + val documentsOrder: List +) + + +interface DocumentOrderModel + +@JsonClass(generateAdapter = true) +data class DocOrder( + @Json(name = "documentType") + val documentType: String, + @Json(name = "order") + val order: Int +): DocumentOrderModel + +@JsonClass(generateAdapter = true) +data class TypeDefinedDocOrder( + @Json(name = "docNumber") + val docNumber: String, + @Json(name = "order") + val order: Int +): DocumentOrderModel \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/DocWeight.kt b/documents/src/main/java/ua/gov/diia/documents/models/DocWeight.kt new file mode 100644 index 0000000..702b2c3 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/DocWeight.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.documents.models + +object DocWeight { + const val WEIGHT_0 = 0 + const val WEIGHT_1 = 1 + const val WEIGHT_2 = 2 + const val WEIGHT_3 = 3 + const val WEIGHT_4 = 4 + const val WEIGHT_5 = 5 + const val WEIGHT_6 = 6 + const val WEIGHT_7 = 7 + const val WEIGHT_8 = 8 + const val WEIGHT_9 = 9 + const val WEIGHT_10 = 10 + const val WEIGHT_11 = 11 + const val WEIGHT_12 = 12 + const val WEIGHT_13 = 13 + const val WEIGHT_14 = 14 + const val WEIGHT_15 = 15 + const val WEIGHT_16 = 16 +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/DocumentCard.kt b/documents/src/main/java/ua/gov/diia/documents/models/DocumentCard.kt new file mode 100644 index 0000000..9262e91 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/DocumentCard.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.documents.models + +import android.graphics.Bitmap +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +data class DocumentCard( + var doc: DiiaDocumentWithMetadata, + var docCount: Int = 1, + var qrLoadException: java.lang.Exception? = null, + var qrBitmap: Bitmap? = null, + var ean13Bitmap: Bitmap? = null, + var eanCode: String? = null, +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/FetchDocumentsResult.kt b/documents/src/main/java/ua/gov/diia/documents/models/FetchDocumentsResult.kt new file mode 100644 index 0000000..272af13 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/FetchDocumentsResult.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.documents.models + +data class FetchDocumentsResult( + val documents: List = emptyList(), + val docOrder: List = emptyList(), + var exception: Exception? = null, + val isSuccessful: Boolean = exception == null +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/GeneratePdfFromDoc.kt b/documents/src/main/java/ua/gov/diia/documents/models/GeneratePdfFromDoc.kt new file mode 100644 index 0000000..52e67e4 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/GeneratePdfFromDoc.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.documents.models + +class GeneratePdfFromDoc ( + val docPDF: String, + val name: String +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/ManualDocs.kt b/documents/src/main/java/ua/gov/diia/documents/models/ManualDocs.kt new file mode 100644 index 0000000..5ffe0e0 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/ManualDocs.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.documents.models + + +import android.content.Context +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.ContextMenuField + +@JsonClass(generateAdapter = true) +@Parcelize +data class ManualDocs( + @Json(name = "documents") + val documents: List +) : Parcelable + + +@JsonClass(generateAdapter = true) +@Parcelize +data class DocAction( + @Json(name = "code") + val code: String, + @Json(name = "name") + val name: String +) : Parcelable, ContextMenuField { + + override fun getActionType() = code + + override fun getSubType() = code + + override fun getDisplayName(c: Context) = name + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/Preferences.kt b/documents/src/main/java/ua/gov/diia/documents/models/Preferences.kt new file mode 100644 index 0000000..6c86024 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/Preferences.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.documents.models + +object Preferences { + const val DEF = "PREF_DEF" +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/QRUrl.kt b/documents/src/main/java/ua/gov/diia/documents/models/QRUrl.kt new file mode 100644 index 0000000..570b318 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/QRUrl.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.documents.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class QRUrl( + @Json(name = "id") + val id: String?, + @Json(name = "link") + val link: String, + @Json(name = "barcode") + val shareCode: String?, + @Json(name = "timerText") + val timerText: String, + @Json(name = "timerTime") + val timerTime: Int +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/UpdatedDoc.kt b/documents/src/main/java/ua/gov/diia/documents/models/UpdatedDoc.kt new file mode 100644 index 0000000..b85a561 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/UpdatedDoc.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.documents.models + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class UpdatedDoc( + @Json(name = "educationDocument") + val educationDocument: DiiaDocument?, + @Json(name = "processCode") + val processCode: String?, + @Json(name = "template") + val template: TemplateDialogModel? +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/Action.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/Action.kt new file mode 100644 index 0000000..0539231 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/Action.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.documents.models.docgroups + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class Action( + @Json(name = "resource") + val resource: String?, + @Json(name = "subtype") + val subtype: String?, + @Json(name = "type") + val type: String? +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/BaseDocumentGroup.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/BaseDocumentGroup.kt new file mode 100644 index 0000000..1127901 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/BaseDocumentGroup.kt @@ -0,0 +1,31 @@ +package ua.gov.diia.documents.models.docgroups + +import com.squareup.moshi.Json +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentGroup +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata.Companion.LAST_DOC_ORDER + +abstract class BaseDocumentGroup( + @property:Json(name = "status") + var status: Int? = null, + @property:Json(name = "expirationDate") + var expirationDate: String = Preferences.DEF, + @property:Json(name = "currentDate") + var currentDate: String = Preferences.DEF, + @property:Json(name = "order") + var order: Int = LAST_DOC_ORDER +) : DiiaDocumentGroup { + + override fun getStatus(): Int = status ?: 0 + + override fun getTimestamp() = currentDate + + override fun getDocExpirationDate() = expirationDate + + override fun getDocOrder() = order + + override fun setNewOrder(newOrder: Int) { + order = newOrder + } +} diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/TaxPayerCard.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/TaxPayerCard.kt new file mode 100644 index 0000000..bb3f417 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/TaxPayerCard.kt @@ -0,0 +1,55 @@ +package ua.gov.diia.documents.models.docgroups + +import android.content.Context +import android.os.Parcelable +import androidx.annotation.DrawableRes +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.documents.R + +@Parcelize +@JsonClass(generateAdapter = true) +data class TaxPayerCard( + @Json(name = "status") + val status: Int, + @Json(name = "number") + val number: String, + @Json(name = "creationDate") + val creationDate: String? = null +) : Parcelable { + + fun getVerificationStatusSting(context: Context): String { + return when (status) { + STATUS_OK -> context.getString(R.string.taxpayer_card_status_ok, creationDate) + STATUS_ON_VERIFICATION -> context.getString(R.string.taxpayer_card_status_in_progress) + STATUS_NOT_VERIFIED -> context.getString(R.string.taxpayer_card_status_failed) + else -> { + throw IllegalArgumentException("Unknown taxpayer card status: $status") + } + } + } + + @DrawableRes + fun getStatusIcon(): Int { + return if (status == STATUS_OK) { + R.drawable.ic_checked + } else { + R.drawable.ic_alert + } + } + + fun displayNumberOrPlaceholder(): String { + return if (status == STATUS_OK) { + number + } else { + "ХХХХХХХХХХ" + } + } + + companion object { + const val STATUS_OK = 200 + const val STATUS_ON_VERIFICATION = 1014 + const val STATUS_NOT_VERIFIED = 1015 + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/Content.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/Content.kt new file mode 100644 index 0000000..91935a1 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/Content.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class Content( + @Json(name = "code") + val code: String?, + @Json(name = "image") + var image: String? +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/Data.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/Data.kt new file mode 100644 index 0000000..bc9b9f1 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/Data.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class Data( + @Json(name = "dataForDisplayingInOrderConfigurations") + val dataForDisplayingInOrderConfigurations: DataForDisplayingInOrderConfigurations?, + @Json(name = "content") + val content: List?, + @Json(name = "docData") + val docData: DocData?, + @Json(name = "docNumber") + val docNumber: String?, + @Json(name = "docStatus") + val docStatus: Int?, + @Json(name = "frontCard") + val frontCard: FrontCard?, + @Json(name = "fullInfo") + val fullInfo: List?, + @Json(name = "id") + val id: String?, + @Json(name = "qr") + val qr: String? +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DataForDisplayingInOrderConfigurations.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DataForDisplayingInOrderConfigurations.kt new file mode 100644 index 0000000..bdf9e9b --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DataForDisplayingInOrderConfigurations.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class DataForDisplayingInOrderConfigurations( + @Json(name = "description") + val description: String?, + @Json(name = "iconLeft") + val iconLeft: IconLeft? = null, + @Json(name = "iconRight") + val iconRight: IconRight? = null, + @Json(name = "id") + val id: String?, + @Json(name = "label") + val label: String?, + @Json(name = "logoLeft") + val logoLeft: String? = null, +): Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocButtonHeadingOrg.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocButtonHeadingOrg.kt new file mode 100644 index 0000000..0a44b02 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocButtonHeadingOrg.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm +import ua.gov.diia.core.models.common_compose.table.HeadingWithSubtitlesMlc + +@Parcelize +@JsonClass(generateAdapter = true) +data class DocButtonHeadingOrg( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "docNumberCopyMlc") + val docNumberCopyMlc: DocNumberCopyMlc? = null, + @Json(name = "headingWithSubtitlesMlc") + val headingWithSubtitlesMlc: HeadingWithSubtitlesMlc? = null, + @Json(name = "headingWithSubtitleWhiteMlc") + val headingWithSubtitleWhiteMlc: HeadingWithSubtitlesMlc? = null, + @Json(name = "stackMlc") + val stackMlc: StackMlc? = null, + @Json(name = "iconAtm") + val iconAtm: IconAtm? = null, + @Json(name = "docNumberCopyWhiteMlc") + val docNumberCopyWhiteMlc: DocNumberCopyMlc? = null, +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocCover.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocCover.kt new file mode 100644 index 0000000..c94b500 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocCover.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +import androidx.annotation.StringRes + +data class DocCover( + @StringRes val title: Int, + @StringRes val description: Int, + val buttonTitle: String?, + val actionKey: String, + val verificationCodesCount: Int +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocData.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocData.kt new file mode 100644 index 0000000..4be5a7b --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocData.kt @@ -0,0 +1,33 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class DocData( + @Json(name = "birthday") + val birthday: String?, + @Json(name = "category") + val category: String?, + @Json(name = "docName") + val docName: String?, + @Json(name = "docType") + val docType: String?, + @Json(name = "expirationDate") + val expirationDate: String?, + @Json(name = "formOfEducation") + val formOfEducation: String?, + @Json(name = "fullName") + val fullName: String?, + @Json(name = "fullNameEN") + val fullNameEN: String?, + @Json(name = "licenceNumber") + val licenceNumber: String?, + @Json(name = "organisation") + val organisation: String?, + @Json(name = "pensionType") + val pensionType: String?, + @Json(name = "rnokpp") + val rnokpp: String? +) \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocHeadingOrg.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocHeadingOrg.kt new file mode 100644 index 0000000..a6e7a63 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocHeadingOrg.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.table.HeadingWithSubtitlesMlc + +@Parcelize +@JsonClass(generateAdapter = true) +data class DocHeadingOrg( + @Json(name = "componentId") + val componentId: String? = null, + @Json(name = "docNumberCopyMlc") + val docNumberCopyMlc: DocNumberCopyMlc? = null, + @Json(name = "docNumberCopyWhiteMlc") + val docNumberCopyWhiteMlc: DocNumberCopyMlc? = null, + @Json(name = "headingWithSubtitleWhiteMlc") + val headingWithSubtitleWhiteMlc: HeadingWithSubtitlesMlc? = null, + @Json(name = "headingWithSubtitlesMlc") + val headingWithSubtitlesMlc: HeadingWithSubtitlesMlc? = null, +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocNumberCopyMlc.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocNumberCopyMlc.kt new file mode 100644 index 0000000..ed7df1a --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/DocNumberCopyMlc.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class DocNumberCopyMlc( + @Json(name = "icon") + val icon: IconAtm?, + @Json(name = "label") + val label: String?, + @Json(name = "value") + val value: String? +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/EN.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/EN.kt new file mode 100644 index 0000000..0253ed3 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/EN.kt @@ -0,0 +1,32 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.chip.ChipStatusAtm +import ua.gov.diia.core.models.common_compose.atm.text.TickerAtm +import ua.gov.diia.core.models.common_compose.mlc.text.SmallEmojiPanelMlc +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsPlaneOrg.TableBlockTwoColumnsPlaneOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockPlaneOrg.TableBlockPlaneOrg + +@Parcelize +@JsonClass(generateAdapter = true) +data class EN( + @Json(name = "docButtonHeadingOrg") + val docButtonHeadingOrg: DocButtonHeadingOrg? = null, + @Json(name = "docHeadingOrg") + val docHeadingOrg: DocHeadingOrg? = null, + @Json(name = "tableBlockTwoColumnsPlaneOrg") + val tableBlockTwoColumnsPlaneOrg: TableBlockTwoColumnsPlaneOrg? = null, + @Json(name = "tableBlockPlaneOrg") + val tableBlockPlaneOrg: TableBlockPlaneOrg? = null, + @Json(name = "tickerAtm") + val tickerAtm: TickerAtm? = null, + @Json(name = "smallEmojiPanelMlc") + val smallEmojiPanelMlc: SmallEmojiPanelMlc? = null, + @Json(name = "subtitleLabelMlc") + val subtitleLabelMlc: SubtitleLabelMlc? = null, + @Json(name = "chipStatusAtm") + val chipStatusAtm: ChipStatusAtm? = null, +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/FrontCard.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/FrontCard.kt new file mode 100644 index 0000000..f9d5d7f --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/FrontCard.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class FrontCard( + @Json(name = "EN") + val en: List?, + @Json(name = "UA") + val ua: List? +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/FullInfo.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/FullInfo.kt new file mode 100644 index 0000000..7e9aff1 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/FullInfo.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.text.TickerAtm +import ua.gov.diia.core.models.common_compose.table.tableBlockOrg.TableBlockOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsOrg.TableBlockTwoColumnsOrg + +@Parcelize +@JsonClass(generateAdapter = true) +data class FullInfo( + @Json(name = "docHeadingOrg") + val docHeadingOrg: DocHeadingOrg? = null, + @Json(name = "tableBlockOrg") + val tableBlockOrg: TableBlockOrg? = null, + @Json(name = "tableBlockTwoColumnsOrg") + val tableBlockTwoColumnsOrg: TableBlockTwoColumnsOrg? = null, + @Json(name = "tickerAtm") + val tickerAtm: TickerAtm? = null +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/IconLeft.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/IconLeft.kt new file mode 100644 index 0000000..0b3de67 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/IconLeft.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class IconLeft( + @Json(name = "code") + val code: String? +): Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/IconRight.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/IconRight.kt new file mode 100644 index 0000000..b103fa2 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/IconRight.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class IconRight( + @Json(name = "code") + val code: String? +): Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/QrCheckStatus.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/QrCheckStatus.kt new file mode 100644 index 0000000..1cc1977 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/QrCheckStatus.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +enum class QrCheckStatus { + STATUS_LOAD, + STATUS_DONE, + STATUS_DONE_ENG, + + STATUS_DOC_NOT_LOADED_ERROR, + STATUS_CODE_NO_REGISTRY, + + STATUS_QR_CODE_TIME_OUT, + + STATUS_NO_NETWORK, + STATUS_CODE_TIME_OUT, + STATUS_UNKNOWN_CODE_TYPE, + STATUS_CERT_VERIFICATION_INVALID, + STATUS_CERT_VERIFICATION_EXPIRED, +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/StackMlc.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/StackMlc.kt new file mode 100644 index 0000000..6823887 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/StackMlc.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.SmallIconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class StackMlc( + @Json(name = "amount") + val amount: Int?, + @Json(name = "smallIconAtm") + val smallIconAtm: SmallIconAtm? +): Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/SubtitleLabelMlc.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/SubtitleLabelMlc.kt new file mode 100644 index 0000000..59f16ba --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/SubtitleLabelMlc.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.icon.IconAtm + +@Parcelize +@JsonClass(generateAdapter = true) +data class SubtitleLabelMlc( + @Json(name = "label") + val label: String?, + @Json(name = "icon") + val icon: IconAtm?, +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/UA.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/UA.kt new file mode 100644 index 0000000..f6df855 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/UA.kt @@ -0,0 +1,33 @@ +package ua.gov.diia.documents.models.docgroups.v2 + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.models.common_compose.atm.chip.ChipStatusAtm +import ua.gov.diia.core.models.common_compose.atm.text.TickerAtm +import ua.gov.diia.core.models.common_compose.mlc.text.SmallEmojiPanelMlc +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsPlaneOrg.TableBlockTwoColumnsPlaneOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockPlaneOrg.TableBlockPlaneOrg + +@Parcelize +@JsonClass(generateAdapter = true) +data class UA( + @Json(name = "docButtonHeadingOrg") + val docButtonHeadingOrg: DocButtonHeadingOrg? = null, + @Json(name = "docHeadingOrg") + val docHeadingOrg: DocHeadingOrg? = null, + @Json(name = "tableBlockTwoColumnsPlaneOrg") + val tableBlockTwoColumnsPlaneOrg: TableBlockTwoColumnsPlaneOrg? = null, + @Json(name = "tableBlockPlaneOrg") + val tableBlockPlaneOrg: TableBlockPlaneOrg? = null, + @Json(name = "tickerAtm") + val tickerAtm: TickerAtm? = null, + @Json(name = "smallEmojiPanelMlc") + val smallEmojiPanelMlc: SmallEmojiPanelMlc? = null, + @Json(name = "subtitleLabelMlc") + val subtitleLabelMlc: SubtitleLabelMlc? = null, + @Json(name = "chipStatusAtm") + val chipStatusAtm: ChipStatusAtm? = null, +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/VaccinationCertificateBody.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/VaccinationCertificateBody.kt new file mode 100644 index 0000000..ac1de76 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/VaccinationCertificateBody.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class VaccinationCertificateBody( + @Json(name = "qr") + val qr: String?, + @Json(name = "isBooster") + val isBooster: Boolean?, +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/VerificationAction.kt b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/VerificationAction.kt new file mode 100644 index 0000000..cb46d24 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/models/docgroups/v2/VerificationAction.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.documents.models.docgroups.v2 + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class VerificationAction( + val actionKey: String, + val position: Int, + val id: String, + val docName: String +) : Parcelable \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/BaseLocalizationChecker.kt b/documents/src/main/java/ua/gov/diia/documents/ui/BaseLocalizationChecker.kt new file mode 100644 index 0000000..5167777 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/BaseLocalizationChecker.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.documents.ui + +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +interface BaseLocalizationChecker { + fun checkLocalizationDocs(doc: DiiaDocumentWithMetadata): String? +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/BottomDoc.kt b/documents/src/main/java/ua/gov/diia/documents/ui/BottomDoc.kt new file mode 100644 index 0000000..ec7a06e --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/BottomDoc.kt @@ -0,0 +1,45 @@ +package ua.gov.diia.documents.ui + +import android.app.Dialog +import android.os.Bundle +import android.view.MotionEvent +import android.view.View +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import ua.gov.diia.documents.R + +abstract class BottomDoc : BottomSheetDialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = + BottomSheetDialog(requireContext(), R.style.AppBottomSheetDialogTheme_Transparent) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + val behavior = BottomSheetBehavior.from(view.parent as View) + behavior.state = BottomSheetBehavior.STATE_EXPANDED + + behavior.addBottomSheetCallback(object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + if (newState == BottomSheetBehavior.STATE_DRAGGING) { + behavior.state = BottomSheetBehavior.STATE_EXPANDED + } + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + bottomSheet.setOnTouchListener { v, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> false + MotionEvent.ACTION_UP -> { + v.performClick() + true + } + + else -> true + } + } + } + }) + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/DocVM.kt b/documents/src/main/java/ua/gov/diia/documents/ui/DocVM.kt new file mode 100644 index 0000000..30aa4b3 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/DocVM.kt @@ -0,0 +1,36 @@ +package ua.gov.diia.documents.ui + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.models.share.ShareByteArr +import ua.gov.diia.core.util.datasource.DataSourceOwner +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction + +interface DocVM: DataSourceOwner, WithRetryLastAction, WithErrorHandlingOnFlow { + fun currentDocId(): String? + fun sendRatingRequest(ratingRequest: RatingRequest) + fun showRating(doc: DiiaDocument) + fun forceUpdateDocument(doc: DiiaDocument) + fun scrollToLastDocPos() + fun confirmDelDocument(docName: String) + fun removeMilitaryBondFromGallery( + documentType: String, + documentId: String + ) + + fun removeDoc(diiaDocument: DiiaDocument) + + fun run(block: suspend ((TemplateDialogModel) -> Unit) -> Unit, dispatcher: CoroutineDispatcher = Dispatchers.Main) + + fun run(block: suspend (String) -> ShareByteArr?, docId: String) + + fun getCertificatePdf(cert: DiiaDocument) + fun onUIAction(event: UIAction) + + fun invalidateAndScroll(type: String) {} +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/DocsConst.kt b/documents/src/main/java/ua/gov/diia/documents/ui/DocsConst.kt new file mode 100644 index 0000000..bc85170 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/DocsConst.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.documents.ui + +object DocsConst { + const val DOCUMENT_TYPE_ALL = "*" + const val RESULT_KEY_RATING = "RATING" + const val TYPE_USER_INITIATIVE = "userInitiative" +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/DocumentComposeMapper.kt b/documents/src/main/java/ua/gov/diia/documents/ui/DocumentComposeMapper.kt new file mode 100644 index 0000000..2c9ef67 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/DocumentComposeMapper.kt @@ -0,0 +1,129 @@ +package ua.gov.diia.documents.ui + +import ua.gov.diia.core.models.common_compose.atm.chip.ChipStatusAtm +import ua.gov.diia.documents.barcode.DocumentBarcodeResult +import ua.gov.diia.documents.models.DocumentCard +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.models.docgroups.v2.DocButtonHeadingOrg +import ua.gov.diia.documents.models.docgroups.v2.DocCover +import ua.gov.diia.documents.models.docgroups.v2.DocHeadingOrg +import ua.gov.diia.documents.models.docgroups.v2.QrCheckStatus +import ua.gov.diia.core.models.common_compose.mlc.text.SmallEmojiPanelMlc +import ua.gov.diia.documents.models.docgroups.v2.SubtitleLabelMlc +import ua.gov.diia.core.models.common_compose.table.tableBlockOrg.TableBlockOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockPlaneOrg.TableBlockPlaneOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsPlaneOrg.TableBlockTwoColumnsPlaneOrg +import ua.gov.diia.core.models.common_compose.atm.text.TickerAtm +import ua.gov.diia.ui_base.components.atom.text.TickerAtomData +import ua.gov.diia.ui_base.components.molecule.text.SubtitleLabelMlcData +import ua.gov.diia.ui_base.components.molecule.tile.SmallEmojiPanelMlcData +import ua.gov.diia.ui_base.components.organism.document.AddDocOrgData +import ua.gov.diia.ui_base.components.organism.document.ContentTableOrgData +import ua.gov.diia.ui_base.components.organism.document.DocCodeOrgData +import ua.gov.diia.ui_base.components.organism.document.DocErrorOrgData +import ua.gov.diia.ui_base.components.organism.document.DocHeadingOrgData +import ua.gov.diia.ui_base.components.organism.document.DocOrgData +import ua.gov.diia.ui_base.components.organism.document.DocPhotoOrgData +import ua.gov.diia.ui_base.components.organism.pager.DocCardFlipData +import ua.gov.diia.ui_base.components.organism.pager.DocCarouselOrgData +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsOrg.TableBlockTwoColumnsOrg + +interface DocumentComposeMapper { + + fun DocHeadingOrg?.toComposeDocHeadingOrg(): DocHeadingOrgData? + + fun TickerAtm?.toComposeTickerAtm( + isTickerClickable: Boolean = false + ): TickerAtomData? + + fun SubtitleLabelMlc?.toComposeSubtitleLabelMlc(): SubtitleLabelMlcData? + + fun SmallEmojiPanelMlc?.toComposeEmojiPanelMlc(): SmallEmojiPanelMlcData? + + fun toComposeDocError(status: QrCheckStatus): DocErrorOrgData? + + fun toComposeDocScanSubtitleLabel(status: QrCheckStatus): SubtitleLabelMlcData? + + fun toComposeDocOrgLoading(): DocPhotoOrgData + + fun toComposeAddDocOrg(docType: String, position: Int): AddDocOrgData + + fun toComposeDocPhoto( + localisation: LocalizationType, + photo: String?, + valueImage: String?, + isStack: Boolean, + stackSize: Int, + showCover: Boolean, + cover: DocCover?, + tableBlockOrg: List?, + docHeadingOrg: DocHeadingOrg?, + docButtonHeadingOrg: DocButtonHeadingOrg?, + subtitleLabelMlc: SubtitleLabelMlc?, + tableBlockTwoColumnsPlaneOrg: List?, + tickerAtm: TickerAtm?, + isTickerClickable: Boolean = false, + smallEmojiPanelMlc: SmallEmojiPanelMlc? = null + ): DocPhotoOrgData + + fun toComposeDocCodeOrg( + barcodeResult: DocumentBarcodeResult, + localizationType: LocalizationType, + showToggle: Boolean = true, + isStack: Boolean = false + ): DocCodeOrgData? + + fun toDocCardFlip( + photo: String?, + id: String?, + position: Int, + docType: String, + barcodeResult: DocumentBarcodeResult?, + localizationType: LocalizationType, + valueImage: String?, + isStack: Boolean, + stackSize: Int, + cover: DocCover?, + tableBlockOrg: List?, + docHeadingOrg: DocHeadingOrg?, + docButtonHeadingOrg: DocButtonHeadingOrg?, + subtitleLabelMlc: SubtitleLabelMlc?, + tableBlockTwoColumnsPlaneOrg: List?, + tickerAtm: TickerAtm?, + isTickerClickable: Boolean, + smallEmojiPanelMlc: SmallEmojiPanelMlc? = null + ): DocCardFlipData + + fun toDocOrg( + id: String?, + position: Int, + docType: String, + isStack: Boolean, + stackSize: Int, + cover: DocCover?, + url: String, + showCover: Boolean, + docHeadingOrg: DocHeadingOrg?, + docButtonHeadingOrg: DocButtonHeadingOrg?, + chipStatusAtm: ChipStatusAtm?, + placeHolder: Int + ): DocOrgData + + fun toDocCarousel( + cards: List, + barcodeResult: DocumentBarcodeResult? + ): DocCarouselOrgData + + fun toComposeContentTableOrg( + tableBlockTwoColumnsOrg: List?, + tableBlockOrg: List?, + photo: String?, + valueImage: String? + ): ContentTableOrgData +} + + +enum class ToggleId(val value: String) { + qr("qr"), + ean("ean"); +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocs.kt b/documents/src/main/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocs.kt new file mode 100644 index 0000000..3707b44 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocs.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.documents.ui + +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +interface WithCheckLocalizationDocs { + + /** + * performs documents localization check and runs update for documents with outdated locale + */ + fun checkLocalizationDocs( + docs: List?, + updateDocs: (List) -> Unit + ) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocsImpl.kt b/documents/src/main/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocsImpl.kt new file mode 100644 index 0000000..3fd709c --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocsImpl.kt @@ -0,0 +1,24 @@ +package ua.gov.diia.documents.ui + +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import javax.inject.Inject + +class WithCheckLocalizationDocsImpl @Inject constructor( + private val localizationCheckers: List<@JvmSuppressWildcards BaseLocalizationChecker>): WithCheckLocalizationDocs { + override fun checkLocalizationDocs( + docs: List?, + updateDocs: (List) -> Unit + ) { + val updateList = mutableListOf() + docs?.forEach { diiaDoc -> + localizationCheckers.forEach { + it.checkLocalizationDocs(diiaDoc)?.let { doc -> + updateList.add(doc) + } + } + } + if (updateList.size > 0) { + updateDocs(updateList) + } + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/WithPdfCertificate.kt b/documents/src/main/java/ua/gov/diia/documents/ui/WithPdfCertificate.kt new file mode 100644 index 0000000..89110ab --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/WithPdfCertificate.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.documents.ui + +import androidx.lifecycle.LiveData +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.GeneratePdfFromDoc + +interface WithPdfCertificate { + val certificatePdf: LiveData> + /** + * loads pdf certificate and provides it into certificatePdf LiveData + * Leave as empty if you do not need to load pdf + */ + suspend fun loadCertificatePdf(cert: DiiaDocument) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/WithRemoveDocument.kt b/documents/src/main/java/ua/gov/diia/documents/ui/WithRemoveDocument.kt new file mode 100644 index 0000000..91e5dee --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/WithRemoveDocument.kt @@ -0,0 +1,33 @@ +package ua.gov.diia.documents.ui + +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.documents.models.DiiaDocument + +interface WithRemoveDocument { + + /** + * performs api call to remove specific document + */ + suspend fun removeDocument( + diiaDocument: DiiaDocument, + removeDocumentCallback: (DiiaDocument) -> Unit + ) + + /** + * performs api call to remove MilitaryBond + */ + suspend fun removeMilitaryBondFromGallery( + documentType: String, + documentId: String, + showTemplateDialogCallback: (TemplateDialogModel) -> Unit + ) + /** + * handles document remove confirmation + */ + suspend fun confirmRemoveDocument( + docName: String, + currentDoc: () -> DiiaDocument?, + showTemplateDialogCallback: (TemplateDialogModel) -> Unit, + removeDocumentCallback: (DiiaDocument) -> Unit + ) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActions.kt b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActions.kt new file mode 100644 index 0000000..367f29a --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActions.kt @@ -0,0 +1,40 @@ +package ua.gov.diia.documents.ui.actions + +enum class ContextMenuType(val code: String) { + FULL_DOC("fullDoc"), + PNP("pnp"), + DOWNLOAD_CERTIFICATE_PDF("downloadCertificatePdf"), + VERIFICATION_CODE("verificationCode"), + INSURANCE("insurance"), + OPEN_SAME_DOC_TYPE("openSameDocType"), + CHANGE_DISPLAY_ORDER("changeDisplayOrder"), + CHANGE_DOC_ORDERING("changeDocOrdering"), + FAQS("faqs"), + REPLACE_DRIVER_LICENSE("replaceDriverLicense"), + REMOVE_DOC("removeDoc"), + TRANSLATE_TO_UA("translateToUa"), + TRANSLATE_TO_ENG("translateToEng"), + MILITARY_BOND("militaryBond"), + MILITARY_BOND_REMOVE_FROM_DOCS("militaryBondRemoveFromDocs"), + HOUSING_CERTIFICATES("housingCertificates"), + FOUNDING_REQUEST("foundingRequest"), + RESIDENCE_CERT("residenceCert"), + RESIDENCE_CERT_CHILD("residenceCertChild"), + EDIT_INTERNALLY_DISPLACED_PERSON_ADDRESS("editInternallyDisplacedPersonAddress"), + INTERNALLY_DISPLACED_CERT_CANCEL("internallyDisplacedCertCancel"), + PROPER_USER_SHARE("properUserShare"), + PROPER_USER_OWNER_CANCEL("properUserOwnerCancel"), + PROPER_USER_PROPER_CANCEL("properUserProperCancel"), + SHARE_WITH_FRIENDS("shareWithFriends"), + BIRTH_CERTIFICATE("birthCertificate"), + VACCINATION_CERTIFICATE("vaccinationCertificate"), + CHILD_VACCINATION_CERTIFICATE("childVaccinationCertificate"), + INTERNATIONAL_VACCINATION_CERTIFICATE("internationalVaccinationCertificate"), + REQUEST_PROPER_USER_ASSIGNING("requestProperUserAssigning"), + PENSION_CARD("pensionCard"), + RESIDENCE_PERMIT_PERMANENT("residencePermitPermanent"), + RESIDENCE_PERMIT_TEMPORARY("residencePermitTemporary"), + RATE_DOCUMENT("rating"), + VEHICLE_RE_REGISTRATION("vehicleReRegistration"), + UPDATE_DOC("updateDoc"), +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsDFCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsDFCompose.kt new file mode 100644 index 0000000..41556b5 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsDFCompose.kt @@ -0,0 +1,244 @@ +package ua.gov.diia.documents.ui.actions + +import android.app.Dialog +import android.graphics.Color +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.extensions.fragment.setNavigationResult +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.models.docgroups.v2.VerificationAction +import ua.gov.diia.documents.util.DocNameProvider +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.atom.button.ButtonWhiteLargeAtomData +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.DocAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.organism.list.ActivityViewOrg +import ua.gov.diia.ui_base.components.organism.list.ActivityViewOrgData +import ua.gov.diia.ui_base.fragments.BaseBottomDialog +import javax.inject.Inject + +@AndroidEntryPoint +class DocActionsDFCompose : BaseBottomDialog() { + + @Inject + lateinit var docNameProvider: DocNameProvider + + @Inject + lateinit var documentsHelper: DocumentsHelper + + @Inject + lateinit var docActionsNavigationHandler: DocActionsNavigationHandler + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + private val vm: DocActionsVMCompose by viewModels() + private var composeView: ComposeView? = null + private val args: DocActionsDFComposeArgs by navArgs() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return super.onCreateDialog(savedInstanceState).apply { + setCanceledOnTouchOutside(true) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + super.onViewStateRestored(savedInstanceState) + (view?.parent as View).setBackgroundColor(Color.TRANSPARENT) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + composeView?.setContent { + + vm.apply { + navigation.collectAsEffect { navigation -> + docActionsNavigationHandler.handleNavigation( + this@DocActionsDFCompose, + navigation, + args + ) + } + docAction.collectAsEffect { docAction -> + handleAction( + this@DocActionsDFCompose, + vm, + docAction, + args + ) + } + dismiss.collectAsEffect { + dismiss() + } + } + + val contextMenuOrgData = vm.provideActions( + args.doc as DiiaDocument, + args.manualDocs, + args.enableStackActions, + resources + ) + + val button = ButtonWhiteLargeAtomData( + title = "Закрити", + id = "", + interactionState = UIState.Interaction.Enabled + ) + val data = ActivityViewOrgData( + contextMenuOrg = contextMenuOrgData, + button = button + ) + ActivityViewOrg( + modifier = Modifier, + data = data, + onUIAction = { + vm.onUIAction(it) + + }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + fun handleAction( + fragment: DocActionsDFCompose, + vm: DocActionsVMCompose, + action: DocAction, + args: DocActionsDFComposeArgs + ) { + documentsHelper.handleAction(fragment, action, args) + + with(fragment) { + when (action) { + is DocActionsVMCompose.DocActions.RemoveDoc -> { + val doc = args.doc + if (doc is DiiaDocument) { + dismiss() + setNavigationResult( + arbitraryDestination = args.resultDestinationId, + key = ActionsConst.RESULT_KEY_REMOVE_DOCUMENT, + data = ConsumableItem(doc) + ) + } + } + + is DocActionsVMCompose.DocActions.UpdateDoc -> { + val doc = args.doc + if (doc is DiiaDocument) { + dismiss() + setNavigationResult( + arbitraryDestination = args.resultDestinationId, + key = ActionsConst.RESULT_KEY_UPDATE_DOCUMENT, + data = ConsumableItem(doc) + ) + } + } + + is DocActionsVMCompose.DocActions.TranslateToUa -> { + val doc = args.doc + if (doc is DiiaDocument) { + vm.switchLocalization(doc, LocalizationType.ua) + } + dismiss() + } + + is DocActionsVMCompose.DocActions.TranslateToEng -> { + val doc = args.doc + if (doc is DiiaDocument) { + vm.switchLocalization(doc, LocalizationType.eng) + } + dismiss() + } + + is DocActionsVMCompose.DocActions.RateDocument -> { + val doc = args.doc + if (doc is DiiaDocument) { + dismiss() + setNavigationResult( + arbitraryDestination = args.resultDestinationId, + key = ActionsConst.RESULT_KEY_RATE_DOCUMENT, + data = ConsumableItem(doc) + ) + } + } + + is DocActionsVMCompose.DocActions.OpenVerificationCode -> { + dismiss() + setNavigationResult( + arbitraryDestination = args.resultDestinationId, + key = ActionsConst.RESULT_KEY_VERIFICATION_CODE, + data = ConsumableItem( + VerificationAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + position = args.position, + id = action.id, + docName = docNameProvider.getDocumentName(args.doc as DiiaDocument) + ) + ) + ) + } + + is DocActionsVMCompose.DocActions.OpenQr -> { + dismiss() + setNavigationResult( + arbitraryDestination = args.resultDestinationId, + key = ActionsConst.RESULT_KEY_QR_CODE, + data = ConsumableItem( + VerificationAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + position = args.position, + id = action.id, + docName = docNameProvider.getDocumentName(args.doc as DiiaDocument) + ) + ) + ) + } + + is DocActionsVMCompose.DocActions.OpenEan13 -> { + dismiss() + setNavigationResult( + arbitraryDestination = args.resultDestinationId, + key = ActionsConst.RESULT_KEY_EAN13_CODE, + data = ConsumableItem( + VerificationAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + position = args.position, + id = action.id, + docName = docNameProvider.getDocumentName(args.doc as DiiaDocument) + ) + ) + ) + } + + } + + } + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsNavigationHandler.kt b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsNavigationHandler.kt new file mode 100644 index 0000000..17c802e --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsNavigationHandler.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.documents.ui.actions + +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath + +interface DocActionsNavigationHandler { + /** + * Handles navigation to other features + */ + fun handleNavigation( + fragment: DocActionsDFCompose, + navigation: NavigationPath, + args: DocActionsDFComposeArgs + ) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsProvider.kt b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsProvider.kt new file mode 100644 index 0000000..b5c454c --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsProvider.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.documents.ui.actions + +import android.content.res.Resources +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.ManualDocs +import ua.gov.diia.ui_base.components.organism.list.ContextMenuOrgData + +interface DocActionsProvider { + + /** + * @return composable context menu list for specific document + */ + fun provideActions( + document: DiiaDocument, + manualDocs: ManualDocs?, + enableStackActions: Boolean, + resources: Resources + ): ContextMenuOrgData + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsProviderImpl.kt b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsProviderImpl.kt new file mode 100644 index 0000000..b57f4d8 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsProviderImpl.kt @@ -0,0 +1,171 @@ +package ua.gov.diia.documents.ui.actions + +import android.content.res.Resources +import android.os.Parcelable +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DocError +import ua.gov.diia.documents.models.ManualDocs +import ua.gov.diia.documents.util.BaseDocActionItemProcessor +import ua.gov.diia.documents.util.BaseDocumentActionProvider +import ua.gov.diia.documents.util.DocumentActionMapper +import ua.gov.diia.ui_base.components.atom.button.ButtonIconCircledLargeAtmData +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import ua.gov.diia.ui_base.components.organism.list.ContextMenuOrgData +import javax.inject.Inject + + + +class DocActionsProviderImpl @Inject constructor( + val actionProviders: List<@JvmSuppressWildcards BaseDocumentActionProvider>, + val documentActionMapper: DocumentActionMapper, + val documentsHelper: DocumentsHelper, + val actionItemProcessorList: List<@JvmSuppressWildcards BaseDocActionItemProcessor>) : DocActionsProvider { + + + override fun provideActions( + document: DiiaDocument, + manualDocs: ManualDocs?, + enableStackActions: Boolean, + resources: Resources + ): ContextMenuOrgData { + val qr = ButtonIconCircledLargeAtmData( + actionKey = VerificationActions.VERIFICATION_CODE_QR, + id = "qr", + label = "QR-код", + icon = UiText.StringResource(ua.gov.diia.ui_base.R.drawable.ic_doc_qr_selected) + ) + val ean13 = ButtonIconCircledLargeAtmData( + actionKey = VerificationActions.VERIFICATION_CODE_EAN13, + id = "ean", + label = "Штрихкод", + icon = UiText.StringResource(ua.gov.diia.ui_base.R.drawable.ic_doc_ean13_selected), + ) + return ContextMenuOrgData( + docActions = listOfActions(document, resources).toMutableList(), + generalActions = listOfGeneralActions( + document, + enableStackActions, + resources + ).toMutableList(), + manualActions = listOfManualActions(document, manualDocs), + qr, ean13, documentsHelper.showVerificationButtons(document) + ) + } + + private fun listOfActions( + document: Parcelable, + resources: Resources + ): List { + val actionProvider = actionProviders.find { it.isDocumentProvider(document) } + return actionProvider?.let { + actionProvider.listOfActions(document, resources) + } ?: emptyList() + } + + private fun listOfGeneralActions( + document: DiiaDocument, + enableStackActions: Boolean, + resources: Resources + ): List { + val generalActions = listOf( + documentActionMapper.docActionForType( + document, + ContextMenuType.RATE_DOCUMENT.code, + ContextMenuType.RATE_DOCUMENT.name, + resources + ), + if (enableStackActions) documentActionMapper.docActionForType( + document, + ContextMenuType.CHANGE_DISPLAY_ORDER.code, + ContextMenuType.CHANGE_DISPLAY_ORDER.name, + resources + ) else documentActionMapper.docActionForType( + document, + ContextMenuType.CHANGE_DOC_ORDERING.code, + ContextMenuType.CHANGE_DOC_ORDERING.name, + resources + ), + documentActionMapper.docActionForType(document, + ContextMenuType.FAQS.code, + ContextMenuType.FAQS.name, + resources) + ) + val housingCertificatesGeneralActions = listOf( + if (enableStackActions) documentActionMapper.docActionForType( + document, + ContextMenuType.CHANGE_DISPLAY_ORDER.code, + ContextMenuType.CHANGE_DISPLAY_ORDER.name, + resources + ) else documentActionMapper.docActionForType( + document, + ContextMenuType.CHANGE_DOC_ORDERING.code, + ContextMenuType.CHANGE_DOC_ORDERING.name, + resources + ), documentActionMapper.docActionForType(document, + ContextMenuType.FAQS.code, + ContextMenuType.FAQS.name, + resources) + + ) + val list = documentsHelper.provideActions(document, enableStackActions, resources) + if(list != null) { + return list + } + return if (documentsHelper.isDocRequireGeneralMenuActions(document)) { + generalActions + } else if(documentsHelper.isDocRequireHousingMenuActions(document)) { + housingCertificatesGeneralActions + } else { + emptyList() + } + } + + private fun listOfManualActions( + document: DiiaDocument, + manualDocs: ManualDocs? + ): List? { + return when (document) { + is DocError -> { + checkAvailableDocsEnum(manualDocs) + } + + else -> { + null + } + } + } + + private fun checkAvailableDocsEnum(docs: ManualDocs?): List { + val result = mutableListOf() + docs?.documents.orEmpty().forEach { + + + val menu = + ContextMenuType.values().find { menu -> menu.code == it.code } + if (menu != null) { + result.add( + ListItemMlcData( + actionKey = menu.code, + id = it.code, + label = UiText.DynamicString(it.name), + action = DataActionWrapper(type = menu.code) + ) + ) + } + + var resultItem: ListItemMlcData? = null + for (itemProcessor in actionItemProcessorList) { + resultItem = itemProcessor.generateListItem(it.code, it.name) + if(resultItem != null) { + result.add(resultItem) + break + } + } + } + return result + } + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsVMCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsVMCompose.kt new file mode 100644 index 0000000..c043cf8 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/actions/DocActionsVMCompose.kt @@ -0,0 +1,260 @@ +package ua.gov.diia.documents.ui.actions + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.documents.di.GlobalActionUpdateDocument +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.ui.actions.VerificationActions.VERIFICATION_CODE_EAN13 +import ua.gov.diia.documents.ui.actions.VerificationActions.VERIFICATION_CODE_QR +import ua.gov.diia.documents.util.BaseDocActionItemProcessor +import ua.gov.diia.ui_base.components.infrastructure.event.DocAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import javax.inject.Inject + +@HiltViewModel +class DocActionsVMCompose @Inject constructor( + @GlobalActionUpdateDocument val globalActionUpdateDocument: MutableStateFlow?>, + private val docActionsProvider: DocActionsProvider, + private val actionItemProcessorList: List<@JvmSuppressWildcards BaseDocActionItemProcessor> +) : ViewModel(), + DocActionsProvider by docActionsProvider { + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val navigation = _navigation.asSharedFlow() + + private val _docAction = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val docAction = _docAction.asSharedFlow() + + private val _dismiss = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val dismiss = _dismiss.asSharedFlow() + + fun switchLocalization(diiaDocument: DiiaDocument, code: LocalizationType) { + viewModelScope.launch { + val doc = diiaDocument.makeCopy() + doc.setLocalization(code) + globalActionUpdateDocument.emit(UiDataEvent(doc)) + } + } + + fun onUIAction(event: UIAction) { + val eventName = event.action?.type ?: event.actionKey + for (itemProcessor in actionItemProcessorList) { + itemProcessor.processEvent(eventName, _docAction, _dismiss, _navigation) + } + + when (eventName) { + ContextMenuType.FAQS.name -> { + _navigation.tryEmit(Navigation.NavToFaqs) + } + + ContextMenuType.PNP.name -> { + _navigation.tryEmit(Navigation.ToNavPnp) + } + + ContextMenuType.REPLACE_DRIVER_LICENSE.name -> { + _navigation.tryEmit(Navigation.NavToDrl) + } + + ContextMenuType.INSURANCE.name -> { + _navigation.tryEmit(Navigation.NavToVehicleInsurance) + } + + ContextMenuType.RESIDENCE_CERT.name -> { + _navigation.tryEmit(Navigation.NavToResidenceCert) + } + + ContextMenuType.RESIDENCE_CERT_CHILD.name -> { + _navigation.tryEmit(Navigation.NavToResidenceCertChild) + } + + ContextMenuType.PENSION_CARD.name -> { + _navigation.tryEmit(Navigation.NavToPensionCard) + } + + ContextMenuType.FULL_DOC.name -> { + _navigation.tryEmit(Navigation.NavToFullInfo) + } + + ContextMenuType.HOUSING_CERTIFICATES.name -> { + _navigation.tryEmit(Navigation.NavToHousingCert) + } + + ContextMenuType.FOUNDING_REQUEST.name -> { + _navigation.tryEmit(Navigation.NavToFoundingRequest) + } + + ContextMenuType.CHANGE_DOC_ORDERING.name -> { + _navigation.tryEmit(Navigation.ToDocStackOrder) + } + + ContextMenuType.CHANGE_DISPLAY_ORDER.name -> { + _navigation.tryEmit(Navigation.ToDocStackOrderWithType(event.data!!)) + } + + ContextMenuType.REMOVE_DOC.name -> { + _docAction.tryEmit(DocActions.RemoveDoc) + } + + ContextMenuType.TRANSLATE_TO_UA.name -> { + _docAction.tryEmit(DocActions.TranslateToUa) + } + + ContextMenuType.TRANSLATE_TO_ENG.name -> { + _docAction.tryEmit(DocActions.TranslateToEng) + } + + ContextMenuType.RATE_DOCUMENT.name -> { + _docAction.tryEmit(DocActions.RateDocument) + } + + ContextMenuType.SHARE_WITH_FRIENDS.name -> { + _docAction.tryEmit(DocActions.ShareWithFriends) + } + + ContextMenuType.VERIFICATION_CODE.name -> { + _docAction.tryEmit(DocActions.OpenVerificationCode(event.data!!)) + } + + ContextMenuType.VEHICLE_RE_REGISTRATION.name -> { + _navigation.tryEmit(Navigation.VehicleReRegistration) + } + + ContextMenuType.BIRTH_CERTIFICATE.code -> { + _navigation.tryEmit(Navigation.BirthCertificate) + } + + ContextMenuType.VACCINATION_CERTIFICATE.code -> { + _navigation.tryEmit(Navigation.VaccinationCertificate) + } + + ContextMenuType.CHILD_VACCINATION_CERTIFICATE.code -> { + _navigation.tryEmit(Navigation.ChildVaccinationCertificate) + } + + ContextMenuType.PROPER_USER_SHARE.name -> { + _navigation.tryEmit(Navigation.ProperUserShare) + } + + ContextMenuType.PROPER_USER_OWNER_CANCEL.name, + ContextMenuType.PROPER_USER_PROPER_CANCEL.name -> { + _navigation.tryEmit(Navigation.ProperUserCancel) + } + + ContextMenuType.INTERNALLY_DISPLACED_CERT_CANCEL.name -> { + _navigation.tryEmit(Navigation.InternallyDisplacedCertCancel) + } + + ContextMenuType.EDIT_INTERNALLY_DISPLACED_PERSON_ADDRESS.name -> { + _navigation.tryEmit(Navigation.EditInternallyDisplacedPersonAddress) + } + + ContextMenuType.PENSION_CARD.code -> { + _navigation.tryEmit(Navigation.PensionCard) + } + + ContextMenuType.RESIDENCE_PERMIT_PERMANENT.code -> { + _navigation.tryEmit(Navigation.ResidencePermitPermanent) + } + + ContextMenuType.RESIDENCE_PERMIT_TEMPORARY.code -> { + _navigation.tryEmit(Navigation.ResidencePermitTemporary) + } + + ContextMenuType.DOWNLOAD_CERTIFICATE_PDF.name -> { + _navigation.tryEmit(Navigation.DownloadPdf) + } + + UIActionKeysCompose.BUTTON_REGULAR -> { + _dismiss.tryEmit(UiEvent()) + } + + VERIFICATION_CODE_QR -> { + event.data?.let { + _docAction.tryEmit(DocActions.OpenQr(event.data!!)) + } + } + + VERIFICATION_CODE_EAN13 -> { + event.data?.let { + _docAction.tryEmit(DocActions.OpenEan13(event.data!!)) + } + } + + ContextMenuType.UPDATE_DOC.name -> { + event.data?.let { + _docAction.tryEmit(DocActions.UpdateDoc(event.data!!)) + } + } + + else -> {} + } + } + + sealed class Navigation : NavigationPath { + object NavToFaqs : Navigation() + object ToNavPnp : Navigation() + object NavToDrl : Navigation() + object NavToVehicleInsurance : Navigation() + object NavToResidenceCert : Navigation() + object NavToResidenceCertChild : Navigation() + object NavToPensionCard : Navigation() + object NavToFullInfo : Navigation() + object NavToHousingCert : Navigation() + object NavToFoundingRequest : Navigation() + object BirthCertificate : Navigation() + object InternallyDisplacedCertCancel : Navigation() + object EditInternallyDisplacedPersonAddress : Navigation() + + object VaccinationCertificate : Navigation() + object ChildVaccinationCertificate : Navigation() + + object ProperUserShare : Navigation() + + object ProperUserCancel : Navigation() + + object PensionCard : Navigation() + object ResidencePermitPermanent : Navigation() + object ResidencePermitTemporary : Navigation() + object DownloadPdf : Navigation() + object ToDocStackOrder : Navigation() + + object VehicleReRegistration : Navigation() + + data class ToDocStackOrderWithType(val docType: String) : Navigation() + + } + + sealed class DocActions : DocAction { + object RemoveDoc : DocActions() + object TranslateToUa : DocActions() + object TranslateToEng : DocActions() + object RateDocument : DocActions() + object ShareWithFriends : DocActions() + data class UpdateDoc(val type: String) : DocActions() + data class OpenQr(val id: String) : DocActions() + data class OpenEan13(val id: String) : DocActions() + data class OpenVerificationCode(val id: String) : DocActions() + + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/actions/VerificationActions.kt b/documents/src/main/java/ua/gov/diia/documents/ui/actions/VerificationActions.kt new file mode 100644 index 0000000..0c3fa7c --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/actions/VerificationActions.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.documents.ui.actions + +object VerificationActions { + const val VERIFICATION_CODE_QR = "verificationCodeQR" + const val VERIFICATION_CODE_EAN13 = "verificationCodeEan13" +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/BaseFullInfoComposeMapper.kt b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/BaseFullInfoComposeMapper.kt new file mode 100644 index 0000000..1ceac9e --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/BaseFullInfoComposeMapper.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.documents.ui.fullinfo + +import androidx.compose.runtime.snapshots.SnapshotStateList +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.ui_base.components.infrastructure.UIElementData + +interface BaseFullInfoComposeMapper { + + fun mapDocToBody( + document: DiiaDocument, + bodyData: SnapshotStateList + ) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/DocFullInfoComposeMapper.kt b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/DocFullInfoComposeMapper.kt new file mode 100644 index 0000000..f9ebdaf --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/DocFullInfoComposeMapper.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.documents.ui.fullinfo + +import androidx.compose.runtime.snapshots.SnapshotStateList +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.ui_base.components.infrastructure.UIElementData + +interface DocFullInfoComposeMapper { + /** + * document transformation to UIElementData and add to the bodyData list + */ + fun mapDocToBody(document: DiiaDocument, bodyData: SnapshotStateList) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/DocFullInfoComposeMapperImpl.kt b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/DocFullInfoComposeMapperImpl.kt new file mode 100644 index 0000000..d2ca5ec --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/DocFullInfoComposeMapperImpl.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.documents.ui.fullinfo + +import androidx.compose.runtime.snapshots.SnapshotStateList +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import javax.inject.Inject + +class DocFullInfoComposeMapperImpl @Inject constructor(val mappers: List<@JvmSuppressWildcards BaseFullInfoComposeMapper> ) : + DocFullInfoComposeMapper { + override fun mapDocToBody( + document: DiiaDocument, + bodyData: SnapshotStateList + ) { + mappers.forEach { + it.mapDocToBody(document, bodyData) + } + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFCompose.kt new file mode 100644 index 0000000..60253e4 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFCompose.kt @@ -0,0 +1,117 @@ +package ua.gov.diia.documents.ui.fullinfo + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.util.extensions.activity.setWindowBrightness +import ua.gov.diia.documents.ui.BottomDoc +import ua.gov.diia.documents.ui.fullinfo.compose.FullInfoBottomSheet +import ua.gov.diia.documents.util.view.showCopyDocIdClipedSnackBar +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import javax.inject.Inject + +@AndroidEntryPoint +class FullInfoFCompose : BottomDoc() { + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private var composeView: ComposeView? = null + private val args: FullInfoFComposeArgs by navArgs() + private val viewModel: FullInfoFComposeVM by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.configureBody(args.doc) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + composeView?.setContent { + val body = viewModel.bodyData + val progressIndicator = + viewModel.progressIndicator.collectAsState( + initial = Pair( + "", + true + ) + ) + + viewModel.apply { + docAction.collectAsEffect { docAction -> + when (docAction) { + is FullInfoFComposeVM.DocActions.DocNumberCopy -> { + composeView?.rootView?.showCopyDocIdClipedSnackBar( + docAction.value, 40f + ) + } + + is FullInfoFComposeVM.DocActions.ItemVerticalValueCopy -> { + composeView?.rootView?.showCopyDocIdClipedSnackBar( + docAction.value, 40f + ) + } + + is FullInfoFComposeVM.DocActions.ItemHorizontalValueCopy -> { + composeView?.rootView?.showCopyDocIdClipedSnackBar( + docAction.value, 40f + ) + } + + is FullInfoFComposeVM.DocActions.ItemPrimaryValueCopy -> { + composeView?.rootView?.showCopyDocIdClipedSnackBar( + docAction.value, 40f + ) + } + + is FullInfoFComposeVM.DocActions.DismissDoc -> { + dismiss() + } + is FullInfoFComposeVM.DocActions.HighBrightness -> { + activity?.setWindowBrightness() + } + + is FullInfoFComposeVM.DocActions.DefaultBrightness -> { + activity?.setWindowBrightness(true) + } + } + } + } + + FullInfoBottomSheet( + progressIndicator = progressIndicator.value, data = body, + onUIAction = { + viewModel.onUIAction(it) + }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + } + + override fun onPause() { + super.onPause() + activity?.setWindowBrightness(true) + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFComposeVM.kt b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFComposeVM.kt new file mode 100644 index 0000000..95f58b9 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFComposeVM.kt @@ -0,0 +1,184 @@ +package ua.gov.diia.documents.ui.fullinfo + +import android.os.Parcelable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.ui.ToggleId +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.documents.barcode.* +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.DocAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.organism.document.DocCodeOrgData +import javax.inject.Inject + +@HiltViewModel +class FullInfoFComposeVM @Inject constructor( + private val dispatcherProvider: DispatcherProvider, + private val errorHandling: WithErrorHandlingOnFlow, + private val withRetryLastAction: WithRetryLastAction, + private val composeMapper: DocumentComposeMapper, + private val barcodeRepository: DocumentBarcodeRepository, + private val docFullComposeMapper: DocFullInfoComposeMapper +) : ViewModel(), + WithErrorHandlingOnFlow by errorHandling, + WithRetryLastAction by withRetryLastAction, + DocumentComposeMapper by composeMapper { + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + private val _progressIndicatorKey = MutableStateFlow("") + private val _progressIndicator = MutableStateFlow(false) + val progressIndicator: Flow> = + _progressIndicator.combine(_progressIndicatorKey) { value, key -> + key to value + } + + private val _docAction = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val docAction = _docAction.asSharedFlow() + + private val _documentCardData = MutableLiveData() + val documentCardData = _documentCardData.asLiveData() + + fun configureBody(document: Parcelable) { + (document as? DiiaDocument)?.let { + docFullComposeMapper.mapDocToBody(it, _bodyData) + loadQR(it) + _documentCardData.postValue(it) + } + } + + fun onUIAction(event: UIAction) { + when (event.actionKey) { + UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE -> { + event.data?.let { + onToggleClick(event.data!!) + } + } + + UIActionKeysCompose.REFRESH_BUTTON -> { + documentCardData.value?.let { loadQR(it) } + } + + UIActionKeysCompose.DOC_NUMBER_COPY -> { + event.data?.let { + _docAction.tryEmit(DocActions.DocNumberCopy(event.data!!)) + } + } + + UIActionKeysCompose.VERTICAL_TABLE_ITEM -> { + event.data?.let { + _docAction.tryEmit(DocActions.ItemVerticalValueCopy(event.data!!)) + } + } + + UIActionKeysCompose.HORIZONTAL_TABLE_ITEM -> { + event.data?.let { + _docAction.tryEmit(DocActions.ItemHorizontalValueCopy(event.data!!)) + } + } + + UIActionKeysCompose.PRIMARY_TABLE_ITEM -> { + event.data?.let { + _docAction.tryEmit(DocActions.ItemPrimaryValueCopy(event.data!!)) + } + } + + UIActionKeysCompose.BOTTOM_SHEET_DISMISS -> { + _docAction.tryEmit(DocActions.DismissDoc) + } + } + } + + private fun onToggleClick(toggleId: String) { + val index = _bodyData.indexOfFirst { it is DocCodeOrgData } + if (index == -1) { + return + } else { + _bodyData[index] = + (_bodyData[index] as DocCodeOrgData).onToggleClick(toggleId) + } + when (toggleId) { + ToggleId.qr.value -> _docAction.tryEmit(DocActions.DefaultBrightness) + ToggleId.ean.value -> _docAction.tryEmit(DocActions.HighBrightness) + } + } + + private fun configureDocCode(barcode: DocumentBarcodeRepositoryResult) { + + val index = _bodyData.indexOfFirst { it is DocCodeOrgData } + + if (index != -1) { + val oldValue: DocCodeOrgData = _bodyData[index] as DocCodeOrgData + + val data = toComposeDocCodeOrg( + barcode.result, + LocalizationType.ua, + barcode.showToggle + ) + + val updatedItem = data?.let { + oldValue.copy( + localization = data.localization, + toggle = it.toggle, + qrBitmap = data.qrBitmap, + ean13Bitmap = data.ean13Bitmap, + eanCode = data.eanCode, + timerText = data.timerText, + exception = data.exception, + expired = data.expired + ) + } + + if (updatedItem != null) { + _bodyData[index] = updatedItem + } + + } + } + + private fun loadQR(doc: DiiaDocument) { + executeActionOnFlow( + dispatcher = dispatcherProvider.ioDispatcher(), + progressIndicator = _progressIndicator.also { + _progressIndicatorKey.value = + UIActionKeysCompose.DOC_CODE_ORG_DATA + } + ) { + + val barcodeResult = barcodeRepository.loadBarcode(doc, 0, true) + configureDocCode(barcodeResult) + } + } + + sealed class DocActions : DocAction { + data class DocNumberCopy(val value: String) : DocActions() + data class ItemHorizontalValueCopy(val value: String) : DocActions() + data class ItemVerticalValueCopy(val value: String) : DocActions() + data class ItemPrimaryValueCopy(val value: String) : DocActions() + object HighBrightness : DocActions() + object DefaultBrightness : DocActions() + object DismissDoc : DocActions() + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/compose/FullInfoBottomSheet.kt b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/compose/FullInfoBottomSheet.kt new file mode 100644 index 0000000..31bf1da --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/fullinfo/compose/FullInfoBottomSheet.kt @@ -0,0 +1,148 @@ +package ua.gov.diia.documents.ui.fullinfo.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.atom.text.TickerAtm +import ua.gov.diia.ui_base.components.atom.text.TickerAtomData +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.organism.document.ContentTableOrg +import ua.gov.diia.ui_base.components.organism.document.ContentTableOrgData +import ua.gov.diia.ui_base.components.organism.document.DocCodeOrg +import ua.gov.diia.ui_base.components.organism.document.DocCodeOrgData +import ua.gov.diia.ui_base.components.organism.document.DocHeadingOrg +import ua.gov.diia.ui_base.components.organism.document.DocHeadingOrgData +import ua.gov.diia.ui_base.components.theme.BlueHighlight +import ua.gov.diia.ui_base.components.theme.Gray + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FullInfoBottomSheet( + modifier: Modifier = Modifier, + progressIndicator: Pair = Pair("", true), + data: SnapshotStateList, + diiaResourceIconProvider: DiiaResourceIconProvider, + onUIAction: (UIAction) -> Unit, +) { + var openBottomSheet by remember { mutableStateOf(true) } + val bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.Expanded + ) + + LaunchedEffect(bottomSheetState.currentValue) { + if (bottomSheetState.currentValue != SheetValue.Expanded) { + onUIAction( + UIAction( + actionKey = UIActionKeysCompose.BOTTOM_SHEET_DISMISS + ) + ) + openBottomSheet = false + } + } + + if (openBottomSheet) { + BottomSheetScaffold( + scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState), + sheetDragHandle = { + Spacer( + modifier = Modifier + .padding(top = 8.dp, bottom = 10.dp) + .width(40.dp) + .height(4.dp) + .background( + Gray, RoundedCornerShape(4.dp) + ) + ) + }, + sheetContent = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .navigationBarsPadding() + .background(color = BlueHighlight) + ) { + data.forEach { item -> + when (item) { + is DocHeadingOrgData -> { + DocHeadingOrg( + modifier = Modifier.padding( + bottom = 24.dp, + start = 24.dp, + end = 24.dp + ), + data = item, + onUIAction = onUIAction + ) + } + + is TickerAtomData -> { + TickerAtm( + data = item, + onUIAction = onUIAction + ) + } + + is ContentTableOrgData -> { + ContentTableOrg( + modifier = Modifier.padding( + bottom = 24.dp + ), + data = item, + onUIAction = onUIAction, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + + is DocCodeOrgData -> { + DocCodeOrg( + modifier = Modifier.padding( + bottom = 24.dp, + start = 24.dp, + end = 24.dp + ), + data = item, + progressIndicator = progressIndicator, + onUIAction = onUIAction + ) + } + } + } + } + }, + content = { + // Nothing + }, + modifier = modifier, + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + containerColor = BlueHighlight, + sheetContainerColor = BlueHighlight, + sheetPeekHeight = 0.dp, + ) + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocActions.kt b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocActions.kt new file mode 100644 index 0000000..6eaef30 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocActions.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.documents.ui.gallery + +object DocActions { + const val DOC_ACTION_IN_LINE = "inLine" + const val DOC_ACTION_TO_DRIVER_ACCOUNT = "toDriverAccount" +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocFSettings.kt b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocFSettings.kt new file mode 100644 index 0000000..1429ae3 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocFSettings.kt @@ -0,0 +1,24 @@ +package ua.gov.diia.documents.ui.gallery + +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize +import ua.gov.diia.documents.ui.DocsConst.DOCUMENT_TYPE_ALL + +@Keep +@Parcelize +data class DocFSettings( + val gradientBackgroundEnabled: Boolean = false, + val documentType: String = DOCUMENT_TYPE_ALL, +) : Parcelable { + + companion object { + + fun default(): DocFSettings { + return DocFSettings(true, DOCUMENT_TYPE_ALL) + } + + val default = DocFSettings(true) + + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryFCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryFCompose.kt new file mode 100644 index 0000000..f6f71ff --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryFCompose.kt @@ -0,0 +1,243 @@ +package ua.gov.diia.documents.ui.gallery + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.extensions.activity.setWindowBrightness +import ua.gov.diia.core.util.extensions.fragment.currentDestinationId +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.core.util.extensions.fragment.sendImage +import ua.gov.diia.core.util.extensions.fragment.sendPdf +import ua.gov.diia.diia_storage.AndroidBase64Wrapper +import ua.gov.diia.documents.NavDocActionsDirections +import ua.gov.diia.documents.R +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.ManualDocs +import ua.gov.diia.documents.ui.fullinfo.FullInfoFCompose +import ua.gov.diia.documents.ui.fullinfo.FullInfoFComposeArgs +import ua.gov.diia.documents.util.view.showCopyDocIdClipedSnackBar +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.HomeScreenTab +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.web.util.extensions.fragment.navigateToWebView +import javax.inject.Inject + +@AndroidEntryPoint +class DocGalleryFCompose : Fragment() { + + @Inject + lateinit var withCrashlytics: WithCrashlytics + + @Inject + lateinit var withBuildConfig: WithBuildConfig + + @Inject + lateinit var documentsHelper: DocumentsHelper + + @Inject + lateinit var navigationSubscriptionHandler: DocGalleryNavigationHelper + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private var composeView: ComposeView? = null + private val viewModel: DocGalleryVMCompose by viewModels() + private val args: DocGalleryFComposeArgs by navArgs() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.doInit(args.settings ?: DocFSettings.default) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.invalidateDataSource() + + composeView?.setContent { + val body = viewModel.bodyData + val progressIndicator = + viewModel.progressIndicator.collectAsState( + initial = Pair( + "", + true + ) + ) + val connectivityState = viewModel.connectivity.collectAsState(true) + + + viewModel.apply { + navigation.collectAsEffect { navigation -> + when (navigation) { + + is DocGalleryVMCompose.Navigation.ToDocActions -> { + navigateToDocActions( + navigation.doc, + navigation.position, + navigation.manualDocs + ) + } + + is DocGalleryVMCompose.Navigation.NavToVehicleInsurance -> { + fullDocAction(navigation.doc) + } + + is DocGalleryVMCompose.Navigation.ToDocStackOrder -> navigateToDocOrder() + + is DocGalleryVMCompose.Navigation.ToDocStack -> { + navigateToStackDocs(navigation.doc) + } + } + } + docAction.collectAsEffect { action -> + when (action) { + is DocGalleryVMCompose.DocActions.OpenDriverAccount -> { + navigateToWebView(getString(R.string.url_driver_e_find_address)) + } + + is DocGalleryVMCompose.DocActions.OpenElectronicQueue -> { + navigateToWebView(getString(R.string.url_driver_e_queue)) + } + + is DocGalleryVMCompose.DocActions.DocNumberCopy -> { + composeView?.showCopyDocIdClipedSnackBar( + action.value, + topPadding = 40f + ) + } + + is DocGalleryVMCompose.DocActions.ShareImage -> { + sendImage( + action.data.byteArray, + action.data.fileName, + withBuildConfig.getApplicationId() + ) + } + + is DocGalleryVMCompose.DocActions.HighBrightness -> { + activity?.setWindowBrightness() + } + + is DocGalleryVMCompose.DocActions.DefaultBrightness -> { + activity?.setWindowBrightness(true) + } + } + } + } + + viewModel.certificatePdf.observe(viewLifecycleOwner) { event -> + event.getContentIfNotHandled()?.let { e -> + val bArray = + AndroidBase64Wrapper().decode(e.docPDF.toByteArray()) + sendPdf( + bArray, + event.peekContent().name, + withBuildConfig.getApplicationId() + ) + } + } + + viewModel.showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + + viewModel.showRatingDialogByUserInitiative.collectAsEffect { + val ratingModel = it.peekContent() + documentsHelper.navigateToRatingService( + this, + viewModel, + ratingModel, + isFromStack = false + ) + } + + HomeScreenTab( + progressIndicator = progressIndicator.value, + connectivityState = connectivityState.value, + body = body, + onEvent = { + viewModel.onUIAction(it) + }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + navigationSubscriptionHandler.subscribeForNavigationEvents( + this, + viewModel + ) + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun fullDocAction(document: DiiaDocument) { + val fullDocFragment = FullInfoFCompose().apply { + arguments = FullInfoFComposeArgs(document).toBundle() + } + fullDocFragment.show( + requireActivity().supportFragmentManager, + FULL_DOC_INFO_TRANSACTION_TAG + ) + } + + private fun navigateToDocActions( + doc: DiiaDocument, + position: Int, + manualDocs: ManualDocs? + ) { + navigate( + NavDocActionsDirections.actionGlobalDestinationDocActionsCompose( + doc = doc, + position = position, + enableStackActions = false, + currentlyDisplayedOdcTypes = viewModel.settings.documentType, + manualDocs = manualDocs, + resultDestinationId = currentDestinationId ?: return + ) + ) + } + + private fun navigateToStackDocs(doc: DiiaDocument) { + documentsHelper.navigateToStackDocs(this, doc) + } + + override fun onPause() { + super.onPause() + activity?.setWindowBrightness(true) + } + + private fun navigateToDocOrder() { + documentsHelper.navigateToDocOrder(this) + } + + override fun onResume() { + super.onResume() + viewModel.scrollToLastDocPos() + } + + companion object { + private const val FULL_DOC_INFO_TRANSACTION_TAG = "FULL_DOC_INF" + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryNavigationHelper.kt b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryNavigationHelper.kt new file mode 100644 index 0000000..8539117 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryNavigationHelper.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.documents.ui.gallery + +import androidx.fragment.app.Fragment +import ua.gov.diia.documents.ui.DocVM + +interface DocGalleryNavigationHelper { + + /** + * subscribes for navigation events and handles navigation + */ + fun subscribeForNavigationEvents(fragment: Fragment, viewModel: DocVM) + + /** + * subscribes for navigation events and handles navigation + */ + fun subscribeForStackNavigationEvents(fragment: Fragment, viewModel: DocVM) + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryVMCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryVMCompose.kt new file mode 100644 index 0000000..d1b1c2d --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/gallery/DocGalleryVMCompose.kt @@ -0,0 +1,745 @@ +package ua.gov.diia.documents.ui.gallery + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import ua.gov.diia.core.di.actions.GlobalActionConfirmDocumentRemoval +import ua.gov.diia.core.di.actions.GlobalActionFocusOnDocument +import ua.gov.diia.core.di.actions.GlobalActionNetworkState +import ua.gov.diia.core.di.actions.GlobalActionSelectedMenuItem +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.models.share.ShareByteArr +import ua.gov.diia.core.network.connectivity.ConnectivityObserver +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRatingDialogOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.documents.barcode.DocumentBarcodeRepository +import ua.gov.diia.documents.barcode.DocumentBarcodeResult +import ua.gov.diia.documents.barcode.DocumentBarcodeResultLoading +import ua.gov.diia.documents.data.api.ApiDocuments +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.di.GlobalActionUpdateDocument +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DocumentCard +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.models.ManualDocs +import ua.gov.diia.documents.ui.DocVM +import ua.gov.diia.documents.ui.DocsConst +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.documents.ui.ToggleId +import ua.gov.diia.documents.ui.WithCheckLocalizationDocs +import ua.gov.diia.documents.ui.WithPdfCertificate +import ua.gov.diia.documents.ui.WithRemoveDocument +import ua.gov.diia.documents.ui.gallery.DocActions.DOC_ACTION_IN_LINE +import ua.gov.diia.documents.ui.gallery.DocActions.DOC_ACTION_TO_DRIVER_ACCOUNT +import ua.gov.diia.documents.util.WithUpdateExpiredDocs +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.event.DocAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.organism.pager.DocCardFlipData +import ua.gov.diia.ui_base.components.organism.pager.DocCarouselOrgData +import ua.gov.diia.ui_base.components.organism.pager.DocsCarouselItem +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import javax.inject.Inject + +@HiltViewModel +class DocGalleryVMCompose @Inject constructor( + private val apiDocs: ApiDocuments, + @GlobalActionConfirmDocumentRemoval val globalActionConfirmDocumentRemoval: MutableStateFlow?>, + @GlobalActionUpdateDocument val globalActionUpdateDocument: MutableStateFlow?>, + @GlobalActionFocusOnDocument val globalActionFocusOnDocument: MutableStateFlow?>, + @GlobalActionSelectedMenuItem val globalActionSelectedMenuItem: MutableStateFlow?>, + @GlobalActionNetworkState private val connectivityObserver: ConnectivityObserver, + private val barcodeRepository: DocumentBarcodeRepository, + private val documentsDataSource: DocumentsDataRepository, + private val dispatcherProvider: DispatcherProvider, + private val errorHandling: WithErrorHandlingOnFlow, + private val withRetryLastAction: WithRetryLastAction, + private val withRatingDialog: WithRatingDialogOnFlow, + private val composeMapper: DocumentComposeMapper, + private val withUpdateExpiredDocs: WithUpdateExpiredDocs, + private val withPdfCertificate: WithPdfCertificate, + private val withCheckLocalizationDocs: WithCheckLocalizationDocs, + private val withRemoveDocument: WithRemoveDocument, + private val documentsHelper: DocumentsHelper +) : ViewModel(), + DocVM, + WithErrorHandlingOnFlow by errorHandling, + WithRetryLastAction by withRetryLastAction, + DocumentComposeMapper by composeMapper, + WithRatingDialogOnFlow by withRatingDialog, + WithPdfCertificate by withPdfCertificate { + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val navigation = _navigation.asSharedFlow() + + private val _docAction = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val docAction = _docAction.asSharedFlow() + + var settings: DocFSettings = DocFSettings.default + + private val _progressIndicatorKey = MutableStateFlow("") + private val _progressIndicator = MutableStateFlow(false) + val progressIndicator: Flow> = + _progressIndicator.combine(_progressIndicatorKey) { value, key -> + key to value + } + + private val _openLink = MutableLiveData>() + val openLink = _openLink.asLiveData() + + private var _error = MutableLiveData>() + val error = _error.asLiveData() + + private val _documentCardData = MutableLiveData>() + private val documentCardData = _documentCardData.asLiveData() + + private val _scrollToFirstPosition = MutableLiveData() + val scrollToFirstPosition = _scrollToFirstPosition.asLiveData() + private val menuDocumentSelected = + MutableLiveData() + + val connectivity = MutableStateFlow(connectivityObserver.isAvailable) + + private var currPos: Int = 0 + + private var documentToFocus: Int? = null + private var focusDocType: String? = null + + private var currentDoc: DiiaDocument? = null + private var isLocalizationChecked: Boolean = false + + init { + + viewModelScope.launch { + connectivityObserver.observe().collect { value -> + connectivity.emit(value) + } + } + + viewModelScope.launch { + globalActionUpdateDocument.collectLatest { event -> + event?.getContentIfNotHandled()?.let { + updateDocument(it) + } + } + } + viewModelScope.launch { + globalActionSelectedMenuItem.collectLatest { event -> + val previous = menuDocumentSelected.value + menuDocumentSelected.value = event?.peekContent() + if (previous?.position == ACTION_HOME_DOCUMENTS && + menuDocumentSelected.value?.position == ACTION_HOME_DOCUMENTS + ) { + _scrollToFirstPosition.postValue(UiEvent()) + } + } + } + + viewModelScope.launch { + globalActionConfirmDocumentRemoval.collectLatest { + it?.getContentIfNotHandled()?.let { docName -> + confirmDelDocument(docName) + } + } + } + + viewModelScope.launch { + globalActionFocusOnDocument.collectLatest { event -> + event?.getContentIfNotHandled()?.let { type -> + focusDocType = type + updateExpDate(focusDocType ?: type) + documentsDataSource.invalidate() + } + } + } + + viewModelScope.launch { + documentsDataSource.data + .mapNotNull { it.data } + .map { it.filter { d -> d.diiaDocument != null } } + .map { it.filter { d -> documentsHelper.isDocumentValid(d) } } + .map { docs -> + if (settings.documentType == DocsConst.DOCUMENT_TYPE_ALL) { + if (!isLocalizationChecked) { + checkLocalizationDocs(docs) + isLocalizationChecked = true + } + docs.sortedBy { it.getDocOrder() }.groupBy { it.type } + .map { docGroup -> + if (docGroup.value.size > 1) { + val doc = + docGroup.value.minByOrNull { it.diiaDocument!!.getDocOrder() }!! + DocumentCard( + doc.copy(diiaDocument = doc.diiaDocument?.makeCopy()), + docCount = docGroup.value.size + ) + } else { + val doc = docGroup.value.first() + DocumentCard(doc.copy(diiaDocument = doc.diiaDocument?.makeCopy())) + } + } + } else { + docs.filter { it.type == settings.documentType } + .sortedBy { it.diiaDocument!!.getDocOrder() } + .map { DocumentCard(it.copy(diiaDocument = it.diiaDocument?.makeCopy())) } + }.also { documents -> + focusDocType?.let { type -> + documents.find { it.doc.type == type }?.let { + documentToFocus = documents.indexOf(it) + focusDocType = null + scrollToDocByPos(documentToFocus) + } + } + } + } + .flowOn(dispatcherProvider.ioDispatcher()) + .collect { + if (it != documentCardData.value) { + _documentCardData.postValue(it) + configureBody(it) + } + } + } + } + + fun doInit(settings: DocFSettings) { + this.settings = settings + } + + private fun configureBody(data: List) { + if (bodyData.isEmpty()) { + _bodyData.addIfNotNull( + composeMapper.toDocCarousel( + data, + barcodeResult = DocumentBarcodeResultLoading(loading = false) + ).copy(focusOnDoc = currPos) + ) + } else { + _bodyData.findAndChangeFirstByInstance { + composeMapper.toDocCarousel( + data, + barcodeResult = DocumentBarcodeResultLoading(loading = false) + ).copy(focusOnDoc = currPos) + } + } + } + + private fun onFlip( + position: Int, + result: DocumentBarcodeResult, + localizationType: LocalizationType, + showToggle: Boolean + ) { + val index = _bodyData.indexOfFirst { it is DocCarouselOrgData } + + if (index != -1) { + val oldValue: DocCarouselOrgData = + _bodyData[index] as DocCarouselOrgData + + if (position >= 0 && position < oldValue.data.size) { + val updatedData = oldValue.data.toMutableList() + + val updatedItem: DocsCarouselItem = + when (val currentItem = updatedData[position]) { + is DocCardFlipData -> { + currentItem.copy( + back = toComposeDocCodeOrg( + result, + localizationType, + showToggle, + isStack = currentItem.front.docButtonHeading?.isStack + ?: false + ) + ) + } + + else -> currentItem // Handle other cases if needed + } + + updatedData[position] = updatedItem + _bodyData[index] = + oldValue.copy(data = updatedData.toMutableStateList()) + } + } + _docAction.tryEmit(DocActions.DefaultBrightness) + } + + private fun onToggleClick(toggleId: String, position: Int) { + val index = _bodyData.indexOfFirst { it is DocCarouselOrgData } + if (index == -1) { + return + } else { + _bodyData[index] = + (_bodyData[index] as DocCarouselOrgData).onToggleClick( + position, + toggleId + ) + } + when (toggleId) { + ToggleId.qr.value -> _docAction.tryEmit(DocActions.DefaultBrightness) + ToggleId.ean.value -> _docAction.tryEmit(DocActions.HighBrightness) + } + + } + + private fun onFlipAndToggle( + position: Int, + result: DocumentBarcodeResult, + toggleId: String, + localizationType: LocalizationType, + showToggle: Boolean + ) { + val index = _bodyData.indexOfFirst { it is DocCarouselOrgData } + + if (index != -1) { + val oldValue: DocCarouselOrgData = + _bodyData[index] as DocCarouselOrgData + + if (position >= 0 && position < oldValue.data.size) { + val updatedData = oldValue.data.toMutableList() + + val updatedItem: DocsCarouselItem = + when (val currentItem = updatedData[position]) { + is DocCardFlipData -> { + currentItem.copy( + back = toComposeDocCodeOrg( + result, + localizationType, + showToggle, + isStack = currentItem.front.docButtonHeading?.isStack + ?: false + ) + ) + } + + else -> currentItem // Handle other cases if needed + } + + updatedData[position] = updatedItem + _bodyData[index] = + oldValue.copy(data = updatedData.toMutableStateList()) + } + } + + onToggleClick(toggleId, position) + } + + + override fun onUIAction(event: UIAction) { + when (event.actionKey) { + UIActionKeysCompose.DOC_CARD_SWIPE_FINISHED -> { + _bodyData.findAndChangeFirstByInstance { + it.flipAllCardsToFrontSideIfNeeded() + } + _docAction.tryEmit(DocActions.DefaultBrightness) + } + + UIActionKeysCompose.DOC_CARD_FLIP -> { + val data = event.data + val optionalId = event.optionalId + optionalId?.let { + _bodyData.findAndChangeFirstByInstance { docData -> + docData.flipCard(optionalId.toIntOrNull() ?: 0) + } + } + if (data != null && optionalId != null) { + val doc = findDoc(data) + if (doc != null) { + loadQR(doc, optionalId.toIntOrNull() ?: 0, null) + } + } + + } + + UIActionKeysCompose.DOC_CARD_FORCE_FLIP -> { + val data = event.data + val optionalId = event.optionalId + optionalId?.let { + _bodyData.findAndChangeFirstByInstance { + it.flipCardForceWithToggle( + optionalId.toIntOrNull() ?: 0, + event.optionalType + ) + } + } + if (data != null && optionalId != null) { + val doc = findDoc(data) + if (doc != null) { + loadQR( + doc, + optionalId.toIntOrNull() ?: 0, + event.optionalType + ) + } + } + + } + + UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE -> { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + onToggleClick( + data, + optionalId.toIntOrNull() ?: 0 + ) + } + } + + UIActionKeysCompose.REFRESH_BUTTON -> { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + val doc = findDoc(data) + if (doc != null) { + loadQR(doc, optionalId.toIntOrNull() ?: 0, null) + } + } + } + + UIActionKeysCompose.DOC_ELLIPSE_MENU -> { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + val doc = findDoc(data) + if (doc != null) { + _navigation.tryEmit( + Navigation.ToDocActions( + doc, + optionalId.toIntOrNull() ?: 0, null + ) + ) + } + } + } + + UIActionKeysCompose.TICKER_ATOM_CLICK -> { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + val doc = findDoc(data) + if (doc != null) { + _navigation.tryEmit( + Navigation.NavToVehicleInsurance(doc) + ) + } + } + } + + UIActionKeysCompose.ADD_DOC_ORG -> { + executeActionOnFlow( + progressIndicator = _progressIndicator, + templateKey = RESUlT_KEY_TEMP_RETRY + ) { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + val doc = findDoc(data) + if (doc != null && doc.getItemType() == DOC_ERROR_TYPE) { + _navigation.tryEmit( + Navigation.ToDocActions( + doc, + optionalId.toIntOrNull() ?: 0, + apiDocs.getDocsManual() + ), + ) + } + } + } + + } + + UIActionKeysCompose.DOC_STACK -> { + val data = event.data + if (data != null) { + val doc = findDoc(data) + if (doc != null) { + _navigation.tryEmit( + Navigation.ToDocStack( + doc + ) + ) + } + } + } + + DOC_ACTION_IN_LINE -> { + _docAction.tryEmit(DocActions.OpenElectronicQueue) + } + + DOC_ACTION_TO_DRIVER_ACCOUNT -> { + _docAction.tryEmit(DocActions.OpenDriverAccount) + } + + UIActionKeysCompose.CHANGE_DOC_ORDER -> { + _navigation.tryEmit(Navigation.ToDocStackOrder) + } + + UIActionKeysCompose.DOC_NUMBER_COPY -> { + event.data?.let { + _docAction.tryEmit(DocActions.DocNumberCopy(event.data!!)) + + } + } + + UIActionKeysCompose.DOC_PAGE_SELECTED -> { + event.data?.toIntOrNull()?.let { currPos = it } + } + } + } + + private fun checkLocalizationDocs(docs: List?) { + viewModelScope.launch { + withCheckLocalizationDocs.checkLocalizationDocs( + docs, + ::updateExpDate + ) + } + } + + override fun invalidateDataSource() { + documentsDataSource.invalidate() + } + + override fun invalidateAndScroll(type: String) { + executeActionOnFlow { + documentsDataSource.invalidate() + documentCardData.value?.find { it.doc.type == type }?.let { doc -> + documentToFocus = doc.doc.getDocOrder() + scrollToDocByPos(documentToFocus) + } + } + } + + override fun getCertificatePdf(cert: DiiaDocument) { + executeActionOnFlow(progressIndicator = _progressIndicator) { + withPdfCertificate.loadCertificatePdf(cert) + } + } + + override fun run(block: suspend ((TemplateDialogModel) -> Unit) -> Unit, dispatcher: CoroutineDispatcher) { + executeActionOnFlow(progressIndicator = _progressIndicator) { + block(::showTemplateDialog) + } + } + + override fun run(block: suspend (String) -> ShareByteArr?, docId: String) { + executeActionOnFlow( + dispatcher = Dispatchers.IO, + progressIndicator = _progressIndicator + ) { + block(docId)?.let { data -> + _docAction.tryEmit(DocActions.ShareImage(data)) + } + } + } + + private fun findDoc(docName: String): DiiaDocument? { + val doc = documentCardData.value?.find { + it.doc.diiaDocument?.getItemType() == docName + + } + return doc?.doc?.diiaDocument + } + + private fun loadQR(doc: DiiaDocument, position: Int, toggleId: String?) { + executeActionOnFlow( + dispatcher = dispatcherProvider.ioDispatcher(), + progressIndicator = _progressIndicator.also { + _progressIndicatorKey.value = + UIActionKeysCompose.DOC_CODE_ORG_DATA + } + ) { + val barcodeResult = barcodeRepository.loadBarcode(doc, position) + if (toggleId == null) { + onFlip( + position, + barcodeResult.result, + doc.localization() ?: LocalizationType.ua, + barcodeResult.showToggle + + ) + + } else { + onFlipAndToggle( + position, + barcodeResult.result, + toggleId, + doc.localization() ?: LocalizationType.ua, + barcodeResult.showToggle + ) + } + } + } + + override fun removeDoc(diiaDocument: DiiaDocument) { + executeActionOnFlow { + withRemoveDocument.removeDocument(diiaDocument, ::removeDocument) + } + } + + private fun removeDocument(diiaDocument: DiiaDocument) { + documentsDataSource.removeDocument(diiaDocument) + currPos = setPrevPos(currPos) + } + + override fun removeMilitaryBondFromGallery( + documentType: String, + documentId: String + ) { + executeActionOnFlow { + withRemoveDocument.removeMilitaryBondFromGallery( + documentType, + documentId, + ::showTemplateDialog) + } + } + + override fun confirmDelDocument(docName: String) { + executeActionOnFlow( + progressIndicator = _progressIndicator + ) { + withRemoveDocument.confirmRemoveDocument( + docName, + { currentDoc }, + ::showTemplateDialog, + ::removeDocument + ) + } + } + + private fun updateDocument(doc: DiiaDocument) { + executeActionOnFlow { + documentsDataSource.updateDocument(doc) + } + } + override fun forceUpdateDocument(doc: DiiaDocument) { + executeActionOnFlow(progressIndicator = _progressIndicator) { + val resp = doc.id?.let { + apiDocs.getDocumentById( + type = doc.getItemType(), + id = it + ) + } + resp?.template?.apply(::showTemplateDialog) + resp?.educationDocument?.let { updateDocument(it) } + } + } + + private fun updateExpDate(type: String) { + executeActionOnFlow(dispatcher = dispatcherProvider.work) { + withUpdateExpiredDocs.updateExpirationDate(type) + } + } + + private fun updateExpDate(types: List) { + executeActionOnFlow(dispatcher = dispatcherProvider.work) { + withUpdateExpiredDocs.updateExpirationDate(types) + } + } + + private fun setPrevPos(position: Int): Int { + return if (position > 0) { + position - 1 + } else { + 0 + } + } + + + override fun sendRatingRequest(ratingRequest: RatingRequest) { + currentDoc?.getItemType() + ?.let { sendRating(ratingRequest, ActionsConst.DOCUMENTS_CODE, it) } + } + + override fun currentDocId() = currentDoc?.docId() + + override fun showRating(doc: DiiaDocument) { + currentDoc = doc + getRating(ActionsConst.DOCUMENTS_CODE, doc.getItemType()) + } + + override fun scrollToLastDocPos() { + _bodyData.findAndChangeFirstByInstance { + it.copy(focusOnDoc = currPos) + } + } + + private fun scrollToDocByPos(position: Int?) { + _bodyData.findAndChangeFirstByInstance { + it.copy(focusOnDoc = position) + } + } + + companion object { + const val RESUlT_KEY_TEMP_RETRY = "result_action_preview_f_add_docs" + const val ACTION_HOME_DOCUMENTS = 1 + private const val DOC_ERROR_TYPE = "doc_error" + } + + sealed class Navigation : NavigationPath { + data class ToDocActions( + val doc: DiiaDocument, + val position: Int, + val manualDocs: ManualDocs? + ) : Navigation() + + data class ToDocStack(val doc: DiiaDocument) : Navigation() + object ToDocStackOrder : Navigation() + + data class NavToVehicleInsurance(val doc: DiiaDocument) : Navigation() + } + + sealed class DocActions : DocAction { + object OpenDriverAccount : DocActions() + object OpenElectronicQueue : DocActions() + object HighBrightness : DocActions() + object DefaultBrightness : DocActions() + + data class ShareImage(val data: ShareByteArr) : DocActions() + data class DocNumberCopy(val value: String) : DocActions() + + } +} diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/stack/DocStackFCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/stack/DocStackFCompose.kt new file mode 100644 index 0000000..ab8f462 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/stack/DocStackFCompose.kt @@ -0,0 +1,242 @@ +package ua.gov.diia.documents.ui.stack + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.extensions.activity.setWindowBrightness +import ua.gov.diia.core.util.extensions.fragment.currentDestinationId +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.sendImage +import ua.gov.diia.core.util.extensions.fragment.sendPdf +import ua.gov.diia.diia_storage.AndroidBase64Wrapper +import ua.gov.diia.documents.NavDocActionsDirections +import ua.gov.diia.documents.R +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.ui.fullinfo.FullInfoFCompose +import ua.gov.diia.documents.ui.fullinfo.FullInfoFComposeArgs +import ua.gov.diia.documents.ui.gallery.DocFSettings +import ua.gov.diia.documents.ui.gallery.DocGalleryNavigationHelper +import ua.gov.diia.documents.ui.stack.compose.StackScreen +import ua.gov.diia.documents.util.view.showCopyDocIdClipedSnackBar +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.web.util.extensions.fragment.navigateToWebView +import javax.inject.Inject + +@AndroidEntryPoint +class DocStackFCompose : Fragment() { + + @Inject + lateinit var withCrashlytics: WithCrashlytics + + @Inject + lateinit var withBuildConfig: WithBuildConfig + + @Inject + lateinit var navigationHelper: DocGalleryNavigationHelper + + @Inject + lateinit var documentsHelper: DocumentsHelper + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private var composeView: ComposeView? = null + private val viewModel: DocStackVMCompose by viewModels() + private val args: DocStackFComposeArgs by navArgs() + private lateinit var settings: DocFSettings + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.configureTopBar(getHeader()) + viewModel.subscribeForDocuments(settings()) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + settings = settings() + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.invalidateDataSource() + composeView?.setContent { + val topBar = viewModel.topBarData + + val body = viewModel.bodyData + val progressIndicator = + viewModel.progressIndicator.collectAsState( + initial = Pair( + "", + true + ) + ) + val contentLoaded = viewModel.contentLoaded.collectAsState( + initial = Pair( + UIActionKeysCompose.PAGE_LOADING_TRIDENT, true + ) + ) + + viewModel.apply { + navigation.collectAsEffect { navigation -> + when (navigation) { + is BaseNavigation.Back -> { + findNavController().popBackStack() + } + + is DocStackVMCompose.Navigation.ToDocActions -> { + navigateToDocActions( + navigation.doc, + navigation.position + ) + } + + is DocStackVMCompose.Navigation.NavToVehicleInsurance -> { + fullDocAction(navigation.doc) + } + } + } + docAction.collectAsEffect { action -> + when (action) { + is DocStackVMCompose.DocActions.OpenDriverAccount -> { + navigateToWebView(getString(R.string.url_driver_e_find_address)) + } + + is DocStackVMCompose.DocActions.OpenElectronicQueue -> { + navigateToWebView(getString(R.string.url_driver_e_queue)) + } + + is DocStackVMCompose.DocActions.ShareImage -> { + sendImage( + action.data.byteArray, + action.data.fileName, + withBuildConfig.getApplicationId() + ) + } + + is DocStackVMCompose.DocActions.HighBrightness -> { + activity?.setWindowBrightness() + } + + is DocStackVMCompose.DocActions.DefaultBrightness -> { + activity?.setWindowBrightness(true) + } + + is DocStackVMCompose.DocActions.DocNumberCopy -> { + composeView?.showCopyDocIdClipedSnackBar( + action.value, 40f + ) + } + } + } + } + + viewModel.certificatePdf.observe(viewLifecycleOwner) { event -> + event.getContentIfNotHandled()?.let { e -> + val bArray = + AndroidBase64Wrapper().decode(e.docPDF.toByteArray()) + sendPdf( + bArray, + event.peekContent().name, + withBuildConfig.getApplicationId() + ) + } + } + + viewModel.showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + viewModel.showRatingDialogByUserInitiative.collectAsEffect { + val ratingModel = it.peekContent() + documentsHelper.navigateToRatingService( + this, + viewModel, + ratingModel, + isFromStack = true + ) + } + + StackScreen( + contentLoaded = contentLoaded.value, + progressIndicator = progressIndicator.value, + toolbar = topBar, + body = body, + onEvent = { + viewModel.onUIAction(it) + }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + navigationHelper.subscribeForStackNavigationEvents(this, viewModel) + } + + private fun settings() = DocFSettings(false, args.docType) + + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun navigateToDocActions(doc: DiiaDocument, position: Int) { + + navigate( + NavDocActionsDirections.actionGlobalDestinationDocActionsCompose( + doc = doc, + position = position, + enableStackActions = true, + currentlyDisplayedOdcTypes = settings().documentType, + manualDocs = null, + resultDestinationId = currentDestinationId ?: return + ) + ) + } + + private fun getHeader(): String { + return documentsHelper.getStackHeader(this, args.docType) + } + + override fun onPause() { + super.onPause() + activity?.setWindowBrightness(true) + } + + private fun fullDocAction(document: DiiaDocument) { + val fullDocFragment = FullInfoFCompose().apply { + arguments = FullInfoFComposeArgs(document).toBundle() + } + fullDocFragment.show( + requireActivity().supportFragmentManager, + FULL_DOC_INFO_TRANSACTION_TAG + ) + } + + override fun onResume() { + super.onResume() + viewModel.scrollToLastDocPos() + } + + companion object { + private const val FULL_DOC_INFO_TRANSACTION_TAG = "FULL_DOC_INF" + } + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/stack/DocStackVMCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/stack/DocStackVMCompose.kt new file mode 100644 index 0000000..1a514d4 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/stack/DocStackVMCompose.kt @@ -0,0 +1,651 @@ +package ua.gov.diia.documents.ui.stack + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.runtime.toMutableStateList +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.launch +import ua.gov.diia.core.di.actions.GlobalActionConfirmDocumentRemoval +import ua.gov.diia.core.di.actions.GlobalActionFocusOnDocument +import ua.gov.diia.core.di.actions.GlobalActionSelectedMenuItem +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.models.share.ShareByteArr +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRatingDialogOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.documents.barcode.DocumentBarcodeRepository +import ua.gov.diia.documents.barcode.DocumentBarcodeResult +import ua.gov.diia.documents.barcode.DocumentBarcodeResultLoading +import ua.gov.diia.documents.data.api.ApiDocuments +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.di.GlobalActionUpdateDocument +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DocumentCard +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.ui.DocVM +import ua.gov.diia.documents.ui.DocsConst +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.documents.ui.ToggleId +import ua.gov.diia.documents.ui.WithCheckLocalizationDocs +import ua.gov.diia.documents.ui.WithPdfCertificate +import ua.gov.diia.documents.ui.WithRemoveDocument +import ua.gov.diia.documents.ui.gallery.DocActions.DOC_ACTION_IN_LINE +import ua.gov.diia.documents.ui.gallery.DocActions.DOC_ACTION_TO_DRIVER_ACCOUNT +import ua.gov.diia.documents.ui.gallery.DocFSettings +import ua.gov.diia.documents.ui.gallery.DocGalleryVMCompose +import ua.gov.diia.documents.util.WithUpdateExpiredDocs +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.event.DocAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.organism.pager.DocCardFlipData +import ua.gov.diia.ui_base.components.organism.pager.DocCarouselOrgData +import ua.gov.diia.ui_base.components.organism.pager.DocsCarouselItem +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.ui_base.util.navigation.generateComposeNavigationPanel +import javax.inject.Inject +import kotlin.reflect.KSuspendFunction1 + +@HiltViewModel +class DocStackVMCompose @Inject constructor( + private val apiDocs: ApiDocuments, + @GlobalActionConfirmDocumentRemoval val globalActionConfirmDocumentRemoval: MutableStateFlow?>, + @GlobalActionUpdateDocument val globalActionUpdateDocument: MutableStateFlow?>, + @GlobalActionFocusOnDocument val globalActionFocusOnDocument: MutableStateFlow?>, + @GlobalActionSelectedMenuItem val globalActionSelectedMenuItem: MutableStateFlow?>, + private val barcodeRepository: DocumentBarcodeRepository, + private val documentsDataSource: DocumentsDataRepository, + private val dispatcherProvider: DispatcherProvider, + private val errorHandling: WithErrorHandlingOnFlow, + private val withRetryLastAction: WithRetryLastAction, + private val withRatingDialog: WithRatingDialogOnFlow, + private val composeMapper: DocumentComposeMapper, + private val withUpdateExpiredDocs: WithUpdateExpiredDocs, + private val withPdfCertificate: WithPdfCertificate, + private val withCheckLocalizationDocs: WithCheckLocalizationDocs, + private val withRemoveDocument: WithRemoveDocument +) : ViewModel(), + DocVM, + WithErrorHandlingOnFlow by errorHandling, + WithRetryLastAction by withRetryLastAction, + DocumentComposeMapper by composeMapper, + WithRatingDialogOnFlow by withRatingDialog, + WithPdfCertificate by withPdfCertificate { + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + private val _topBarData = mutableStateListOf() + val topBarData: SnapshotStateList = _topBarData + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val navigation = _navigation.asSharedFlow() + + private val _docAction = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val docAction = _docAction.asSharedFlow() + + private val _progressIndicatorKey = MutableStateFlow("") + private val _progressIndicator = MutableStateFlow(false) + val progressIndicator: Flow> = + _progressIndicator.combine(_progressIndicatorKey) { value, key -> + key to value + } + private val _contentLoadedKey = MutableStateFlow(UIActionKeysCompose.PAGE_LOADING_TRIDENT) + private val _contentLoaded = MutableStateFlow(true) + val contentLoaded: Flow> = + _contentLoaded.combine(_contentLoadedKey) { value, key -> + key to value + } + + private val _openLink = MutableLiveData>() + val openLink = _openLink.asLiveData() + + private var _error = MutableLiveData>() + val error = _error.asLiveData() + + private val _documentCardData = MutableLiveData>() + val documentCardData = _documentCardData.asLiveData() + + private val _scrollToFirstPosition = MutableLiveData() + val scrollToFirstPosition = _scrollToFirstPosition.asLiveData() + private val menuDocumentSelected = MutableLiveData() + + var currPos: Int = 0 + + var documentToFocus: Int? = null + private var focusDocType: String? = null + + private var currentDoc: DiiaDocument? = null + private var isLocalizationChecked: Boolean = false + + init { + viewModelScope.launch { + globalActionUpdateDocument.collectLatest { event -> + event?.getContentIfNotHandled()?.let { + updateDocument(it) + } + } + } + viewModelScope.launch { + globalActionSelectedMenuItem.collectLatest { event -> + val previous = menuDocumentSelected.value + menuDocumentSelected.value = event?.peekContent() + if (previous?.position == ACTION_HOME_DOCUMENTS && + menuDocumentSelected.value?.position == ACTION_HOME_DOCUMENTS + ) { + _scrollToFirstPosition.postValue(UiEvent()) + } + } + } + + viewModelScope.launch { + globalActionConfirmDocumentRemoval.collectLatest { + it?.getContentIfNotHandled()?.let { docName -> + confirmDelDocument(docName) + } + } + } + + viewModelScope.launch { + globalActionFocusOnDocument.collectLatest { event -> + event?.getContentIfNotHandled()?.let { type -> + focusDocType = type + updateExpDate(focusDocType ?: type) + documentsDataSource.invalidate() + } + } + } + + } + + fun subscribeForDocuments(settings: DocFSettings) { + viewModelScope.launch { + val docType = settings.documentType + documentsDataSource.data + .flowOn(dispatcherProvider.ioDispatcher()) + .mapNotNull { it.data } + .map { it.filter { d -> d.diiaDocument != null } } + .map { docs -> + if (docType == DocsConst.DOCUMENT_TYPE_ALL) { + if (!isLocalizationChecked) { + checkLocalizationDocs(docs) + isLocalizationChecked = true + } + docs.sortedBy { it.getDocOrder() }.groupBy { it.type }.map { docGroup -> + if (docGroup.value.size > 1) { + val doc = + docGroup.value.minByOrNull { it.diiaDocument!!.getDocOrder() }!! + DocumentCard( + doc.copy(diiaDocument = doc.diiaDocument?.makeCopy()), + docCount = docGroup.value.size + ) + } else { + val doc = docGroup.value.first() + DocumentCard(doc.copy(diiaDocument = doc.diiaDocument?.makeCopy())) + } + } + } else { + docs.filter { it.type == docType } + .sortedBy { it.diiaDocument!!.getDocOrder() } + .map { DocumentCard(it.copy(diiaDocument = it.diiaDocument?.makeCopy())) } + }.also { documents -> + focusDocType?.let { type -> + documents.find { it.doc.type == type }?.let { + documentToFocus = documents.indexOf(it) + focusDocType = null + } + } + } + }.collect { + + if (it != documentCardData.value) { + _documentCardData.value?.let { docCard -> + if (docCard.size <= 1) _navigation.tryEmit( + BaseNavigation.Back + ) + } + _documentCardData.postValue(it) + configureBody(it) + } + } + } + } + + private fun configureBody(data: List) { + _bodyData.clear() + _bodyData.addIfNotNull( + composeMapper.toDocCarousel( + data, + barcodeResult = DocumentBarcodeResultLoading(loading = false) + ).copy(focusOnDoc = currPos) + ) + } + + override fun scrollToLastDocPos() { + _bodyData.findAndChangeFirstByInstance { + it.copy(focusOnDoc = currPos) + } + } + + private fun onFlip( + position: Int, + result: DocumentBarcodeResult, + localizationType: LocalizationType, + showToggle: Boolean + ) { + val index = _bodyData.indexOfFirst { it is DocCarouselOrgData } + + if (index != -1) { + val oldValue: DocCarouselOrgData = _bodyData[index] as DocCarouselOrgData + + if (position >= 0 && position < oldValue.data.size) { + val updatedData = oldValue.data.toMutableList() + + val updatedItem: DocsCarouselItem = when (val currentItem = updatedData[position]) { + is DocCardFlipData -> { + currentItem.copy( + back = toComposeDocCodeOrg(result, localizationType, showToggle) + ) + } + + else -> currentItem // Handle other cases if needed + } + + updatedData[position] = updatedItem + _bodyData[index] = oldValue.copy(data = updatedData.toMutableStateList()) + } + } + _docAction.tryEmit(DocActions.DefaultBrightness) + } + + private fun onToggleClick(toggleId: String, position: Int) { + val index = _bodyData.indexOfFirst { it is DocCarouselOrgData } + if (index == -1) { + return + } else { + _bodyData[index] = + (_bodyData[index] as DocCarouselOrgData).onToggleClick(position, toggleId) + } + when (toggleId) { + ToggleId.qr.value -> _docAction.tryEmit(DocActions.DefaultBrightness) + ToggleId.ean.value -> _docAction.tryEmit(DocActions.HighBrightness) + } + } + + private fun onFlipAndToggle( + position: Int, + result: DocumentBarcodeResult, + toggleId: String, + localizationType: LocalizationType, + showToggle: Boolean + ) { + val index = _bodyData.indexOfFirst { it is DocCarouselOrgData } + + if (index != -1) { + val oldValue: DocCarouselOrgData = _bodyData[index] as DocCarouselOrgData + + if (position >= 0 && position < oldValue.data.size) { + val updatedData = oldValue.data.toMutableList() + + val updatedItem: DocsCarouselItem = when (val currentItem = updatedData[position]) { + is DocCardFlipData -> { + currentItem.copy( + back = toComposeDocCodeOrg(result, localizationType, showToggle) + ) + } + + else -> currentItem + } + + updatedData[position] = updatedItem + _bodyData[index] = oldValue.copy(data = updatedData.toMutableStateList()) + } + } + onToggleClick(toggleId, position) + } + + fun configureTopBar(title: String) { + _topBarData.addIfNotNull(generateComposeNavigationPanel(title = title)) + } + + override fun onUIAction(event: UIAction) { + when (event.actionKey) { + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + UIActionKeysCompose.DOC_CARD_SWIPE_FINISHED -> { + _bodyData.findAndChangeFirstByInstance { + it.flipAllCardsToFrontSideIfNeeded() + } + _docAction.tryEmit(DocActions.DefaultBrightness) + } + + UIActionKeysCompose.DOC_CARD_FLIP -> { + val data = event.data + val optionalId = event.optionalId + optionalId?.let { + _bodyData.findAndChangeFirstByInstance { + it.flipCard(optionalId.toIntOrNull() ?: 0) + } + } + if (data != null && optionalId != null) { + val doc = findDoc(data, optionalId.toIntOrNull() ?: 0) + if (doc != null) { + loadQR(doc, optionalId.toIntOrNull() ?: 0, null) + } + } + + } + + UIActionKeysCompose.TICKER_ATOM_CLICK -> { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + val doc = findDoc(data, optionalId.toIntOrNull() ?: 0) + if (doc != null) { + _navigation.tryEmit(Navigation.NavToVehicleInsurance(doc)) + + } + } + } + + UIActionKeysCompose.DOC_CARD_FORCE_FLIP -> { + val data = event.data + val optionalId = event.optionalId + optionalId?.let { + _bodyData.findAndChangeFirstByInstance { + it.flipCardForceWithToggle( + optionalId.toIntOrNull() ?: 0, + event.optionalType + ) + } + } + if (data != null && optionalId != null) { + val doc = findDoc(data, optionalId.toIntOrNull() ?: 0) + if (doc != null) { + loadQR(doc, optionalId.toIntOrNull() ?: 0, event.optionalType) + } + } + + } + + UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE -> { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + onToggleClick(data, optionalId.toIntOrNull() ?: 0) + } + } + + UIActionKeysCompose.DOC_NUMBER_COPY -> { + event.data?.let { + _docAction.tryEmit(DocActions.DocNumberCopy(it)) + + } + } + + UIActionKeysCompose.REFRESH_BUTTON -> { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + val doc = findDoc(data, optionalId.toIntOrNull() ?: 0) + if (doc != null) { + loadQR(doc, optionalId.toIntOrNull() ?: 0, null) + } + } + } + + UIActionKeysCompose.DOC_ELLIPSE_MENU -> { + val data = event.data + val optionalId = event.optionalId + if (data != null && optionalId != null) { + val doc = findDoc(data, optionalId.toIntOrNull() ?: 0) + if (doc != null) { + _navigation.tryEmit( + Navigation.ToDocActions( + doc, + optionalId.toIntOrNull() ?: 0 + ) + ) + } + } + } + + UIActionKeysCompose.DOC_PAGE_SELECTED -> { + event.data?.toIntOrNull()?.let { currPos = it } + } + + DOC_ACTION_IN_LINE -> { + _docAction.tryEmit(DocActions.OpenElectronicQueue) + } + + DOC_ACTION_TO_DRIVER_ACCOUNT -> { + _docAction.tryEmit(DocActions.OpenDriverAccount) + } + } + } + + private fun checkLocalizationDocs(docs: List?) { + viewModelScope.launch { + withCheckLocalizationDocs.checkLocalizationDocs( + docs, + ::updateExpDate + ) + } + } + + override fun invalidateDataSource() { + documentsDataSource.invalidate() + } + + override fun getCertificatePdf(cert: DiiaDocument) { + executeActionOnFlow(progressIndicator = _progressIndicator) { + withPdfCertificate.loadCertificatePdf(cert) + } + } + + override fun removeMilitaryBondFromGallery( + documentType: String, + documentId: String + ) { + executeActionOnFlow { + withRemoveDocument.removeMilitaryBondFromGallery( + documentType, + documentId, + ::showTemplateDialog) + } + } + + override fun run(block: suspend ((TemplateDialogModel) -> Unit) -> Unit, dispatcher: CoroutineDispatcher) { + executeActionOnFlow(progressIndicator = _progressIndicator) { + block(::showTemplateDialog) + } + } + + override fun run(block: suspend (String) -> ShareByteArr?, docId: String) { + executeActionOnFlow( + dispatcher = Dispatchers.IO, + progressIndicator = _progressIndicator + ) { + block(docId)?.let { data -> + _docAction.tryEmit(DocGalleryVMCompose.DocActions.ShareImage(data)) + } + } + } + + private fun findDoc(docName: String, position: Int): DiiaDocument? { + val documentCardList = + documentCardData.value?.toList() // Make a copy to avoid potential modifications + + return documentCardList?.find { documentCard -> + val itemTypeMatches = documentCard.doc.diiaDocument?.getItemType() == docName + val indexMatches = documentCardList.indexOf(documentCard) == position + itemTypeMatches && indexMatches + }?.doc?.diiaDocument + } + + + private fun loadQR(doc: DiiaDocument, position: Int, toggleId: String?) { + executeActionOnFlow( + dispatcher = dispatcherProvider.ioDispatcher(), + progressIndicator = _progressIndicator.also { + _progressIndicatorKey.value = + UIActionKeysCompose.DOC_CODE_ORG_DATA + } + ) { + val barcodeResult = barcodeRepository.loadBarcode(doc, position) + if (toggleId == null) { + onFlip( + position, + barcodeResult.result, + doc.localization() ?: LocalizationType.ua, + barcodeResult.showToggle + + ) + + } else { + onFlipAndToggle( + position, + barcodeResult.result, + toggleId, + doc.localization() ?: LocalizationType.ua, + barcodeResult.showToggle + ) + } + } + } + + override fun removeDoc(diiaDocument: DiiaDocument) { + executeActionOnFlow { + withRemoveDocument.removeDocument(diiaDocument, ::removeDocument) + } + } + + private fun removeDocument(diiaDocument: DiiaDocument) { + documentsDataSource.removeDocument(diiaDocument) + currPos = setPrevPos(currPos) + } + + private fun updateDocument(doc: DiiaDocument) { + executeActionOnFlow { + documentsDataSource.updateDocument(doc) + } + } + + override fun forceUpdateDocument(doc: DiiaDocument) { + executeActionOnFlow(progressIndicator = _progressIndicator) { + val resp = doc.id?.let { + apiDocs.getDocumentById( + type = doc.getItemType(), + id = it + ) + } + resp?.template?.apply(::showTemplateDialog) + resp?.educationDocument?.let { updateDocument(it) } + } + } + + private fun updateExpDate(type: String) { + executeActionOnFlow(dispatcher = dispatcherProvider.work) { + withUpdateExpiredDocs.updateExpirationDate(type) + } + } + + private fun updateExpDate(types: List) { + executeActionOnFlow(dispatcher = dispatcherProvider.work) { + withUpdateExpiredDocs.updateExpirationDate(types) + } + } + + private fun setPrevPos(position: Int): Int { + return if (position > 0) { + position - 1 + } else { + 0 + } + } + + + override fun sendRatingRequest(ratingRequest: RatingRequest) { + currentDoc?.getItemType() + ?.let { sendRating(ratingRequest, ActionsConst.DOCUMENTS_CODE, it) } + } + + + override fun currentDocId() = currentDoc?.docId() + + override fun showRating(doc: DiiaDocument) { + currentDoc = doc + getRating(ActionsConst.DOCUMENTS_CODE, doc.getItemType()) + } + + override fun confirmDelDocument(docName: String) { + executeActionOnFlow( + progressIndicator = _progressIndicator + ) { + withRemoveDocument.confirmRemoveDocument( + docName, + { currentDoc }, + ::showTemplateDialog, + ::removeDocument + ) + } + } + + companion object { + private const val ACTION_HOME_DOCUMENTS = 1 + } + + sealed class Navigation : NavigationPath { + data class ToDocActions(val doc: DiiaDocument, val position: Int) : Navigation() + data class NavToVehicleInsurance(val doc: DiiaDocument) : Navigation() + + } + + sealed class DocActions : DocAction { + object OpenDriverAccount : DocActions() + object OpenElectronicQueue : DocActions() + object HighBrightness : DocActions() + object DefaultBrightness : DocActions() + + data class DocNumberCopy(val value: String) : DocActions() + + data class ShareImage(val data: ShareByteArr) : DocActions() + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/stack/compose/StackScreen.kt b/documents/src/main/java/ua/gov/diia/documents/ui/stack/compose/StackScreen.kt new file mode 100644 index 0000000..617e19b --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/stack/compose/StackScreen.kt @@ -0,0 +1,102 @@ +package ua.gov.diia.documents.ui.stack.compose + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.animateLottieCompositionAsState +import com.airbnb.lottie.compose.rememberLottieComposition +import ua.gov.diia.ui_base.R +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.screen.BodyRootContainer +import ua.gov.diia.ui_base.components.infrastructure.screen.BottomBarRootContainer +import ua.gov.diia.ui_base.components.infrastructure.screen.ComposeRootScreen +import ua.gov.diia.ui_base.components.infrastructure.screen.ToolbarRootContainer +import ua.gov.diia.ui_base.components.molecule.header.NavigationPanelMlcData +import ua.gov.diia.ui_base.components.provideTestTagsAsResourceId + +@Composable +fun StackScreen( + modifier: Modifier = Modifier, + contentLoaded: Pair = Pair("", true), + progressIndicator: Pair = Pair("", true), + toolbar: SnapshotStateList, + connectivityState: Boolean = true, + body: SnapshotStateList, + bottom: SnapshotStateList? = null, + diiaResourceIconProvider: DiiaResourceIconProvider, + onEvent: (UIAction) -> Unit +) { + + val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.gradient_bg)) + val progress by animateLottieCompositionAsState( + composition, + iterations = LottieConstants.IterateForever + ) + + Box( + modifier = Modifier.fillMaxSize().provideTestTagsAsResourceId(), + contentAlignment = Alignment.Center + ) { + LottieAnimation( + modifier = Modifier + .fillMaxSize(), + alignment = Alignment.Center, + contentScale = ContentScale.FillBounds, + composition = composition, + progress = { progress }, + + ) + BackHandler { + onEvent(UIAction(actionKey = toolbar.firstOrNull { + it is NavigationPanelMlcData + }?.let { + (it as NavigationPanelMlcData).backAction + } ?: UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK)) + } + ComposeRootScreen( + modifier = modifier, + contentLoaded = contentLoaded, + toolbar = { + ToolbarRootContainer( + toolbarViews = toolbar, + onUIAction = onEvent, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + }, + body = { + BodyRootContainer( + bodyViews = body, + progressIndicator = progressIndicator, + contentLoaded = contentLoaded, + onUIAction = onEvent, + connectivityState = connectivityState, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + }, + bottom = { + if (bottom != null) { + BottomBarRootContainer( + bottomViews = bottom, + progressIndicator = progressIndicator, + onUIAction = onEvent, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + }, + onEvent = onEvent, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } +} diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/stack/order/StackOrderFCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/stack/order/StackOrderFCompose.kt new file mode 100644 index 0000000..3b311dd --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/stack/order/StackOrderFCompose.kt @@ -0,0 +1,90 @@ +package ua.gov.diia.documents.ui.stack.order + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.screen.StackOrderScreen +import javax.inject.Inject + +@AndroidEntryPoint +class StackOrderFCompose : Fragment() { + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private var composeView: ComposeView? = null + private val viewModel: StackOrderVMCompose by viewModels() + private val args: StackOrderFComposeArgs by navArgs() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.doInit(args.doc) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + composeView?.setContent { + val topBar = viewModel.topBarData + val body = viewModel.bodyData + + viewModel.apply { + navigation.collectAsEffect { navigation -> + when (navigation) { + is BaseNavigation.Back -> { + findNavController().popBackStack() + } + + is StackOrderVMCompose.Navigation.ToStackTypedOrder -> { + navigate( + StackOrderFComposeDirections.actionGlobalToStackOrder( + navigation.type + ) + ) + } + } + } + showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + } + StackOrderScreen( + toolbar = topBar, + body = body, + onUIAction = { viewModel.onUIAction(it) }, + onMove = { a, b -> viewModel.onMove(a, b) }, + diiaResourceIconProvider = diiaResourceIconProvider + ) + } + } + + override fun onPause() { + super.onPause() + viewModel.saveCurrentOrder() + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/ui/stack/order/StackOrderVMCompose.kt b/documents/src/main/java/ua/gov/diia/documents/ui/stack/order/StackOrderVMCompose.kt new file mode 100644 index 0000000..e4ddfa0 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/ui/stack/order/StackOrderVMCompose.kt @@ -0,0 +1,237 @@ +package ua.gov.diia.documents.ui.stack.order + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst.ACTION_NAVIGATE_BACK +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRatingDialogOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.ui_base.util.navigation.generateComposeNavigationPanel +import ua.gov.diia.documents.R +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DocError +import ua.gov.diia.documents.models.DocOrder +import ua.gov.diia.documents.models.DocumentOrderModel +import ua.gov.diia.documents.models.TypeDefinedDocOrder +import ua.gov.diia.documents.ui.DocsConst +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.list.ListItemDragMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.ListItemDragOrgData +import javax.inject.Inject + +@HiltViewModel +class StackOrderVMCompose @Inject constructor( + private val documentsDataSource: DocumentsDataRepository, + private val errorHandling: WithErrorHandlingOnFlow, + private val withRetryLastAction: WithRetryLastAction, + private val withRatingDialog: WithRatingDialogOnFlow, + private val composeMapper: DocumentComposeMapper, + private val documentsHelper: DocumentsHelper +) : ViewModel(), + WithErrorHandlingOnFlow by errorHandling, + WithRetryLastAction by withRetryLastAction, + DocumentComposeMapper by composeMapper, + WithRatingDialogOnFlow by withRatingDialog { + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + private val _topBarData = mutableStateListOf() + val topBarData: SnapshotStateList = _topBarData + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private var docType: String = DocsConst.DOCUMENT_TYPE_ALL + private var originalOrder: List? = null + + fun doInit(docType: String) { + this.docType = docType + viewModelScope.launch { + documentsDataSource.data.collectLatest { documents -> + documents.data?.let { dataResult -> + val docs = if (docType == DocsConst.DOCUMENT_TYPE_ALL) { + dataResult + .asSequence() + .filter { it.diiaDocument != null && it.diiaDocument !is DocError } + .filter { d -> documentsHelper.isDocumentValid(d) } + .groupBy { it.diiaDocument!!.getItemType() } + .mapNotNull { diaDocument -> diaDocument.value.minByOrNull { it.diiaDocument!!.getDocOrder() }!! } + .sortedBy { it.getDocOrder() } + .toList() + } else { + dataResult + .filter { it.diiaDocument != null && it.diiaDocument.getItemType() == docType } + .sortedBy { it.diiaDocument!!.getDocOrder() } + } + + val list = if (docType == DocsConst.DOCUMENT_TYPE_ALL) { + docs.toStateList { type -> dataResult.count { it.type == type } } + } else { + docs.toTypeStateList() + } + + originalOrder = docs + + _topBarData.clear() + _topBarData.addIfNotNull(getNavigationData(docs.getOrNull(0))) + + _bodyData.clear() + _bodyData.add(ListItemDragOrgData(list)) + } + } + } + } + + + fun onUIAction(event: UIAction) { + when (event.actionKey) { + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + UIActionKeysCompose.LIST_ITEM_CLICK -> { + val type = event.data ?: return + _navigation.tryEmit(Navigation.ToStackTypedOrder(type)) + } + UIActionKeysCompose.TITLE_GROUP_MLC -> { + event.action?.type?.let { + if (it == ACTION_NAVIGATE_BACK) { + _navigation.tryEmit(BaseNavigation.Back) + } + } + } + } + } + + private fun getDocOrder(documents: List): List { + return if (docType == DocsConst.DOCUMENT_TYPE_ALL) { + documents.mapIndexed { index, documentCard -> + DocOrder(documentCard.id, index.inc()) + } + } else { + documents.mapIndexed { index, documentCard -> + TypeDefinedDocOrder(documentCard.id, index.inc()) + } + } + } + + private fun List?.toTypeStateList(): SnapshotStateList { + val list = SnapshotStateList() + if (this == null) return list + forEach { + it.toTypedDragMlcData()?.let { d -> list.add(d) } + } + return list + } + + private fun List?.toStateList(countOfDocs: (type: String) -> Int): SnapshotStateList { + val list = SnapshotStateList() + if (this == null) return list + forEach { + it.toDragMlcData(countOfDocs)?.let { d -> list.add(d) } + } + return list + } + + private fun DiiaDocumentWithMetadata.toDragMlcData(countOfDocs: (type: String) -> Int): ListItemDragMlcData? { + val name = diiaDocument?.getDocName() + return if (!name.isNullOrEmpty()) { + ListItemDragMlcData( + id = type, + label = UiText.DynamicString(name), + countOfDocGroup = countOfDocs(type) + ) + } else null + } + + private fun DiiaDocumentWithMetadata.toTypedDragMlcData(): ListItemDragMlcData? { + if (diiaDocument == null) return null + val id = diiaDocument.getDocNum() ?: diiaDocument.docId() + val num = diiaDocument.getDocOrderLabel() + val date = diiaDocument.getDocOrderDescription() ?: diiaDocument.getDisplayDate() + return if (!num.isNullOrEmpty()) { + ListItemDragMlcData( + id = id, + label = UiText.DynamicString(num), + desc = UiText.DynamicString(date) + ) + } else null + } + + fun onMove(a: Int, b: Int) { + _bodyData.findAndChangeFirstByInstance { + val list = it.items.toMutableList() + list.add(a, list.removeAt(b)) + + val state = SnapshotStateList().apply { addAll(list) } + ListItemDragOrgData(state) + } + } + + fun saveCurrentOrder() { + val element = (_bodyData.find { it is ListItemDragOrgData } as? ListItemDragOrgData) + val currentOrder = getDocOrder(element?.items.orEmpty()) + + if (docType == DocsConst.DOCUMENT_TYPE_ALL) { + documentsDataSource.saveDocTypeOrder(currentOrder as List) + } else { + documentsDataSource.saveDocOrderForSpecificType( + docOrders = currentOrder as List, + docType = docType + ) + } + } + + private fun getNavigationData(docData: DiiaDocumentWithMetadata?): UIElementData? { + return if (docType == DocsConst.DOCUMENT_TYPE_ALL) { + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.StringResource(R.string.stack_order_title), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + accessibilityDescription = UiText.StringResource(R.string.accessibility_back_button), + action = DataActionWrapper( + type = ACTION_NAVIGATE_BACK, + subtype = null, + resource = null + ) + ) + ) + ) + } else { + val name = docData?.diiaDocument?.getDocName() + generateComposeNavigationPanel(title = name) + } + } + + sealed class Navigation : NavigationPath { + data class ToStackTypedOrder(val type: String) : Navigation() + } + +} diff --git a/documents/src/main/java/ua/gov/diia/documents/util/BaseDocActionItemProcessor.kt b/documents/src/main/java/ua/gov/diia/documents/util/BaseDocActionItemProcessor.kt new file mode 100644 index 0000000..b6f67b0 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/util/BaseDocActionItemProcessor.kt @@ -0,0 +1,32 @@ +package ua.gov.diia.documents.util + +import kotlinx.coroutines.flow.MutableSharedFlow +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.ui_base.components.infrastructure.event.DocAction +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData + +open class BaseDocActionItemProcessor(val code: String, val name: String) { + + fun generateListItem(itemCode: String, manualActionName: String = ""): ListItemMlcData? { + if (itemCode == code) { + return getListItem(manualActionName) + } + return null + } + + protected open fun getListItem(manualActionName: String): ListItemMlcData? { + return null + } + + fun processEvent(event: String, docAction: MutableSharedFlow, dismiss: MutableSharedFlow, navigation: MutableSharedFlow) { + when(event) { + code -> processCode(docAction, dismiss, navigation) + name -> processName(docAction, dismiss, navigation) + } + } + + protected open fun processCode(docAction: MutableSharedFlow, dismiss: MutableSharedFlow, navigation: MutableSharedFlow) {} + protected open fun processName(docAction: MutableSharedFlow, dismiss: MutableSharedFlow, navigation: MutableSharedFlow) {} + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/util/BaseDocumentActionProvider.kt b/documents/src/main/java/ua/gov/diia/documents/util/BaseDocumentActionProvider.kt new file mode 100644 index 0000000..7e00154 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/util/BaseDocumentActionProvider.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.documents.util + +import android.content.res.Resources +import android.os.Parcelable +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData + +interface BaseDocumentActionProvider { + fun isDocumentProvider(document: Parcelable): Boolean + fun listOfActions( + docParcelable: Parcelable, + resources: Resources): List + +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/util/DocNameProvider.kt b/documents/src/main/java/ua/gov/diia/documents/util/DocNameProvider.kt new file mode 100644 index 0000000..6c96d35 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/util/DocNameProvider.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.documents.util + +import ua.gov.diia.documents.models.DiiaDocument + +interface DocNameProvider { + /** + * @return string name for specific document + */ + fun getDocumentName(document: DiiaDocument): String +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/util/DocumentActionMapper.kt b/documents/src/main/java/ua/gov/diia/documents/util/DocumentActionMapper.kt new file mode 100644 index 0000000..92f32ce --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/util/DocumentActionMapper.kt @@ -0,0 +1,282 @@ +package ua.gov.diia.documents.util + +import android.content.res.Resources +import android.os.Parcelable +import ua.gov.diia.documents.R +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.ui.actions.ContextMenuType +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import javax.inject.Inject + +class DocumentActionMapper @Inject constructor( + private val actionItemProcessorList: List<@JvmSuppressWildcards BaseDocActionItemProcessor> +) { + + fun docActionForType( + doc: DiiaDocument, + typeCode: String, + typeName: String, + resources: Resources + ): ListItemMlcData { + actionItemProcessorList.firstNotNullOfOrNull { it.generateListItem(typeCode) }?.let { + return it + } + + return when (typeCode) { + ContextMenuType.FULL_DOC.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.full_info), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.DOC_INFO.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.HOUSING_CERTIFICATES.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.full_info), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.DOC_INFO.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.PNP.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.pnp), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.DOWNLOAD_CERTIFICATE_PDF.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.download_certificate_pdf), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.DOWNLOAD.code), + action = DataActionWrapper(type = typeName) + ) + ContextMenuType.VERIFICATION_CODE.code -> { + val verificationCodesCount = getVerificationCodesCount(doc) + val text = resources.getQuantityText( + R.plurals.code_verification_plural, + verificationCodesCount + ) + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.DynamicString(text.toString()), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.QR.code), + action = DataActionWrapper(type = typeName) + ) + } + + ContextMenuType.INSURANCE.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.vl_insurance), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.POLICE.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.OPEN_SAME_DOC_TYPE.code -> + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.stack_all_same_doc_type), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.CHANGE_DISPLAY_ORDER.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.stack_change_doc_type_ordering), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.REORDER.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.CHANGE_DOC_ORDERING.code -> + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.change_doc_ordering), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.REORDER.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.FAQS.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.faq), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.FAQ.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.REPLACE_DRIVER_LICENSE.code -> + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.dl_replace_feature_title), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.REFRESH.code), + action = DataActionWrapper(type = typeName) + ) + + + ContextMenuType.REMOVE_DOC.code -> + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.remove_doc_action), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.DELETE.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.TRANSLATE_TO_UA.code -> + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.translate_to_ua), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.UA.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.TRANSLATE_TO_ENG.code -> + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.translate_to_eng), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.EN.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.MILITARY_BOND.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.military_bonds_menu_go_to_mb), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.TRIDENT.code), + action = DataActionWrapper(type = typeName) + + ) + + ContextMenuType.MILITARY_BOND_REMOVE_FROM_DOCS.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.military_bonds_remove_from_docs), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.DELETE.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.RESIDENCE_CERT.code -> + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.residence_cert), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.HOME_DOC.code), + action = DataActionWrapper(type = typeName) + ) + + + ContextMenuType.RESIDENCE_CERT_CHILD.code -> + ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.residence_cert), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.HOME_DOC.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.EDIT_INTERNALLY_DISPLACED_PERSON_ADDRESS.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.idp_edit_person_address), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.EDIT.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.INTERNALLY_DISPLACED_CERT_CANCEL.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.idp_action_button_cancel), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.CANCEL.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.PROPER_USER_SHARE.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.idp_share_car), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.SHARE.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.PROPER_USER_OWNER_CANCEL.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.idp_share_car_owner_cancel), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.CANCEL.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.PROPER_USER_PROPER_CANCEL.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.idp_share_car_proper_cancel), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.CANCEL.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.RATE_DOCUMENT.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.rate_document), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.RATING.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.SHARE_WITH_FRIENDS.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.settings_share_with_friends), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.SHARE.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.VEHICLE_RE_REGISTRATION.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.vehicle_re_registration), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.WALLET.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.FOUNDING_REQUEST.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.dp_recovery_menu_go_to_founding_request), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.WALLET.code), + action = DataActionWrapper(type = typeName) + ) + + ContextMenuType.UPDATE_DOC.code -> ListItemMlcData( + actionKey = typeName, + id = typeName, + label = UiText.StringResource(R.string.update_document), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.REFRESH.code), + action = DataActionWrapper(type = typeName) + ) + + + //Dynamic items, data is fetched from web (api/v1/documents/manual) +// ContextMenuType.BIRTH_CERTIFICATE, ContextMenuType.VACCINATION_CERTIFICATE, ContextMenuType.CHILD_VACCINATION_CERTIFICATE, +// ContextMenuType.INTERNATIONAL_VACCINATION_CERTIFICATE, ContextMenuType.REQUEST_PROPER_USER_ASSIGNING, ContextMenuType.PENSION_CARD, +// ContextMenuType.RESIDENCE_PERMIT_PERMANENT, ContextMenuType.RESIDENCE_PERMIT_TEMPORARY + else -> throw Exception() + } + } + + private fun getVerificationCodesCount(document: Parcelable): Int { + check(document is DiiaDocument) + return document.verificationCodesCount() + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/util/WithUpdateExpiredDocs.kt b/documents/src/main/java/ua/gov/diia/documents/util/WithUpdateExpiredDocs.kt new file mode 100644 index 0000000..91e7223 --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/util/WithUpdateExpiredDocs.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.documents.util + +interface WithUpdateExpiredDocs { + + /** + * @return performs update expiration date for specific document + */ + suspend fun updateExpirationDate(focusDocType: String) + + /** + * @return performs update expiration date for list of documents + */ + suspend fun updateExpirationDate(types: List) +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/util/WithUpdateExpiredDocsImpl.kt b/documents/src/main/java/ua/gov/diia/documents/util/WithUpdateExpiredDocsImpl.kt new file mode 100644 index 0000000..7aa0dbe --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/util/WithUpdateExpiredDocsImpl.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.documents.util + +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.helper.DocumentsHelper +import javax.inject.Inject + +class WithUpdateExpiredDocsImpl @Inject constructor(private val documentsDataSource: DocumentsDataRepository, + private val documentsHelper: DocumentsHelper) : + WithUpdateExpiredDocs { + override suspend fun updateExpirationDate(focusDocType: String) { + documentsHelper.provideListOfDocumentsRequireUpdateOfExpirationDate(focusDocType)?.let { + updateExpirationDate(it) + } + } + + override suspend fun updateExpirationDate(types: List) { + documentsDataSource.replaceExpDateByType(types) + documentsDataSource.invalidate() + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/util/datasource/ExpirationStrategy.kt b/documents/src/main/java/ua/gov/diia/documents/util/datasource/ExpirationStrategy.kt new file mode 100644 index 0000000..181c31a --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/util/datasource/ExpirationStrategy.kt @@ -0,0 +1,41 @@ +package ua.gov.diia.documents.util.datasource + +import ua.gov.diia.core.util.date.CurrentDateProvider +import ua.gov.diia.core.util.extensions.date_time.getUTCDate +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.documents.models.Expiring + +/** + * Predicate to mark item already expired + */ +interface ExpirationStrategy { + fun isExpired(data: Any): Boolean +} + +/** + * Update if expiration data is before current date + * Note: you should check that data you provide implements + * @see #ua.gov.diia.app.models.documents.Expiring + * + * @param currentDateProvider provides current date to compare + */ +class DateCompareExpirationStrategy(private val currentDateProvider: CurrentDateProvider) : + ExpirationStrategy { + + private var currentDate = currentDateProvider.getDate() + + fun reset() { + currentDate = currentDateProvider.getDate() + } + + override fun isExpired(data: Any): Boolean { + if (data !is Expiring) { + throw IllegalArgumentException("Class ${data::class.java.name} must implement ${Expiring::class.java}") + } + if (data.getDocExpirationDate() == Preferences.DEF) { + return true + } + val docExpirationDate = getUTCDate(data.getDocExpirationDate()) + return docExpirationDate?.before(currentDate) ?: true + } +} \ No newline at end of file diff --git a/documents/src/main/java/ua/gov/diia/documents/util/view/Ext.kt b/documents/src/main/java/ua/gov/diia/documents/util/view/Ext.kt new file mode 100644 index 0000000..ab14bcf --- /dev/null +++ b/documents/src/main/java/ua/gov/diia/documents/util/view/Ext.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.documents.util.view + +import android.content.ClipData +import android.view.View +import com.google.android.material.snackbar.Snackbar +import ua.gov.diia.core.util.extensions.context.serviceClipboard +import ua.gov.diia.ui_base.util.view.showTopSnackBar +import ua.gov.diia.documents.R + + +fun View.showCopyDocIdClipedSnackBar(docId: String, topPadding: Float) { + val clip = ClipData.newPlainText("docId", docId) + + context.serviceClipboard + ?.setPrimaryClip(clip) + + showTopSnackBar(R.string.doc_id_copied, Snackbar.LENGTH_LONG, topPadding) +} \ No newline at end of file diff --git a/documents/src/main/res/drawable/ic_alert.xml b/documents/src/main/res/drawable/ic_alert.xml new file mode 100644 index 0000000..0e8f065 --- /dev/null +++ b/documents/src/main/res/drawable/ic_alert.xml @@ -0,0 +1,12 @@ + + + + diff --git a/documents/src/main/res/drawable/ic_checked.xml b/documents/src/main/res/drawable/ic_checked.xml new file mode 100644 index 0000000..2aa4695 --- /dev/null +++ b/documents/src/main/res/drawable/ic_checked.xml @@ -0,0 +1,16 @@ + + + + diff --git a/documents/src/main/res/navigation/nav_doc_actions.xml b/documents/src/main/res/navigation/nav_doc_actions.xml new file mode 100644 index 0000000..4561a9b --- /dev/null +++ b/documents/src/main/res/navigation/nav_doc_actions.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + \ + + + + + + + + + \ No newline at end of file diff --git a/documents/src/main/res/navigation/nav_doc_full_info.xml b/documents/src/main/res/navigation/nav_doc_full_info.xml new file mode 100644 index 0000000..2d69ee5 --- /dev/null +++ b/documents/src/main/res/navigation/nav_doc_full_info.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/documents/src/main/res/navigation/nav_doc_gallery.xml b/documents/src/main/res/navigation/nav_doc_gallery.xml new file mode 100644 index 0000000..c5f8e2e --- /dev/null +++ b/documents/src/main/res/navigation/nav_doc_gallery.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/documents/src/main/res/navigation/nav_doc_stack.xml b/documents/src/main/res/navigation/nav_doc_stack.xml new file mode 100644 index 0000000..2800f49 --- /dev/null +++ b/documents/src/main/res/navigation/nav_doc_stack.xml @@ -0,0 +1,20 @@ + + + + + + + + \ No newline at end of file diff --git a/documents/src/main/res/navigation/nav_stack_order.xml b/documents/src/main/res/navigation/nav_stack_order.xml new file mode 100644 index 0000000..0af544e --- /dev/null +++ b/documents/src/main/res/navigation/nav_stack_order.xml @@ -0,0 +1,23 @@ + + + + + + + + \ No newline at end of file diff --git a/documents/src/main/res/values/nav_ids.xml b/documents/src/main/res/values/nav_ids.xml new file mode 100644 index 0000000..fd753f2 --- /dev/null +++ b/documents/src/main/res/values/nav_ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/documents/src/main/res/values/plurals.xml b/documents/src/main/res/values/plurals.xml new file mode 100644 index 0000000..ea3433a --- /dev/null +++ b/documents/src/main/res/values/plurals.xml @@ -0,0 +1,8 @@ + + + + Код для перевірки + Коди для перевірки + + + \ No newline at end of file diff --git a/documents/src/main/res/values/strings.xml b/documents/src/main/res/values/strings.xml new file mode 100644 index 0000000..3e0306b --- /dev/null +++ b/documents/src/main/res/values/strings.xml @@ -0,0 +1,43 @@ + + + + Пройшов перевірку Державною податковою службою %1$s + Перевіряється Державною податковою службою + Не пройшов перевірку Державною податковою службою + + https://eq.hsc.gov.ua/ + https://hsc.gov.ua/kontakti/kontakti-gsts-pidrozdiliv/ + + + Змінити порядок + Перетягніть документи, щоб\nзмінити порядок їх відображення + Всі документи цього типу + Змінити порядок документів + + Змінити порядок документів + Видалити документ + Повна інформація + Номер скопійовано + + Подсвічення на повернення + Завантажити сертифікат (PDF) + Страховий поліс ОСЦПВ + Питання та відповіді + Заміна посвідчення + Перекласти українською + Перекласти англійською + Перейти в облігації + Видалити з галереї + Витяг про місце проживання + Змінити адресу фактичного проживання + Скасувати статус ВПО + Поділитись транспортним засобом + Скасувати шеринг + Відмовитися від користування авто + Оцінити документ + Розповісти друзям + Продати транспортний засіб + Забронювати кошти + Оновити документ + + \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/LiveDataTestUtil.kt b/documents/src/test/java/ua/gov/diia/documents/LiveDataTestUtil.kt new file mode 100644 index 0000000..b459b9a --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/LiveDataTestUtil.kt @@ -0,0 +1,55 @@ +package ua.gov.diia.documents + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * Gets the value of a [LiveData] or waits for it to have one, with a timeout. + * + * Use this extension from host-side (JVM) tests. It's recommended to use it alongside + * `InstantTaskExecutorRule` or a similar mechanism to execute tasks synchronously. + */ +fun LiveData.getOrAwaitValue( + time: Long = 2, + timeUnit: TimeUnit = TimeUnit.SECONDS, + afterObserve: () -> Unit = {} +): T { + var data: T? = null + val latch = CountDownLatch(1) + val observer = object : Observer { + override fun onChanged(value: T?) { + data = value + latch.countDown() + this@getOrAwaitValue.removeObserver(this) + } + } + this.observeForever(observer) + + afterObserve.invoke() + + // Don't wait indefinitely if the LiveData is not set. + if (!latch.await(time, timeUnit)) { + this.removeObserver(observer) + throw TimeoutException("LiveData value was never set.") + } + + @Suppress("UNCHECKED_CAST") + return data as T +} + +/** + * Observes a [LiveData] until the `block` is done executing. + */ +suspend fun LiveData.observeForTesting(block: suspend () -> Unit) { + + val observer = Observer { } + try { + observeForever(observer) + block() + } finally { + removeObserver(observer) + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/Mocks.kt b/documents/src/test/java/ua/gov/diia/documents/Mocks.kt new file mode 100644 index 0000000..bf593f3 --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/Mocks.kt @@ -0,0 +1,149 @@ +package ua.gov.diia.documents + +import kotlinx.parcelize.Parcelize +import ua.gov.diia.core.network.Http +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.LocalizationType +import java.util.UUID + +@Parcelize +data class DocTest( + val empty: String = DOCUMENT_TEST, + val message: String = "", + override val id: String? = "123" +) : DiiaDocument { + + override fun docId() = id ?: "" + override fun getItemType() = DOCUMENT_TEST + override fun getDocExpirationDate(): String = Preferences.DEF + override fun getExpirationDateISO(): String = Preferences.DEF + override fun getStatus() = Http.HTTP_200 + override fun getWeight() = Int.MAX_VALUE + override fun getDocOrder() = DiiaDocumentWithMetadata.LAST_DOC_ORDER + override fun setNewOrder(newOrder: Int) { + } + + override fun getDocColor() = R.color.colorPrimary + override fun getDocNum() = id + override fun makeCopy(): DiiaDocument = this.copy() + override fun verificationCodesCount() = 1 + override fun getPersonName(): String = "" + override fun getDisplayDate(): String = "" + override fun birthCertificateId() = "" + override fun localization(): LocalizationType = LocalizationType.ua + override fun setLocalization(code: LocalizationType) {} + override fun getDocName() = "test" + override fun getDocOrderDescription() = "" + override fun getDocOrderLabel() = "" + + companion object { + private const val DOCUMENT_TEST = "doc_test" + } +} + +@Parcelize +data class DocTest2( + val empty: String = DOCUMENT_TEST2, + val message: String = "", + override val id: String? = UUID.randomUUID().toString() +) : DiiaDocument { + + override fun docId() = id ?: "" + override fun getItemType() = DOCUMENT_TEST2 + override fun getDocExpirationDate(): String = Preferences.DEF + override fun getExpirationDateISO(): String = Preferences.DEF + override fun getStatus() = Http.HTTP_200 + override fun getWeight() = Int.MAX_VALUE + override fun getDocOrder() = DiiaDocumentWithMetadata.LAST_DOC_ORDER + override fun setNewOrder(newOrder: Int) { + } + + override fun getDocColor() = R.color.colorPrimary + override fun getDocNum() = id + override fun makeCopy(): DiiaDocument = this.copy() + override fun verificationCodesCount() = 1 + override fun getPersonName(): String = "" + override fun getDisplayDate(): String = "" + override fun birthCertificateId() = "" + override fun localization(): LocalizationType = LocalizationType.ua + override fun setLocalization(code: LocalizationType) {} + override fun getDocName() = "test2" + override fun getDocOrderDescription() = "" + override fun getDocOrderLabel() = "test" + + companion object { + private const val DOCUMENT_TEST2 = "doc_test2" + } +} + +@Parcelize +data class DocTest3( + val empty: String = DOCUMENT_TEST3, + val message: String = "", + override val id: String? = UUID.randomUUID().toString() +) : DiiaDocument { + + override fun docId() = id ?: "" + override fun getItemType() = DOCUMENT_TEST3 + override fun getDocExpirationDate(): String = Preferences.DEF + override fun getExpirationDateISO(): String = Preferences.DEF + override fun getStatus() = Http.HTTP_200 + override fun getWeight() = Int.MAX_VALUE + override fun getDocOrder() = DiiaDocumentWithMetadata.LAST_DOC_ORDER + override fun setNewOrder(newOrder: Int) { + } + + override fun getDocColor() = R.color.colorPrimary + override fun getDocNum() = id + override fun makeCopy(): DiiaDocument = this.copy() + override fun verificationCodesCount() = 1 + override fun getPersonName(): String = "" + override fun getDisplayDate(): String = "" + override fun birthCertificateId() = "" + override fun localization(): LocalizationType = LocalizationType.ua + override fun setLocalization(code: LocalizationType) {} + override fun getDocName() = "test3" + override fun getDocOrderDescription() = "" + override fun getDocOrderLabel() = "" + + companion object { + private const val DOCUMENT_TEST3 = "doc_test3" + } +} + +@Parcelize +data class DocTest4( + val empty: String = DOCUMENT_TEST4, + val message: String = "", + override val id: String? = UUID.randomUUID().toString() +) : DiiaDocument { + + override fun docId() = id ?: "" + override fun getItemType() = DOCUMENT_TEST4 + override fun getDocExpirationDate(): String = Preferences.DEF + override fun getExpirationDateISO(): String = Preferences.DEF + override fun getStatus() = Http.HTTP_200 + override fun getWeight() = Int.MAX_VALUE + override fun getDocOrder() = DiiaDocumentWithMetadata.LAST_DOC_ORDER + override fun setNewOrder(newOrder: Int) { + } + + override fun getDocColor() = R.color.colorPrimary + override fun getDocNum() = id + override fun makeCopy(): DiiaDocument = this.copy() + override fun verificationCodesCount() = 1 + override fun getPersonName(): String = "" + override fun getDisplayDate(): String = "" + override fun birthCertificateId() = "" + override fun localization(): LocalizationType = LocalizationType.ua + override fun setLocalization(code: LocalizationType) {} + override fun getDocName() = "" + override fun getDocOrderDescription() = "" + override fun getDocOrderLabel() = "" + + companion object { + private const val DOCUMENT_TEST4 = "doc_test4" + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/data/datasource/local/DefaultDocGroupUpdateBehaviorTest.kt b/documents/src/test/java/ua/gov/diia/documents/data/datasource/local/DefaultDocGroupUpdateBehaviorTest.kt new file mode 100644 index 0000000..dc27a4d --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/data/datasource/local/DefaultDocGroupUpdateBehaviorTest.kt @@ -0,0 +1,142 @@ +package ua.gov.diia.documents.data.datasource.local + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.mockito.Mockito.spy +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.network.Http +import ua.gov.diia.core.network.Http.HTTP_200 +import ua.gov.diia.core.network.Http.HTTP_403 +import ua.gov.diia.core.network.Http.HTTP_500 +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.Preferences +import ua.gov.diia.documents.rules.MainDispatcherRule + +@RunWith(MockitoJUnitRunner::class) +class DefaultDocGroupUpdateBehaviorTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + lateinit var defaultDocGroupUpdateBehavior: DefaultDocGroupUpdateBehavior + + @Before + fun setUp() { + defaultDocGroupUpdateBehavior = DefaultDocGroupUpdateBehavior() + } + + @Test + fun `test handle metadata for document with status 200 `() = runBlocking { + val currentDocValue = mock(DiiaDocumentWithMetadata::class.java)//= DiiaDocumentWithMetadata(null, "timestamp", "expdata", 200, "document") + `when`(currentDocValue.type).thenReturn("document") + val newDocValue = mock(DiiaDocumentWithMetadata::class.java)//DiiaDocumentWithMetadata(null, "timestamp2", "expdata2", 200, "document")// +// `when`(newDocValue.type).thenReturn("document") + val docValue: MutableList = mutableListOf() + docValue.add(newDocValue) + val docsToPersist: MutableList = mutableListOf() + docsToPersist.add(currentDocValue) + val existsId: List = mutableListOf() + + defaultDocGroupUpdateBehavior.handleUpdate("document", docValue, Http.HTTP_200, docsToPersist, existsId) + + assertEquals(1, docsToPersist.size) + assertTrue(docsToPersist.contains(newDocValue)) + assertFalse(docsToPersist.contains(currentDocValue)) + } + + @Test + fun `test handle metadata for document with status 404 `() = runBlocking { + val currentDocValue = mock(DiiaDocumentWithMetadata::class.java) + `when`(currentDocValue.type).thenReturn("document") + val newDocValue = mock(DiiaDocumentWithMetadata::class.java) + val docValue: MutableList = mutableListOf() + docValue.add(newDocValue) + val docsToPersist: MutableList = mutableListOf() + docsToPersist.add(currentDocValue) + val existsId: List = mutableListOf() + + defaultDocGroupUpdateBehavior.handleUpdate("document", docValue, Http.HTTP_404, docsToPersist, existsId) + + assertEquals(1, docsToPersist.size) + assertTrue(docsToPersist.contains(newDocValue)) + assertFalse(docsToPersist.contains(currentDocValue)) + } + + @Test + fun `test handle metadata for document with covid cert in progress status`() = runBlocking { + val newDocValue = spy(DiiaDocumentWithMetadata(null, "timestemp", "expDateCurrent", HTTP_200, "document")) + + val docValue: MutableList = mutableListOf() + docValue.add(newDocValue) + val docsToPersist: MutableList = mutableListOf() + + val existsId: List = mutableListOf() + + defaultDocGroupUpdateBehavior.handleUpdate("document", docValue, Http.COVID_CERT_IN_PROGRESS_STATUS, docsToPersist, existsId) + + assertEquals(1, docsToPersist.size) + assertEquals(Preferences.DEF, docsToPersist[0].expirationDate) + } + + @Test + fun `test handle metadata with other status if persist not contain this type of doc`() = runBlocking { + val newDocValue = mock(DiiaDocumentWithMetadata::class.java) + val docValue: MutableList = mutableListOf() + docValue.add(newDocValue) + val docsToPersist: MutableList = mutableListOf() + val existsId: List = mutableListOf() + + defaultDocGroupUpdateBehavior.handleUpdate("document", docValue, Http.HTTP_500, docsToPersist, existsId) + + assertEquals(1, docsToPersist.size) + assertTrue(docsToPersist.contains(newDocValue)) + } + + @Test + fun `test rewrite expiration date and status if status is not 403`() = runBlocking { + val currentDocValue = DiiaDocumentWithMetadata(null, "timestemp", "expDateCurrent", HTTP_403, "document") + + val newDocValue = mock(DiiaDocumentWithMetadata::class.java) + `when`(newDocValue.expirationDate).thenReturn("expDateNew") + val docValue: MutableList = mutableListOf() + docValue.add(newDocValue) + val docsToPersist: MutableList = mutableListOf() + docsToPersist.add(currentDocValue) + val existsId: List = mutableListOf() + + defaultDocGroupUpdateBehavior.handleUpdate("document", docValue, Http.HTTP_500, docsToPersist, existsId) + + assertEquals(1, docsToPersist.size) + assertEquals("expDateNew", docsToPersist[0].expirationDate) + assertEquals(Http.HTTP_500, docsToPersist[0].status) + } + + @Test + fun `test rewrite only expiration date if status is 403`() = runBlocking { + val currentDocValue = DiiaDocumentWithMetadata(null, "timestemp", "expDateCurrent", HTTP_500, "document") + + val newDocValue = mock(DiiaDocumentWithMetadata::class.java) + `when`(newDocValue.expirationDate).thenReturn("expDateNew") + val docValue: MutableList = mutableListOf() + docValue.add(newDocValue) + val docsToPersist: MutableList = mutableListOf() + docsToPersist.add(currentDocValue) + val existsId: List = mutableListOf() + + defaultDocGroupUpdateBehavior.handleUpdate("document", docValue, Http.HTTP_403, docsToPersist, existsId) + + assertEquals(1, docsToPersist.size) + assertEquals("expDateNew", docsToPersist[0].expirationDate) + assertEquals(Http.HTTP_500, docsToPersist[0].status) + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/data/datasource/local/KeyValueDocumentsDataSourceTest.kt b/documents/src/test/java/ua/gov/diia/documents/data/datasource/local/KeyValueDocumentsDataSourceTest.kt new file mode 100644 index 0000000..d6c9275 --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/data/datasource/local/KeyValueDocumentsDataSourceTest.kt @@ -0,0 +1,397 @@ +package ua.gov.diia.documents.data.datasource.local + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.squareup.moshi.JsonAdapter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.anyString +import org.mockito.Mockito.mock +import org.mockito.Mockito.`when` +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.network.Http +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.rules.MainDispatcherRule +import ua.gov.diia.documents.util.datasource.DateCompareExpirationStrategy +import ua.gov.diia.documents.util.datasource.ExpirationStrategy + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class KeyValueDocumentsDataSourceTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Mock + lateinit var diiaStorage: DiiaStorage + @Mock + lateinit var jsonAdapter: JsonAdapter> + @Mock + lateinit var documentsTransformation: DocumentsTransformation + + lateinit var docTransformations: MutableList + + lateinit var docTypesAvailableToUsers: MutableSet + @Mock + lateinit var expirationStrategy: ExpirationStrategy + + lateinit var docGroupUpdateBehaviors: MutableList + @Mock + lateinit var defaultDocGroupUpdateBehavior: DefaultDocGroupUpdateBehavior + @Mock + lateinit var brokenDocFilter: BrokenDocFilter + @Mock + lateinit var removeExpiredDocBehavior: RemoveExpiredDocBehavior + @Mock + lateinit var withCrashlytics: WithCrashlytics + @Mock + lateinit var documentsHelper: DocumentsHelper + + lateinit var keyValueDocumentsDataSource: KeyValueDocumentsDataSource + + @Before + fun setUp() { + docGroupUpdateBehaviors = mutableListOf() + docTransformations = mutableListOf() + docTypesAvailableToUsers = mutableSetOf() + docTransformations.add(documentsTransformation) + keyValueDocumentsDataSource = KeyValueDocumentsDataSource(jsonAdapter, diiaStorage, docTransformations, docTypesAvailableToUsers, expirationStrategy, documentsHelper, docGroupUpdateBehaviors, defaultDocGroupUpdateBehavior, brokenDocFilter, removeExpiredDocBehavior, withCrashlytics) + } + + @Test + fun `test fetchDocuments load data from storage`() = runTest { + val list = mutableListOf() + list.add(mock(DiiaDocumentWithMetadata::class.java)) + val storeData = "storeData" + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(true) + `when`(documentsHelper.migrateDocuments(any(), any())).thenReturn(list) + `when`(diiaStorage.getString(Preferences.Documents, Preferences.DEF)).thenReturn(storeData) + `when`(jsonAdapter.fromJson(storeData)).thenReturn(list) + + assertEquals(list, keyValueDocumentsDataSource.fetchDocuments()) + + verify(diiaStorage, times(2)).containsKey(Preferences.Documents) + verify(diiaStorage, times(1)).getString(Preferences.Documents, Preferences.DEF) + verify(jsonAdapter, times(1)).fromJson(storeData) + verify(documentsHelper, times(1)).migrateDocuments(any(), any()) + } + + @Test + fun `test fetchDocuments not load data from storage if storage not contain it`() = runTest { + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(false) + + assertNull(keyValueDocumentsDataSource.fetchDocuments()) + + verify(diiaStorage, times(1)).containsKey(Preferences.Documents) + verify(diiaStorage, times(0)).getString(any(), any()) + verify(jsonAdapter, times(0)).fromJson(anyString()) + verify(documentsHelper, times(0)).migrateDocuments(any(), any()) + } + + @Test + fun `test updateData`() = runTest { + val docType = "docType" + val itemMetadata = mock(DiiaDocumentWithMetadata::class.java) + + val list = mutableListOf() + list.add(itemMetadata) + val storeData = "storeData" + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(true) + `when`(jsonAdapter.toJson(any())).thenReturn("list") + `when`(diiaStorage.getString(Preferences.Documents, Preferences.DEF)).thenReturn(storeData) + `when`(jsonAdapter.fromJson(storeData)).thenReturn(list) + + val passItem = mock(DiiaDocumentWithMetadata::class.java) + `when`(passItem.type).thenReturn(docType) + `when`(passItem.status).thenReturn(200) + val data = mutableListOf() + data.add(passItem) + val result = keyValueDocumentsDataSource.updateData(data) + + assertEquals(true, result!!.isSuccessful) + assertEquals(list, result.data) + verify(brokenDocFilter, times(1)).filter(any(), any(), any()) + verify(documentsTransformation, times(1)).transform(list) + + verify(defaultDocGroupUpdateBehavior, times(1)).handleUpdate(docType, listOf(passItem), 200, list, listOf()) + } + + @Test + fun `test updateData uses behavior from list if it can handle it`() = runTest { + val behavior = mock(DocGroupUpdateBehavior::class.java) + `when`(behavior.canHandleType(any())).thenReturn(true) + docGroupUpdateBehaviors.add(behavior) + + val docType = "docType" + val itemMetadata = mock(DiiaDocumentWithMetadata::class.java) + + val list = mutableListOf() + list.add(itemMetadata) + val storeData = "storeData" + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(true) + `when`(jsonAdapter.toJson(any())).thenReturn("list") + `when`(diiaStorage.getString(Preferences.Documents, Preferences.DEF)).thenReturn(storeData) + `when`(jsonAdapter.fromJson(storeData)).thenReturn(list) + + val passItem = mock(DiiaDocumentWithMetadata::class.java) + `when`(passItem.type).thenReturn(docType) + `when`(passItem.status).thenReturn(200) + val data = mutableListOf() + data.add(passItem) + + val result = keyValueDocumentsDataSource.updateData(data) + + assertEquals(true, result!!.isSuccessful) + + verify(defaultDocGroupUpdateBehavior, times(0)).handleUpdate(any(), any(), any(), any(), any()) + verify(behavior, times(1)).handleUpdate(docType, listOf(passItem), 200, list, listOf()) + + docGroupUpdateBehaviors.remove(behavior) + } + + @Test + fun `test updateData returns null if empty docs list was passed`() = runTest { + val list = mutableListOf() + + val result = keyValueDocumentsDataSource.updateData(list) + + assertNull(result) + } + + @Test + fun `test processExpiredData`() = runTest { + val availableDoc = "driver_licence" + docTypesAvailableToUsers.add(availableDoc) + val itemMetadata = mock(DiiaDocumentWithMetadata::class.java) + `when`(itemMetadata.type).thenReturn("document") + `when`(expirationStrategy.isExpired(itemMetadata)).thenReturn(true) + + val list = mutableListOf() + list.add(itemMetadata) + + val result = keyValueDocumentsDataSource.processExpiredData(list) + + verify(removeExpiredDocBehavior, times(1)).removeExpiredDocs(any(), any()) + verify(expirationStrategy, times(1)).isExpired(itemMetadata) + assertEquals(2, result.size) + assertEquals(itemMetadata, result[0]) + + assertEquals(availableDoc, result[1].type) + assertEquals(Http.HTTP_404, result[1].status) + assertEquals(Preferences.DEF, result[1].expirationDate) + assertEquals(null, result[1].diiaDocument) + assertEquals("", result[1].timestamp) + assertEquals(DiiaDocumentWithMetadata.LAST_DOC_ORDER, result[1].order) + + docTypesAvailableToUsers.remove(availableDoc) + } + + @Test + fun `test processExpiredData not add availeble document if it has the same type`() = runTest { + val availableDoc = "document" + docTypesAvailableToUsers.add(availableDoc) + val itemMetadata = mock(DiiaDocumentWithMetadata::class.java) + `when`(itemMetadata.type).thenReturn("document") + `when`(expirationStrategy.isExpired(itemMetadata)).thenReturn(true) + + val list = mutableListOf() + list.add(itemMetadata) + + val result = keyValueDocumentsDataSource.processExpiredData(list) + + assertEquals(list, result) + + docTypesAvailableToUsers.remove(availableDoc) + } + + @Test + fun `test processExpiredData reset strategy if it is DateCompareExpirationStrategy`() = runTest { + val dateCompareExpirationStrategy: DateCompareExpirationStrategy = mock(DateCompareExpirationStrategy::class.java) + keyValueDocumentsDataSource = KeyValueDocumentsDataSource(jsonAdapter, diiaStorage, docTransformations, docTypesAvailableToUsers, dateCompareExpirationStrategy, documentsHelper, docGroupUpdateBehaviors, defaultDocGroupUpdateBehavior, brokenDocFilter, removeExpiredDocBehavior, withCrashlytics) + + val itemMetadata = mock(DiiaDocumentWithMetadata::class.java) + `when`(itemMetadata.type).thenReturn("document") + + val list = mutableListOf() + list.add(itemMetadata) + + keyValueDocumentsDataSource.processExpiredData(list) + + verify(dateCompareExpirationStrategy, times(1)).reset() + } + + @Test + fun `test removeDocument`() = runTest { + val diiaDocument: DiiaDocument = mock(DiiaDocument::class.java) + val diiaDocument2: DiiaDocument = mock(DiiaDocument::class.java) + + val list = mutableListOf() + val docWithMetadata = mock(DiiaDocumentWithMetadata::class.java) + `when`(docWithMetadata.diiaDocument).thenReturn(diiaDocument) + + val docWithMetadata2 = mock(DiiaDocumentWithMetadata::class.java) + `when`(docWithMetadata2.diiaDocument).thenReturn(diiaDocument2) + list.add(docWithMetadata) + list.add(docWithMetadata2) + val storeData = "storeData" + val jsonList = "jsonList" + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(true) + + `when`(diiaStorage.getString(Preferences.Documents, Preferences.DEF)).thenReturn(storeData) + `when`(jsonAdapter.fromJson(storeData)).thenReturn(list) + `when`(jsonAdapter.toJson(listOf(docWithMetadata2))).thenReturn(jsonList) + + val result = keyValueDocumentsDataSource.removeDocument(diiaDocument) + + assertEquals(true, result!!.isSuccessful) + assertEquals(listOf(docWithMetadata2), result.data) + verify(jsonAdapter, times(1)).toJson(listOf(docWithMetadata2)) + verify(diiaStorage, times(1)).set(Preferences.Documents, jsonList) + } + + @Test + fun `test removeDocument return null if storage is empty`() = runTest { + val diiaDocument: DiiaDocument = mock(DiiaDocument::class.java) + + val result = keyValueDocumentsDataSource.removeDocument(diiaDocument) + + assertNull(result) + } + + @Test + fun `test updateDocument`() = runTest { + //GIVEN + val copiedDocWithMetadata = mock(DiiaDocumentWithMetadata::class.java) + val docId = "docId" + val diiaDocumentToUpdate: DiiaDocument = mock(DiiaDocument::class.java) + val diiaDocument: DiiaDocument = mock(DiiaDocument::class.java) + `when`(diiaDocument.docId()).thenReturn(docId) + `when`(diiaDocumentToUpdate.docId()).thenReturn(docId) + val diiaDocument2: DiiaDocument = mock(DiiaDocument::class.java) + + val list = mutableListOf() + val docWithMetadata = mock(DiiaDocumentWithMetadata::class.java) + + `when`(docWithMetadata.diiaDocument).thenReturn(diiaDocument) + `when`(docWithMetadata.copy(diiaDocument = diiaDocumentToUpdate)).thenReturn(copiedDocWithMetadata) + + val docWithMetadata2 = mock(DiiaDocumentWithMetadata::class.java) + `when`(docWithMetadata2.diiaDocument).thenReturn(diiaDocument2) + list.add(docWithMetadata) + list.add(docWithMetadata2) + val storeData = "storeData" + val jsonList = "jsonList" + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(true) + + `when`(diiaStorage.getString(Preferences.Documents, Preferences.DEF)).thenReturn(storeData) + `when`(jsonAdapter.fromJson(storeData)).thenReturn(list) + `when`(jsonAdapter.toJson(listOf(copiedDocWithMetadata, docWithMetadata2))).thenReturn(jsonList) + + //WHEN + val result = keyValueDocumentsDataSource.updateDocument(diiaDocumentToUpdate) + + //THEN + assertEquals(true, result!!.isSuccessful) + assertEquals(listOf(copiedDocWithMetadata, docWithMetadata2), result.data) + verify(jsonAdapter, times(1)).toJson(listOf(copiedDocWithMetadata, docWithMetadata2)) + verify(diiaStorage, times(1)).set(Preferences.Documents, jsonList) + } + + @Test + fun `test updateDocument returns null if no data to update`() = runTest { + //GIVEN + val docId = "docId" + val diiaDocumentToUpdate: DiiaDocument = mock(DiiaDocument::class.java) + val diiaDocument: DiiaDocument = mock(DiiaDocument::class.java) + `when`(diiaDocument.docId()).thenReturn(docId) + `when`(diiaDocumentToUpdate.docId()).thenReturn("docId2") + val diiaDocument2: DiiaDocument = mock(DiiaDocument::class.java) + + val list = mutableListOf() + val docWithMetadata = mock(DiiaDocumentWithMetadata::class.java) + + `when`(docWithMetadata.diiaDocument).thenReturn(diiaDocument) + + val docWithMetadata2 = mock(DiiaDocumentWithMetadata::class.java) + `when`(docWithMetadata2.diiaDocument).thenReturn(diiaDocument2) + list.add(docWithMetadata) + list.add(docWithMetadata2) + val storeData = "storeData" + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(true) + + `when`(diiaStorage.getString(Preferences.Documents, Preferences.DEF)).thenReturn(storeData) + `when`(jsonAdapter.fromJson(storeData)).thenReturn(list) + + //WHEN + val result = keyValueDocumentsDataSource.updateDocument(diiaDocumentToUpdate) + + //THEN + assertNull(result) + } + + @Test + fun `test replaceExpDateByType`() = runTest { + //GIVEN + val list = mutableListOf() + val docWithMetadata = mock(DiiaDocumentWithMetadata::class.java) + val docWithMetadata2 = mock(DiiaDocumentWithMetadata::class.java) + list.add(docWithMetadata) + list.add(docWithMetadata2) + val storeData = "storeData" + val jsonList = "jsonList" + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(true) + + `when`(diiaStorage.getString(Preferences.Documents, Preferences.DEF)).thenReturn(storeData) + `when`(jsonAdapter.fromJson(storeData)).thenReturn(list) + `when`(jsonAdapter.toJson(any())).thenReturn(jsonList) + + val processNum = 10 + val newType = "newType" + val documentTypes = mutableListOf() + documentTypes.add(newType) + `when`(documentsHelper.getExpiredDocStatus(newType)).thenReturn(processNum) + + //WHEN + val result = keyValueDocumentsDataSource.replaceExpDateByType(documentTypes) + + //THEN + assertEquals(true, result!!.isSuccessful) + assertEquals(3, result.data!!.size) + assertEquals(newType, result.data!![2].type) + assertEquals(processNum, result.data!![2].status) + assertEquals(Preferences.DEF, result.data!![2].expirationDate) + assertEquals(null, result.data!![2].diiaDocument) + assertEquals("", result.data!![2].timestamp) + } + + + @Test + fun `test replaceExpDateByType returns null if no data to update`() = runTest { + //GIVEN + val storeData = "storeData" + `when`(diiaStorage.containsKey(Preferences.Documents)).thenReturn(true) + `when`(diiaStorage.getString(Preferences.Documents, Preferences.DEF)).thenReturn(storeData) + + //WHEN + val result = keyValueDocumentsDataSource.replaceExpDateByType(listOf()) + + //THEN + assertNull(result) + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/data/datasource/remote/NetworkDocumentsDataSourceTest.kt b/documents/src/test/java/ua/gov/diia/documents/data/datasource/remote/NetworkDocumentsDataSourceTest.kt new file mode 100644 index 0000000..0e1277c --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/data/datasource/remote/NetworkDocumentsDataSourceTest.kt @@ -0,0 +1,164 @@ +package ua.gov.diia.documents.data.datasource.remote + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.documents.data.api.ApiDocuments +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DiiaDocumentsWithOrder +import ua.gov.diia.documents.models.DocOrder +import ua.gov.diia.documents.models.DocumentsOrder +import ua.gov.diia.documents.models.FetchDocumentsResult +import ua.gov.diia.documents.models.TypeDefinedDocOrder +import ua.gov.diia.documents.models.TypeDefinedDocumentsOrder + +@RunWith(MockitoJUnitRunner::class) +class NetworkDocumentsDataSourceTest { + + @Mock + private lateinit var apiDocs: ApiDocuments + + @Mock + private lateinit var withCrashlytics: WithCrashlytics + + @Mock + private lateinit var mockDocument: DiiaDocument + + private lateinit var dataSource: NetworkDocumentsDataSource + + @Before + fun setUp() { + dataSource = NetworkDocumentsDataSource( + apiDocs = apiDocs, + withCrashlytics = withCrashlytics + ) + } + + @Test + fun `fetch data successful`() = runTest { + whenever(apiDocs.fetchDocuments(any())).thenReturn(listOf(doc("t0"), doc("t1"))) + val result = dataSource.fetchData(setOf("t0", "t1")) + Assert.assertEquals( + DataSourceDataResult.successful(listOf(doc("t0"), doc("t1"))), + result + ) + verify(apiDocs).fetchDocuments( + mapOf( + "filter[0]" to "t0", + "filter[1]" to "t1", + ) + ) + } + + @Test + fun `fetch data error`() = runTest { + val error = RuntimeException() + whenever(apiDocs.fetchDocuments(any())).thenThrow(error) + val result = dataSource.fetchData(setOf("t0", "t1")) + Assert.assertEquals( + DataSourceDataResult.failed(error), + result + ) + verify(withCrashlytics).sendNonFatalError(error) + } + + @Test + fun `fetch documents with types successful`() = runTest { + whenever(apiDocs.fetchDocumentsWithTypes(any())).thenReturn( + DiiaDocumentsWithOrder(listOf(doc("t0"), doc("t1")), listOf("t0", "t1")) + ) + val result = dataSource.fetchDocsWithTypes(setOf("t0", "t1")) + Assert.assertEquals( + FetchDocumentsResult( + documents = listOf(doc("t0"), doc("t1")), + docOrder = listOf("t0", "t1"), + exception = null, + isSuccessful = true, + ), + result + ) + verify(apiDocs).fetchDocumentsWithTypes( + mapOf( + "filter[0]" to "t0", + "filter[1]" to "t1", + ) + ) + } + + @Test + fun `fetch documents with types error`() = runTest { + val error = RuntimeException() + whenever(apiDocs.fetchDocumentsWithTypes(any())).thenThrow(error) + val result = dataSource.fetchDocsWithTypes(setOf("t0", "t1")) + Assert.assertEquals( + FetchDocumentsResult( + documents = emptyList(), + docOrder = emptyList(), + exception = error, + isSuccessful = false, + ), + result + ) + } + + @Test + fun `save documents order for specific type successful`() = runTest { + val docOrder = listOf( + TypeDefinedDocOrder("14234", 0), + TypeDefinedDocOrder("14234", 1), + ) + dataSource.saveDocOrderForSpecificType("t0", TypeDefinedDocumentsOrder(docOrder)) + verify(apiDocs).setTypedDocumentsOrder("t0", TypeDefinedDocumentsOrder(docOrder)) + } + + @Test + fun `save documents order for specific type error`() = runTest { + val error = RuntimeException() + whenever(apiDocs.setTypedDocumentsOrder(any(), any())).thenThrow(error) + dataSource.saveDocOrderForSpecificType("t0", TypeDefinedDocumentsOrder(listOf())) + verify(withCrashlytics).sendNonFatalError(error) + } + + @Test + fun `set documents order successful`() = runTest { + val order = DocumentsOrder( + listOf( + DocOrder("t0", 1), + DocOrder("t1", 3), + DocOrder("t2", 2), + ) + ) + dataSource.setDocumentsOrder(order) + verify(apiDocs).setDocumentsOrder(order) + } + + @Test + fun `set documents order error`() = runTest { + val error = RuntimeException() + whenever(apiDocs.setDocumentsOrder(any())).thenThrow(error) + dataSource.setDocumentsOrder(DocumentsOrder(listOf())) + verify(withCrashlytics).sendNonFatalError(error) + } + + private fun doc( + type: String, + order: Int = DiiaDocumentWithMetadata.LAST_DOC_ORDER, + ) = DiiaDocumentWithMetadata( + diiaDocument = mockDocument, + timestamp = "2023-12-22T08:09:11.143Z", + expirationDate = "2023-12-22T14:09:11.143Z", + status = 200, + type = type, + order = order + ) +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/data/repository/DocumentsDataRepositoryImplTest.kt b/documents/src/test/java/ua/gov/diia/documents/data/repository/DocumentsDataRepositoryImplTest.kt new file mode 100644 index 0000000..afc6c14 --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/data/repository/DocumentsDataRepositoryImplTest.kt @@ -0,0 +1,301 @@ +package ua.gov.diia.documents.data.repository + +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.job +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.documents.data.datasource.local.KeyValueDocumentsDataSource +import ua.gov.diia.documents.data.datasource.remote.NetworkDocumentsDataSource +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DocOrder +import ua.gov.diia.documents.models.FetchDocumentsResult +import ua.gov.diia.documents.models.TypeDefinedDocOrder +import ua.gov.diia.documents.models.TypeDefinedDocumentsOrder + +@RunWith(MockitoJUnitRunner::class) +class DocumentsDataRepositoryImplTest { + + @Mock + private lateinit var keyValueDataSource: KeyValueDocumentsDataSource + + @Mock + private lateinit var networkDocumentsDataSource: NetworkDocumentsDataSource + + @Mock + private lateinit var beforePublishAction: BeforePublishAction + + private lateinit var docTypesAvailableToUsers: Set + + @Mock + private lateinit var withCrashlytics: WithCrashlytics + + @Mock + private lateinit var mockDocument: DiiaDocument + + private lateinit var scope: CoroutineScope + + private lateinit var repository: DocumentsDataRepositoryImpl + + private val baseDocumentList = listOf(DiiaDocumentWithMetadata.DOC_ERROR) + + @Before + fun setUp() { + scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + docTypesAvailableToUsers = setOf("t0", "t1", "t2") + repository = DocumentsDataRepositoryImpl( + scope = scope, + keyValueDataSource = keyValueDataSource, + networkDocumentsDataSource = networkDocumentsDataSource, + beforePublishActions = listOf(beforePublishAction), + docTypesAvailableToUsers = docTypesAvailableToUsers, + withCrashlytics = withCrashlytics + ) + } + + @After + fun tearDown() { + scope.cancel() + } + + @Test + fun clear() = runTest { + repository.data.test { + repository.clear() + Assert.assertEquals(DataSourceDataResult.successful(baseDocumentList), awaitItem()) + } + } + + @Test + fun `update order`() = runTest { + whenever(keyValueDataSource.loadData()).thenReturn(docTypesAvailableToUsers.mapIndexed { i, x -> + doc(x, i + 1) + }) + val newOrder = listOf("t2", "t0", "t1") + repository.updateDocOrder(newOrder) + scope.joinChildren() + val expected = docTypesAvailableToUsers.map { x -> doc(x, newOrder.indexOf(x) + 1) } + verify(keyValueDataSource).updateData(expected) + } + + @Test + fun `update order error`() = runTest { + whenever(keyValueDataSource.loadData()).thenReturn(docTypesAvailableToUsers.mapIndexed { i, x -> + doc(x, i + 1) + }) + val error = RuntimeException() + whenever(keyValueDataSource.updateData(any())).thenThrow(error) + val newOrder = listOf("t2", "t0", "t1") + repository.updateDocOrder(newOrder) + scope.joinChildren() + verify(withCrashlytics).sendNonFatalError(error) + } + + @Test + fun `update order the same`() = runTest { + whenever(keyValueDataSource.loadData()).thenReturn(docTypesAvailableToUsers.mapIndexed { i, x -> + doc(x, i + 1) + }) + val newOrder = docTypesAvailableToUsers.toList() + repository.updateDocOrder(newOrder) + scope.joinChildren() + verify(keyValueDataSource, never()).updateData(any()) + } + + @Test + fun `invalidate with expiration`() = runTest { + val docs = listOf(doc("t0"), doc("t2")) + var data: List? = null + whenever(keyValueDataSource.updateData(any())).thenAnswer { + data = it.arguments[0] as List? + DataSourceDataResult.successful(checkNotNull(data)) + } + whenever(keyValueDataSource.loadData()).thenAnswer { data } + whenever(networkDocumentsDataSource.fetchDocsWithTypes(any())).thenAnswer { + val types = it.arguments[0] as Set + FetchDocumentsResult(documents = docs.filter { it.type in types }) + } + whenever(keyValueDataSource.fetchDocuments()).thenReturn(docs) + whenever(keyValueDataSource.processExpiredData(any())).thenReturn(listOf(doc("t0"))) + + repository.data.test { + repository.invalidate() + scope.joinChildren() + Assert.assertEquals( + DataSourceDataResult.successful(baseDocumentList + listOf(doc("t0"))), + awaitItem() + ) + } + } + + @Test + fun `invalidate without expiration`() = runTest { + val docs = listOf(doc("t0"), doc("t2")) + whenever(keyValueDataSource.fetchDocuments()).thenReturn(docs) + whenever(keyValueDataSource.processExpiredData(any())).thenReturn(listOf()) + + repository.data.test { + repository.invalidate() + scope.joinChildren() + Assert.assertEquals( + DataSourceDataResult.successful(baseDocumentList + docs), + awaitItem() + ) + } + } + + @Test + fun `invalidate concurrent`() = runTest { + val docs = listOf(doc("t0"), doc("t2")) + whenever(keyValueDataSource.fetchDocuments()).thenReturn(docs) + whenever(keyValueDataSource.processExpiredData(any())).thenReturn(listOf()) + + repository.invalidate() + repository.invalidate() + Assert.assertFalse(scope.coroutineContext.job.children.count() > 1) + } + + @Test + fun `attach external document`() = runTest { + whenever(keyValueDataSource.loadData()).thenReturn(docTypesAvailableToUsers.mapIndexed { i, x -> + doc(x, i + 1) + }) + whenever(keyValueDataSource.updateData(any())).thenAnswer { + DataSourceDataResult.successful(it.arguments[0]) + } + val externalDoc = doc("t2") + repository.data.test { + repository.attachExternalDocument(externalDoc) + Assert.assertEquals( + DataSourceDataResult.successful(baseDocumentList + listOf(doc("t2"))), + awaitItem() + ) + } + } + + @Test + fun `remove document`() = runTest { + whenever(keyValueDataSource.removeDocument(any())).thenReturn( + DataSourceDataResult.successful( + listOf(doc("t0")) + ) + ) + repository.data.test { + repository.removeDocument(mockDocument) + awaitItem() + } + scope.joinChildren() + verify(keyValueDataSource).removeDocument(mockDocument) + } + + @Test + fun `update document`() = runTest { + whenever(keyValueDataSource.updateDocument(any())).thenReturn( + DataSourceDataResult.successful( + listOf(doc("t0")) + ) + ) + repository.data.test { + repository.updateDocument(mockDocument) + awaitItem() + } + scope.joinChildren() + verify(keyValueDataSource).updateDocument(mockDocument) + } + + @Test + fun `replace exp date by type`() = runTest { + val types = listOf("t1", "t2") + whenever(keyValueDataSource.replaceExpDateByType(any())).thenReturn( + DataSourceDataResult.successful( + listOf(doc("t0")) + ) + ) + repository.data.test { + repository.replaceExpDateByType(types) + awaitItem() + } + scope.joinChildren() + verify(keyValueDataSource).replaceExpDateByType(types) + } + + @Test + fun `get documents by type`() = runTest { + whenever(keyValueDataSource.loadData()).thenReturn(null) + Assert.assertNull(repository.getDocsByType("t2")) + + whenever(keyValueDataSource.loadData()).thenReturn( + docTypesAvailableToUsers.map { doc(it) } + ) + Assert.assertEquals(listOf(doc("t2").diiaDocument), repository.getDocsByType("t2")) + } + + @Test + fun `save doc type order`() = runTest { + whenever(keyValueDataSource.loadData()).thenReturn( + docTypesAvailableToUsers.map { doc(it) } + ) + repository.saveDocTypeOrder(listOf(DocOrder("t1", 1), DocOrder("t0", 2))) + scope.joinChildren() + verify(keyValueDataSource).updateData( + listOf(doc("t0", 2), doc("t1", 1), doc("t2")) + ) + } + + @Test + fun `save doc order for specific type`() = runTest { + val docs = listOf(doc("t1", 2), doc("t1", 1)) + whenever(keyValueDataSource.loadData()).thenReturn(docs) + + repository.saveDocOrderForSpecificType( + docOrders = listOf( + TypeDefinedDocOrder("123", 1), + TypeDefinedDocOrder("543", 2) + ), + docType = "t1" + ) + scope.joinChildren() + verify(networkDocumentsDataSource).saveDocOrderForSpecificType( + documentType = "t1", + docOrder = TypeDefinedDocumentsOrder( + documentsOrder = listOf( + TypeDefinedDocOrder("123", 1), + TypeDefinedDocOrder("543", 2) + ) + ) + ) + } + + @Suppress("SuspendFunctionOnCoroutineScope") + private suspend fun CoroutineScope.joinChildren() { + coroutineContext.job.children.forEach { it.join() } + } + + private fun doc( + type: String, + order: Int = DiiaDocumentWithMetadata.LAST_DOC_ORDER, + ) = DiiaDocumentWithMetadata( + diiaDocument = mockDocument, + timestamp = "2023-12-22T08:09:11.143Z", + expirationDate = "2023-12-22T14:09:11.143Z", + status = 200, + type = type, + order = order + ) +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/rules/MainDispatcherRule.kt b/documents/src/test/java/ua/gov/diia/documents/rules/MainDispatcherRule.kt new file mode 100644 index 0000000..4bcc1ff --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/rules/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.documents.rules + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/documents/src/test/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocsImplTest.kt b/documents/src/test/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocsImplTest.kt new file mode 100644 index 0000000..b3abc20 --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/ui/WithCheckLocalizationDocsImplTest.kt @@ -0,0 +1,71 @@ +package ua.gov.diia.documents.ui + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +class WithCheckLocalizationDocsImplTest { + + private val checker1: BaseLocalizationChecker = mock() + + private val checker2: BaseLocalizationChecker = mock() + + private lateinit var withCheckLocalizationDocsImpl: WithCheckLocalizationDocsImpl + + private val localizationCheckers = listOf(checker1, checker2) + + @Before + fun setUp() { + withCheckLocalizationDocsImpl = WithCheckLocalizationDocsImpl(localizationCheckers) + } + + @After + fun cleanUp() { + Mockito.clearAllCaches() + } + + @Test + fun `checkLocalizationDocs should update documents correctly`() { + val doc1 = Mockito.mock(DiiaDocumentWithMetadata::class.java) + val doc2 = Mockito.mock(DiiaDocumentWithMetadata::class.java) + val docs = listOf(doc1, doc2) + + whenever(checker1.checkLocalizationDocs(any())).thenReturn(null) + whenever(checker2.checkLocalizationDocs(doc1)).thenReturn("doc1Update") + whenever(checker2.checkLocalizationDocs(doc2)).thenReturn(null) + + val updatedDocs = mutableListOf() + withCheckLocalizationDocsImpl.checkLocalizationDocs(docs) { updatedDocs.addAll(it) } + + assertTrue("doc1Update" in updatedDocs) + assertEquals(1, updatedDocs.size) + } + + @Test + fun `checkLocalizationDocs should not update if no docs are eligible`() { + val doc1 = Mockito.mock(DiiaDocumentWithMetadata::class.java) + val docs = listOf(doc1) + + whenever(checker1.checkLocalizationDocs(any())).thenReturn(null) + whenever(checker2.checkLocalizationDocs(any())).thenReturn(null) + + var updateCalled = false + withCheckLocalizationDocsImpl.checkLocalizationDocs(docs) { updateCalled = true } + + assertFalse(updateCalled) + } + + @Test + fun `checkLocalizationDocs should handle null docs gracefully`() { + var updateCalled = false + withCheckLocalizationDocsImpl.checkLocalizationDocs(null) { updateCalled = true } + + assertFalse(updateCalled) + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/ui/actions/DocActionsVMComposeTest.kt b/documents/src/test/java/ua/gov/diia/documents/ui/actions/DocActionsVMComposeTest.kt new file mode 100644 index 0000000..00a1beb --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/ui/actions/DocActionsVMComposeTest.kt @@ -0,0 +1,364 @@ +package ua.gov.diia.documents.ui.actions + +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mock +import org.mockito.Mockito +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.rules.MainDispatcherRule +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose + +class DocActionsVMComposeTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private lateinit var vm: DocActionsVMCompose + private var globalActionUpdateDocument: MutableStateFlow?> = MutableStateFlow(null) + + @Mock + lateinit var docActionsProvider: DocActionsProvider + + @Before + fun beforeTest(){ + globalActionUpdateDocument = MutableStateFlow(null) + docActionsProvider = mock() + vm = DocActionsVMCompose(globalActionUpdateDocument, docActionsProvider, listOf()) + } + + @Test + fun `test action using DataActionWrapper`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(action = DataActionWrapper(type = ContextMenuType.REMOVE_DOC.name), actionKey = "")) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.RemoveDoc) + } + } + + + // doc actions + @Test + fun `test remove document action`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.REMOVE_DOC.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.RemoveDoc) + } + } + + @Test + fun `test TRANSLATE_TO_UA action`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.TRANSLATE_TO_UA.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.TranslateToUa) + } + } + + @Test + fun `test TRANSLATE_TO_ENG action`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.TRANSLATE_TO_ENG.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.TranslateToEng) + } + } + + @Test + fun `test RATE_DOCUMENT action`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.RATE_DOCUMENT.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.RateDocument) + } + } + + @Test + fun `test SHARE_WITH_FRIENDS action`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.SHARE_WITH_FRIENDS.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.ShareWithFriends) + } + } + + @Test + fun `test VERIFICATION_CODE action`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.VERIFICATION_CODE.name, data = "")) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.OpenVerificationCode("")) + } + } + + @Test + fun `test VERIFICATION_CODE_QR action`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = VerificationActions.VERIFICATION_CODE_QR, data = "")) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.OpenQr("")) + } + } + + @Test + fun `test VERIFICATION_CODE_QR null action`() = runTest{ + var emissionFailed = false + try { + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = VerificationActions.VERIFICATION_CODE_QR, data = null)) + awaitError() + } + } catch (e: Throwable){ + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test VERIFICATION_CODE_EAN13 action`() = runTest{ + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = VerificationActions.VERIFICATION_CODE_EAN13, data = "")) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.DocActions.OpenEan13("")) + } + } + + @Test + fun `test VERIFICATION_CODE_EAN13 null action`() = runTest{ + var emissionFailed = false + try { + vm.docAction.test{ + vm.onUIAction(UIAction(actionKey = VerificationActions.VERIFICATION_CODE_EAN13, data = null)) + awaitError() + } + } catch (e: Throwable){ + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + // navigation actions + @Test + fun `test nav to faq action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.FAQS.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToFaqs) + } + } + + @Test + fun `test nav to PNP action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.PNP.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ToNavPnp) + } + } + + @Test + fun `test nav to Drl action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.REPLACE_DRIVER_LICENSE.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToDrl) + } + } + + @Test + fun `test nav to INSURANCE action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.INSURANCE.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToVehicleInsurance) + } + } + + @Test + fun `test nav to RESIDENCE_CERT action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.RESIDENCE_CERT.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToResidenceCert) + } + } + + @Test + fun `test nav to RESIDENCE_CERT_CHILD action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.RESIDENCE_CERT_CHILD.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToResidenceCertChild) + } + } + + @Test + fun `test nav to PENSION_CARD action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.PENSION_CARD.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToPensionCard) + } + } + + @Test + fun `test nav to PENSION_CARD 2 action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.PENSION_CARD.code)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.PensionCard) + } + } + + @Test + fun `test nav to FULL_DOC action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.FULL_DOC.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToFullInfo) + } + } + @Test + fun `test nav to HOUSING_CERTIFICATES action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.HOUSING_CERTIFICATES.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToHousingCert) + } + } + + @Test + fun `test nav to FOUNDING_REQUEST action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.FOUNDING_REQUEST.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.NavToFoundingRequest) + } + } + + @Test + fun `test nav to CHANGE_DOC_ORDERING action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.CHANGE_DOC_ORDERING.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ToDocStackOrder) + } + } + + @Test + fun `test nav to CHANGE_DISPLAY_ORDER action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.CHANGE_DISPLAY_ORDER.name, data = "")) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ToDocStackOrderWithType("")) + } + } + + @Test + fun `test nav to VEHICLE_RE_REGISTRATION action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.VEHICLE_RE_REGISTRATION.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.VehicleReRegistration) + } + } + + @Test + fun `test nav to BIRTH_CERTIFICATE action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.BIRTH_CERTIFICATE.code)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.BirthCertificate) + } + } + + @Test + fun `test nav to VACCINATION_CERTIFICATE action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.VACCINATION_CERTIFICATE.code)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.VaccinationCertificate) + } + } + + @Test + fun `test nav to CHILD_VACCINATION_CERTIFICATE action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.CHILD_VACCINATION_CERTIFICATE.code)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ChildVaccinationCertificate) + } + } + + @Test + fun `test nav to PROPER_USER_SHARE action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.PROPER_USER_SHARE.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ProperUserShare) + } + } + + @Test + fun `test nav to PROPER_USER_OWNER_CANCEL action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.PROPER_USER_OWNER_CANCEL.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ProperUserCancel) + } + } + + @Test + fun `test nav to PROPER_USER_PROPER_CANCEL action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.PROPER_USER_PROPER_CANCEL.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ProperUserCancel) + } + } + + @Test + fun `test nav to INTERNALLY_DISPLACED_CERT_CANCEL action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.INTERNALLY_DISPLACED_CERT_CANCEL.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.InternallyDisplacedCertCancel) + } + } + + @Test + fun `test nav to EDIT_INTERNALLY_DISPLACED_PERSON_ADDRESS action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.EDIT_INTERNALLY_DISPLACED_PERSON_ADDRESS.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.EditInternallyDisplacedPersonAddress) + } + } + + @Test + fun `test nav to RESIDENCE_PERMIT_PERMANENT action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.RESIDENCE_PERMIT_PERMANENT.code)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ResidencePermitPermanent) + } + } + + @Test + fun `test nav to RESIDENCE_PERMIT_TEMPORARY action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.RESIDENCE_PERMIT_TEMPORARY.code)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.ResidencePermitTemporary) + } + } + + @Test + fun `test nav to DOWNLOAD_CERTIFICATE_PDF action`() = runTest{ + vm.navigation.test{ + vm.onUIAction(UIAction(actionKey = ContextMenuType.DOWNLOAD_CERTIFICATE_PDF.name)) + Assert.assertEquals(awaitItem(), DocActionsVMCompose.Navigation.DownloadPdf) + } + } + + // Dismiss + + @Test + fun `test dismiss action`() = runTest{ + vm.dismiss.test{ + vm.onUIAction(UIAction(actionKey = UIActionKeysCompose.BUTTON_REGULAR)) + Assert.assertNotNull(awaitItem()) + } + } + + //Localization + + @Test + fun `test switch localization`() = runTest{ + globalActionUpdateDocument.filterNotNull().test{ + val loc = LocalizationType.eng + val mockDoc = Mockito.mock(DiiaDocument::class.java) + val mockDocCopy = Mockito.mock(DiiaDocument::class.java) + Mockito.`when`(mockDoc.makeCopy()).thenReturn(mockDocCopy) + vm.switchLocalization(mockDoc, loc) + Assert.assertEquals(mockDocCopy, awaitItem().peekContent()) + } + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFComposeVMTest.kt b/documents/src/test/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFComposeVMTest.kt new file mode 100644 index 0000000..c50bb99 --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/ui/fullinfo/FullInfoFComposeVMTest.kt @@ -0,0 +1,637 @@ +package ua.gov.diia.documents.ui.fullinfo + +import android.os.Parcelable +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.runtime.snapshots.SnapshotStateList +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.documents.DocTest +import ua.gov.diia.documents.barcode.DocumentBarcode +import ua.gov.diia.documents.barcode.DocumentBarcodeRepository +import ua.gov.diia.documents.barcode.DocumentBarcodeRepositoryResult +import ua.gov.diia.documents.barcode.DocumentBarcodeSuccessfulLoadResult +import ua.gov.diia.documents.getOrAwaitValue +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.rules.MainDispatcherRule +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.documents.ui.ToggleId +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.molecule.button.BtnToggleMlcData +import ua.gov.diia.ui_base.components.organism.document.DocCodeOrgData +import ua.gov.diia.ui_base.components.organism.document.Localization +import ua.gov.diia.ui_base.components.organism.group.ToggleButtonGroupData +import java.util.concurrent.TimeoutException + +class FullInfoFComposeVMTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val dispatcherProvider: DispatcherProvider = mock() + private val errorHandling: WithErrorHandlingOnFlow = mock() + private val withRetryLastAction: WithRetryLastAction = mock() + private val composeMapper: DocumentComposeMapper = mock() + private val barcodeRepository: DocumentBarcodeRepository = mock() + private val docFullComposeMapper: DocFullInfoComposeMapper = + object : DocFullInfoComposeMapper { + override fun mapDocToBody( + document: DiiaDocument, + bodyData: SnapshotStateList + ) { + bodyData.add(DocCodeOrgData( + "", + Localization.ua, + Mockito.mock(ToggleButtonGroupData::class.java), + null, + null, + null, + null, + null, + false, + true, + false + )) + } + + } + private lateinit var vm: FullInfoFComposeVM + + @Before + fun before() { + Mockito.`when`(dispatcherProvider.ioDispatcher()) + .thenReturn(mainDispatcherRule.testDispatcher) + vm = FullInfoFComposeVM( + dispatcherProvider, + errorHandling, + withRetryLastAction, + composeMapper, + barcodeRepository, + docFullComposeMapper + ) + } + + @After + fun after() { + Mockito.clearAllCaches() + } + + @Test + fun `test configure body`() = runTest { + val mockDock = Mockito.mock(DiiaDocument::class.java) + + vm.progressIndicator.test { + vm.configureBody(mockDock) + skipItems(1) + val item = awaitItem() + Assert.assertEquals( + UIActionKeysCompose.DOC_CODE_ORG_DATA, + item.first + ) + Assert.assertFalse(item.second) + cancelAndIgnoreRemainingEvents() + } + + Assert.assertEquals(mockDock, vm.documentCardData.getOrAwaitValue()) + } + + @Test + fun `test configure body without doc`() = runTest { + val mockDock = Mockito.mock(Parcelable::class.java) + vm.configureBody(mockDock) + Assert.assertThrows(TimeoutException::class.java) { vm.documentCardData.getOrAwaitValue() } + } + + @Test + fun `test configure doc code with null data`() = runTest { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + Mockito.mock(ToggleButtonGroupData::class.java), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, true)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ).thenReturn(mockData) + vm.configureBody(doc) + advanceUntilIdle() + + verify(composeMapper, times(1)).toComposeDocCodeOrg( + any(), + any(), + any(), + any() + ) + val index = vm.bodyData.indexOfFirst { it is DocCodeOrgData } + val data = vm.bodyData[index] as DocCodeOrgData + Assert.assertEquals(data, mockData) + } + + @Test + fun `test configure doc code`() = runTest { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, true)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(null) + vm.configureBody(doc) + advanceUntilIdle() + + verify(composeMapper, times(1)).toComposeDocCodeOrg( + any(), + any(), + any(), + any() + ) + } + + //actions + + @Test + fun `test TOGGLE_BUTTON_MOLECULE qr action`() = runTest { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, true)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.configureBody(doc) + advanceUntilIdle() + + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + data = ToggleId.qr.value + ) + ) + Assert.assertEquals( + awaitItem(), + FullInfoFComposeVM.DocActions.DefaultBrightness + ) + } + } + + + @Test + fun `test TOGGLE_BUTTON_MOLECULE ean action`() = runTest { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, true)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.configureBody(doc) + advanceUntilIdle() + + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + data = ToggleId.ean.value + ) + ) + Assert.assertEquals( + awaitItem(), + FullInfoFComposeVM.DocActions.HighBrightness + ) + } + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE null data action`() = runTest { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.configureBody(doc) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test REFRESH_BUTTON action`() = runTest { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, true)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.configureBody(doc) + advanceUntilIdle() + + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON + ) + ) + advanceUntilIdle() + verify(composeMapper, times(2)).toComposeDocCodeOrg( + any(), + any(), + any(), + any() + ) + } + } + + @Test + fun `test REFRESH_BUTTON null document card ction`() = runTest { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + advanceUntilIdle() + + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON + ) + ) + advanceUntilIdle() + verify(composeMapper, times(0)).toComposeDocCodeOrg( + any(), + any(), + any(), + any() + ) + } + } + + + @Test + fun `test DOC_NUMBER_COPY action`() = runTest { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_NUMBER_COPY, + data = "" + ) + ) + Assert.assertEquals( + awaitItem(), + FullInfoFComposeVM.DocActions.DocNumberCopy("") + ) + } + } + + @Test + fun `test DOC_NUMBER_COPY null data action`() = runTest { + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_NUMBER_COPY, + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test VERTICAL_TABLE_ITEM action`() = runTest { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.VERTICAL_TABLE_ITEM, + data = "" + ) + ) + Assert.assertEquals( + awaitItem(), + FullInfoFComposeVM.DocActions.ItemVerticalValueCopy("") + ) + } + } + + @Test + fun `test VERTICAL_TABLE_ITEM null data action`() = runTest { + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.VERTICAL_TABLE_ITEM, + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test HORIZONTAL_TABLE_ITEM action`() = runTest { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.HORIZONTAL_TABLE_ITEM, + data = "" + ) + ) + Assert.assertEquals( + awaitItem(), + FullInfoFComposeVM.DocActions.ItemHorizontalValueCopy("") + ) + } + } + + @Test + fun `test HORIZONTAL_TABLE_ITEM null data action`() = runTest { + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.HORIZONTAL_TABLE_ITEM, + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test PRIMARY_TABLE_ITEM action`() = runTest { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PRIMARY_TABLE_ITEM, + data = "" + ) + ) + Assert.assertEquals( + awaitItem(), + FullInfoFComposeVM.DocActions.ItemPrimaryValueCopy("") + ) + } + } + + @Test + fun `test PRIMARY_TABLE_ITEM null data action`() = runTest { + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PRIMARY_TABLE_ITEM, + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test BOTTOM_SHEET_DISMISS action`() = runTest { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.BOTTOM_SHEET_DISMISS, + data = "" + ) + ) + Assert.assertEquals( + awaitItem(), + FullInfoFComposeVM.DocActions.DismissDoc + ) + } + } + +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/ui/gallery/DocGalleryVMComposeTest.kt b/documents/src/test/java/ua/gov/diia/documents/ui/gallery/DocGalleryVMComposeTest.kt new file mode 100644 index 0000000..3cf1255 --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/ui/gallery/DocGalleryVMComposeTest.kt @@ -0,0 +1,1917 @@ +package ua.gov.diia.documents.ui.gallery + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.runtime.snapshots.SnapshotStateList +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import ua.gov.diia.core.network.connectivity.ConnectivityObserver +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRatingDialogOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.documents.DocTest +import ua.gov.diia.documents.barcode.DocumentBarcode +import ua.gov.diia.documents.barcode.DocumentBarcodeRepository +import ua.gov.diia.documents.barcode.DocumentBarcodeRepositoryResult +import ua.gov.diia.documents.barcode.DocumentBarcodeSuccessfulLoadResult +import ua.gov.diia.documents.data.api.ApiDocuments +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DocError +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.models.ManualDocs +import ua.gov.diia.documents.rules.MainDispatcherRule +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.documents.ui.ToggleId +import ua.gov.diia.documents.ui.WithCheckLocalizationDocs +import ua.gov.diia.documents.ui.WithPdfCertificate +import ua.gov.diia.documents.ui.WithRemoveDocument +import ua.gov.diia.documents.util.WithUpdateExpiredDocs +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.molecule.button.BtnToggleMlcData +import ua.gov.diia.ui_base.components.organism.document.DocCodeOrgData +import ua.gov.diia.ui_base.components.organism.document.DocOrgData +import ua.gov.diia.ui_base.components.organism.document.DocPhotoOrgData +import ua.gov.diia.ui_base.components.organism.document.Localization +import ua.gov.diia.ui_base.components.organism.group.ToggleButtonGroupData +import ua.gov.diia.ui_base.components.organism.pager.CardFace +import ua.gov.diia.ui_base.components.organism.pager.DocCardFlipData +import ua.gov.diia.ui_base.components.organism.pager.DocCarouselOrgData +import ua.gov.diia.ui_base.components.organism.pager.DocsCarouselItem +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import kotlin.time.Duration.Companion.seconds + +@ExperimentalCoroutinesApi +class DocGalleryVMComposeTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val apiDocs: ApiDocuments = mock() + private lateinit var globalActionConfirmDocumentRemoval: MutableStateFlow?> + private lateinit var globalActionUpdateDocument: MutableStateFlow?> + private lateinit var globalActionFocusOnDocument: MutableStateFlow?> + private lateinit var globalActionSelectedMenuItem: MutableStateFlow?> + private val connectivityObserver: ConnectivityObserver = mock() + private val barcodeRepository: DocumentBarcodeRepository = mock() + private val documentsDataSource: DocumentsDataRepository = mock() + private val dispatcherProvider: DispatcherProvider = mock() + private val errorHandling: WithErrorHandlingOnFlow = mock() + private val withRetryLastAction: WithRetryLastAction = mock() + private val withRatingDialog: WithRatingDialogOnFlow = mock() + private val composeMapper: DocumentComposeMapper = mock() + private val withUpdateExpiredDocs: WithUpdateExpiredDocs = mock() + private val withPdfCertificate: WithPdfCertificate = mock() + private val withCheckLocalizationDocs: WithCheckLocalizationDocs = mock() + private val withRemoveDocument: WithRemoveDocument = mock() + private val documentsHelper: DocumentsHelper = mock() + private val mockDoc = + DiiaDocumentWithMetadata(DocError(), "", "", 200, "doc_error") + private val mockDoc2 = + DiiaDocumentWithMetadata(DocTest(), "", "", 200, "doc_error") + + private val documentsFlow: Flow>> = + flowOf(DataSourceDataResult.successful(listOf(mockDoc2, mockDoc))) + + private val documentsFlow2: Flow>> = + flowOf(DataSourceDataResult.successful(listOf(mockDoc, mockDoc2))) + private lateinit var vm: DocGalleryVMCompose + + private fun initVM() { + vm = DocGalleryVMCompose( + apiDocs, + globalActionConfirmDocumentRemoval, + globalActionUpdateDocument, + globalActionFocusOnDocument, + globalActionSelectedMenuItem, + connectivityObserver, + barcodeRepository, + documentsDataSource, + dispatcherProvider, + errorHandling, + withRetryLastAction, + withRatingDialog, + composeMapper, + withUpdateExpiredDocs, + withPdfCertificate, + withCheckLocalizationDocs, + withRemoveDocument, + documentsHelper + ) + } + + @Before + fun before() { + globalActionConfirmDocumentRemoval = MutableStateFlow(null) + globalActionUpdateDocument = MutableStateFlow(null) + globalActionFocusOnDocument = MutableStateFlow(null) + globalActionSelectedMenuItem = MutableStateFlow(null) + val mockUiElement = DocCarouselOrgData( + data = SnapshotStateList().apply { + add( + DocCardFlipData( + UIActionKeysCompose.DOC_ORG_DATA, + "", + "test_doc", + 0, + CardFace.Front, + DocPhotoOrgData(), + Mockito.mock(DocCodeOrgData::class.java), + true, + ) + ) + }, + focusOnDoc = 0 + ) + Mockito.`when`(connectivityObserver.observe()).thenReturn(flowOf()) + Mockito.`when`(dispatcherProvider.ioDispatcher()) + .thenReturn(StandardTestDispatcher()) + Mockito.`when`(documentsDataSource.data).thenReturn(documentsFlow) + Mockito.`when`(composeMapper.toDocCarousel(any(), anyOrNull())) + .thenReturn(mockUiElement) + Mockito.`when`(documentsHelper.isDocumentValid(any())) + .thenReturn(true) + } + + @After + fun after() { + Mockito.clearAllCaches() + } + + @Test + fun `test do init`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + Assert.assertEquals(settings, vm.settings) + } + + @Test + fun `test default settings`() = runTest { + initVM() + advanceUntilIdle() + Assert.assertEquals(DocFSettings.default, vm.settings) + } + + @Test + fun `test current doc id`() = runTest { + initVM() + vm.showRating(DocTest()) + Assert.assertEquals("123", vm.currentDocId()) + } + + @Test + fun `test current doc id null`() = runTest { + initVM() + Assert.assertEquals(null, vm.currentDocId()) + } + + @Test + fun `test configure body`() = runTest { + val docsFlow = MutableStateFlow( + DataSourceDataResult.successful( + listOf( + mockDoc2, + mockDoc + ) + ) + ) + Mockito.`when`(documentsDataSource.data).thenReturn(docsFlow) + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + advanceUntilIdle() + docsFlow.emit( + DataSourceDataResult.successful( + listOf( + mockDoc, + mockDoc2 + ) + ) + ) + advanceUntilIdle() + verify(composeMapper, times(2)).toDocCarousel(any(), any()) + } + + @Test + fun `test remove doc`() = runTest { + initVM() + val mock = Mockito.mock(DiiaDocument::class.java) + vm.removeDoc(mock) + advanceUntilIdle() + verify(withRemoveDocument, times(1)).removeDocument(any(), any()) + } + + @Test + fun `test invalidate datasource`() = runTest { + initVM() + vm.invalidateDataSource() + verify(documentsDataSource, times(1)).invalidate() + } + + @Test + fun `test getCertificatePdf`() = runTest { + initVM() + vm.getCertificatePdf(DocTest()) + advanceUntilIdle() + verify(withPdfCertificate, times(1)).loadCertificatePdf(any()) + } + + @Test + fun `test remove military bond`() = runTest { + initVM() + vm.removeMilitaryBondFromGallery("", "") + advanceUntilIdle() + verify( + withRemoveDocument, + times(1) + ).removeMilitaryBondFromGallery(any(), any(), any()) + } + + @Test + fun `test confirm delete doc`() = runTest { + initVM() + vm.confirmDelDocument("") + advanceUntilIdle() + verify(withRemoveDocument, times(1)).confirmRemoveDocument( + any(), + any(), + any(), + any() + ) + } + + // actions + + @Test + fun `test TOGGLE_BUTTON_MOLECULE qr action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = "0", + data = ToggleId.qr.value + ) + ) + advanceUntilIdle() + Assert.assertEquals( + DocGalleryVMCompose.DocActions.DefaultBrightness, + awaitItem() + ) + } + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE qr wrong optionalId action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = "", + data = ToggleId.qr.value + ) + ) + advanceUntilIdle() + Assert.assertEquals( + DocGalleryVMCompose.DocActions.DefaultBrightness, + awaitItem() + ) + } + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE null data action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = "0", + data = null + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE qr null OptionalId action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = null, + data = ToggleId.qr.value + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE ean action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = "0", + data = ToggleId.ean.value + ) + ) + advanceUntilIdle() + Assert.assertEquals( + DocGalleryVMCompose.DocActions.HighBrightness, + awaitItem() + ) + } + } + + @Test + fun `test REFRESH_BUTTON action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = "0", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test REFRESH_BUTTON wrong optionalId action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = "", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test REFRESH_BUTTON data null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = "0", + data = null + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test REFRESH_BUTTON optionalId null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = null, + data = "doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test REFRESH_BUTTON doc null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = "0", + data = "wrong_doc" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FLIP action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "0", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + } + @Test + fun `test DOC_CARD_FLIP wrong optionalId action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + } + @Test + fun `test DOC_CARD_FLIP null data action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "0", + data = null + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FLIP null optionalId action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = null, + data = "doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FLIP wrong doc action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "0", + data = "wrong_doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FLIP action without CardFlipData`() = runTest { + val mockUiElement = DocCarouselOrgData( + data = SnapshotStateList().apply { + add( + DocOrgData( + UIActionKeysCompose.DOC_ORG_DATA, + "", + null, + null, + null, + "test_doc", + 0, + true, + null, + 0 + ) + ) + }, + focusOnDoc = 0 + ) + Mockito.`when`(composeMapper.toDocCarousel(any(), anyOrNull())) + .thenReturn(mockUiElement) + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "0", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test DOC_CARD_FORCE_FLIP qr action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.qr.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test DOC_CARD_FORCE_FLIP ean action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.ean.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.HighBrightness + ) + } + } + + @Test + fun `test DOC_CARD_FORCE_FLIP wrong optionalId action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "", + optionalType = ToggleId.qr.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + } + + + @Test + fun `test DOC_CARD_FORCE_FLIP action without CardFlipData`() = runTest { + val mockUiElement = DocCarouselOrgData( + data = SnapshotStateList().apply { + add( + DocOrgData( + UIActionKeysCompose.DOC_ORG_DATA, + "", + null, + null, + null, + "test_doc", + 0, + true, + null, + 0 + ) + ) + }, + focusOnDoc = 0 + ) + Mockito.`when`(composeMapper.toDocCarousel(any(), anyOrNull())) + .thenReturn(mockUiElement) + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.qr.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + this@runTest.coroutineContext.cancelChildren() + } + + @Test + fun `test DOC_CARD_FORCE_FLIP null data action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.qr.value, + data = null + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FORCE_FLIP null optionalId action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = null, + optionalType = ToggleId.qr.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + @Test + fun `test DOC_CARD_FORCE_FLIP wrong doc action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.qr.value, + data = "wrong_doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_ELLIPSE_MENU action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = "0", + data = "doc_test" + ) + ) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.Navigation.ToDocActions( + DocTest(), + 0, + null + ) + ) + } + } + + @Test + fun `test DOC_ELLIPSE_MENU wrong optionalId action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = "", + data = "doc_test" + ) + ) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.Navigation.ToDocActions( + DocTest(), + 0, + null + ) + ) + } + } + + @Test + fun `test DOC_ELLIPSE_MENU data null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = "0", + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + + @Test + fun `test DOC_ELLIPSE_MENU optionalId null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = null, + data = "doc_test" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_ELLIPSE_MENU doc null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = "0", + data = "wrong_doc" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TICKER_ATOM_CLICK action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TICKER_ATOM_CLICK, + optionalId = "0", + data = "doc_test" + ) + ) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.Navigation.NavToVehicleInsurance( + DocTest() + ) + ) + } + } + + @Test + fun `test TICKER_ATOM_CLICK data null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TICKER_ATOM_CLICK, + optionalId = "0", + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TICKER_ATOM_CLICK optionalId null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TICKER_ATOM_CLICK, + optionalId = null, + data = "doc_test" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TICKER_ATOM_CLICK doc null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TICKER_ATOM_CLICK, + optionalId = "0", + data = "wrong doc" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test ADD_DOC_ORG action`() = runTest { + Mockito.`when`(documentsDataSource.data).thenReturn(documentsFlow2) + val manualDocs = ManualDocs(emptyList()) + Mockito.`when`(apiDocs.getDocsManual()).thenReturn(manualDocs) + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.ADD_DOC_ORG, + optionalId = "0", + data = "doc_error" + ) + ) + advanceUntilIdle() + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.Navigation.ToDocActions( + mockDoc.diiaDocument!!, 0, manualDocs + ) + ) + } + } + + @Test + fun `test DOC_STACK action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_STACK, + data = "doc_test" + ) + ) + val item = awaitItem() + assert(item is DocGalleryVMCompose.Navigation.ToDocStack) + Assert.assertEquals( + (item as DocGalleryVMCompose.Navigation.ToDocStack).doc, + DocTest() + ) + } + } + + @Test + fun `test DOC_STACK data null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_STACK, + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_STACK doc null action`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.doInit(settings) + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_STACK, + data = "wrong_doc" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_SWIPE_FINISHED action`() = runTest { + initVM() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_SWIPE_FINISHED, + ) + ) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test DOC_ACTION_IN_LINE action`() = runTest { + initVM() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = DocActions.DOC_ACTION_IN_LINE, + ) + ) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.OpenElectronicQueue + ) + } + } + + @Test + fun `test DOC_ACTION_TO_DRIVER_ACCOUNT action`() = runTest { + initVM() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = DocActions.DOC_ACTION_TO_DRIVER_ACCOUNT, + ) + ) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.OpenDriverAccount + ) + } + } + + @Test + fun `test CHANGE_DOC_ORDER action`() = runTest { + initVM() + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.CHANGE_DOC_ORDER, + ) + ) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.Navigation.ToDocStackOrder + ) + } + } + + @Test + fun `test DOC_NUMBER_COPY action`() = runTest { + initVM() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_NUMBER_COPY, + data = "" + ) + ) + Assert.assertEquals( + awaitItem(), + DocGalleryVMCompose.DocActions.DocNumberCopy("") + ) + } + } + + @Test + fun `test DOC_NUMBER_COPY null data action`() = runTest { + initVM() + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_NUMBER_COPY, + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/ui/stack/DocStackVMComposeTest.kt b/documents/src/test/java/ua/gov/diia/documents/ui/stack/DocStackVMComposeTest.kt new file mode 100644 index 0000000..7dc9917 --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/ui/stack/DocStackVMComposeTest.kt @@ -0,0 +1,1792 @@ +package ua.gov.diia.documents.ui.stack + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.runtime.snapshots.SnapshotStateList +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.anyOrNull +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import ua.gov.diia.core.network.connectivity.ConnectivityObserver +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRatingDialogOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.documents.DocTest +import ua.gov.diia.documents.barcode.DocumentBarcode +import ua.gov.diia.documents.barcode.DocumentBarcodeRepository +import ua.gov.diia.documents.barcode.DocumentBarcodeRepositoryResult +import ua.gov.diia.documents.barcode.DocumentBarcodeSuccessfulLoadResult +import ua.gov.diia.documents.data.api.ApiDocuments +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.getOrAwaitValue +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DocError +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.rules.MainDispatcherRule +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.documents.ui.ToggleId +import ua.gov.diia.documents.ui.WithCheckLocalizationDocs +import ua.gov.diia.documents.ui.WithPdfCertificate +import ua.gov.diia.documents.ui.WithRemoveDocument +import ua.gov.diia.documents.ui.gallery.DocActions +import ua.gov.diia.documents.ui.gallery.DocFSettings +import ua.gov.diia.documents.util.WithUpdateExpiredDocs +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.molecule.button.BtnToggleMlcData +import ua.gov.diia.ui_base.components.organism.document.DocCodeOrgData +import ua.gov.diia.ui_base.components.organism.document.DocOrgData +import ua.gov.diia.ui_base.components.organism.document.DocPhotoOrgData +import ua.gov.diia.ui_base.components.organism.document.Localization +import ua.gov.diia.ui_base.components.organism.group.ToggleButtonGroupData +import ua.gov.diia.ui_base.components.organism.pager.CardFace +import ua.gov.diia.ui_base.components.organism.pager.DocCardFlipData +import ua.gov.diia.ui_base.components.organism.pager.DocCarouselOrgData +import ua.gov.diia.ui_base.components.organism.pager.DocsCarouselItem +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import kotlin.time.Duration.Companion.seconds + +@ExperimentalCoroutinesApi +class DocStackVMComposeTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val apiDocs: ApiDocuments = mock() + private lateinit var globalActionConfirmDocumentRemoval: MutableStateFlow?> + private lateinit var globalActionUpdateDocument: MutableStateFlow?> + private lateinit var globalActionFocusOnDocument: MutableStateFlow?> + private lateinit var globalActionSelectedMenuItem: MutableStateFlow?> + private val connectivityObserver: ConnectivityObserver = mock() + private val barcodeRepository: DocumentBarcodeRepository = mock() + private val documentsDataSource: DocumentsDataRepository = mock() + private val dispatcherProvider: DispatcherProvider = mock() + private val errorHandling: WithErrorHandlingOnFlow = mock() + private val withRetryLastAction: WithRetryLastAction = mock() + private val withRatingDialog: WithRatingDialogOnFlow = mock() + private val composeMapper: DocumentComposeMapper = mock() + private val withUpdateExpiredDocs: WithUpdateExpiredDocs = mock() + private val withPdfCertificate: WithPdfCertificate = mock() + private val withCheckLocalizationDocs: WithCheckLocalizationDocs = mock() + private val withRemoveDocument: WithRemoveDocument = mock() + private val mockDoc = + DiiaDocumentWithMetadata(DocError(), "", "", 200, "doc_error") + private val mockDoc2 = + DiiaDocumentWithMetadata(DocTest(), "", "", 200, "doc_error") + + private val documentsFlow: Flow>> = + flowOf(DataSourceDataResult.successful(listOf(mockDoc2, mockDoc))) + + private val documentsFlow2: Flow>> = + flowOf(DataSourceDataResult.successful(listOf(mockDoc, mockDoc2))) + private lateinit var vm: DocStackVMCompose + + private fun initVM() { + vm = DocStackVMCompose( + apiDocs, + globalActionConfirmDocumentRemoval, + globalActionUpdateDocument, + globalActionFocusOnDocument, + globalActionSelectedMenuItem, + barcodeRepository, + documentsDataSource, + dispatcherProvider, + errorHandling, + withRetryLastAction, + withRatingDialog, + composeMapper, + withUpdateExpiredDocs, + withPdfCertificate, + withCheckLocalizationDocs, + withRemoveDocument + ) + } + + @Before + fun before() { + globalActionConfirmDocumentRemoval = MutableStateFlow(null) + globalActionUpdateDocument = MutableStateFlow(null) + globalActionFocusOnDocument = MutableStateFlow(null) + globalActionSelectedMenuItem = MutableStateFlow(null) + val mockUiElement: DocCarouselOrgData = DocCarouselOrgData( + data = SnapshotStateList().apply { + add( + DocCardFlipData( + UIActionKeysCompose.DOC_ORG_DATA, + "", + "test_doc", + 0, + CardFace.Front, + DocPhotoOrgData(), + Mockito.mock(DocCodeOrgData::class.java), + true, + ) + ) + }, + focusOnDoc = 0 + ) + Mockito.`when`(connectivityObserver.observe()).thenReturn(flowOf()) + Mockito.`when`(dispatcherProvider.ioDispatcher()) + .thenReturn(StandardTestDispatcher()) + Mockito.`when`(documentsDataSource.data).thenReturn(documentsFlow) + Mockito.`when`(composeMapper.toDocCarousel(any(), anyOrNull())) + .thenReturn(mockUiElement) + } + @After + fun after() { + Mockito.clearAllCaches() + } + + @Test + fun `test do init`() = runTest { + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + assert(vm.documentCardData.getOrAwaitValue().isNotEmpty()) + } + + @Test + fun `test current doc id`() = runTest { + initVM() + vm.showRating(DocTest()) + Assert.assertEquals("123", vm.currentDocId()) + } + + @Test + fun `test current doc id null`() = runTest { + initVM() + Assert.assertEquals(null, vm.currentDocId()) + } + + @Test + fun `test configure body`() = runTest { + val docsFlow = MutableStateFlow( + DataSourceDataResult.successful( + listOf( + mockDoc2, + mockDoc + ) + ) + ) + Mockito.`when`(documentsDataSource.data).thenReturn(docsFlow) + initVM() + advanceUntilIdle() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + docsFlow.emit( + DataSourceDataResult.successful( + listOf( + mockDoc, + mockDoc2 + ) + ) + ) + advanceUntilIdle() + verify(composeMapper, times(2)).toDocCarousel(any(), any()) + } + + @Test + fun `test remove doc`() = runTest { + initVM() + val mock = Mockito.mock(DiiaDocument::class.java) + vm.removeDoc(mock) + advanceUntilIdle() + verify(withRemoveDocument, times(1)).removeDocument(any(), any()) + } + + @Test + fun `test invalidate datasource`() = runTest { + initVM() + vm.invalidateDataSource() + verify(documentsDataSource, times(1)).invalidate() + } + + @Test + fun `test getCertificatePdf`() = runTest { + initVM() + vm.getCertificatePdf(DocTest()) + advanceUntilIdle() + verify(withPdfCertificate, times(1)).loadCertificatePdf(any()) + } + + @Test + fun `test remove military bond`() = runTest { + initVM() + vm.removeMilitaryBondFromGallery("", "") + advanceUntilIdle() + verify( + withRemoveDocument, + times(1) + ).removeMilitaryBondFromGallery(any(), any(), any()) + } + + @Test + fun `test confirm delete doc`() = runTest { + initVM() + vm.confirmDelDocument("") + advanceUntilIdle() + verify(withRemoveDocument, times(1)).confirmRemoveDocument( + any(), + any(), + any(), + any() + ) + } + + // actions + + @Test + fun `test TOGGLE_BUTTON_MOLECULE qr action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = "0", + data = ToggleId.qr.value + ) + ) + advanceUntilIdle() + Assert.assertEquals( + DocStackVMCompose.DocActions.DefaultBrightness, + awaitItem() + ) + } + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE qr wrong optionalId action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = "", + data = ToggleId.qr.value + ) + ) + advanceUntilIdle() + Assert.assertEquals( + DocStackVMCompose.DocActions.DefaultBrightness, + awaitItem() + ) + } + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE null data action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = "0", + data = null + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE qr null OptionalId action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = null, + data = ToggleId.qr.value + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TOGGLE_BUTTON_MOLECULE ean action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TOGGLE_BUTTON_MOLECULE, + optionalId = "0", + data = ToggleId.ean.value + ) + ) + advanceUntilIdle() + Assert.assertEquals( + DocStackVMCompose.DocActions.HighBrightness, + awaitItem() + ) + } + } + + @Test + fun `test REFRESH_BUTTON action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = "0", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test REFRESH_BUTTON wrong optionalId action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = "", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test REFRESH_BUTTON data null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = "0", + data = null + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test REFRESH_BUTTON optionalId null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = null, + data = "doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test REFRESH_BUTTON doc null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.REFRESH_BUTTON, + optionalId = "0", + data = "wrong_doc" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FLIP action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "0", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + } + @Test + fun `test DOC_CARD_FLIP wrong optionalId action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + } + @Test + fun `test DOC_CARD_FLIP null data action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "0", + data = null + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FLIP null optionalId action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = null, + data = "doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FLIP wrong doc action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData( + BtnToggleMlcData(), + BtnToggleMlcData() + ), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "0", + data = "wrong_doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FLIP action without CardFlipData`() = runTest { + val mockUiElement = DocCarouselOrgData( + data = SnapshotStateList().apply { + add( + DocOrgData( + UIActionKeysCompose.DOC_ORG_DATA, + "", + null, + null, + null, + "test_doc", + 0, + true, + null, + 0 + ) + ) + }, + focusOnDoc = 0 + ) + Mockito.`when`(composeMapper.toDocCarousel(any(), anyOrNull())) + .thenReturn(mockUiElement) + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + false + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FLIP, + optionalId = "0", + data = "doc_test" + ) + ) + advanceUntilIdle() + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test DOC_CARD_FORCE_FLIP qr action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.qr.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test DOC_CARD_FORCE_FLIP ean action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.ean.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.HighBrightness + ) + } + } + + @Test + fun `test DOC_CARD_FORCE_FLIP wrong optionalId action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "", + optionalType = ToggleId.qr.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + } + + + @Test + fun `test DOC_CARD_FORCE_FLIP action without CardFlipData`() = runTest { + val mockUiElement = DocCarouselOrgData( + data = SnapshotStateList().apply { + add( + DocOrgData( + UIActionKeysCompose.DOC_ORG_DATA, + "", + null, + null, + null, + "test_doc", + 0, + true, + null, + 0 + ) + ) + }, + focusOnDoc = 0 + ) + Mockito.`when`(composeMapper.toDocCarousel(any(), anyOrNull())) + .thenReturn(mockUiElement) + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0, false)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.qr.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + + verify(barcodeRepository, times(1)).loadBarcode(any(), any(), any()) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + this@runTest.coroutineContext.cancelChildren() + } + + @Test + fun `test DOC_CARD_FORCE_FLIP null data action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.qr.value, + data = null + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_FORCE_FLIP null optionalId action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = null, + optionalType = ToggleId.qr.value, + data = "doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + @Test + fun `test DOC_CARD_FORCE_FLIP wrong doc action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.docAction.test(timeout = 8.seconds) { + val doc = DocTest() + val barcode = Mockito.mock(DocumentBarcode::class.java) + val barcodeResult = DocumentBarcodeSuccessfulLoadResult( + shareQr = barcode, + null, + null, + 0, + null, + null + ) + val mockData = DocCodeOrgData( + "", + Localization.eng, + ToggleButtonGroupData(BtnToggleMlcData(), BtnToggleMlcData()), + null, + null, + null, + null, + null, + false, + true, + false + ) + val barcodeRepoResult = + DocumentBarcodeRepositoryResult(barcodeResult, false) + Mockito.`when`(barcodeRepository.loadBarcode(doc, 0)) + .thenReturn(barcodeRepoResult) + Mockito.`when`( + composeMapper.toComposeDocCodeOrg( + barcodeResult, LocalizationType.ua, + true + ) + ) + .thenReturn(mockData) + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_FORCE_FLIP, + optionalId = "0", + optionalType = ToggleId.qr.value, + data = "wrong_doc_test" + ) + ) + advanceUntilIdle() + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_ELLIPSE_MENU action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = "0", + data = "doc_test" + ) + ) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.Navigation.ToDocActions( + DocTest(), + 0 + ) + ) + } + } + + @Test + fun `test DOC_ELLIPSE_MENU wrong optionalId action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = "", + data = "doc_test" + ) + ) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.Navigation.ToDocActions( + DocTest(), + 0 + ) + ) + } + } + + @Test + fun `test DOC_ELLIPSE_MENU data null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = "0", + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + + @Test + fun `test DOC_ELLIPSE_MENU optionalId null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = null, + data = "doc_test" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_ELLIPSE_MENU doc null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_ELLIPSE_MENU, + optionalId = "0", + data = "wrong_doc" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TICKER_ATOM_CLICK action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TICKER_ATOM_CLICK, + optionalId = "0", + data = "doc_test" + ) + ) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.Navigation.NavToVehicleInsurance( + DocTest() + ) + ) + } + } + + @Test + fun `test TICKER_ATOM_CLICK data null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TICKER_ATOM_CLICK, + optionalId = "0", + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TICKER_ATOM_CLICK optionalId null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TICKER_ATOM_CLICK, + optionalId = null, + data = "doc_test" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TICKER_ATOM_CLICK doc null action`() = runTest { + initVM() + val settings = DocFSettings() + vm.subscribeForDocuments(settings) + advanceUntilIdle() + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TICKER_ATOM_CLICK, + optionalId = "0", + data = "wrong doc" + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test DOC_CARD_SWIPE_FINISHED action`() = runTest { + initVM() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_CARD_SWIPE_FINISHED, + ) + ) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DefaultBrightness + ) + } + } + + @Test + fun `test DOC_ACTION_IN_LINE action`() = runTest { + initVM() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = DocActions.DOC_ACTION_IN_LINE, + ) + ) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.OpenElectronicQueue + ) + } + } + + @Test + fun `test DOC_ACTION_TO_DRIVER_ACCOUNT action`() = runTest { + initVM() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = DocActions.DOC_ACTION_TO_DRIVER_ACCOUNT, + ) + ) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.OpenDriverAccount + ) + } + } + + @Test + fun `test DOC_NUMBER_COPY action`() = runTest { + initVM() + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_NUMBER_COPY, + data = "" + ) + ) + Assert.assertEquals( + awaitItem(), + DocStackVMCompose.DocActions.DocNumberCopy("") + ) + } + } + + @Test + fun `test DOC_NUMBER_COPY null data action`() = runTest { + initVM() + var emissionFailed = false + try { + vm.docAction.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.DOC_NUMBER_COPY, + data = null + ) + ) + awaitItem() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/ui/stack/order/StackOrderVMComposeTest.kt b/documents/src/test/java/ua/gov/diia/documents/ui/stack/order/StackOrderVMComposeTest.kt new file mode 100644 index 0000000..4a1674c --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/ui/stack/order/StackOrderVMComposeTest.kt @@ -0,0 +1,260 @@ +package ua.gov.diia.documents.ui.stack.order + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.mockito.Mockito +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRatingDialogOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.documents.DocTest +import ua.gov.diia.documents.DocTest2 +import ua.gov.diia.documents.DocTest3 +import ua.gov.diia.documents.DocTest4 +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.rules.MainDispatcherRule +import ua.gov.diia.documents.ui.DocsConst +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.organism.list.ListItemDragOrgData + +class StackOrderVMComposeTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val documentsDataSource: DocumentsDataRepository = mock() + private val errorHandling: WithErrorHandlingOnFlow = mock() + private val withRetryLastAction: WithRetryLastAction = mock() + private val withRatingDialog: WithRatingDialogOnFlow = mock() + private val composeMapper: DocumentComposeMapper = mock() + private val documentsHelper: DocumentsHelper = mock() + private val mockDoc = DiiaDocumentWithMetadata(DocTest(), "", "", 200, "") + private val mockDoc2 = DiiaDocumentWithMetadata(DocTest2(), "", "", 200, "") + private val mockDoc3 = DiiaDocumentWithMetadata(DocTest3(), "", "", 200, "") + private val mockDoc4 = DiiaDocumentWithMetadata(DocTest4(), "", "", 200, "") + private val documentsFlow: Flow>> = + flowOf(DataSourceDataResult.successful(listOf(mockDoc, mockDoc2, mockDoc3, mockDoc4))) + private val documentsFlowNull: Flow>> = + flowOf( + DataSourceDataResult.successful( + listOf( + mockDoc.copy(diiaDocument = null), + mockDoc2.copy(diiaDocument = null) + ) + ) + ) + + private lateinit var vm: StackOrderVMCompose + + @Before + fun before() { + Mockito.`when`(documentsDataSource.data).thenReturn(documentsFlow) + Mockito.`when`(documentsHelper.isDocumentValid(any())) + .thenReturn(true) + vm = StackOrderVMCompose( + documentsDataSource, + errorHandling, + withRetryLastAction, + withRatingDialog, + composeMapper, + documentsHelper + ) + } + + @After + fun after() { + Mockito.clearAllCaches() + } + + + @Test + fun `test init`() = runTest { + vm.doInit(DocsConst.DOCUMENT_TYPE_ALL) + advanceUntilIdle() + val state = vm.bodyData.first() as ListItemDragOrgData + Assert.assertEquals( + 3, + state.items.size + ) + } + + @Test + fun `test init with type`() = runTest { + vm.doInit("doc_test2") + advanceUntilIdle() + val state = vm.bodyData.first() as ListItemDragOrgData + Assert.assertEquals( + 1, + state.items.size + ) + } + + @Test + fun `test init with type and null doc`() = runTest { + Mockito.`when`(documentsDataSource.data).thenReturn(documentsFlowNull) + vm.doInit("doc_test2") + advanceUntilIdle() + val state = vm.bodyData.first() as ListItemDragOrgData + Assert.assertEquals( + 0, + state.items.size + ) + } + + @Test + fun `test init with type and empty doc org label`() = runTest { + Mockito.`when`(documentsDataSource.data).thenReturn(documentsFlow) + vm.doInit("doc_test3") + advanceUntilIdle() + val state = vm.bodyData.first() as ListItemDragOrgData + Assert.assertEquals( + 0, + state.items.size + ) + } + + @Test + fun `test move`() = runTest { + vm.doInit(DocsConst.DOCUMENT_TYPE_ALL) + advanceUntilIdle() + val state = vm.bodyData.first() as ListItemDragOrgData + val items = state.items.toList() + vm.onMove(0, 1) + val newState = vm.bodyData.first() as ListItemDragOrgData + val newItems = newState.items.toList() + Assert.assertEquals(items[0], newItems[1]) + Assert.assertEquals(items[1], newItems[0]) + } + + @Test + fun `test save order`() = runTest { + vm.doInit(DocsConst.DOCUMENT_TYPE_ALL) + vm.saveCurrentOrder() + verify(documentsDataSource).saveDocTypeOrder(any()) + } + + @Test + fun `test save order for specific type`() = runTest { + vm.doInit("doc_test2") + vm.saveCurrentOrder() + verify(documentsDataSource).saveDocOrderForSpecificType(any(), any()) + } + + //actions + @Test + fun `test TOOLBAR_NAVIGATION_BACK action`() = runTest { + vm.navigation.test { + vm.onUIAction(UIAction(actionKey = UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK)) + Assert.assertEquals(awaitItem(), BaseNavigation.Back) + } + } + + @Test + fun `test LIST_ITEM_CLICK null action`() = runTest { + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.LIST_ITEM_CLICK, + data = null + ) + ) + awaitError() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test LIST_ITEM_CLICK action`() = runTest { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.LIST_ITEM_CLICK, + data = "" + ) + ) + Assert.assertEquals( + awaitItem(), + StackOrderVMCompose.Navigation.ToStackTypedOrder("") + ) + } + } + + @Test + fun `test TITLE_GROUP_MLC action`() = runTest { + vm.navigation.test { + vm.onUIAction( + UIAction( + action = DataActionWrapper(ActionsConst.ACTION_NAVIGATE_BACK), + actionKey = UIActionKeysCompose.TITLE_GROUP_MLC + ) + ) + Assert.assertEquals(awaitItem(), BaseNavigation.Back) + } + } + + + @Test + fun `test TITLE_GROUP_MLC null action`() = runTest { + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.LIST_ITEM_CLICK, + action = null + ) + ) + awaitError() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } + + @Test + fun `test TITLE_GROUP_MLC other action`() = runTest { + var emissionFailed = false + try { + vm.navigation.test { + vm.onUIAction( + UIAction( + action = DataActionWrapper("other action"), + actionKey = UIActionKeysCompose.LIST_ITEM_CLICK + ) + ) + awaitError() + } + } catch (e: Throwable) { + emissionFailed = true + } + Assert.assertTrue(emissionFailed) + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/util/WithUpdateExpiredDocsImplTest.kt b/documents/src/test/java/ua/gov/diia/documents/util/WithUpdateExpiredDocsImplTest.kt new file mode 100644 index 0000000..fcbf4a3 --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/util/WithUpdateExpiredDocsImplTest.kt @@ -0,0 +1,52 @@ +package ua.gov.diia.documents.util + +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.helper.DocumentsHelper + +class WithUpdateExpiredDocsImplTest { + + private val documentsDataSource: DocumentsDataRepository = mock() + + private val documentsHelper: DocumentsHelper = mock() + + private lateinit var withUpdateExpiredDocsImpl: WithUpdateExpiredDocsImpl + + @Before + fun setUp() { + withUpdateExpiredDocsImpl = WithUpdateExpiredDocsImpl(documentsDataSource, documentsHelper) + } + + @After + fun cleanUp() { + Mockito.clearAllCaches() + } + + @Test + fun `updateExpirationDate with focusDocType calls update with list`() = runBlocking { + val focusDocType = "someType" + val typesToUpdate = listOf("type1", "type2") + whenever(documentsHelper.provideListOfDocumentsRequireUpdateOfExpirationDate(focusDocType)).thenReturn(typesToUpdate) + + withUpdateExpiredDocsImpl.updateExpirationDate(focusDocType) + + verify(documentsDataSource).replaceExpDateByType(typesToUpdate) + verify(documentsDataSource).invalidate() + } + @Test + fun `updateExpirationDate with list updates expiration dates and invalidates data source`() = runBlocking { + val typesToUpdate = listOf("type1", "type2") + + withUpdateExpiredDocsImpl.updateExpirationDate(typesToUpdate) + + verify(documentsDataSource).replaceExpDateByType(typesToUpdate) + verify(documentsDataSource).invalidate() + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/util/datasource/ExpirationStrategyTest.kt b/documents/src/test/java/ua/gov/diia/documents/util/datasource/ExpirationStrategyTest.kt new file mode 100644 index 0000000..192299a --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/util/datasource/ExpirationStrategyTest.kt @@ -0,0 +1,70 @@ +package ua.gov.diia.documents.util.datasource + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import org.junit.Assert +import org.junit.Test +import ua.gov.diia.core.util.date.CurrentDateProvider +import ua.gov.diia.core.util.extensions.date_time.getUTCDate +import ua.gov.diia.documents.models.Expiring +import ua.gov.diia.documents.models.Preferences +import java.util.Date + +class ExpirationStrategyTest { + + @Test + fun `test reset`() { + val currentDateProvider = mockk(relaxed = true) + val strategy = DateCompareExpirationStrategy(currentDateProvider) + strategy.reset() + verify(atLeast = 2) { currentDateProvider.getDate() } + } + + @Test + fun `test illegal class`() { + val currentDateProvider = mockk(relaxed = true) + val strategy = DateCompareExpirationStrategy(currentDateProvider) + Assert.assertThrows(IllegalArgumentException::class.java) { + strategy.isExpired( + Any() + ) + } + } + + @Test + fun `test date Preferences DEF`() { + val currentDateProvider = mockk(relaxed = true) + val strategy = DateCompareExpirationStrategy(currentDateProvider) + val element = mockk() + every { element.getDocExpirationDate() } returns Preferences.DEF + assert(strategy.isExpired(element)) + } + + @Test + fun `test not expired date`(){ + val currentDateProvider = mockk(relaxed = true) + every { currentDateProvider.getDate() } returns Date() + val strategy = DateCompareExpirationStrategy(currentDateProvider) + val element = mockk() + every { element.getDocExpirationDate() } returns "" + val date = Date(System.currentTimeMillis() + (1000 * 60 * 60 * 24)) + mockkStatic(::getUTCDate) + every { getUTCDate(any()) } returns date + assert(strategy.isExpired(element).not()) + } + + @Test + fun `test expired date`(){ + val currentDateProvider = mockk(relaxed = true) + val date = Date(System.currentTimeMillis() + (1000 * 60 * 60 * 24)) + every { currentDateProvider.getDate() } returns date + val strategy = DateCompareExpirationStrategy(currentDateProvider) + val element = mockk() + every { element.getDocExpirationDate() } returns "" + mockkStatic(::getUTCDate) + every { getUTCDate(any()) } returns Date() + assert(strategy.isExpired(element)) + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/util/datasource/local/BrokenDocFilterImplTest.kt b/documents/src/test/java/ua/gov/diia/documents/util/datasource/local/BrokenDocFilterImplTest.kt new file mode 100644 index 0000000..8ee019f --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/util/datasource/local/BrokenDocFilterImplTest.kt @@ -0,0 +1,68 @@ +package ua.gov.diia.documents.util.datasource.local + +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import ua.gov.diia.documents.data.datasource.local.BrokenDocFilterImpl +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata + +class BrokenDocFilterImplTest { + + private val documentsHelper: DocumentsHelper = mock() + + private lateinit var brokenDocFilterImpl: BrokenDocFilterImpl + + @Before + fun setUp() { + brokenDocFilterImpl = BrokenDocFilterImpl(documentsHelper) + } + @After + fun cleanUp() { + Mockito.clearAllCaches() + } + + @Test + fun `filter separates documents correctly`() { + val docCanBreakWithId = Mockito.mock(DiiaDocumentWithMetadata::class.java) + val docCanBreakWithoutId = Mockito.mock(DiiaDocumentWithMetadata::class.java) + val docCannotBreak = Mockito.mock(DiiaDocumentWithMetadata::class.java) + + val docCanBreakWithIdDoc = Mockito.mock(DiiaDocument::class.java) + val docCanBreakWithoutIdDoc = Mockito.mock(DiiaDocument::class.java) + val docCannotBreakDoc = Mockito.mock(DiiaDocument::class.java) + + whenever(docCanBreakWithId.type).thenReturn("docCanBreakWithId") + whenever(docCanBreakWithId.diiaDocument).thenReturn(docCanBreakWithIdDoc) + whenever(docCanBreakWithIdDoc.docId()).thenReturn("123") + + whenever(docCanBreakWithoutId.type).thenReturn("docCanBreakWithoutId") + whenever(docCanBreakWithoutId.diiaDocument).thenReturn(docCanBreakWithoutIdDoc) + whenever(docCanBreakWithoutIdDoc.docId()).thenReturn(null) + + whenever(docCannotBreak.type).thenReturn("docCannotBreak") + whenever(docCannotBreak.diiaDocument).thenReturn(docCannotBreakDoc) + whenever(docCannotBreakDoc.docId()).thenReturn("docCannotBreak") + + whenever(documentsHelper.isDocCanBeBroken(any())).thenAnswer { invocation -> + val type = invocation.getArgument(0) + type == docCanBreakWithId.type || type == docCanBreakWithoutId.type + } + + val existsId = mutableListOf() + val removeList = mutableListOf() + val docs = listOf(docCanBreakWithId, docCanBreakWithoutId, docCannotBreak) + + brokenDocFilterImpl.filter(docs, existsId, removeList) + + assertTrue(existsId.contains(docCanBreakWithId.diiaDocument?.docId())) + assertTrue(removeList.contains(docCanBreakWithoutId)) + assertTrue(!existsId.contains(docCannotBreak.diiaDocument?.docId()) && !removeList.contains(docCannotBreak)) + } +} \ No newline at end of file diff --git a/documents/src/test/java/ua/gov/diia/documents/util/datasource/local/RemoveExpiredDocBehaviorImplTest.kt b/documents/src/test/java/ua/gov/diia/documents/util/datasource/local/RemoveExpiredDocBehaviorImplTest.kt new file mode 100644 index 0000000..ab02aea --- /dev/null +++ b/documents/src/test/java/ua/gov/diia/documents/util/datasource/local/RemoveExpiredDocBehaviorImplTest.kt @@ -0,0 +1,82 @@ +package ua.gov.diia.documents.util.datasource.local + +import io.mockk.* +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import ua.gov.diia.core.util.date.CurrentDateProvider +import ua.gov.diia.core.util.extensions.date_time.getUTCDate +import ua.gov.diia.documents.data.datasource.local.RemoveExpiredDocBehaviorImpl +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.Preferences +import java.util.* + +class RemoveExpiredDocBehaviorImplTest { + + private val currentDateProvider: CurrentDateProvider = mockk() + + private val documentsHelper: DocumentsHelper = mockk() + + private lateinit var removeExpiredDocBehaviorImpl: RemoveExpiredDocBehaviorImpl + + @Before + fun setUp() { + removeExpiredDocBehaviorImpl = RemoveExpiredDocBehaviorImpl(currentDateProvider, documentsHelper) + } + @After + fun cleanUp() { + clearAllMocks() + } + + @Test + fun `removeExpiredDocs removes expired documents`() = runTest { + val expiredDoc = mockk() + val expiredDocDoc = mockk() + val validDoc = mockk() + val validDocDoc = mockk() + val ineligibleDoc = mockk() + + every { expiredDoc.type } returns "expiredDoc" + every { expiredDoc.diiaDocument } returns expiredDocDoc + every { expiredDocDoc.getExpirationDateISO() } returns "expiredDoc" + + every { validDoc.type } returns "validDoc" + every { validDoc.diiaDocument } returns validDocDoc + every { validDocDoc.getExpirationDateISO() } returns "validDoc" + + every { ineligibleDoc.type } returns "ineligibleDoc" + every { ineligibleDoc.diiaDocument } returns null + + every { documentsHelper.isDocEligibleForDeletion("expiredDoc") } returns true + every { documentsHelper.isDocEligibleForDeletion("validDoc") } returns true + every { documentsHelper.isDocEligibleForDeletion("ineligibleDoc") } returns false + + val utcDateValid = Date(System.currentTimeMillis() + (1000 * 60 * 60 * 24)) + val utcDateExpired = Date(System.currentTimeMillis() - (1000 * 60 * 60 * 24)) + mockkStatic(::getUTCDate) + every { getUTCDate(Preferences.DEF) } returns utcDateExpired + every { getUTCDate("expiredDoc") } returns utcDateExpired + every { getUTCDate("validDoc") } returns utcDateValid + + every { currentDateProvider.getDate() } returns Date(System.currentTimeMillis()) + + val data = listOf(expiredDoc, validDoc, ineligibleDoc) + + val removeDocDelegate = mockk() + coJustRun { removeDocDelegate.removeDoc(any()) } + + removeExpiredDocBehaviorImpl.removeExpiredDocs(data, removeDocDelegate::removeDoc) + advanceUntilIdle() + + coVerify(atLeast = 1) { removeDocDelegate.removeDoc(expiredDocDoc) } + coVerify(atLeast = 0) { removeDocDelegate.removeDoc(validDocDoc) } + } + + class RemoveDocDelegate{ + suspend fun removeDoc(doc: DiiaDocument){} + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..8147e51 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +org.gradle.parallel=true +#Sat May 15 12:42:50 EEST 2021 +kotlin.code.style=official +org.gradle.jvmargs=-Xmx9G -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC +android.enableJetifier=false +android.jetifier.ignorelist=moshi-1.13.0 +android.useAndroidX=true +org.gradle.daemon=true +kapt.incremental.apt=true +android.databinding.incremental=true +# Upgrade lint to a newer version to work around https://issuetracker.google.com/issues/185418482. +android.experimental.lint.version=8.1.0-rc01 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f6b961fd5a86aa5fbfe90f707c3138408be7c718 GIT binary patch literal 54329 zcmagFV|ZrKvM!pAZQHhO+qP}9lTNj?q^^Y^VFp)SH8qbSJ)2BQ2giqr}t zFG7D6)c?v~^Z#E_K}1nTQbJ9gQ9<%vVRAxVj)8FwL5_iTdUB>&m3fhE=kRWl;g`&m z!W5kh{WsV%fO*%je&j+Lv4xxK~zsEYQls$Q-p&dwID|A)!7uWtJF-=Tm1{V@#x*+kUI$=%KUuf2ka zjiZ{oiL1MXE2EjciJM!jrjFNwCh`~hL>iemrqwqnX?T*MX;U>>8yRcZb{Oy+VKZos zLiFKYPw=LcaaQt8tj=eoo3-@bG_342HQ%?jpgAE?KCLEHC+DmjxAfJ%Og^$dpC8Xw zAcp-)tfJm}BPNq_+6m4gBgBm3+CvmL>4|$2N$^Bz7W(}fz1?U-u;nE`+9`KCLuqg} zwNstNM!J4Uw|78&Y9~9>MLf56to!@qGkJw5Thx%zkzj%Ek9Nn1QA@8NBXbwyWC>9H z#EPwjMNYPigE>*Ofz)HfTF&%PFj$U6mCe-AFw$U%-L?~-+nSXHHKkdgC5KJRTF}`G zE_HNdrE}S0zf4j{r_f-V2imSqW?}3w-4=f@o@-q+cZgaAbZ((hn))@|eWWhcT2pLpTpL!;_5*vM=sRL8 zqU##{U#lJKuyqW^X$ETU5ETeEVzhU|1m1750#f}38_5N9)B_2|v@1hUu=Kt7-@dhA zq_`OMgW01n`%1dB*}C)qxC8q;?zPeF_r;>}%JYmlER_1CUbKa07+=TV45~symC*g8 zW-8(gag#cAOuM0B1xG8eTp5HGVLE}+gYTmK=`XVVV*U!>H`~j4+ROIQ+NkN$LY>h4 zqpwdeE_@AX@PL};e5vTn`Ro(EjHVf$;^oiA%@IBQq>R7_D>m2D4OwwEepkg}R_k*M zM-o;+P27087eb+%*+6vWFCo9UEGw>t&WI17Pe7QVuoAoGHdJ(TEQNlJOqnjZ8adCb zI`}op16D@v7UOEo%8E-~m?c8FL1utPYlg@m$q@q7%mQ4?OK1h%ODjTjFvqd!C z-PI?8qX8{a@6d&Lb_X+hKxCImb*3GFemm?W_du5_&EqRq!+H?5#xiX#w$eLti-?E$;Dhu`{R(o>LzM4CjO>ICf z&DMfES#FW7npnbcuqREgjPQM#gs6h>`av_oEWwOJZ2i2|D|0~pYd#WazE2Bbsa}X@ zu;(9fi~%!VcjK6)?_wMAW-YXJAR{QHxrD5g(ou9mR6LPSA4BRG1QSZT6A?kelP_g- zH(JQjLc!`H4N=oLw=f3{+WmPA*s8QEeEUf6Vg}@!xwnsnR0bl~^2GSa5vb!Yl&4!> zWb|KQUsC$lT=3A|7vM9+d;mq=@L%uWKwXiO9}a~gP4s_4Yohc!fKEgV7WbVo>2ITbE*i`a|V!^p@~^<={#?Gz57 zyPWeM2@p>D*FW#W5Q`1`#5NW62XduP1XNO(bhg&cX`-LYZa|m-**bu|>}S;3)eP8_ zpNTnTfm8 ze+7wDH3KJ95p)5tlwk`S7mbD`SqHnYD*6`;gpp8VdHDz%RR_~I_Ar>5)vE-Pgu7^Y z|9Px+>pi3!DV%E%4N;ii0U3VBd2ZJNUY1YC^-e+{DYq+l@cGtmu(H#Oh%ibUBOd?C z{y5jW3v=0eV0r@qMLgv1JjZC|cZ9l9Q)k1lLgm))UR@#FrJd>w^`+iy$c9F@ic-|q zVHe@S2UAnc5VY_U4253QJxm&Ip!XKP8WNcnx9^cQ;KH6PlW8%pSihSH2(@{2m_o+m zr((MvBja2ctg0d0&U5XTD;5?d?h%JcRJp{_1BQW1xu&BrA3(a4Fh9hon-ly$pyeHq zG&;6q?m%NJ36K1Sq_=fdP(4f{Hop;_G_(i?sPzvB zDM}>*(uOsY0I1j^{$yn3#U(;B*g4cy$-1DTOkh3P!LQ;lJlP%jY8}Nya=h8$XD~%Y zbV&HJ%eCD9nui-0cw!+n`V~p6VCRqh5fRX z8`GbdZ@73r7~myQLBW%db;+BI?c-a>Y)m-FW~M=1^|<21_Sh9RT3iGbO{o-hpN%d6 z7%++#WekoBOP^d0$$|5npPe>u3PLvX_gjH2x(?{&z{jJ2tAOWTznPxv-pAv<*V7r$ z6&glt>7CAClWz6FEi3bToz-soY^{ScrjwVPV51=>n->c(NJngMj6TyHty`bfkF1hc zkJS%A@cL~QV0-aK4>Id!9dh7>0IV;1J9(myDO+gv76L3NLMUm9XyPauvNu$S<)-|F zZS}(kK_WnB)Cl`U?jsdYfAV4nrgzIF@+%1U8$poW&h^c6>kCx3;||fS1_7JvQT~CV zQ8Js+!p)3oW>Df(-}uqC`Tcd%E7GdJ0p}kYj5j8NKMp(KUs9u7?jQ94C)}0rba($~ zqyBx$(1ae^HEDG`Zc@-rXk1cqc7v0wibOR4qpgRDt#>-*8N3P;uKV0CgJE2SP>#8h z=+;i_CGlv+B^+$5a}SicVaSeaNn29K`C&=}`=#Nj&WJP9Xhz4mVa<+yP6hkrq1vo= z1rX4qg8dc4pmEvq%NAkpMK>mf2g?tg_1k2%v}<3`$6~Wlq@ItJ*PhHPoEh1Yi>v57 z4k0JMO)*=S`tKvR5gb-(VTEo>5Y>DZJZzgR+j6{Y`kd|jCVrg!>2hVjz({kZR z`dLlKhoqT!aI8=S+fVp(5*Dn6RrbpyO~0+?fy;bm$0jmTN|t5i6rxqr4=O}dY+ROd zo9Et|x}!u*xi~>-y>!M^+f&jc;IAsGiM_^}+4|pHRn{LThFFpD{bZ|TA*wcGm}XV^ zr*C6~@^5X-*R%FrHIgo-hJTBcyQ|3QEj+cSqp#>&t`ZzB?cXM6S(lRQw$I2?m5=wd z78ki`R?%;o%VUhXH?Z#(uwAn9$m`npJ=cA+lHGk@T7qq_M6Zoy1Lm9E0UUysN)I_x zW__OAqvku^>`J&CB=ie@yNWsaFmem}#L3T(x?a`oZ+$;3O-icj2(5z72Hnj=9Z0w% z<2#q-R=>hig*(t0^v)eGq2DHC%GymE-_j1WwBVGoU=GORGjtaqr0BNigOCqyt;O(S zKG+DoBsZU~okF<7ahjS}bzwXxbAxFfQAk&O@>LsZMsZ`?N?|CDWM(vOm%B3CBPC3o z%2t@%H$fwur}SSnckUm0-k)mOtht`?nwsDz=2#v=RBPGg39i#%odKq{K^;bTD!6A9 zskz$}t)sU^=a#jLZP@I=bPo?f-L}wpMs{Tc!m7-bi!Ldqj3EA~V;4(dltJmTXqH0r z%HAWKGutEc9vOo3P6Q;JdC^YTnby->VZ6&X8f{obffZ??1(cm&L2h7q)*w**+sE6dG*;(H|_Q!WxU{g)CeoT z(KY&bv!Usc|m+Fqfmk;h&RNF|LWuNZ!+DdX*L=s-=_iH=@i` z?Z+Okq^cFO4}_n|G*!)Wl_i%qiMBaH8(WuXtgI7EO=M>=i_+;MDjf3aY~6S9w0K zUuDO7O5Ta6+k40~xh~)D{=L&?Y0?c$s9cw*Ufe18)zzk%#ZY>Tr^|e%8KPb0ht`b( zuP@8#Ox@nQIqz9}AbW0RzE`Cf>39bOWz5N3qzS}ocxI=o$W|(nD~@EhW13Rj5nAp; zu2obEJa=kGC*#3=MkdkWy_%RKcN=?g$7!AZ8vBYKr$ePY(8aIQ&yRPlQ=mudv#q$q z4%WzAx=B{i)UdLFx4os?rZp6poShD7Vc&mSD@RdBJ=_m^&OlkEE1DFU@csgKcBifJ zz4N7+XEJhYzzO=86 z#%eBQZ$Nsf2+X0XPHUNmg#(sNt^NW1Y0|M(${e<0kW6f2q5M!2YE|hSEQ*X-%qo(V zHaFwyGZ0on=I{=fhe<=zo{=Og-_(to3?cvL4m6PymtNsdDINsBh8m>a%!5o3s(en) z=1I z6O+YNertC|OFNqd6P=$gMyvmfa`w~p9*gKDESFqNBy(~Zw3TFDYh}$iudn)9HxPBi zdokK@o~nu?%imcURr5Y~?6oo_JBe}t|pU5qjai|#JDyG=i^V~7+a{dEnO<(y>ahND#_X_fcEBNiZ)uc&%1HVtx8Ts z*H_Btvx^IhkfOB#{szN*n6;y05A>3eARDXslaE>tnLa>+`V&cgho?ED+&vv5KJszf zG4@G;7i;4_bVvZ>!mli3j7~tPgybF5|J6=Lt`u$D%X0l}#iY9nOXH@(%FFJLtzb%p zzHfABnSs;v-9(&nzbZytLiqqDIWzn>JQDk#JULcE5CyPq_m#4QV!}3421haQ+LcfO*>r;rg6K|r#5Sh|y@h1ao%Cl)t*u`4 zMTP!deC?aL7uTxm5^nUv#q2vS-5QbBKP|drbDXS%erB>fYM84Kpk^au99-BQBZR z7CDynflrIAi&ahza+kUryju5LR_}-Z27g)jqOc(!Lx9y)e z{cYc&_r947s9pteaa4}dc|!$$N9+M38sUr7h(%@Ehq`4HJtTpA>B8CLNO__@%(F5d z`SmX5jbux6i#qc}xOhumzbAELh*Mfr2SW99=WNOZRZgoCU4A2|4i|ZVFQt6qEhH#B zK_9G;&h*LO6tB`5dXRSBF0hq0tk{2q__aCKXYkP#9n^)@cq}`&Lo)1KM{W+>5mSed zKp~=}$p7>~nK@va`vN{mYzWN1(tE=u2BZhga5(VtPKk(*TvE&zmn5vSbjo zZLVobTl%;t@6;4SsZ>5+U-XEGUZGG;+~|V(pE&qqrp_f~{_1h@5ZrNETqe{bt9ioZ z#Qn~gWCH!t#Ha^n&fT2?{`}D@s4?9kXj;E;lWV9Zw8_4yM0Qg-6YSsKgvQ*fF{#Pq z{=(nyV>#*`RloBVCs;Lp*R1PBIQOY=EK4CQa*BD0MsYcg=opP?8;xYQDSAJBeJpw5 zPBc_Ft9?;<0?pBhCmOtWU*pN*;CkjJ_}qVic`}V@$TwFi15!mF1*m2wVX+>5p%(+R zQ~JUW*zWkalde{90@2v+oVlkxOZFihE&ZJ){c?hX3L2@R7jk*xjYtHi=}qb+4B(XJ z$gYcNudR~4Kz_WRq8eS((>ALWCO)&R-MXE+YxDn9V#X{_H@j616<|P(8h(7z?q*r+ zmpqR#7+g$cT@e&(%_|ipI&A%9+47%30TLY(yuf&*knx1wNx|%*H^;YB%ftt%5>QM= z^i;*6_KTSRzQm%qz*>cK&EISvF^ovbS4|R%)zKhTH_2K>jP3mBGn5{95&G9^a#4|K zv+!>fIsR8z{^x4)FIr*cYT@Q4Z{y}};rLHL+atCgHbfX*;+k&37DIgENn&=k(*lKD zG;uL-KAdLn*JQ?@r6Q!0V$xXP=J2i~;_+i3|F;_En;oAMG|I-RX#FwnmU&G}w`7R{ z788CrR-g1DW4h_`&$Z`ctN~{A)Hv_-Bl!%+pfif8wN32rMD zJDs$eVWBYQx1&2sCdB0!vU5~uf)=vy*{}t{2VBpcz<+~h0wb7F3?V^44*&83Z2#F` z32!rd4>uc63rQP$3lTH3zb-47IGR}f)8kZ4JvX#toIpXH`L%NnPDE~$QI1)0)|HS4 zVcITo$$oWWwCN@E-5h>N?Hua!N9CYb6f8vTFd>h3q5Jg-lCI6y%vu{Z_Uf z$MU{{^o~;nD_@m2|E{J)q;|BK7rx%`m``+OqZAqAVj-Dy+pD4-S3xK?($>wn5bi90CFAQ+ACd;&m6DQB8_o zjAq^=eUYc1o{#+p+ zn;K<)Pn*4u742P!;H^E3^Qu%2dM{2slouc$AN_3V^M7H_KY3H)#n7qd5_p~Za7zAj|s9{l)RdbV9e||_67`#Tu*c<8!I=zb@ z(MSvQ9;Wrkq6d)!9afh+G`!f$Ip!F<4ADdc*OY-y7BZMsau%y?EN6*hW4mOF%Q~bw z2==Z3^~?q<1GTeS>xGN-?CHZ7a#M4kDL zQxQr~1ZMzCSKFK5+32C%+C1kE#(2L=15AR!er7GKbp?Xd1qkkGipx5Q~FI-6zt< z*PTpeVI)Ngnnyaz5noIIgNZtb4bQdKG{Bs~&tf)?nM$a;7>r36djllw%hQxeCXeW^ z(i6@TEIuxD<2ulwLTt|&gZP%Ei+l!(%p5Yij6U(H#HMkqM8U$@OKB|5@vUiuY^d6X zW}fP3;Kps6051OEO(|JzmVU6SX(8q>*yf*x5QoxDK={PH^F?!VCzES_Qs>()_y|jg6LJlJWp;L zKM*g5DK7>W_*uv}{0WUB0>MHZ#oJZmO!b3MjEc}VhsLD~;E-qNNd?x7Q6~v zR=0$u>Zc2Xr}>x_5$-s#l!oz6I>W?lw;m9Ae{Tf9eMX;TI-Wf_mZ6sVrMnY#F}cDd z%CV*}fDsXUF7Vbw>PuDaGhu631+3|{xp<@Kl|%WxU+vuLlcrklMC!Aq+7n~I3cmQ! z`e3cA!XUEGdEPSu``&lZEKD1IKO(-VGvcnSc153m(i!8ohi`)N2n>U_BemYJ`uY>8B*Epj!oXRLV}XK}>D*^DHQ7?NY*&LJ9VSo`Ogi9J zGa;clWI8vIQqkngv2>xKd91K>?0`Sw;E&TMg&6dcd20|FcTsnUT7Yn{oI5V4@Ow~m zz#k~8TM!A9L7T!|colrC0P2WKZW7PNj_X4MfESbt<-soq*0LzShZ}fyUx!(xIIDwx zRHt^_GAWe0-Vm~bDZ(}XG%E+`XhKpPlMBo*5q_z$BGxYef8O!ToS8aT8pmjbPq)nV z%x*PF5ZuSHRJqJ!`5<4xC*xb2vC?7u1iljB_*iUGl6+yPyjn?F?GOF2_KW&gOkJ?w z3e^qc-te;zez`H$rsUCE0<@7PKGW?7sT1SPYWId|FJ8H`uEdNu4YJjre`8F*D}6Wh z|FQ`xf7yiphHIAkU&OYCn}w^ilY@o4larl?^M7&8YI;hzBIsX|i3UrLsx{QDKwCX< zy;a>yjfJ6!sz`NcVi+a!Fqk^VE^{6G53L?@Tif|j!3QZ0fk9QeUq8CWI;OmO-Hs+F zuZ4sHLA3{}LR2Qlyo+{d@?;`tpp6YB^BMoJt?&MHFY!JQwoa0nTSD+#Ku^4b{5SZVFwU9<~APYbaLO zu~Z)nS#dxI-5lmS-Bnw!(u15by(80LlC@|ynj{TzW)XcspC*}z0~8VRZq>#Z49G`I zgl|C#H&=}n-ajxfo{=pxPV(L*7g}gHET9b*s=cGV7VFa<;Htgjk>KyW@S!|z`lR1( zGSYkEl&@-bZ*d2WQ~hw3NpP=YNHF^XC{TMG$Gn+{b6pZn+5=<()>C!N^jncl0w6BJ zdHdnmSEGK5BlMeZD!v4t5m7ct7{k~$1Ie3GLFoHjAH*b?++s<|=yTF+^I&jT#zuMx z)MLhU+;LFk8bse|_{j+d*a=&cm2}M?*arjBPnfPgLwv)86D$6L zLJ0wPul7IenMvVAK$z^q5<^!)7aI|<&GGEbOr=E;UmGOIa}yO~EIr5xWU_(ol$&fa zR5E(2vB?S3EvJglTXdU#@qfDbCYs#82Yo^aZN6`{Ex#M)easBTe_J8utXu(fY1j|R z9o(sQbj$bKU{IjyhosYahY{63>}$9_+hWxB3j}VQkJ@2$D@vpeRSldU?&7I;qd2MF zSYmJ>zA(@N_iK}m*AMPIJG#Y&1KR)6`LJ83qg~`Do3v^B0>fU&wUx(qefuTgzFED{sJ65!iw{F2}1fQ3= ziFIP{kezQxmlx-!yo+sC4PEtG#K=5VM9YIN0z9~c4XTX?*4e@m;hFM!zVo>A`#566 z>f&3g94lJ{r)QJ5m7Xe3SLau_lOpL;A($wsjHR`;xTXgIiZ#o&vt~ zGR6KdU$FFbLfZCC3AEu$b`tj!9XgOGLSV=QPIYW zjI!hSP#?8pn0@ezuenOzoka8!8~jXTbiJ6+ZuItsWW03uzASFyn*zV2kIgPFR$Yzm zE<$cZlF>R8?Nr2_i?KiripBc+TGgJvG@vRTY2o?(_Di}D30!k&CT`>+7ry2!!iC*X z<@=U0_C#16=PN7bB39w+zPwDOHX}h20Ap);dx}kjXX0-QkRk=cr};GYsjSvyLZa-t zzHONWddi*)RDUH@RTAsGB_#&O+QJaaL+H<<9LLSE+nB@eGF1fALwjVOl8X_sdOYme z0lk!X=S(@25=TZHR7LlPp}fY~yNeThMIjD}pd9+q=j<_inh0$>mIzWVY+Z9p<{D^#0Xk+b_@eNSiR8;KzSZ#7lUsk~NGMcB8C2c=m2l5paHPq`q{S(kdA7Z1a zyfk2Y;w?^t`?@yC5Pz9&pzo}Hc#}mLgDmhKV|PJ3lKOY(Km@Fi2AV~CuET*YfUi}u zfInZnqDX(<#vaS<^fszuR=l)AbqG{}9{rnyx?PbZz3Pyu!eSJK`uwkJU!ORQXy4x83r!PNgOyD33}}L=>xX_93l6njNTuqL8J{l%*3FVn3MG4&Fv*`lBXZ z?=;kn6HTT^#SrPX-N)4EZiIZI!0ByXTWy;;J-Tht{jq1mjh`DSy7yGjHxIaY%*sTx zuy9#9CqE#qi>1misx=KRWm=qx4rk|}vd+LMY3M`ow8)}m$3Ggv&)Ri*ON+}<^P%T5 z_7JPVPfdM=Pv-oH<tecoE}(0O7|YZc*d8`Uv_M*3Rzv7$yZnJE6N_W=AQ3_BgU_TjA_T?a)U1csCmJ&YqMp-lJe`y6>N zt++Bi;ZMOD%%1c&-Q;bKsYg!SmS^#J@8UFY|G3!rtyaTFb!5@e(@l?1t(87ln8rG? z--$1)YC~vWnXiW3GXm`FNSyzu!m$qT=Eldf$sMl#PEfGmzQs^oUd=GIQfj(X=}dw+ zT*oa0*oS%@cLgvB&PKIQ=Ok?>x#c#dC#sQifgMwtAG^l3D9nIg(Zqi;D%807TtUUCL3_;kjyte#cAg?S%e4S2W>9^A(uy8Ss0Tc++ZTjJw1 z&Em2g!3lo@LlDyri(P^I8BPpn$RE7n*q9Q-c^>rfOMM6Pd5671I=ZBjAvpj8oIi$! zl0exNl(>NIiQpX~FRS9UgK|0l#s@#)p4?^?XAz}Gjb1?4Qe4?j&cL$C8u}n)?A@YC zfmbSM`Hl5pQFwv$CQBF=_$Sq zxsV?BHI5bGZTk?B6B&KLdIN-40S426X3j_|ceLla*M3}3gx3(_7MVY1++4mzhH#7# zD>2gTHy*%i$~}mqc#gK83288SKp@y3wz1L_e8fF$Rb}ex+`(h)j}%~Ld^3DUZkgez zOUNy^%>>HHE|-y$V@B}-M|_{h!vXpk01xaD%{l{oQ|~+^>rR*rv9iQen5t?{BHg|% zR`;S|KtUb!X<22RTBA4AAUM6#M?=w5VY-hEV)b`!y1^mPNEoy2K)a>OyA?Q~Q*&(O zRzQI~y_W=IPi?-OJX*&&8dvY0zWM2%yXdFI!D-n@6FsG)pEYdJbuA`g4yy;qrgR?G z8Mj7gv1oiWq)+_$GqqQ$(ZM@#|0j7})=#$S&hZwdoijFI4aCFLVI3tMH5fLreZ;KD zqA`)0l~D2tuIBYOy+LGw&hJ5OyE+@cnZ0L5+;yo2pIMdt@4$r^5Y!x7nHs{@>|W(MzJjATyWGNwZ^4j+EPU0RpAl-oTM@u{lx*i0^yyWPfHt6QwPvYpk9xFMWfBFt!+Gu6TlAmr zeQ#PX71vzN*_-xh&__N`IXv6`>CgV#eA_%e@7wjgkj8jlKzO~Ic6g$cT`^W{R{606 zCDP~+NVZ6DMO$jhL~#+!g*$T!XW63#(ngDn#Qwy71yj^gazS{e;3jGRM0HedGD@pt z?(ln3pCUA(ekqAvvnKy0G@?-|-dh=eS%4Civ&c}s%wF@0K5Bltaq^2Os1n6Z3%?-Q zAlC4goQ&vK6TpgtzkHVt*1!tBYt-`|5HLV1V7*#45Vb+GACuU+QB&hZ=N_flPy0TY zR^HIrdskB#<$aU;HY(K{a3(OQa$0<9qH(oa)lg@Uf>M5g2W0U5 zk!JSlhrw8quBx9A>RJ6}=;W&wt@2E$7J=9SVHsdC?K(L(KACb#z)@C$xXD8^!7|uv zZh$6fkq)aoD}^79VqdJ!Nz-8$IrU(_-&^cHBI;4 z^$B+1aPe|LG)C55LjP;jab{dTf$0~xbXS9!!QdcmDYLbL^jvxu2y*qnx2%jbL%rB z{aP85qBJe#(&O~Prk%IJARcdEypZ)vah%ZZ%;Zk{eW(U)Bx7VlzgOi8)x z`rh4l`@l_Ada7z&yUK>ZF;i6YLGwI*Sg#Fk#Qr0Jg&VLax(nNN$u-XJ5=MsP3|(lEdIOJ7|(x3iY;ea)5#BW*mDV%^=8qOeYO&gIdJVuLLN3cFaN=xZtFB=b zH{l)PZl_j^u+qx@89}gAQW7ofb+k)QwX=aegihossZq*+@PlCpb$rpp>Cbk9UJO<~ zDjlXQ_Ig#W0zdD3&*ei(FwlN#3b%FSR%&M^ywF@Fr>d~do@-kIS$e%wkIVfJ|Ohh=zc zF&Rnic^|>@R%v?@jO}a9;nY3Qrg_!xC=ZWUcYiA5R+|2nsM*$+c$TOs6pm!}Z}dfM zGeBhMGWw3$6KZXav^>YNA=r6Es>p<6HRYcZY)z{>yasbC81A*G-le8~QoV;rtKnkx z;+os8BvEe?0A6W*a#dOudsv3aWs?d% z0oNngyVMjavLjtjiG`!007#?62ClTqqU$@kIY`=x^$2e>iqIy1>o|@Tw@)P)B8_1$r#6>DB_5 zmaOaoE~^9TolgDgooKFuEFB#klSF%9-~d2~_|kQ0Y{Ek=HH5yq9s zDq#1S551c`kSiWPZbweN^A4kWiP#Qg6er1}HcKv{fxb1*BULboD0fwfaNM_<55>qM zETZ8TJDO4V)=aPp_eQjX%||Ud<>wkIzvDlpNjqW>I}W!-j7M^TNe5JIFh#-}zAV!$ICOju8Kx)N z0vLtzDdy*rQN!7r>Xz7rLw8J-(GzQlYYVH$WK#F`i_i^qVlzTNAh>gBWKV@XC$T-` z3|kj#iCquDhiO7NKum07i|<-NuVsX}Q}mIP$jBJDMfUiaWR3c|F_kWBMw0_Sr|6h4 zk`_r5=0&rCR^*tOy$A8K;@|NqwncjZ>Y-75vlpxq%Cl3EgH`}^^~=u zoll6xxY@a>0f%Ddpi;=cY}fyG!K2N-dEyXXmUP5u){4VnyS^T4?pjN@Ot4zjL(Puw z_U#wMH2Z#8Pts{olG5Dy0tZj;N@;fHheu>YKYQU=4Bk|wcD9MbA`3O4bj$hNRHwzb zSLcG0SLV%zywdbuwl(^E_!@&)TdXge4O{MRWk2RKOt@!8E{$BU-AH(@4{gxs=YAz9LIob|Hzto0}9cWoz6Tp2x0&xi#$ zHh$dwO&UCR1Ob2w00-2eG7d4=cN(Y>0R#$q8?||q@iTi+7-w-xR%uMr&StFIthC<# zvK(aPduwuNB}oJUV8+Zl)%cnfsHI%4`;x6XW^UF^e4s3Z@S<&EV8?56Wya;HNs0E> z`$0dgRdiUz9RO9Au3RmYq>K#G=X%*_dUbSJHP`lSfBaN8t-~@F>)BL1RT*9I851A3 z<-+Gb#_QRX>~av#Ni<#zLswtu-c6{jGHR>wflhKLzC4P@b%8&~u)fosoNjk4r#GvC zlU#UU9&0Hv;d%g72Wq?Ym<&&vtA3AB##L}=ZjiTR4hh7J)e>ei} zt*u+>h%MwN`%3}b4wYpV=QwbY!jwfIj#{me)TDOG`?tI!%l=AwL2G@9I~}?_dA5g6 zCKgK(;6Q0&P&K21Tx~k=o6jwV{dI_G+Ba*Zts|Tl6q1zeC?iYJTb{hel*x>^wb|2RkHkU$!+S4OU4ZOKPZjV>9OVsqNnv5jK8TRAE$A&^yRwK zj-MJ3Pl?)KA~fq#*K~W0l4$0=8GRx^9+?w z!QT8*-)w|S^B0)ZeY5gZPI2G(QtQf?DjuK(s^$rMA!C%P22vynZY4SuOE=wX2f8$R z)A}mzJi4WJnZ`!bHG1=$lwaxm!GOnRbR15F$nRC-M*H<*VfF|pQw(;tbSfp({>9^5 zw_M1-SJ9eGF~m(0dvp*P8uaA0Yw+EkP-SWqu zqal$hK8SmM7#Mrs0@OD+%_J%H*bMyZiWAZdsIBj#lkZ!l2c&IpLu(5^T0Ge5PHzR} zn;TXs$+IQ_&;O~u=Jz+XE0wbOy`=6>m9JVG} zJ~Kp1e5m?K3x@@>!D)piw^eMIHjD4RebtR`|IlckplP1;r21wTi8v((KqNqn%2CB< zifaQc&T}*M&0i|LW^LgdjIaX|o~I$`owHolRqeH_CFrqCUCleN130&vH}dK|^kC>) z-r2P~mApHotL4dRX$25lIcRh_*kJaxi^%ZN5-GAAMOxfB!6flLPY-p&QzL9TE%ho( zRwftE3sy5<*^)qYzKkL|rE>n@hyr;xPqncY6QJ8125!MWr`UCWuC~A#G1AqF1@V$kv>@NBvN&2ygy*{QvxolkRRb%Ui zsmKROR%{*g*WjUUod@@cS^4eF^}yQ1>;WlGwOli z+Y$(8I`0(^d|w>{eaf!_BBM;NpCoeem2>J}82*!em=}}ymoXk>QEfJ>G(3LNA2-46 z5PGvjr)Xh9>aSe>vEzM*>xp{tJyZox1ZRl}QjcvX2TEgNc^(_-hir@Es>NySoa1g^ zFow_twnHdx(j?Q_3q51t3XI7YlJ4_q&(0#)&a+RUy{IcBq?)eaWo*=H2UUVIqtp&lW9JTJiP&u zw8+4vo~_IJXZIJb_U^&=GI1nSD%e;P!c{kZALNCm5c%%oF+I3DrA63_@4)(v4(t~JiddILp7jmoy+>cD~ivwoctFfEL zP*#2Rx?_&bCpX26MBgp^4G>@h`Hxc(lnqyj!*t>9sOBcXN(hTwEDpn^X{x!!gPX?1 z*uM$}cYRwHXuf+gYTB}gDTcw{TXSOUU$S?8BeP&sc!Lc{{pEv}x#ELX>6*ipI1#>8 zKes$bHjiJ1OygZge_ak^Hz#k;=od1wZ=o71ba7oClBMq>Uk6hVq|ePPt)@FM5bW$I z;d2Or@wBjbTyZj|;+iHp%Bo!Vy(X3YM-}lasMItEV_QrP-Kk_J4C>)L&I3Xxj=E?| zsAF(IfVQ4w+dRRnJ>)}o^3_012YYgFWE)5TT=l2657*L8_u1KC>Y-R{7w^S&A^X^U}h20jpS zQsdeaA#WIE*<8KG*oXc~$izYilTc#z{5xhpXmdT-YUnGh9v4c#lrHG6X82F2-t35} zB`jo$HjKe~E*W$=g|j&P>70_cI`GnOQ;Jp*JK#CT zuEGCn{8A@bC)~0%wsEv?O^hSZF*iqjO~_h|>xv>PO+?525Nw2472(yqS>(#R)D7O( zg)Zrj9n9$}=~b00=Wjf?E418qP-@8%MQ%PBiCTX=$B)e5cHFDu$LnOeJ~NC;xmOk# z>z&TbsK>Qzk)!88lNI8fOE2$Uxso^j*1fz>6Ot49y@=po)j4hbTIcVR`ePHpuJSfp zxaD^Dn3X}Na3@<_Pc>a;-|^Pon(>|ytG_+U^8j_JxP=_d>L$Hj?|0lz>_qQ#a|$+( z(x=Lipuc8p4^}1EQhI|TubffZvB~lu$zz9ao%T?%ZLyV5S9}cLeT?c} z>yCN9<04NRi~1oR)CiBakoNhY9BPnv)kw%*iv8vdr&&VgLGIs(-FbJ?d_gfbL2={- zBk4lkdPk~7+jIxd4{M(-W1AC_WcN&Oza@jZoj zaE*9Y;g83#m(OhA!w~LNfUJNUuRz*H-=$s*z+q+;snKPRm9EptejugC-@7-a-}Tz0 z@KHra#Y@OXK+KsaSN9WiGf?&jlZ!V7L||%KHP;SLksMFfjkeIMf<1e~t?!G3{n)H8 zQAlFY#QwfKuj;l@<$YDATAk;%PtD%B(0<|8>rXU< zJ66rkAVW_~Dj!7JGdGGi4NFuE?7ZafdMxIh65Sz7yQoA7fBZCE@WwysB=+`kT^LFX zz8#FlSA5)6FG9(qL3~A24mpzL@@2D#>0J7mMS1T*9UJ zvOq!!a(%IYY69+h45CE?(&v9H4FCr>gK0>mK~F}5RdOuH2{4|}k@5XpsX7+LZo^Qa4sH5`eUj>iffoBVm+ zz4Mtf`h?NW$*q1yr|}E&eNl)J``SZvTf6Qr*&S%tVv_OBpbjnA0&Vz#(;QmGiq-k! zgS0br4I&+^2mgA15*~Cd00cXLYOLA#Ep}_)eED>m+K@JTPr_|lSN}(OzFXQSBc6fM z@f-%2;1@BzhZa*LFV z-LrLmkmB%<<&jEURBEW>soaZ*rSIJNwaV%-RSaCZi4X)qYy^PxZ=oL?6N-5OGOMD2 z;q_JK?zkwQ@b3~ln&sDtT5SpW9a0q+5Gm|fpVY2|zqlNYBR}E5+ahgdj!CvK$Tlk0 z9g$5N;aar=CqMsudQV>yb4l@hN(9Jcc=1(|OHsqH6|g=K-WBd8GxZ`AkT?OO z-z_Ued-??Z*R4~L7jwJ%-`s~FK|qNAJ;EmIVDVpk{Lr7T4l{}vL)|GuUuswe9c5F| zv*5%u01hlv08?00Vpwyk*Q&&fY8k6MjOfpZfKa@F-^6d=Zv|0@&4_544RP5(s|4VPVP-f>%u(J@23BHqo2=zJ#v9g=F!cP((h zpt0|(s++ej?|$;2PE%+kc6JMmJjDW)3BXvBK!h!E`8Y&*7hS{c_Z?4SFP&Y<3evqf z9-ke+bSj$%Pk{CJlJbWwlBg^mEC^@%Ou?o>*|O)rl&`KIbHrjcpqsc$Zqt0^^F-gU2O=BusO+(Op}!jNzLMc zT;0YT%$@ClS%V+6lMTfhuzzxomoat=1H?1$5Ei7&M|gxo`~{UiV5w64Np6xV zVK^nL$)#^tjhCpTQMspXI({TW^U5h&Wi1Jl8g?P1YCV4=%ZYyjSo#5$SX&`r&1PyC zzc;uzCd)VTIih|8eNqFNeBMe#j_FS6rq81b>5?aXg+E#&$m++Gz9<+2)h=K(xtn}F ziV{rmu+Y>A)qvF}ms}4X^Isy!M&1%$E!rTO~5(p+8{U6#hWu>(Ll1}eD64Xa>~73A*538wry?v$vW z>^O#FRdbj(k0Nr&)U`Tl(4PI*%IV~;ZcI2z&rmq=(k^}zGOYZF3b2~Klpzd2eZJl> zB=MOLwI1{$RxQ7Y4e30&yOx?BvAvDkTBvWPpl4V8B7o>4SJn*+h1Ms&fHso%XLN5j z-zEwT%dTefp~)J_C8;Q6i$t!dnlh-!%haR1X_NuYUuP-)`IGWjwzAvp!9@h`kPZhf zwLwFk{m3arCdx8rD~K2`42mIN4}m%OQ|f)4kf%pL?Af5Ul<3M2fv>;nlhEPR8b)u} zIV*2-wyyD%%) zl$G@KrC#cUwoL?YdQyf9WH)@gWB{jd5w4evI& zOFF)p_D8>;3-N1z6mES!OPe>B^<;9xsh)){Cw$Vs-ez5nXS95NOr3s$IU;>VZSzKn zBvub8_J~I%(DozZW@{)Vp37-zevxMRZ8$8iRfwHmYvyjOxIOAF2FUngKj289!(uxY zaClWm!%x&teKmr^ABrvZ(ikx{{I-lEzw5&4t3P0eX%M~>$wG0ZjA4Mb&op+0$#SO_ z--R`>X!aqFu^F|a!{Up-iF(K+alKB{MNMs>e(i@Tpy+7Z-dK%IEjQFO(G+2mOb@BO zP>WHlS#fSQm0et)bG8^ZDScGnh-qRKIFz zfUdnk=m){ej0i(VBd@RLtRq3Ep=>&2zZ2%&vvf?Iex01hx1X!8U+?>ER;yJlR-2q4 z;Y@hzhEC=d+Le%=esE>OQ!Q|E%6yG3V_2*uh&_nguPcZ{q?DNq8h_2ahaP6=pP-+x zK!(ve(yfoYC+n(_+chiJ6N(ZaN+XSZ{|H{TR1J_s8x4jpis-Z-rlRvRK#U%SMJ(`C z?T2 zF(NNfO_&W%2roEC2j#v*(nRgl1X)V-USp-H|CwFNs?n@&vpRcj@W@xCJwR6@T!jt377?XjZ06=`d*MFyTdyvW!`mQm~t3luzYzvh^F zM|V}rO>IlBjZc}9Z zd$&!tthvr>5)m;5;96LWiAV0?t)7suqdh0cZis`^Pyg@?t>Ms~7{nCU;z`Xl+raSr zXpp=W1oHB*98s!Tpw=R5C)O{{Inl>9l7M*kq%#w9a$6N~v?BY2GKOVRkXYCgg*d

<5G2M1WZP5 zzqSuO91lJod(SBDDw<*sX(+F6Uq~YAeYV#2A;XQu_p=N5X+#cmu19Qk>QAnV=k!?wbk5I;tDWgFc}0NkvC*G=V+Yh1cyeJVq~9czZiDXe+S=VfL2g`LWo8om z$Y~FQc6MFjV-t1Y`^D9XMwY*U_re2R?&(O~68T&D4S{X`6JYU-pz=}ew-)V0AOUT1 zVOkHAB-8uBcRjLvz<9HS#a@X*Kc@|W)nyiSgi|u5$Md|P()%2(?olGg@ypoJwp6>m z*dnfjjWC>?_1p;%1brqZyDRR;8EntVA92EJ3ByOxj6a+bhPl z;a?m4rQAV1@QU^#M1HX)0+}A<7TCO`ZR_RzF}X9-M>cRLyN4C+lCk2)kT^3gN^`IT zNP~fAm(wyIoR+l^lQDA(e1Yv}&$I!n?&*p6?lZcQ+vGLLd~fM)qt}wsbf3r=tmVYe zl)ntf#E!P7wlakP9MXS7m0nsAmqxZ*)#j;M&0De`oNmFgi$ov#!`6^4)iQyxg5Iuj zjLAhzQ)r`^hf7`*1`Rh`X;LVBtDSz@0T?kkT1o!ijeyTGt5vc^Cd*tmNgiNo^EaWvaC8$e+nb_{W01j3%=1Y&92YacjCi>eNbwk%-gPQ@H-+4xskQ}f_c=jg^S-# zYFBDf)2?@5cy@^@FHK5$YdAK9cI;!?Jgd}25lOW%xbCJ>By3=HiK@1EM+I46A)Lsd zeT|ZH;KlCml=@;5+hfYf>QNOr^XNH%J-lvev)$Omy8MZ`!{`j>(J5cG&ZXXgv)TaF zg;cz99i$4CX_@3MIb?GL0s*8J=3`#P(jXF(_(6DXZjc@(@h&=M&JG)9&Te1?(^XMW zjjC_70|b=9hB6pKQi`S^Ls7JyJw^@P>Ko^&q8F&?>6i;#CbxUiLz1ZH4lNyd@QACd zu>{!sqjB!2Dg}pbAXD>d!3jW}=5aN0b;rw*W>*PAxm7D)aw(c*RX2@bTGEI|RRp}vw7;NR2wa;rXN{L{Q#=Fa z$x@ms6pqb>!8AuV(prv>|aU8oWV={C&$c zMa=p=CDNOC2tISZcd8~18GN5oTbKY+Vrq;3_obJlfSKRMk;Hdp1`y`&LNSOqeauR_ z^j*Ojl3Ohzb5-a49A8s|UnM*NM8tg}BJXdci5%h&;$afbmRpN0&~9rCnBA`#lG!p zc{(9Y?A0Y9yo?wSYn>iigf~KP$0*@bGZ>*YM4&D;@{<%Gg5^uUJGRrV4 z(aZOGB&{_0f*O=Oi0k{@8vN^BU>s3jJRS&CJOl3o|BE{FAA&a#2YYiX3pZz@|Go-F z|Fly;7eX2OTs>R}<`4RwpHFs9nwh)B28*o5qK1Ge=_^w0m`uJOv!=&!tzt#Save(C zgKU=Bsgql|`ui(e1KVxR`?>Dx>(rD1$iWp&m`v)3A!j5(6vBm*z|aKm*T*)mo(W;R zNGo2`KM!^SS7+*9YxTm6YMm_oSrLceqN*nDOAtagULuZl5Q<7mOnB@Hq&P|#9y{5B z!2x+2s<%Cv2Aa0+u{bjZXS);#IFPk(Ph-K7K?3i|4ro> zRbqJoiOEYo(Im^((r}U4b8nvo_>4<`)ut`24?ILnglT;Pd&U}$lV3U$F9#PD(O=yV zgNNA=GW|(E=&m_1;uaNmipQe?pon4{T=zK!N!2_CJL0E*R^XXIKf*wi!>@l}3_P9Z zF~JyMbW!+n-+>!u=A1ESxzkJy$DRuG+$oioG7(@Et|xVbJ#BCt;J43Nvj@MKvTxzy zMmjNuc#LXBxFAwIGZJk~^!q$*`FME}yKE8d1f5Mp}KHNq(@=Z8YxV}0@;YS~|SpGg$_jG7>_8WWYcVx#4SxpzlV9N4aO>K{c z$P?a_fyDzGX$Of3@ykvedGd<@-R;M^Shlj*SswJLD+j@hi_&_>6WZ}#AYLR0iWMK|A zH_NBeu(tMyG=6VO-=Pb>-Q#$F*or}KmEGg*-n?vWQREURdB#+6AvOj*I%!R-4E_2$ zU5n9m>RWs|Wr;h2DaO&mFBdDb-Z{APGQx$(L`if?C|njd*fC=rTS%{o69U|meRvu?N;Z|Y zbT|ojL>j;q*?xXmnHH#3R4O-59NV1j=uapkK7}6@Wo*^Nd#(;$iuGsb;H315xh3pl zHaJ>h-_$hdNl{+|Zb%DZH%ES;*P*v0#}g|vrKm9;j-9e1M4qX@zkl&5OiwnCz=tb6 zz<6HXD+rGIVpGtkb{Q^LIgExOm zz?I|oO9)!BOLW#krLmWvX5(k!h{i>ots*EhpvAE;06K|u_c~y{#b|UxQ*O@Ks=bca z^_F0a@61j3I(Ziv{xLb8AXQj3;R{f_l6a#H5ukg5rxwF9A$?Qp-Mo54`N-SKc}fWp z0T)-L@V$$&my;l#Ha{O@!fK4-FSA)L&3<${Hcwa7ue`=f&YsXY(NgeDU#sRlT3+9J z6;(^(sjSK@3?oMo$%L-nqy*E;3pb0nZLx6 z;h5)T$y8GXK1DS-F@bGun8|J(v-9o=42&nLJy#}M5D0T^5VWBNn$RpC zZzG6Bt66VY4_?W=PX$DMpKAI!d`INr) zkMB{XPQ<52rvWVQqgI0OL_NWxoe`xxw&X8yVftdODPj5|t}S6*VMqN$-h9)1MBe0N zYq?g0+e8fJCoAksr0af1)FYtz?Me!Cxn`gUx&|T;)695GG6HF7!Kg1zzRf_{VWv^bo81v4$?F6u2g|wxHc6eJQAg&V z#%0DnWm2Rmu71rPJ8#xFUNFC*V{+N_qqFH@gYRLZ6C?GAcVRi>^n3zQxORPG)$-B~ z%_oB?-%Zf7d*Fe;cf%tQwcGv2S?rD$Z&>QC2X^vwYjnr5pa5u#38cHCt4G3|efuci z@3z=#A13`+ztmp;%zjXwPY_aq-;isu*hecWWX_=Z8paSqq7;XYnUjK*T>c4~PR4W7 z#C*%_H&tfGx`Y$w7`dXvVhmovDnT>btmy~SLf>>~84jkoQ%cv=MMb+a{JV&t0+1`I z32g_Y@yDhKe|K^PevP~MiiVl{Ou7^Mt9{lOnXEQ`xY^6L8D$705GON{!1?1&YJEl#fTf5Z)da=yiEQ zGgtC-soFGOEBEB~ZF_{7b(76En>d}mI~XIwNw{e>=Fv)sgcw@qOsykWr?+qAOZSVrQfg}TNI ztKNG)1SRrAt6#Q?(me%)>&A_^DM`pL>J{2xu>xa$3d@90xR61TQDl@fu%_85DuUUA za9tn64?At;{`BAW6oykwntxHeDpXsV#{tmt5RqdN7LtcF4vR~_kZNT|wqyR#z^Xcd zFdymVRZvyLfTpBT>w9<)Ozv@;Yk@dOSVWbbtm^y@@C>?flP^EgQPAwsy75bveo=}T zFxl(f)s)j(0#N_>Or(xEuV(n$M+`#;Pc$1@OjXEJZumkaekVqgP_i}p`oTx;terTx zZpT+0dpUya2hqlf`SpXN{}>PfhajNk_J0`H|2<5E;U5Vh4F8er z;RxLSFgpGhkU>W?IwdW~NZTyOBrQ84H7_?gviIf71l`EETodG9a1!8e{jW?DpwjL? zGEM&eCzwoZt^P*8KHZ$B<%{I}>46IT%jJ3AnnB5P%D2E2Z_ z1M!vr#8r}1|KTqWA4%67ZdbMW2YJ81b(KF&SQ2L1Qn(y-=J${p?xLMx3W7*MK;LFQ z6Z`aU;;mTL4XrrE;HY*Rkh6N%?qviUGNAKiCB~!P}Z->IpO6E(gGd7I#eDuT7j|?nZ zK}I(EJ>$Kb&@338M~O+em9(L!+=0zBR;JAQesx|3?Ok90)D1aS9P?yTh6Poh8Cr4X zk3zc=f2rE7jj+aP7nUsr@~?^EGP>Q>h#NHS?F{Cn`g-gD<8F&dqOh-0sa%pfL`b+1 zUsF*4a~)KGb4te&K0}bE>z3yb8% zibb5Q%Sfiv7feb1r0tfmiMv z@^4XYwg@KZI=;`wC)`1jUA9Kv{HKe2t$WmRcR4y8)VAFjRi zaz&O7Y2tDmc5+SX(bj6yGHYk$dBkWc96u3u&F)2yEE~*i0F%t9Kg^L6MJSb&?wrXi zGSc;_rln$!^ybwYBeacEFRsVGq-&4uC{F)*Y;<0y7~USXswMo>j4?~5%Zm!m@i@-> zXzi82sa-vpU{6MFRktJy+E0j#w`f`>Lbog{zP|9~hg(r{RCa!uGe>Yl536cn$;ouH za#@8XMvS-kddc1`!1LVq;h57~zV`7IYR}pp3u!JtE6Q67 zq3H9ZUcWPm2V4IukS}MCHSdF0qg2@~ufNx9+VMjQP&exiG_u9TZAeAEj*jw($G)zL zq9%#v{wVyOAC4A~AF=dPX|M}MZV)s(qI9@aIK?Pe+~ch|>QYb+78lDF*Nxz2-vpRbtQ*F4$0fDbvNM#CCatgQ@z1+EZWrt z2dZfywXkiW=no5jus-92>gXn5rFQ-COvKyegmL=4+NPzw6o@a?wGE-1Bt;pCHe;34K%Z z-FnOb%!nH;)gX+!a3nCk?5(f1HaWZBMmmC@lc({dUah+E;NOros{?ui1zPC-Q0);w zEbJmdE$oU$AVGQPdm{?xxI_0CKNG$LbY*i?YRQ$(&;NiA#h@DCxC(U@AJ$Yt}}^xt-EC_ z4!;QlLkjvSOhdx!bR~W|Ezmuf6A#@T`2tsjkr>TvW*lFCMY>Na_v8+{Y|=MCu1P8y z89vPiH5+CKcG-5lzk0oY>~aJC_0+4rS@c@ZVKLAp`G-sJB$$)^4*A!B zmcf}lIw|VxV9NSoJ8Ag3CwN&d7`|@>&B|l9G8tXT^BDHOUPrtC70NgwN4${$k~d_4 zJ@eo6%YQnOgq$th?0{h`KnqYa$Nz@vlHw<%!C5du6<*j1nwquk=uY}B8r7f|lY+v7 zm|JU$US08ugor8E$h3wH$c&i~;guC|3-tqJy#T;v(g( zBZtPMSyv%jzf->435yM(-UfyHq_D=6;ouL4!ZoD+xI5uCM5ay2m)RPmm$I}h>()hS zO!0gzMxc`BPkUZ)WXaXam%1;)gedA7SM8~8yIy@6TPg!hR0=T>4$Zxd)j&P-pXeSF z9W`lg6@~YDhd19B9ETv(%er^Xp8Yj@AuFVR_8t*KS;6VHkEDKI#!@l!l3v6`W1`1~ zP{C@keuV4Q`Rjc08lx?zmT$e$!3esc9&$XZf4nRL(Z*@keUbk!GZi(2Bmyq*saOD? z3Q$V<*P-X1p2}aQmuMw9nSMbOzuASsxten7DKd6A@ftZ=NhJ(0IM|Jr<91uAul4JR zADqY^AOVT3a(NIxg|U;fyc#ZnSzw2cr}#a5lZ38>nP{05D)7~ad7JPhw!LqOwATXtRhK!w0X4HgS1i<%AxbFmGJx9?sEURV+S{k~g zGYF$IWSlQonq6}e;B(X(sIH|;52+(LYW}v_gBcp|x%rEAVB`5LXg_d5{Q5tMDu0_2 z|LOm$@K2?lrLNF=mr%YP|U-t)~9bqd+wHb4KuPmNK<}PK6e@aosGZK57=Zt+kcszVOSbe;`E^dN! ze7`ha3WUUU7(nS0{?@!}{0+-VO4A{7+nL~UOPW9_P(6^GL0h${SLtqG!} zKl~Ng5#@Sy?65wk9z*3SA`Dpd4b4T^@C8Fhd8O)k_4%0RZL5?#b~jmgU+0|DB%0Z) zql-cPC>A9HPjdOTpPC` zQwvF}uB5kG$Xr4XnaH#ruSjM*xG?_hT7y3G+8Ox`flzU^QIgb_>2&-f+XB6MDr-na zSi#S+c!ToK84<&m6sCiGTd^8pNdXo+$3^l3FL_E`0 z>8it5YIDxtTp2Tm(?}FX^w{fbfgh7>^8mtvN>9fWgFN_*a1P`Gz*dyOZF{OV7BC#j zQV=FQM5m>47xXgapI$WbPM5V`V<7J9tD)oz@d~MDoM`R^Y6-Na(lO~uvZlpu?;zw6 zVO1faor3dg#JEb5Q*gz4<W8tgC3nE2BG2jeIQs1)<{In&7hJ39x=;ih;CJDy)>0S1at*7n?Wr0ahYCpFjZ|@u91Zl7( zv;CSBRC65-6f+*JPf4p1UZ)k=XivKTX6_bWT~7V#rq0Xjas6hMO!HJN8GdpBKg_$B zwDHJF6;z?h<;GXFZan8W{XFNPpOj!(&I1`&kWO86p?Xz`a$`7qV7Xqev|7nn_lQuX ziGpU1MMYt&5dE2A62iX3;*0WzNB9*nSTzI%62A+N?f?;S>N@8M=|ef3gtQTIA*=yq zQAAjOqa!CkHOQo4?TsqrrsJLclXcP?dlAVv?v`}YUjo1Htt;6djP@NPFH+&p1I+f_ z)Y279{7OWomY8baT(4TAOlz1OyD{4P?(DGv3XyJTA2IXe=kqD)^h(@*E3{I~w;ws8 z)ZWv7E)pbEM zd3MOXRH3mQhks9 zv6{s;k0y5vrcjXaVfw8^>YyPo=oIqd5IGI{)+TZq5Z5O&hXAw%ZlL}^6FugH;-%vP zAaKFtt3i^ag226=f0YjzdPn6|4(C2sC5wHFX{7QF!tG1E-JFA`>eZ`}$ymcRJK?0c zN363o{&ir)QySOFY0vcu6)kX#;l??|7o{HBDVJN+17rt|w3;(C_1b>d;g9Gp=8YVl zYTtA52@!7AUEkTm@P&h#eg+F*lR zQ7iotZTcMR1frJ0*V@Hw__~CL>_~2H2cCtuzYIUD24=Cv!1j6s{QS!v=PzwQ(a0HS zBKx04KA}-Ue+%9d`?PG*hIij@54RDSQpA7|>qYVIrK_G6%6;#ZkR}NjUgmGju)2F`>|WJoljo)DJgZr4eo1k1i1+o z1D{>^RlpIY8OUaOEf5EBu%a&~c5aWnqM zxBpJq98f=%M^{4mm~5`CWl%)nFR64U{(chmST&2jp+-r z3675V<;Qi-kJud%oWnCLdaU-)xTnMM%rx%Jw6v@=J|Ir=4n-1Z23r-EVf91CGMGNz zb~wyv4V{H-hkr3j3WbGnComiqmS0vn?n?5v2`Vi>{Ip3OZUEPN7N8XeUtF)Ry6>y> zvn0BTLCiqGroFu|m2zG-;Xb6;W`UyLw)@v}H&(M}XCEVXZQoWF=Ykr5lX3XWwyNyF z#jHv)A*L~2BZ4lX?AlN3X#axMwOC)PoVy^6lCGse9bkGjb=qz%kDa6}MOmSwK`cVO zt(e*MW-x}XtU?GY5}9{MKhRhYOlLhJE5=ca+-RmO04^ z66z{40J=s=ey9OCdc(RCzy zd7Zr1%!y3}MG(D=wM_ebhXnJ@MLi7cImDkhm0y{d-Vm81j`0mbi4lF=eirlr)oW~a zCd?26&j^m4AeXEsIUXiTal)+SPM4)HX%%YWF1?(FV47BaA`h9m67S9x>hWMVHx~Hg z1meUYoLL(p@b3?x|9DgWeI|AJ`Ia84*P{Mb%H$ZRROouR4wZhOPX15=KiBMHl!^JnCt$Az`KiH^_d>cev&f zaG2>cWf$=A@&GP~DubsgYb|L~o)cn5h%2`i^!2)bzOTw2UR!>q5^r&2Vy}JaWFUQE04v>2;Z@ZPwXr?y&G(B^@&y zsd6kC=hHdKV>!NDLIj+3rgZJ|dF`%N$DNd;B)9BbiT9Ju^Wt%%u}SvfM^=|q-nxDG zuWCQG9e#~Q5cyf8@y76#kkR^}{c<_KnZ0QsZcAT|YLRo~&tU|N@BjxOuy`#>`X~Q< z?R?-Gsk$$!oo(BveQLlUrcL#eirhgBLh`qHEMg`+sR1`A=1QX7)ZLMRT+GBy?&mM8 zQG^z-!Oa&J-k7I(3_2#Q6Bg=NX<|@X&+YMIOzfEO2$6Mnh}YV!m!e^__{W@-CTprr zbdh3f=BeCD$gHwCrmwgM3LAv3!Mh$wM)~KWzp^w)Cu6roO7uUG5z*}i0_0j47}pK; ztN530`ScGatLOL06~zO)Qmuv`h!gq5l#wx(EliKe&rz-5qH(hb1*fB#B+q`9=jLp@ zOa2)>JTl7ovxMbrif`Xe9;+fqB1K#l=Dv!iT;xF zdkCvS>C5q|O;}ns3AgoE({Ua-zNT-9_5|P0iANmC6O76Sq_(AN?UeEQJ>#b54fi3k zFmh+P%b1x3^)0M;QxXLP!BZ^h|AhOde*{9A=f3|Xq*JAs^Y{eViF|=EBfS6L%k4ip zk+7M$gEKI3?bQg?H3zaE@;cyv9kv;cqK$VxQbFEsy^iM{XXW0@2|DOu$!-k zSFl}Y=jt-VaT>Cx*KQnHTyXt}f9XswFB9ibYh+k2J!ofO+nD?1iw@mwtrqI4_i?nE zhLkPp41ED62me}J<`3RN80#vjW;wt`pP?%oQ!oqy7`miL>d-35a=qotK$p{IzeSk# ze_$CFYp_zIkrPFVaW^s#U4xT1lI^A0IBe~Y<4uS%zSV=wcuLr%gQT=&5$&K*bwqx| zWzCMiz>7t^Et@9CRUm9E+@hy~sBpm9fri$sE1zgLU((1?Yg{N1Sars=DiW&~Zw=3I zi7y)&oTC?UWD2w97xQ&5vx zRXEBGeJ(I?Y}eR0_O{$~)bMJRTsNUPIfR!xU9PE7A>AMNr_wbrFK>&vVw=Y;RH zO$mlpmMsQ}-FQ2cSj7s7GpC+~^Q~dC?y>M}%!-3kq(F3hGWo9B-Gn02AwUgJ>Z-pKOaj zysJBQx{1>Va=*e@sLb2z&RmQ7ira;aBijM-xQ&cpR>X3wP^foXM~u1>sv9xOjzZpX z0K;EGouSYD~oQ&lAafj3~EaXfFShC+>VsRlEMa9cg9i zFxhCKO}K0ax6g4@DEA?dg{mo>s+~RPI^ybb^u--^nTF>**0l5R9pocwB?_K)BG_)S zyLb&k%XZhBVr7U$wlhMqwL)_r&&n%*N$}~qijbkfM|dIWP{MyLx}X&}ES?}7i;9bW zmTVK@zR)7kE2+L42Q`n4m0VVg5l5(W`SC9HsfrLZ=v%lpef=Gj)W59VTLe+Z$8T8i z4V%5+T0t8LnM&H>Rsm5C%qpWBFqgTwL{=_4mE{S3EnBXknM&u8n}A^IIM4$s3m(Rd z>zq=CP-!9p9es2C*)_hoL@tDYABn+o#*l;6@7;knWIyDrt5EuakO99S$}n((Fj4y} zD!VvuRzghcE{!s;jC*<_H$y6!6QpePo2A3ZbX*ZzRnQq*b%KK^NF^z96CHaWmzU@f z#j;y?X=UP&+YS3kZx7;{ zDA{9(wfz7GF`1A6iB6fnXu0?&d|^p|6)%3$aG0Uor~8o? z*e}u#qz7Ri?8Uxp4m_u{a@%bztvz-BzewR6bh*1Xp+G=tQGpcy|4V_&*aOqu|32CM zz3r*E8o8SNea2hYJpLQ-_}R&M9^%@AMx&`1H8aDx4j%-gE+baf2+9zI*+Pmt+v{39 zDZ3Ix_vPYSc;Y;yn68kW4CG>PE5RoaV0n@#eVmk?p$u&Fy&KDTy!f^Hy6&^-H*)#u zdrSCTJPJw?(hLf56%2;_3n|ujUSJOU8VPOTlDULwt0jS@j^t1WS z!n7dZIoT+|O9hFUUMbID4Ec$!cc($DuQWkocVRcYSikFeM&RZ=?BW)mG4?fh#)KVG zcJ!<=-8{&MdE)+}?C8s{k@l49I|Zwswy^ZN3;E!FKyglY~Aq?4m74P-0)sMTGXqd5(S<-(DjjM z&7dL-Mr8jhUCAG$5^mI<|%`;JI5FVUnNj!VO2?Jiqa|c2;4^n!R z`5KK0hyB*F4w%cJ@Un6GC{mY&r%g`OX|1w2$B7wxu97%<@~9>NlXYd9RMF2UM>(z0 zouu4*+u+1*k;+nFPk%ly!nuMBgH4sL5Z`@Rok&?Ef=JrTmvBAS1h?C0)ty5+yEFRz zY$G=coQtNmT@1O5uk#_MQM1&bPPnspy5#>=_7%WcEL*n$;sSAZcXxMpcXxLe;_mLA z5F_paad+bGZV*oh@8h0(|D2P!q# zTHjmiphJ=AazSeKQPkGOR-D8``LjzToyx{lfK-1CDD6M7?pMZOdLKFtjZaZMPk4}k zW)97Fh(Z+_Fqv(Q_CMH-YYi?fR5fBnz7KOt0*t^cxmDoIokc=+`o# zrud|^h_?KW=Gv%byo~(Ln@({?3gnd?DUf-j2J}|$Mk>mOB+1{ZQ8HgY#SA8END(Zw z3T+W)a&;OO54~m}ffemh^oZ!Vv;!O&yhL0~hs(p^(Yv=(3c+PzPXlS5W79Er8B1o* z`c`NyS{Zj_mKChj+q=w)B}K za*zzPhs?c^`EQ;keH{-OXdXJet1EsQ)7;{3eF!-t^4_Srg4(Ot7M*E~91gwnfhqaM zNR7dFaWm7MlDYWS*m}CH${o?+YgHiPC|4?X?`vV+ws&Hf1ZO-w@OGG^o4|`b{bLZj z&9l=aA-Y(L11!EvRjc3Zpxk7lc@yH1e$a}8$_-r$)5++`_eUr1+dTb@ zU~2P1HM#W8qiNN3b*=f+FfG1!rFxnNlGx{15}BTIHgxO>Cq4 z;#9H9YjH%>Z2frJDJ8=xq>Z@H%GxXosS@Z>cY9ppF+)e~t_hWXYlrO6)0p7NBMa`+ z^L>-#GTh;k_XnE)Cgy|0Dw;(c0* zSzW14ZXozu)|I@5mRFF1eO%JM=f~R1dkNpZM+Jh(?&Zje3NgM{2ezg1N`AQg5%+3Y z64PZ0rPq6;_)Pj-hyIOgH_Gh`1$j1!jhml7ksHA1`CH3FDKiHLz+~=^u@kUM{ilI5 z^FPiJ7mSrzBs9{HXi2{sFhl5AyqwUnU{sPcUD{3+l-ZHAQ)C;c$=g1bdoxeG(5N01 zZy=t8i{*w9m?Y>V;uE&Uy~iY{pY4AV3_N;RL_jT_QtLFx^KjcUy~q9KcLE3$QJ{!)@$@En{UGG7&}lc*5Kuc^780;7Bj;)X?1CSy*^^ zPP^M)Pr5R>mvp3_hmCtS?5;W^e@5BjE>Cs<`lHDxj<|gtOK4De?Sf0YuK5GX9G93i zMYB{8X|hw|T6HqCf7Cv&r8A$S@AcgG1cF&iJ5=%+x;3yB`!lQ}2Hr(DE8=LuNb~Vs z=FO&2pdc16nD$1QL7j+!U^XWTI?2qQKt3H8=beVTdHHa9=MiJ&tM1RRQ-=+vy!~iz zj3O{pyRhCQ+b(>jC*H)J)%Wq}p>;?@W*Eut@P&?VU+Sdw^4kE8lvX|6czf{l*~L;J zFm*V~UC;3oQY(ytD|D*%*uVrBB}BbAfjK&%S;z;7$w68(8PV_whC~yvkZmX)xD^s6 z{$1Q}q;99W?*YkD2*;)tRCS{q2s@JzlO~<8x9}X<0?hCD5vpydvOw#Z$2;$@cZkYrp83J0PsS~!CFtY%BP=yxG?<@#{7%2sy zOc&^FJxsUYN36kSY)d7W=*1-{7ghPAQAXwT7z+NlESlkUH&8ODlpc8iC*iQ^MAe(B z?*xO4i{zFz^G=^G#9MsLKIN64rRJykiuIVX5~0#vAyDWc9-=6BDNT_aggS2G{B>dD ze-B%d3b6iCfc5{@yz$>=@1kdK^tX9qh0=ocv@9$ai``a_ofxT=>X7_Y0`X}a^M?d# z%EG)4@`^Ej_=%0_J-{ga!gFtji_byY&Vk@T1c|ucNAr(JNr@)nCWj?QnCyvXg&?FW;S-VOmNL6^km_dqiVjJuIASVGSFEos@EVF7St$WE&Z%)`Q##+0 zjaZ=JI1G@0!?l|^+-ZrNd$WrHBi)DA0-Eke>dp=_XpV<%CO_Wf5kQx}5e<90dt>8k zAi00d0rQ821nA>B4JHN7U8Zz=0;9&U6LOTKOaC1FC8GgO&kc=_wHIOGycL@c*$`ce703t%>S}mvxEnD-V!;6c`2(p74V7D0No1Xxt`urE66$0(ThaAZ1YVG#QP$ zy~NN%kB*zhZ2Y!kjn826pw4bh)75*e!dse+2Db(;bN34Uq7bLpr47XTX{8UEeC?2i z*{$`3dP}32${8pF$!$2Vq^gY|#w+VA_|o(oWmQX8^iw#n_crb(K3{69*iU?<%C-%H zuKi)3M1BhJ@3VW>JA`M>L~5*_bxH@Euy@niFrI$82C1}fwR$p2E&ZYnu?jlS}u7W9AyfdXh2pM>78bIt3 z)JBh&XE@zA!kyCDfvZ1qN^np20c1u#%P6;6tU&dx0phT1l=(mw7`u!-0e=PxEjDds z9E}{E!7f9>jaCQhw)&2TtG-qiD)lD(4jQ!q{`x|8l&nmtHkdul# zy+CIF8lKbp9_w{;oR+jSLtTfE+B@tOd6h=QePP>rh4@~!8c;Hlg9m%%&?e`*Z?qz5-zLEWfi>`ord5uHF-s{^bexKAoMEV@9nU z^5nA{f{dW&g$)BAGfkq@r5D)jr%!Ven~Q58c!Kr;*Li#`4Bu_?BU0`Y`nVQGhNZk@ z!>Yr$+nB=`z#o2nR0)V3M7-eVLuY`z@6CT#OTUXKnxZn$fNLPv7w1y7eGE=Qv@Hey`n;`U=xEl|q@CCV^#l)s0ZfT+mUf z^(j5r4)L5i2jnHW4+!6Si3q_LdOLQi<^fu?6WdohIkn79=jf%Fs3JkeXwF(?_tcF? z?z#j6iXEd(wJy4|p6v?xNk-)iIf2oX5^^Y3q3ziw16p9C6B;{COXul%)`>nuUoM*q zzmr|NJ5n)+sF$!yH5zwp=iM1#ZR`O%L83tyog-qh1I z0%dcj{NUs?{myT~33H^(%0QOM>-$hGFeP;U$puxoJ>>o-%Lk*8X^rx1>j|LtH$*)>1C!Pv&gd16%`qw5LdOIUbkNhaBBTo}5iuE%K&ZV^ zAr_)kkeNKNYJRgjsR%vexa~&8qMrQYY}+RbZ)egRg9_$vkoyV|Nc&MH@8L)`&rpqd zXnVaI@~A;Z^c3+{x=xgdhnocA&OP6^rr@rTvCnhG6^tMox$ulw2U7NgUtW%|-5VeH z_qyd47}1?IbuKtqNbNx$HR`*+9o=8`%vM8&SIKbkX9&%TS++x z5|&6P<%=F$C?owUI`%uvUq^yW0>`>yz!|WjzsoB9dT;2Dx8iSuK%%_XPgy0dTD4kd zDXF@&O_vBVVKQq(9YTClUPM30Sk7B!v7nOyV`XC!BA;BIVwphh+c)?5VJ^(C;GoQ$ zvBxr7_p*k$T%I1ke}`U&)$uf}I_T~#3XTi53OX)PoXVgxEcLJgZG^i47U&>LY(l%_ z;9vVDEtuMCyu2fqZeez|RbbIE7@)UtJvgAcVwVZNLccswxm+*L&w`&t=ttT=sv6Aq z!HouSc-24Y9;0q$>jX<1DnnGmAsP))- z^F~o99gHZw`S&Aw7e4id6Lg7kMk-e)B~=tZ!kE7sGTOJ)8@q}np@j7&7Sy{2`D^FH zI7aX%06vKsfJ168QnCM2=l|i>{I{%@gcr>ExM0Dw{PX6ozEuqFYEt z087%MKC;wVsMV}kIiuu9Zz9~H!21d!;Cu#b;hMDIP7nw3xSX~#?5#SSjyyg+Y@xh| z%(~fv3`0j#5CA2D8!M2TrG=8{%>YFr(j)I0DYlcz(2~92?G*?DeuoadkcjmZszH5& zKI@Lis%;RPJ8mNsbrxH@?J8Y2LaVjUIhRUiO-oqjy<&{2X~*f|)YxnUc6OU&5iac= z*^0qwD~L%FKiPmlzi&~a*9sk2$u<7Al=_`Ox^o2*kEv?p`#G(p(&i|ot8}T;8KLk- zPVf_4A9R`5^e`Om2LV*cK59EshYXse&IoByj}4WZaBomoHAPKqxRKbPcD`lMBI)g- zeMRY{gFaUuecSD6q!+b5(?vAnf>c`Z(8@RJy%Ulf?W~xB1dFAjw?CjSn$ph>st5bc zUac1aD_m6{l|$#g_v6;=32(mwpveQDWhmjR7{|B=$oBhz`7_g7qNp)n20|^^op3 zSfTdWV#Q>cb{CMKlWk91^;mHap{mk)o?udk$^Q^^u@&jd zfZ;)saW6{e*yoL6#0}oVPb2!}r{pAUYtn4{P~ES9tTfC5hXZnM{HrC8^=Pof{G4%Bh#8 ze~?C9m*|fd8MK;{L^!+wMy>=f^8b&y?yr6KnTq28$pFMBW9Oy7!oV5z|VM$s-cZ{I|Xf@}-)1=$V&x7e;9v81eiTi4O5-vs?^5pCKy2l>q);!MA zS!}M48l$scB~+Umz}7NbwyTn=rqt@`YtuwiQSMvCMFk2$83k50Q>OK5&fe*xCddIm)3D0I6vBU<+!3=6?(OhkO|b4fE_-j zimOzyfBB_*7*p8AmZi~X2bgVhyPy>KyGLAnOpou~sx9)S9%r)5dE%ADs4v%fFybDa_w*0?+>PsEHTbhKK^G=pFz z@IxLTCROWiKy*)cV3y%0FwrDvf53Ob_XuA1#tHbyn%Ko!1D#sdhBo`;VC*e1YlhrC z?*y3rp86m#qI|qeo8)_xH*G4q@70aXN|SP+6MQ!fJQqo1kwO_v7zqvUfU=Gwx`CR@ zRFb*O8+54%_8tS(ADh}-hUJzE`s*8wLI>1c4b@$al)l}^%GuIXjzBK!EWFO8W`>F^ ze7y#qPS0NI7*aU)g$_ziF(1ft;2<}6Hfz10cR8P}67FD=+}MfhrpOkF3hFhQu;Q1y zu%=jJHTr;0;oC94Hi@LAF5quAQ(rJG(uo%BiRQ@8U;nhX)j0i?0SL2g-A*YeAqF>RVCBOTrn{0R27vu}_S zS>tX4!#&U4W;ikTE!eFH+PKw%p+B(MR2I%n#+m0{#?qRP_tR@zpgCb=4rcrL!F=;A zh%EIF8m6%JG+qb&mEfuFTLHSxUAZEvC-+kvZKyX~SA3Umt`k}}c!5dy?-sLIM{h@> z!2=C)@nx>`;c9DdwZ&zeUc(7t<21D7qBj!|1^Mp1eZ6)PuvHx+poKSDCSBMFF{bKy z;9*&EyKitD99N}%mK8431rvbT+^%|O|HV23{;RhmS{$5tf!bIPoH9RKps`-EtoW5h zo6H_!s)Dl}2gCeGF6>aZtah9iLuGd19^z0*OryPNt{70RvJSM<#Ox9?HxGg04}b^f zrVEPceD%)#0)v5$YDE?f`73bQ6TA6wV;b^x*u2Ofe|S}+q{s5gr&m~4qGd!wOu|cZ||#h_u=k*fB;R6&k?FoM+c&J;ISg70h!J7*xGus)ta4veTdW)S^@sU@ z4$OBS=a~@F*V0ECic;ht4@?Jw<9kpjBgHfr2FDPykCCz|v2)`JxTH55?b3IM={@DU z!^|9nVO-R#s{`VHypWyH0%cs;0GO3E;It6W@0gX6wZ%W|Dzz&O%m17pa19db(er}C zUId1a4#I+Ou8E1MU$g=zo%g7K(=0Pn$)Rk z<4T2u<0rD)*j+tcy2XvY+0 z0d2pqm4)4lDewsAGThQi{2Kc3&C=|OQF!vOd#WB_`4gG3@inh-4>BoL!&#ij8bw7? zqjFRDaQz!J-YGitV4}$*$hg`vv%N)@#UdzHFI2E<&_@0Uw@h_ZHf}7)G;_NUD3@18 zH5;EtugNT0*RXVK*by>WS>jaDDfe!A61Da=VpIK?mcp^W?!1S2oah^wowRnrYjl~`lgP-mv$?yb6{{S55CCu{R z$9;`dyf0Y>uM1=XSl_$01Lc1Iy68IosWN8Q9Op=~I(F<0+_kKfgC*JggjxNgK6 z-3gQm6;sm?J&;bYe&(dx4BEjvq}b`OT^RqF$J4enP1YkeBK#>l1@-K`ajbn05`0J?0daOtnzh@l3^=BkedW1EahZlRp;`j*CaT;-21&f2wU z+Nh-gc4I36Cw+;3UAc<%ySb`#+c@5y ze~en&bYV|kn?Cn|@fqmGxgfz}U!98$=drjAkMi`43I4R%&H0GKEgx-=7PF}y`+j>r zg&JF`jomnu2G{%QV~Gf_-1gx<3Ky=Md9Q3VnK=;;u0lyTBCuf^aUi?+1+`4lLE6ZK zT#(Bf`5rmr(tgTbIt?yA@y`(Ar=f>-aZ}T~>G32EM%XyFvhn&@PWCm#-<&ApLDCXT zD#(9m|V(OOo7PmE@`vD4$S5;+9IQm19dd zvMEU`)E1_F+0o0-z>YCWqg0u8ciIknU#{q02{~YX)gc_u;8;i233D66pf(IkTDxeN zL=4z2)?S$TV9=ORVr&AkZMl<4tTh(v;Ix1{`pPVqI3n2ci&4Dg+W|N8TBUfZ*WeLF zqCH_1Q0W&f9T$lx3CFJ$o@Lz$99 zW!G&@zFHxTaP!o#z^~xgF|(vrHz8R_r9eo;TX9}2ZyjslrtH=%6O)?1?cL&BT(Amp zTGFU1%%#xl&6sH-UIJk_PGk_McFn7=%yd6tAjm|lnmr8bE2le3I~L{0(ffo}TQjyo zHZZI{-}{E4ohYTlZaS$blB!h$Jq^Rf#(ch}@S+Ww&$b);8+>g84IJcLU%B-W?+IY& zslcZIR>+U4v3O9RFEW;8NpCM0w1ROG84=WpKxQ^R`{=0MZCubg3st z48AyJNEvyxn-jCPTlTwp4EKvyEwD3e%kpdY?^BH0!3n6Eb57_L%J1=a*3>|k68A}v zaW`*4YitylfD}ua8V)vb79)N_Ixw_mpp}yJGbNu+5YYOP9K-7nf*jA1#<^rb4#AcS zKg%zCI)7cotx}L&J8Bqo8O1b0q;B1J#B5N5Z$Zq=wX~nQFgUfAE{@u0+EnmK{1hg> zC{vMfFLD;L8b4L+B51&LCm|scVLPe6h02rws@kGv@R+#IqE8>Xn8i|vRq_Z`V;x6F zNeot$1Zsu`lLS92QlLWF54za6vOEKGYQMdX($0JN*cjG7HP&qZ#3+bEN$8O_PfeAb z0R5;=zXac2IZ?fxu59?Nka;1lKm|;0)6|#RxkD05P5qz;*AL@ig!+f=lW5^Jbag%2 z%9@iM0ph$WFlxS!`p31t92z~TB}P-*CS+1Oo_g;7`6k(Jyj8m8U|Q3Sh7o-Icp4kV zK}%qri5>?%IPfamXIZ8pXbm-#{ytiam<{a5A+3dVP^xz!Pvirsq7Btv?*d7eYgx7q zWFxrzb3-%^lDgMc=Vl7^={=VDEKabTG?VWqOngE`Kt7hs236QKidsoeeUQ_^FzsXjprCDd@pW25rNx#6x&L6ZEpoX9Ffzv@olnH3rGOSW( zG-D|cV0Q~qJ>-L}NIyT?T-+x+wU%;+_GY{>t(l9dI%Ximm+Kmwhee;FK$%{dnF;C% zFjM2&$W68Sz#d*wtfX?*WIOXwT;P6NUw}IHdk|)fw*YnGa0rHx#paG!m=Y6GkS4VX zX`T$4eW9k1W!=q8!(#8A9h67fw))k_G)Q9~Q1e3f`aV@kbcSv7!priDUN}gX(iXTy zr$|kU0Vn%*ylmyDCO&G0Z3g>%JeEPFAW!5*H2Ydl>39w3W+gEUjL&vrRs(xGP{(ze zy7EMWF14@Qh>X>st8_029||TP0>7SG9on_xxeR2Iam3G~Em$}aGsNt$iES9zFa<3W zxtOF*!G@=PhfHO!=9pVPXMUVi30WmkPoy$02w}&6A7mF)G6-`~EVq5CwD2`9Zu`kd)52``#V zNSb`9dG~8(dooi1*-aSMf!fun7Sc`-C$-E(3BoSC$2kKrVcI!&yC*+ff2+C-@!AT_ zsvlAIV+%bRDfd{R*TMF><1&_a%@yZ0G0lg2K;F>7b+7A6pv3-S7qWIgx+Z?dt8}|S z>Qbb6x(+^aoV7FQ!Ph8|RUA6vXWQH*1$GJC+wXLXizNIc9p2yLzw9 z0=MdQ!{NnOwIICJc8!+Jp!zG}**r#E!<}&Te&}|B4q;U57$+pQI^}{qj669zMMe_I z&z0uUCqG%YwtUc8HVN7?0GHpu=bL7&{C>hcd5d(iFV{I5c~jpX&!(a{yS*4MEoYXh z*X4|Y@RVfn;piRm-C%b@{0R;aXrjBtvx^HO;6(>i*RnoG0Rtcd25BT6edxTNOgUAOjn zJ2)l{ipj8IP$KID2}*#F=M%^n&=bA0tY98@+2I+7~A&T-tw%W#3GV>GTmkHaqftl)#+E zMU*P(Rjo>8%P@_@#UNq(_L{}j(&-@1iY0TRizhiATJrnvwSH0v>lYfCI2ex^><3$q znzZgpW0JlQx?JB#0^^s-Js1}}wKh6f>(e%NrMwS`Q(FhazkZb|uyB@d%_9)_xb$6T zS*#-Bn)9gmobhAtvBmL+9H-+0_0US?g6^TOvE8f3v=z3o%NcPjOaf{5EMRnn(_z8- z$|m0D$FTU zDy;21v-#0i)9%_bZ7eo6B9@Q@&XprR&oKl4m>zIj-fiRy4Dqy@VVVs?rscG| zmzaDQ%>AQTi<^vYCmv#KOTd@l7#2VIpsj?nm_WfRZzJako`^uU%Nt3e;cU*y*|$7W zLm%fX#i_*HoUXu!NI$ey>BA<5HQB=|nRAwK!$L#n-Qz;~`zACig0PhAq#^5QS<8L2 zS3A+8%vbVMa7LOtTEM?55apt(DcWh#L}R^P2AY*c8B}Cx=6OFAdMPj1f>k3#^#+Hk z6uW1WJW&RlBRh*1DLb7mJ+KO>!t^t8hX1#_Wk`gjDio9)9IGbyCAGI4DJ~orK+YRv znjxRMtshZQHc$#Y-<-JOV6g^Cr@odj&Xw5B(FmI)*qJ9NHmIz_r{t)TxyB`L-%q5l ztzHgD;S6cw?7Atg*6E1!c6*gPRCb%t7D%z<(xm+K{%EJNiI2N0l8ud0Ch@_av_RW? zIr!nO4dL5466WslE6MsfMss7<)-S!e)2@r2o=7_W)OO`~CwklRWzHTfpB)_HYwgz=BzLhgZ9S<{nLBOwOIgJU=94uj6r!m>Xyn9>&xP+=5!zG_*yEoRgM0`aYts z^)&8(>z5C-QQ*o_s(8E4*?AX#S^0)aqB)OTyX>4BMy8h(cHjA8ji1PRlox@jB*1n? zDIfyDjzeg91Ao(;Q;KE@zei$}>EnrF6I}q&Xd=~&$WdDsyH0H7fJX|E+O~%LS*7^Q zYzZ4`pBdY{b7u72gZm6^5~O-57HwzwAz{)NvVaowo`X02tL3PpgLjwA`^i9F^vSpN zAqH3mRjG8VeJNHZ(1{%!XqC+)Z%D}58Qel{_weSEHoygT9pN@i zi=G;!Vj6XQk2tuJC>lza%ywz|`f7TIz*EN2Gdt!s199Dr4Tfd_%~fu8gXo~|ogt5Q zlEy_CXEe^BgsYM^o@L?s33WM14}7^T(kqohOX_iN@U?u;$l|rAvn{rwy>!yfZw13U zB@X9)qt&4;(C6dP?yRsoTMI!j-f1KC!<%~i1}u7yLXYn)(#a;Z6~r>hp~kfP));mi zcG%kdaB9H)z9M=H!f>kM->fTjRVOELNwh1amgKQT=I8J66kI)u_?0@$$~5f`u%;zl zC?pkr^p2Fe=J~WK%4ItSzKA+QHqJ@~m|Cduv=Q&-P8I5rQ-#G@bYH}YJr zUS(~(w|vKyU(T(*py}jTUp%I%{2!W!K(i$uvotcPjVddW z8_5HKY!oBCwGZcs-q`4Yt`Zk~>K?mcxg51wkZlX5e#B08I75F7#dgn5yf&Hrp`*%$ zQ;_Qg>TYRzBe$x=T(@WI9SC!ReSas9vDm(yslQjBJZde5z8GDU``r|N(MHcxNopGr z_}u39W_zwWDL*XYYt>#Xo!9kL#97|EAGyGBcRXtLTd59x%m=3i zL^9joWYA)HfL15l9%H?q`$mY27!<9$7GH(kxb%MV>`}hR4a?+*LH6aR{dzrX@?6X4 z3e`9L;cjqYb`cJmophbm(OX0b)!AFG?5`c#zLagzMW~o)?-!@e80lvk!p#&CD8u5_r&wp4O0zQ>y!k5U$h_K;rWGk=U)zX!#@Q%|9g*A zWx)qS1?fq6X<$mQTB$#3g;;5tHOYuAh;YKSBz%il3Ui6fPRv#v62SsrCdMRTav)Sg zTq1WOu&@v$Ey;@^+_!)cf|w_X<@RC>!=~+A1-65O0bOFYiH-)abINwZvFB;hJjL_$ z(9iScmUdMp2O$WW!520Hd0Q^Yj?DK%YgJD^ez$Z^?@9@Ab-=KgW@n8nC&88)TDC+E zlJM)L3r+ZJfZW_T$;Imq*#2<(j+FIk8ls7)WJ6CjUu#r5PoXxQs4b)mZza<8=v{o)VlLRM<9yw^0En#tXAj`Sylxvki{<1DPe^ zhjHwx^;c8tb?Vr$6ZB;$Ff$+3(*oinbwpN-#F)bTsXq@Sm?43MC#jQ~`F|twI=7oC zH4TJtu#;ngRA|Y~w5N=UfMZi?s0%ZmKUFTAye&6Y*y-%c1oD3yQ%IF2q2385Zl+=> zfz=o`Bedy|U;oxbyb^rB9ixG{Gb-{h$U0hVe`J;{ql!s_OJ_>>eoQn(G6h7+b^P48 zG<=Wg2;xGD-+d@UMZ!c;0>#3nws$9kIDkK13IfloGT@s14AY>&>>^#>`PT7GV$2Hp zN<{bN*ztlZu_%W=&3+=#3bE(mka6VoHEs~0BjZ$+=0`a@R$iaW)6>wp2w)=v2@|2d z%?34!+iOc5S@;AAC4hELWLH56RGxo4jw8MDMU0Wk2k_G}=Vo(>eRFo(g3@HjG|`H3 zm8b*dK=moM*oB<)*A$M9!!5o~4U``e)wxavm@O_R(`P|u%9^LGi(_%IF<6o;NLp*0 zKsfZ0#24GT8(G`i4UvoMh$^;kOhl?`0yNiyrC#HJH=tqOH^T_d<2Z+ zeN>Y9Zn!X4*DMCK^o75Zk2621bdmV7Rx@AX^alBG4%~;G_vUoxhfhFRlR&+3WwF^T zaL)8xPq|wCZoNT^>3J0K?e{J-kl+hu2rZI>CUv#-z&u@`hjeb+bBZ>bcciQVZ{SbW zez04s9oFEgc8Z+Kp{XFX`MVf-s&w9*dx7wLen(_@y34}Qz@&`$2+osqfxz4&d}{Ql z*g1ag00Gu+$C`0avds{Q65BfGsu9`_`dML*rX~hyWIe$T>CsPRoLIr%MTk3pJ^2zH1qub1MBzPG}PO;Wmav9w%F7?%l=xIf#LlP`! z_Nw;xBQY9anH5-c8A4mME}?{iewjz(Sq-29r{fV;Fc>fv%0!W@(+{={Xl-sJ6aMoc z)9Q+$bchoTGTyWU_oI19!)bD=IG&OImfy;VxNXoIO2hYEfO~MkE#IXTK(~?Z&!ae! zl8z{D&2PC$Q*OBC(rS~-*-GHNJ6AC$@eve>LB@Iq;jbBZj`wk4|LGogE||Ie=M5g= z9d`uYQ1^Sr_q2wmZE>w2WG)!F%^KiqyaDtIAct?}D~JP4shTJy5Bg+-(EA8aXaxbd~BKMtTf2iQ69jD1o* zZF9*S3!v-TdqwK$%&?91Sh2=e63;X0Lci@n7y3XOu2ofyL9^-I767eHESAq{m+@*r zbVDx!FQ|AjT;!bYsXv8ilQjy~Chiu&HNhFXt3R_6kMC8~ChEFqG@MWu#1Q1#=~#ix zrkHpJre_?#r=N0wv`-7cHHqU`phJX2M_^{H0~{VP79Dv{6YP)oA1&TSfKPEPZn2)G z9o{U1huZBLL;Tp_0OYw@+9z(jkrwIGdUrOhKJUbwy?WBt zlIK)*K0lQCY0qZ!$%1?3A#-S70F#YyUnmJF*`xx?aH5;gE5pe-15w)EB#nuf6B*c~ z8Z25NtY%6Wlb)bUA$w%HKs5$!Z*W?YKV-lE0@w^{4vw;J>=rn?u!rv$&eM+rpU6rc=j9>N2Op+C{D^mospMCjF2ZGhe4eADA#skp2EA26%p3Ex9wHW8l&Y@HX z$Qv)mHM}4*@M*#*ll5^hE9M^=q~eyWEai*P;4z<9ZYy!SlNE5nlc7gm;M&Q zKhKE4d*%A>^m0R?{N}y|i6i^k>^n4(wzKvlQeHq{l&JuFD~sTsdhs`(?lFK@Q{pU~ zb!M3c@*3IwN1RUOVjY5>uT+s-2QLWY z4T2>fiSn>>Fob+%B868-v9D@AfWr#M8eM6w#eAlhc#zk6jkLxGBGk`E3$!A@*am!R zy>29&ptYK6>cvP`b!syNp)Q$0UOW|-O@)8!?94GOYF_}+zlW%fCEl|Tep_zx05g6q z>tp47e-&R*hSNe{6{H!mL?+j$c^TXT{C&@T-xIaesNCl05 z9SLb@q&mSb)I{VXMaiWa3PWj=Ed!>*GwUe;^|uk=Pz$njNnfFY^MM>E?zqhf6^{}0 zx&~~dA5#}1ig~7HvOQ#;d9JZBeEQ+}-~v$at`m!(ai z$w(H&mWCC~;PQ1$%iuz3`>dWeb3_p}X>L2LK%2l59Tyc}4m0>9A!8rhoU3m>i2+hl zx?*qs*c^j}+WPs>&v1%1Ko8_ivAGIn@QK7A`hDz-Emkcgv2@wTbYhkiwX2l=xz*XG zaiNg+j4F-I>9v+LjosI-QECrtKjp&0T@xIMKVr+&)gyb4@b3y?2CA?=ooN zT#;rU86WLh(e@#mF*rk(NV-qSIZyr z$6!ZUmzD)%yO-ot`rw3rp6?*_l*@Z*IB0xn4|BGPWHNc-1ZUnNSMWmDh=EzWJRP`) zl%d%J613oXzh5;VY^XWJi{lB`f#u+ThvtP7 zq(HK<4>tw(=yzSBWtYO}XI`S1pMBe3!jFxBHIuwJ(@%zdQFi1Q_hU2eDuHqXte7Ki zOV55H2D6u#4oTfr7|u*3p75KF&jaLEDpxk!4*bhPc%mpfj)Us3XIG3 zIKMX^s^1wt8YK7Ky^UOG=w!o5e7W-<&c|fw2{;Q11vm@J{)@N3-p1U>!0~sKWHaL= zWV(0}1IIyt1p%=_-Fe5Kfzc71wg}`RDDntVZv;4!=&XXF-$48jS0Sc;eDy@Sg;+{A zFStc{dXT}kcIjMXb4F7MbX~2%i;UrBxm%qmLKb|2=?uPr00-$MEUIGR5+JG2l2Nq` zkM{{1RO_R)+8oQ6x&-^kCj)W8Z}TJjS*Wm4>hf+4#VJP)OBaDF%3pms7DclusBUw} z{ND#!*I6h85g6DzNvdAmnwWY{&+!KZM4DGzeHI?MR@+~|su0{y-5-nICz_MIT_#FE zm<5f3zlaKq!XyvY3H`9s&T};z!cK}G%;~!rpzk9-6L}4Rg7vXtKFsl}@sT#U#7)x- z7UWue5sa$R>N&b{J61&gvKcKlozH*;OjoDR+elkh|4bJ!_3AZNMOu?n9&|L>OTD78 z^i->ah_Mqc|Ev)KNDzfu1P3grBIM#%`QZqj5W{qu(HocQhjyS;UINoP`{J+DvV?|1 z_sw6Yr3z6%e7JKVDY<$P=M)dbk@~Yw9|2!Cw!io3%j92wTD!c^e9Vj+7VqXo3>u#= zv#M{HHJ=e$X5vQ>>ML?E8#UlmvJgTnb73{PSPTf*0)mcj6C z{KsfUbDK|F$E(k;ER%8HMdDi`=BfpZzP3cl5yJHu;v^o2FkHNk;cXc17tL8T!CsYI zfeZ6sw@;8ia|mY_AXjCS?kUfxdjDB28)~Tz1dGE|{VfBS9`0m2!m1yG?hR})er^pl4c@9Aq+|}ZlDaHL)K$O| z%9Jp-imI-Id0|(d5{v~w6mx)tUKfbuVD`xNt04Mry%M+jXzE>4(TBsx#&=@wT2Vh) z1yeEY&~17>0%P(eHP0HB^|7C+WJxQBTG$uyOWY@iDloRIb-Cf!p<{WQHR!422#F34 zG`v|#CJ^G}y9U*7jgTlD{D&y$Iv{6&PYG>{Ixg$pGk?lWrE#PJ8KunQC@}^6OP!|< zS;}p3to{S|uZz%kKe|;A0bL0XxPB&Q{J(9PyX`+Kr`k~r2}yP^ND{8!v7Q1&vtk& z2Y}l@J@{|2`oA%sxvM9i0V+8IXrZ4;tey)d;LZI70Kbim<4=WoTPZy=Yd|34v#$Kh zx|#YJ8s`J>W&jt#GcMpx84w2Z3ur-rK7gf-p5cE)=w1R2*|0mj12hvapuUWM0b~dG zMg9p8FmAZI@i{q~0@QuY44&mMUNXd7z>U58shA3o`p5eVLpq>+{(<3->DWuSFVZwC zxd50Uz(w~LxC4}bgag#q#NNokK@yNc+Q|Ap!u>Ddy+df>v;j@I12CDNN9do+0^n8p zMQs7X#+FVF0C5muGfN{r0|Nkql%BQT|K(DDNdR2pzM=_ea5+GO|J67`05AV92t@4l z0Qno0078PIHdaQGHZ~Scw!dzgqjK~3B7kf>BcP__&lLyU(cu3B^uLo%{j|Mb0NR)tkeT7Hcwp4O# z)yzu>cvG(d9~0a^)eZ;;%3ksk@F&1eEBje~ zW+-_s)&RgiweQc!otF>4%vbXKaOU41{!hw?|2`Ld3I8$&#WOsq>EG)1ANb!{N4z9@ zsU!bPG-~-bqCeIDzo^Q;gnucB{tRzm{ZH^Orphm2U+REA!*<*J6YQV83@&xoDl%#wnl5qcBqCcAF-vX5{30}(oJrnSH z{RY85hylK2dMOh2%oO1J8%)0?8TOL%rS8)+CsDv}aQ>4D)Jv+DLK)9gI^n-T^$)Tc zFPUD75qJm!Y-KBqj;JP4dV4 z`X{lGmn<)1IGz330}s}Jrjtf{(lnuuNHe5(ezA(pYa=1|Ff-LhPFK8 zyJh_b{yzu0yll6ZkpRzRjezyYivjyjW7QwO;@6X`m;2Apn2EK2!~7S}-*=;5*7K$B z`x(=!^?zgj(-`&ApZJXI09aDLXaT@<;CH=?fBOY5d|b~wBA@@p^K#nxr`)?i?SqTupI_PJ(A3cx`z~9mX_*)>L F{|7XC?P&l2 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..dfc7651 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon May 22 15:34:16 EEST 2023 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/home/.gitignore b/home/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/home/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/home/README.md b/home/README.md new file mode 100644 index 0000000..c72d63c --- /dev/null +++ b/home/README.md @@ -0,0 +1,40 @@ +# Description + +This module is responsible for representing home screen and logic around it + +# How to install +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':home') +``` + +2. Module requires next modules to work + +```groovy + implementation project(':core') + implementation project(':diia_storage') + implementation project(':ui_base') +``` + +3. nav_id file describe all ids that this module requires. Entry point should implement all those ids. + +`./src/main/res/values/nav_ids.xml` + +4. Add next nav graphs to main navigation graph + +```xml + +``` + +5. The following action should be added into the root navigation graph + +```xml + +``` + +6. The module requires implementation of HomeHelper. It should be implemented and provided by an entry point. + +`./src/java/ua/gov/diia/core/helper/HomeHelper.kt` diff --git a/home/build.gradle b/home/build.gradle new file mode 100644 index 0000000..962120b --- /dev/null +++ b/home/build.gradle @@ -0,0 +1,128 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'androidx.navigation.safeargs.kotlin' + id 'kotlin-kapt' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'dagger.hilt.android.plugin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.home' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + + implementation project(':core') + implementation project(':diia_storage') + implementation deps.activity_ktx + implementation deps.fragment_ktx + implementation deps.appcompat + implementation deps.lottie + implementation project(path: ':ui_base') + + //Compose + implementation deps.activity_compose + + implementation deps.fragment_ktx + implementation deps.legacy_support + implementation deps.appcompat + implementation deps.constraint_layout + implementation deps.recyclerview + implementation deps.viewpager + + //lifecycle + implementation deps.lifecycle_livedata_ktx + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + kapt deps.hilt_compiler + + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.turbine +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/home/consumer-rules.pro b/home/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/home/excludes.jacoco b/home/excludes.jacoco new file mode 100644 index 0000000..b6417a9 --- /dev/null +++ b/home/excludes.jacoco @@ -0,0 +1,2 @@ +ua/gov/diia/home/ui/**/*F.* +ua/gov/diia/home/**/*$*.* \ No newline at end of file diff --git a/home/proguard-rules.pro b/home/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/home/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/home/src/main/AndroidManifest.xml b/home/src/main/AndroidManifest.xml new file mode 100644 index 0000000..582e2d8 --- /dev/null +++ b/home/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/home/src/main/java/ua/gov/diia/home/di/HomeScreenTabsMappersModule.kt b/home/src/main/java/ua/gov/diia/home/di/HomeScreenTabsMappersModule.kt new file mode 100644 index 0000000..af86da1 --- /dev/null +++ b/home/src/main/java/ua/gov/diia/home/di/HomeScreenTabsMappersModule.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.home.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import ua.gov.diia.home.ui.HomeScreenComposeMapper +import ua.gov.diia.home.ui.HomeScreenComposeMapperImpl + +@Module +@InstallIn(ViewModelComponent::class) +interface HomeScreenTabsMappersModule { + + @Binds + fun bindHomeScreenComposeMapper( + impl: HomeScreenComposeMapperImpl + ): HomeScreenComposeMapper + +} \ No newline at end of file diff --git a/home/src/main/java/ua/gov/diia/home/helper/HomeHelper.kt b/home/src/main/java/ua/gov/diia/home/helper/HomeHelper.kt new file mode 100644 index 0000000..0224f61 --- /dev/null +++ b/home/src/main/java/ua/gov/diia/home/helper/HomeHelper.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.home.helper + +import androidx.fragment.app.Fragment +import androidx.navigation.NavDirections +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor + +interface HomeHelper { + + /** + * @return HomeMenuItemConstructor which represent passed fragment menu item + * */ + fun getNavMenuItem(classObj: Class): HomeMenuItemConstructor + + /** + * @return id of navigation graph that represents tabs navigation + * */ + fun getGraphId(): Int + + /** + * @return navigation action to specific home screen tab item + * */ + fun getNavDirection(position: Int): NavDirections +} \ No newline at end of file diff --git a/home/src/main/java/ua/gov/diia/home/model/HomeMenuItem.kt b/home/src/main/java/ua/gov/diia/home/model/HomeMenuItem.kt new file mode 100644 index 0000000..2311a07 --- /dev/null +++ b/home/src/main/java/ua/gov/diia/home/model/HomeMenuItem.kt @@ -0,0 +1,34 @@ +package ua.gov.diia.home.model + +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import ua.gov.diia.home.R +import ua.gov.diia.home.ui.HomeActions + +object HomeMenuItem { + val DOCUMENTS = HomeMenuItemConstructor( + HomeActions.HOME_DOCUMENTS, + R.color.colorPrimary, + R.color.colorPrimary, + R.string.app_bar_title_empty + ) + val SERVICES = HomeMenuItemConstructor( + HomeActions.HOME_SERVICES, + R.color.colorPrimary, + R.color.colorPrimary, + R.string.app_bar_title_empty + ) + + val MENU = HomeMenuItemConstructor( + HomeActions.HOME_MENU, + R.color.colorPrimary, + R.color.colorPrimary, + R.string.app_bar_title_empty + ) + + val FEED = HomeMenuItemConstructor( + HomeActions.HOME_FEED, + R.color.colorPrimary, + R.color.colorPrimary, + R.string.app_bar_title_empty + ) +} \ No newline at end of file diff --git a/home/src/main/java/ua/gov/diia/home/ui/HomeActions.kt b/home/src/main/java/ua/gov/diia/home/ui/HomeActions.kt new file mode 100644 index 0000000..08a8dd5 --- /dev/null +++ b/home/src/main/java/ua/gov/diia/home/ui/HomeActions.kt @@ -0,0 +1,9 @@ +package ua.gov.diia.home.ui + +object HomeActions { + + const val HOME_FEED = 0 + const val HOME_DOCUMENTS = 1 + const val HOME_SERVICES = 2 + const val HOME_MENU = 3 +} \ No newline at end of file diff --git a/home/src/main/java/ua/gov/diia/home/ui/HomeF.kt b/home/src/main/java/ua/gov/diia/home/ui/HomeF.kt new file mode 100644 index 0000000..c4b845d --- /dev/null +++ b/home/src/main/java/ua/gov/diia/home/ui/HomeF.kt @@ -0,0 +1,192 @@ +package ua.gov.diia.home.ui + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.app.NotificationManagerCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.CoreConstants.CHECK_SAFETY_NET +import ua.gov.diia.home.helper.HomeHelper +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import ua.gov.diia.core.util.delegation.Permission +import ua.gov.diia.core.util.delegation.WithPermission +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.event.observeUiEvent +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.navigation.KeepStateNavigator +import ua.gov.diia.home.NavHomeDirections +import ua.gov.diia.home.R +import ua.gov.diia.home.databinding.FragmentHomeBinding +import ua.gov.diia.ui_base.components.infrastructure.screen.TabBarRootContainer +import javax.inject.Inject + +@AndroidEntryPoint +class HomeF @Inject constructor( + private val permission: WithPermission, + private val homeHelper: HomeHelper +) : Fragment(), + WithPermission by permission { + + private var _navController: NavController? = null + + private fun obtainNavController(): NavController { + val controller = _navController + + return if (controller == null) { + val navHostFragment = childFragmentManager.fragments + .find { it is NavHostFragment } as NavHostFragment + + navHostFragment.navController.also { _navController = it } + } else { + controller + } + } + + private val viewModel: HomeVM by viewModels() + + private var binding: FragmentHomeBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycle.addObserver(permission) + + lifecycleScope.launchWhenStarted { + viewModel.handleDeepLinks() + } + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentHomeBinding.inflate(inflater, container, false).apply { + composeTabBar.apply { + setContent { + TabBarRootContainer( + tabBarVies = viewModel.bottomData, + onUIAction = { + viewModel.onUIAction(it) + } + ) + } + } + lifecycleOwner = viewLifecycleOwner + vm = viewModel + } + viewModel.checkNotificationsRequested() + + viewModel.showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + + viewModel.allowAuthorizedDeepLinks() + + viewModel.apply { + notificationsRequested.observeUiDataEvent(viewLifecycleOwner) { requested -> + if (!requested) { + checkNotificationEnabled() + } + } + + showTemplate.observeUiDataEvent(viewLifecycleOwner) { + openTemplateDialog(it) + } + processNavigation.observeUiDataEvent(viewLifecycleOwner) { + navigate(it) + } + viewModel.selectedMenuItem.observe(viewLifecycleOwner) { + onNavigationTransactionComplete(it?.peekContent()) + } + } + return binding?.root + } + + private fun onNavigationTransactionComplete(currentNavItem: HomeMenuItemConstructor?) { + binding?.apply { + if (currentNavItem?.position == HomeActions.HOME_DOCUMENTS) { + gradientBg.visibility = View.VISIBLE + gradientBg.setAnimation(R.raw.gradient_bg) + gradientBg.playAnimation() + gradientBg.scaleType = ImageView.ScaleType.FIT_XY + } else { + gradientBg.visibility = View.GONE + + } + + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + inflateHomeChildNavigator() + + doOnCameraPermissionGrantedEvent.observeUiEvent( + viewLifecycleOwner, + ::navigateToQrScannerDestination + ) + doOnPostNotificationPermissionGrantedEvent.observeUiEvent(viewLifecycleOwner) { + viewModel.allowNotifications() + } + doOnPermissionDeniedEvent.observeUiEvent(viewLifecycleOwner) { + viewModel.denyNotifications() + } + + viewModel.selectedMenuItem.observe(viewLifecycleOwner) { + it?.peekContent()?.let { menuItem -> + navigate(homeHelper.getNavDirection(menuItem.position), obtainNavController()) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun inflateHomeChildNavigator() { + val navigator = KeepStateNavigator( + context = requireContext(), + manager = childFragmentManager, + containerId = R.id.home_content + ) { + val menuItem = homeHelper.getNavMenuItem(it) + } + + with(obtainNavController()) { + navigatorProvider.addNavigator(navigator) + + setGraph(homeHelper.getGraphId()) + } + } + + + override fun onResume() { + super.onResume() + LocalBroadcastManager + .getInstance(requireContext()) + .sendBroadcast(Intent(CHECK_SAFETY_NET)) + } + + private fun navigateToQrScannerDestination() { + navigate(NavHomeDirections.actionGlobalToQrScanF()) + } + + private fun checkNotificationEnabled() { + if (NotificationManagerCompat.from(requireContext()).areNotificationsEnabled().not()) { + approvePermission(Permission.POST_NOTIFICATIONS) + } + } + + private companion object { + const val ACTION_PROMO_DO_NOT_SHOW = "doNotShow" + const val ACTION_PROMO_SUBSCRIBE = "serviceSubscribe" + } +} diff --git a/home/src/main/java/ua/gov/diia/home/ui/HomeScreenComposeMapper.kt b/home/src/main/java/ua/gov/diia/home/ui/HomeScreenComposeMapper.kt new file mode 100644 index 0000000..3a5d8ea --- /dev/null +++ b/home/src/main/java/ua/gov/diia/home/ui/HomeScreenComposeMapper.kt @@ -0,0 +1,45 @@ +package ua.gov.diia.home.ui + +import androidx.compose.runtime.snapshots.SnapshotStateList +import ua.gov.diia.ui_base.components.molecule.tab.TabItemMoleculeData +import ua.gov.diia.ui_base.components.organism.bottom.TabBarOrganismData +import javax.inject.Inject + +interface HomeScreenComposeMapper { + + fun TabItemMoleculeData.toComposeTabItemMolecule(): TabItemMoleculeData + fun List.toComposeTabBarOrganism(): TabBarOrganismData + +} + +class HomeScreenComposeMapperImpl @Inject constructor(): HomeScreenComposeMapper{ + + + override fun TabItemMoleculeData.toComposeTabItemMolecule(): TabItemMoleculeData { + return TabItemMoleculeData( + actionKey = this.actionKey, + id = this.id, + label = this.label, + iconSelected = this.iconSelected, + iconUnselected = this.iconUnselected, + iconSelectedWithBadge = this.iconSelectedWithBadge, + iconUnselectedWithBadge = this.iconUnselectedWithBadge, + showBadge = this.showBadge + ) + } + + override fun List.toComposeTabBarOrganism(): TabBarOrganismData{ + return mapTabItemMoleculeDataToTabBarOrganismData(this) + } + + fun mapTabItemMoleculeDataToTabBarOrganismData(list: List): TabBarOrganismData { + with(list) { + return this.let { + return TabBarOrganismData( + tabs = SnapshotStateList().apply { + addAll(it.map { it.toComposeTabItemMolecule() }) + }) + } + } + } +} \ No newline at end of file diff --git a/home/src/main/java/ua/gov/diia/home/ui/HomeVM.kt b/home/src/main/java/ua/gov/diia/home/ui/HomeVM.kt new file mode 100644 index 0000000..8806d4f --- /dev/null +++ b/home/src/main/java/ua/gov/diia/home/ui/HomeVM.kt @@ -0,0 +1,313 @@ +package ua.gov.diia.home.ui + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavDirections +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import ua.gov.diia.core.controller.DeeplinkProcessor +import ua.gov.diia.core.controller.NotificationController +import ua.gov.diia.core.controller.PromoController +import ua.gov.diia.core.di.actions.GlobalActionAllowAuthorizedLinks +import ua.gov.diia.core.di.actions.GlobalActionConfirmDocumentRemoval +import ua.gov.diia.core.di.actions.GlobalActionDocLoadingIndicator +import ua.gov.diia.core.di.actions.GlobalActionFocusOnDocument +import ua.gov.diia.core.di.actions.GlobalActionSelectedMenuItem +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.delegation.WithDeeplinkHandling +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.diia_storage.store.datasource.itn.ItnDataRepository +import ua.gov.diia.home.R +import ua.gov.diia.home.model.HomeMenuItem +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.tab.TabItemMoleculeData +import ua.gov.diia.ui_base.components.organism.bottom.TabBarOrganismData +import javax.inject.Inject + +@HiltViewModel +class HomeVM @Inject constructor( + private val promoController: PromoController, + private val notificationController: NotificationController, + private val itnDataSource: ItnDataRepository, + private val dispatcherProvider: DispatcherProvider, + @GlobalActionAllowAuthorizedLinks val allowAuthorizedLinksFlow: MutableSharedFlow>, + @GlobalActionDocLoadingIndicator val globalActionDocLoadingIndicator: MutableSharedFlow>, + @GlobalActionConfirmDocumentRemoval val globalActionConfirmDocumentRemoval: MutableStateFlow?>, + @GlobalActionFocusOnDocument val globalActionFocusOnDocument: MutableStateFlow?>, + @GlobalActionSelectedMenuItem val globalActionSelectedMenuItem: MutableStateFlow?>, + private val withRetryLastAction: WithRetryLastAction, + private val errorHandlingDelegate: WithErrorHandling, + private val deepLinkDelegate: WithDeeplinkHandling, + private val composeMapper: HomeScreenComposeMapper, + private val deeplinkProcessor: DeeplinkProcessor, + private val withCrashlytics: WithCrashlytics, +) : ViewModel(), + WithErrorHandling by errorHandlingDelegate, + WithRetryLastAction by withRetryLastAction, + WithDeeplinkHandling by deepLinkDelegate, + HomeScreenComposeMapper by composeMapper { + + private val _bottomData = mutableStateListOf() + val bottomData: SnapshotStateList = _bottomData + + private var _processNavigation = MutableLiveData>() + val processNavigation: LiveData> + get() = _processNavigation + + private val _showTemplate = MutableLiveData>() + val showTemplate: LiveData> + get() = _showTemplate + + private val _processCode = MutableLiveData() + + private val _hasUnreadNotifications = MediatorLiveData(null) + val hasUnreadNotifications: LiveData + get() = _hasUnreadNotifications + + val selectedMenuItem: LiveData?> = + globalActionSelectedMenuItem.asLiveData() + + val isLoadIndicatorHomeScreen: LiveData = + globalActionDocLoadingIndicator.asLiveData().map { + it.getContentIfNotHandled() ?: false + } + + private val _nonce = MutableLiveData() + val nonce: LiveData + get() = _nonce + + private var selectedTabId: String? = null + + private var _notificationsRequested = MutableLiveData>() + val notificationsRequested: LiveData> + get() = _notificationsRequested + + init { + viewModelScope.launch { + notificationController.getNotificationsInitial() + notificationController.collectUnreadNotificationCounts { + val hasUnreadNotification = it != 0 + val previousBadgeValue = _hasUnreadNotifications.value + _hasUnreadNotifications.postValue(hasUnreadNotification) + + if (hasUnreadNotification != previousBadgeValue) { + updateMenuTabItemData(hasUnreadNotification) + } + } + } + + checkPromo() + + invalidateDataSource() + + //selects initial menu item + configureBottomTabBar() + setSelectedMenuItem(HomeMenuItem.FEED) + checkPushTokenInSync() + } + + private fun configureBottomTabBar() { + val tabFeed = TabItemMoleculeData( + label = "Стрічка", + iconSelected = UiText.StringResource(R.drawable.ic_tab_feed_selected), + iconUnselected = UiText.StringResource(R.drawable.ic_tab_feed_unselected), + actionKey = HomeActions.HOME_FEED.toString(), + id = HomeActions.HOME_FEED.toString() + ) + val tabDocuments = TabItemMoleculeData( + label = "Документи", + iconSelected = UiText.StringResource(R.drawable.ic_tab_documents_selected), + iconUnselected = UiText.StringResource(R.drawable.ic_tab_documents_unselected), + actionKey = HomeActions.HOME_DOCUMENTS.toString(), + id = HomeActions.HOME_DOCUMENTS.toString() + ) + val tabServices = TabItemMoleculeData( + label = "Сервіси", + iconSelected = UiText.StringResource(R.drawable.ic_tab_services_selected), + iconUnselected = UiText.StringResource(R.drawable.ic_tab_services_unselected), + actionKey = HomeActions.HOME_SERVICES.toString(), + id = HomeActions.HOME_SERVICES.toString() + ) + val tabMenu = TabItemMoleculeData( + label = "Меню", + iconSelected = UiText.StringResource(R.drawable.ic_tab_menu_selected), + iconUnselected = UiText.StringResource(R.drawable.ic_tab_menu_unselected), + iconSelectedWithBadge = UiText.StringResource(R.drawable.ic_tab_menu_selected_badge), + iconUnselectedWithBadge = UiText.StringResource(R.drawable.ic_tab_menu_unselected_badge), + actionKey = HomeActions.HOME_MENU.toString(), + id = HomeActions.HOME_MENU.toString(), + showBadge = hasUnreadNotifications.value ?: false + ) + val tabs = listOf(tabFeed, tabDocuments, tabServices, tabMenu) + val resultList = tabs.toComposeTabBarOrganism() + + _bottomData.addIfNotNull(resultList) + } + + fun onUIAction(event: UIAction) { + executeAction { + when (event.actionKey) { + HomeActions.HOME_MENU.toString() -> setSelectedMenuItem(HomeMenuItem.MENU) + HomeActions.HOME_DOCUMENTS.toString() -> setSelectedMenuItem(HomeMenuItem.DOCUMENTS) + HomeActions.HOME_SERVICES.toString() -> setSelectedMenuItem(HomeMenuItem.SERVICES) + HomeActions.HOME_FEED.toString() -> setSelectedMenuItem(HomeMenuItem.FEED) + + } + } + } + + private fun onTabChoice(data: String) { + selectedTabId = data + val index = _bottomData.indexOfFirst { + it is TabBarOrganismData + } + if (index == -1) { + return + } else { + _bottomData[index] = + (_bottomData[index] as TabBarOrganismData).onTabClicked(selectedTabId) + } + } + + private fun setSelectedMenuItem(menuItem: HomeMenuItemConstructor) { + viewModelScope.launch { + globalActionSelectedMenuItem.emit(UiDataEvent(menuItem)) + onTabChoice(menuItem.position.toString()) + } + } + + private fun invalidateDataSource() { + viewModelScope.launch { + try { + notificationController.invalidateNotificationDataSource() + itnDataSource.invalidate() + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + } + fun confirmRemoveDocFromGallery(docName: String) { + viewModelScope.launch { + globalActionConfirmDocumentRemoval.emit(UiDataEvent(docName)) + } + } + + fun showDataLoadingIndicator(load: Boolean) { + viewModelScope.launch { + globalActionDocLoadingIndicator.emit(UiDataEvent(load)) + } + } + + private fun checkPushTokenInSync() { + viewModelScope.launch(dispatcherProvider.ioDispatcher()) { + + try { + notificationController.checkPushTokenInSync() + } catch (exc: Exception) { + withCrashlytics.sendNonFatalError(exc) + } + } + } + + private fun checkPromo() { + viewModelScope.launch { + try { + promoController.checkPromo {promoTemplate -> + promoTemplate.processCode.let { _processCode.value = it } + _showTemplate.value = UiDataEvent(promoTemplate.template) + } + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + } + + + fun updatePromoProcessCode() { + viewModelScope.launch { + _processCode.value?.let { promoController.updatePromoProcessCode(it) } + } + } + + fun subscribeToBetaByCode() { + viewModelScope.launch { + try { + promoController.subscribeToBetaByCode(_processCode.value) + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + } + + fun allowAuthorizedDeepLinks() { + viewModelScope.launch { + allowAuthorizedLinksFlow.emit(UiDataEvent(true)) + } + } + + fun checkNotificationsRequested() { + viewModelScope.launch { + notificationController.checkNotificationsRequested()?.let { + _notificationsRequested.value = UiDataEvent(it) + } + } + } + + fun allowNotifications() { + viewModelScope.launch { + notificationController.allowNotifications() + } + } + + fun denyNotifications() { + viewModelScope.launch { + notificationController.denyNotifications() + } + } + + private fun updateMenuTabItemData(hasUnreadNotification: Boolean) { + _bottomData.findAndChangeFirstByInstance { tabOrganism -> + + val menuItem = tabOrganism.tabs.find { it.id == HomeActions.HOME_MENU.toString()} ?: return@findAndChangeFirstByInstance tabOrganism + val index = tabOrganism.tabs.indexOfLast { it.id == HomeActions.HOME_MENU.toString() } + tabOrganism.tabs[index] = menuItem.copy(showBadge = hasUnreadNotification) + tabOrganism + } + } + + fun focusOnDocumentType(documentType: String) { + viewModelScope.launch { + globalActionFocusOnDocument.emit(UiDataEvent(documentType)) + } + } + + suspend fun handleDeepLinks() { + deeplinkFlow.collectLatest { + it?.getContentIfNotHandled()?.let { action -> + deeplinkProcessor.handleDeepLinkAction(action)?.let {route -> + _processNavigation.value = UiDataEvent(route) + } + } + } + } +} \ No newline at end of file diff --git a/home/src/main/java/ua/gov/diia/home/ui/views/DiiaAppBarCV.kt b/home/src/main/java/ua/gov/diia/home/ui/views/DiiaAppBarCV.kt new file mode 100644 index 0000000..e5a04c0 --- /dev/null +++ b/home/src/main/java/ua/gov/diia/home/ui/views/DiiaAppBarCV.kt @@ -0,0 +1,58 @@ +package ua.gov.diia.home.ui.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.DrawableRes +import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat +import ua.gov.diia.core.util.extensions.validateResource +import ua.gov.diia.home.R + +class DiiaAppBarCV @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : FrameLayout(context, attrs, defStyleAttr) { + + var onQrScanButtonPressed: (() -> Unit)? = null + + init { + inflate(context, R.layout.view_diia_app_bar, this) + + findViewById(R.id.iv_scan_qr).setOnClickListener { + onQrScanButtonPressed?.invoke() + } + attrs?.let { + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.DiiaAppBarCV) + if (typedArray.hasValue(R.styleable.DiiaAppBarCV_header)) { + val title = typedArray.getString(R.styleable.DiiaAppBarCV_header) + findViewById(R.id.app_bar_header).text = title + } + typedArray.recycle() + } + } + + fun setHeader(header: String) { + findViewById(R.id.app_bar_header).text = header + } + + fun setIcon(@DrawableRes res: Int?) { + val ic = findViewById(R.id.iv_scan_qr) + res.validateResource( + valid = { drawableRes -> + ic.setImageDrawable(VectorDrawableCompat.create(resources, drawableRes, null)) + }, + invalid = { + ic.visibility = View.GONE + } + ) + } + + fun setIconVisible(visible: Boolean) { + findViewById(R.id.iv_scan_qr).visibility = if (visible) View.VISIBLE else View.GONE + } + +} \ No newline at end of file diff --git a/home/src/main/res/drawable/ic_qr.xml b/home/src/main/res/drawable/ic_qr.xml new file mode 100644 index 0000000..ab9fe76 --- /dev/null +++ b/home/src/main/res/drawable/ic_qr.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/home/src/main/res/layout/fragment_home.xml b/home/src/main/res/layout/fragment_home.xml new file mode 100644 index 0000000..2ab64f9 --- /dev/null +++ b/home/src/main/res/layout/fragment_home.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/home/src/main/res/layout/view_diia_app_bar.xml b/home/src/main/res/layout/view_diia_app_bar.xml new file mode 100644 index 0000000..0e1c6bb --- /dev/null +++ b/home/src/main/res/layout/view_diia_app_bar.xml @@ -0,0 +1,47 @@ + + + + + + + + + \ No newline at end of file diff --git a/home/src/main/res/navigation/nav_home.xml b/home/src/main/res/navigation/nav_home.xml new file mode 100644 index 0000000..08c9880 --- /dev/null +++ b/home/src/main/res/navigation/nav_home.xml @@ -0,0 +1,15 @@ + + + + + + + \ No newline at end of file diff --git a/home/src/main/res/raw/gradient_bg.json b/home/src/main/res/raw/gradient_bg.json new file mode 100644 index 0000000..58a8510 --- /dev/null +++ b/home/src/main/res/raw/gradient_bg.json @@ -0,0 +1 @@ +{"nm":"export","ddd":0,"h":2436,"w":1125,"meta":{"g":"@lottiefiles/toolkit-js 0.33.2"},"layers":[{"ty":4,"nm":"gradient1","sr":1,"st":0,"op":360,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[558.944,1749.6,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Rectangle 1","ix":1,"cix":2,"np":2,"it":[{"ty":"rc","bm":0,"hd":false,"mn":"ADBE Vector Shape - Rect","nm":"Rectangle Path 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"s":{"a":0,"k":[1125,2436],"ix":2}},{"ty":"gf","bm":0,"hd":false,"mn":"ADBE Vector Graphic - G-Fill","nm":"Gradient Fill 1","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.919},"s":[446.793,-1249.8],"t":0},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.915},"s":[449.657,-1249.329],"t":1},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.91},"s":[452.37,-1248.681],"t":2},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.904},"s":[454.915,-1247.88],"t":3},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.896},"s":[457.285,-1246.923],"t":4},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.888},"s":[459.472,-1245.816],"t":5},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.879},"s":[461.466,-1244.564],"t":6},{"o":{"x":0.167,"y":0.125},"i":{"x":0.833,"y":0.87},"s":[463.26,-1243.174],"t":7},{"o":{"x":0.167,"y":0.134},"i":{"x":0.833,"y":0.861},"s":[464.85,-1241.655],"t":8},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.854},"s":[466.24,-1240.024],"t":9},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.847},"s":[467.431,-1238.294],"t":10},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.842},"s":[468.423,-1236.48],"t":11},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[469.22,-1234.596],"t":12},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.836},"s":[469.824,-1232.661],"t":13},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.834},"s":[470.24,-1230.691],"t":14},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.834},"s":[470.473,-1228.704],"t":15},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[470.53,-1226.719],"t":16},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.837},"s":[470.419,-1224.755],"t":17},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.84},"s":[470.149,-1222.831],"t":18},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.843},"s":[469.729,-1220.967],"t":19},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.848},"s":[469.172,-1219.185],"t":20},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.854},"s":[468.487,-1217.503],"t":21},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.861},"s":[467.689,-1215.944],"t":22},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.869},"s":[466.791,-1214.529],"t":23},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.88},"s":[465.807,-1213.28],"t":24},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.892},"s":[464.752,-1212.22],"t":25},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.896},"s":[463.642,-1211.37],"t":26},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.879},"s":[462.494,-1210.754],"t":27},{"o":{"x":0.167,"y":0.103},"i":{"x":0.833,"y":0.863},"s":[461.315,-1210.162],"t":28},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.854},"s":[460.102,-1209.199],"t":29},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.848},"s":[458.871,-1207.889],"t":30},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.845},"s":[457.638,-1206.265],"t":31},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.842},"s":[456.416,-1204.358],"t":32},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.84},"s":[455.219,-1202.196],"t":33},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.839},"s":[454.06,-1199.808],"t":34},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.838},"s":[452.949,-1197.221],"t":35},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.837},"s":[451.898,-1194.461],"t":36},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.836},"s":[450.916,-1191.552],"t":37},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.836},"s":[450.013,-1188.518],"t":38},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[449.196,-1185.381],"t":39},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.835},"s":[448.475,-1182.161],"t":40},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.834},"s":[447.855,-1178.879],"t":41},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.834},"s":[447.343,-1175.552],"t":42},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.834},"s":[446.945,-1172.197],"t":43},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.834},"s":[446.665,-1168.83],"t":44},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.834},"s":[446.51,-1165.463],"t":45},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.834},"s":[446.482,-1162.111],"t":46},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[446.586,-1158.784],"t":47},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[446.825,-1155.492],"t":48},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.836},"s":[447.201,-1152.242],"t":49},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[447.717,-1149.042],"t":50},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.839},"s":[448.375,-1145.898],"t":51},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[449.177,-1142.812],"t":52},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.843},"s":[450.123,-1139.787],"t":53},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.845},"s":[451.217,-1136.823],"t":54},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.847},"s":[452.457,-1133.921],"t":55},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.852},"s":[453.845,-1131.076],"t":56},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.856},"s":[455.382,-1128.286],"t":57},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.857},"s":[456.977,-1125.592],"t":58},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.857},"s":[458.465,-1123.082],"t":59},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.858},"s":[459.838,-1120.76],"t":60},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.859},"s":[461.091,-1118.631],"t":61},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.859},"s":[462.221,-1116.696],"t":62},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.86},"s":[463.223,-1114.958],"t":63},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.861},"s":[464.092,-1113.418],"t":64},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.861},"s":[464.826,-1112.076],"t":65},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.863},"s":[465.42,-1110.933],"t":66},{"o":{"x":0.167,"y":0.179},"i":{"x":0.833,"y":0.864},"s":[465.871,-1109.989],"t":67},{"o":{"x":0.167,"y":0.192},"i":{"x":0.833,"y":0.867},"s":[466.174,-1109.243],"t":68},{"o":{"x":0.167,"y":0.212},"i":{"x":0.833,"y":0.874},"s":[466.325,-1108.693],"t":69},{"o":{"x":0.167,"y":0.188},"i":{"x":0.833,"y":0.881},"s":[466.322,-1108.338],"t":70},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.881},"s":[466.161,-1108.174],"t":71},{"o":{"x":0.167,"y":0.073},"i":{"x":0.833,"y":0.874},"s":[465.839,-1108.199],"t":72},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.871},"s":[465.351,-1108.411],"t":73},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.87},"s":[464.695,-1108.806],"t":74},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.87},"s":[463.868,-1109.379],"t":75},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.878},"s":[462.866,-1110.129],"t":76},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.894},"s":[461.685,-1111.05],"t":77},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.907},"s":[460.321,-1111.984],"t":78},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.916},"s":[458.783,-1112.656],"t":79},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.92},"s":[457.095,-1113.077],"t":80},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.92},"s":[455.28,-1113.263],"t":81},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.917},"s":[453.362,-1113.227],"t":82},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.913},"s":[451.364,-1112.985],"t":83},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.909},"s":[449.306,-1112.549],"t":84},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.904},"s":[447.21,-1111.935],"t":85},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.899},"s":[445.097,-1111.157],"t":86},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.895},"s":[442.984,-1110.227],"t":87},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.891},"s":[440.889,-1109.161],"t":88},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.901},"s":[438.833,-1107.968],"t":89},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.91},"s":[436.826,-1106.671],"t":90},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.906},"s":[434.602,-1105.951],"t":91},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.902},"s":[432.467,-1105.13],"t":92},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.897},"s":[430.436,-1104.222],"t":93},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.893},"s":[428.522,-1103.239],"t":94},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[426.736,-1102.194],"t":95},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.883},"s":[425.09,-1101.099],"t":96},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.878},"s":[423.592,-1099.968],"t":97},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.872},"s":[422.252,-1098.814],"t":98},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.867},"s":[421.077,-1097.649],"t":99},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.861},"s":[420.073,-1096.487],"t":100},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.855},"s":[419.246,-1095.341],"t":101},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.849},"s":[418.599,-1094.226],"t":102},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.844},"s":[418.136,-1093.155],"t":103},{"o":{"x":0.167,"y":0.173},"i":{"x":0.833,"y":0.842},"s":[417.859,-1092.142],"t":104},{"o":{"x":0.167,"y":0.174},"i":{"x":0.833,"y":0.843},"s":[417.767,-1091.202],"t":105},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.842},"s":[417.859,-1090.351],"t":106},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.836},"s":[418.134,-1089.602],"t":107},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.838},"s":[418.54,-1088.874],"t":108},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.84},"s":[418.985,-1088.007],"t":109},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.841},"s":[419.463,-1087.027],"t":110},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.843},"s":[419.967,-1085.961],"t":111},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.845},"s":[420.492,-1084.834],"t":112},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.847},"s":[421.034,-1083.673],"t":113},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.849},"s":[421.587,-1082.502],"t":114},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.851},"s":[422.144,-1081.347],"t":115},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.854},"s":[422.702,-1080.23],"t":116},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.857},"s":[423.255,-1079.175],"t":117},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.862},"s":[423.798,-1078.203],"t":118},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.869},"s":[424.326,-1077.335],"t":119},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.878},"s":[424.835,-1076.591],"t":120},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.893},"s":[425.319,-1075.99],"t":121},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.912},"s":[425.775,-1075.55],"t":122},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.913},"s":[426.198,-1075.286],"t":123},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.868},"s":[426.585,-1075.215],"t":124},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.826},"s":[426.933,-1075.351],"t":125},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.815},"s":[427.239,-1075.706],"t":126},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.814},"s":[427.499,-1076.293],"t":127},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.816},"s":[427.694,-1077.12],"t":128},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.819},"s":[427.79,-1078.187],"t":129},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.821},"s":[427.783,-1079.49],"t":130},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.824},"s":[427.675,-1081.022],"t":131},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.826},"s":[427.464,-1082.779],"t":132},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.828},"s":[427.153,-1084.757],"t":133},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.829},"s":[426.744,-1086.949],"t":134},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.83},"s":[426.239,-1089.352],"t":135},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.831},"s":[425.642,-1091.958],"t":136},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.832},"s":[424.959,-1094.762],"t":137},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.833},"s":[424.196,-1097.758],"t":138},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.834},"s":[423.359,-1100.936],"t":139},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.834},"s":[422.455,-1104.29],"t":140},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[421.495,-1107.81],"t":141},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[420.488,-1111.484],"t":142},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[419.445,-1115.303],"t":143},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.836},"s":[418.377,-1119.252],"t":144},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[417.298,-1123.318],"t":145},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[416.222,-1127.484],"t":146},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.836},"s":[415.165,-1131.733],"t":147},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.836},"s":[414.141,-1136.046],"t":148},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.836},"s":[413.17,-1140.402],"t":149},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.836},"s":[412.269,-1144.777],"t":150},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.836},"s":[411.459,-1149.146],"t":151},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.836},"s":[410.759,-1153.482],"t":152},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.837},"s":[410.193,-1157.755],"t":153},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.837},"s":[409.783,-1161.932],"t":154},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.838},"s":[409.554,-1165.979],"t":155},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.837},"s":[409.532,-1169.858],"t":156},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.834},"s":[409.743,-1173.529],"t":157},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.835},"s":[410.211,-1177.058],"t":158},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.837},"s":[410.928,-1180.622],"t":159},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.839},"s":[411.871,-1184.213],"t":160},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[413.018,-1187.824],"t":161},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.843},"s":[414.347,-1191.446],"t":162},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.845},"s":[415.835,-1195.07],"t":163},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.846},"s":[417.463,-1198.69],"t":164},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.848},"s":[419.208,-1202.296],"t":165},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.849},"s":[421.051,-1205.882],"t":166},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.851},"s":[422.971,-1209.439],"t":167},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.852},"s":[424.949,-1212.961],"t":168},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.853},"s":[426.966,-1216.439],"t":169},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.853},"s":[429.003,-1219.868],"t":170},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.854},"s":[431.042,-1223.24],"t":171},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.854},"s":[433.065,-1226.547],"t":172},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.854},"s":[435.056,-1229.782],"t":173},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.855},"s":[436.998,-1232.939],"t":174},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.855},"s":[438.875,-1236.009],"t":175},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.854},"s":[440.673,-1238.986],"t":176},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.854},"s":[442.377,-1241.861],"t":177},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.853},"s":[443.974,-1244.627],"t":178},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.882},"s":[445.449,-1247.277],"t":179},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.918},"s":[446.793,-1249.8],"t":180},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.915},"s":[449.657,-1249.329],"t":181},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.91},"s":[452.37,-1248.681],"t":182},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.904},"s":[454.915,-1247.88],"t":183},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.896},"s":[457.285,-1246.923],"t":184},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.888},"s":[459.472,-1245.816],"t":185},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.879},"s":[461.466,-1244.564],"t":186},{"o":{"x":0.167,"y":0.125},"i":{"x":0.833,"y":0.87},"s":[463.26,-1243.174],"t":187},{"o":{"x":0.167,"y":0.134},"i":{"x":0.833,"y":0.861},"s":[464.85,-1241.655],"t":188},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.854},"s":[466.24,-1240.024],"t":189},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.847},"s":[467.431,-1238.294],"t":190},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.842},"s":[468.423,-1236.48],"t":191},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[469.22,-1234.596],"t":192},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.836},"s":[469.824,-1232.661],"t":193},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.834},"s":[470.24,-1230.691],"t":194},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.834},"s":[470.473,-1228.704],"t":195},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[470.53,-1226.719],"t":196},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.837},"s":[470.419,-1224.755],"t":197},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.84},"s":[470.149,-1222.831],"t":198},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.843},"s":[469.729,-1220.967],"t":199},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.848},"s":[469.172,-1219.185],"t":200},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.854},"s":[468.487,-1217.503],"t":201},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.861},"s":[467.689,-1215.944],"t":202},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.869},"s":[466.791,-1214.529],"t":203},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.88},"s":[465.807,-1213.28],"t":204},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.892},"s":[464.752,-1212.22],"t":205},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.896},"s":[463.642,-1211.37],"t":206},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.879},"s":[462.494,-1210.754],"t":207},{"o":{"x":0.167,"y":0.103},"i":{"x":0.833,"y":0.863},"s":[461.315,-1210.162],"t":208},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.854},"s":[460.102,-1209.199],"t":209},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.848},"s":[458.871,-1207.889],"t":210},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.845},"s":[457.638,-1206.265],"t":211},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.842},"s":[456.416,-1204.358],"t":212},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.84},"s":[455.219,-1202.196],"t":213},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.839},"s":[454.06,-1199.808],"t":214},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.838},"s":[452.949,-1197.221],"t":215},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.837},"s":[451.898,-1194.461],"t":216},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.836},"s":[450.916,-1191.552],"t":217},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.836},"s":[450.013,-1188.518],"t":218},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[449.196,-1185.381],"t":219},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.835},"s":[448.475,-1182.161],"t":220},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.834},"s":[447.855,-1178.879],"t":221},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.834},"s":[447.343,-1175.552],"t":222},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.834},"s":[446.945,-1172.197],"t":223},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.834},"s":[446.665,-1168.83],"t":224},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.834},"s":[446.51,-1165.463],"t":225},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.834},"s":[446.482,-1162.111],"t":226},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[446.586,-1158.784],"t":227},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[446.825,-1155.492],"t":228},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.836},"s":[447.201,-1152.242],"t":229},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[447.717,-1149.042],"t":230},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.839},"s":[448.375,-1145.898],"t":231},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[449.177,-1142.812],"t":232},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.843},"s":[450.123,-1139.787],"t":233},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.845},"s":[451.217,-1136.823],"t":234},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.847},"s":[452.457,-1133.921],"t":235},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.852},"s":[453.845,-1131.076],"t":236},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.856},"s":[455.382,-1128.286],"t":237},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.857},"s":[456.977,-1125.592],"t":238},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.857},"s":[458.465,-1123.082],"t":239},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.858},"s":[459.838,-1120.76],"t":240},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.859},"s":[461.091,-1118.631],"t":241},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.859},"s":[462.221,-1116.696],"t":242},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.86},"s":[463.223,-1114.958],"t":243},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.861},"s":[464.092,-1113.418],"t":244},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.861},"s":[464.826,-1112.076],"t":245},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.863},"s":[465.42,-1110.933],"t":246},{"o":{"x":0.167,"y":0.179},"i":{"x":0.833,"y":0.864},"s":[465.871,-1109.989],"t":247},{"o":{"x":0.167,"y":0.192},"i":{"x":0.833,"y":0.867},"s":[466.174,-1109.243],"t":248},{"o":{"x":0.167,"y":0.212},"i":{"x":0.833,"y":0.874},"s":[466.325,-1108.693],"t":249},{"o":{"x":0.167,"y":0.188},"i":{"x":0.833,"y":0.881},"s":[466.322,-1108.338],"t":250},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.881},"s":[466.161,-1108.174],"t":251},{"o":{"x":0.167,"y":0.073},"i":{"x":0.833,"y":0.874},"s":[465.839,-1108.199],"t":252},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.871},"s":[465.351,-1108.411],"t":253},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.87},"s":[464.695,-1108.806],"t":254},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.87},"s":[463.868,-1109.379],"t":255},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.878},"s":[462.866,-1110.129],"t":256},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.894},"s":[461.685,-1111.05],"t":257},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.907},"s":[460.321,-1111.984],"t":258},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.916},"s":[458.783,-1112.656],"t":259},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.92},"s":[457.095,-1113.077],"t":260},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.92},"s":[455.28,-1113.263],"t":261},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.917},"s":[453.362,-1113.227],"t":262},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.913},"s":[451.364,-1112.985],"t":263},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.909},"s":[449.306,-1112.549],"t":264},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.904},"s":[447.21,-1111.935],"t":265},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.899},"s":[445.097,-1111.157],"t":266},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.895},"s":[442.984,-1110.227],"t":267},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.891},"s":[440.889,-1109.161],"t":268},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.901},"s":[438.833,-1107.968],"t":269},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.91},"s":[436.826,-1106.671],"t":270},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.906},"s":[434.602,-1105.951],"t":271},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.902},"s":[432.467,-1105.13],"t":272},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.897},"s":[430.436,-1104.222],"t":273},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.893},"s":[428.522,-1103.239],"t":274},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[426.736,-1102.194],"t":275},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.883},"s":[425.09,-1101.099],"t":276},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.878},"s":[423.592,-1099.968],"t":277},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.872},"s":[422.252,-1098.814],"t":278},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.867},"s":[421.077,-1097.649],"t":279},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.861},"s":[420.073,-1096.487],"t":280},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.855},"s":[419.246,-1095.341],"t":281},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.849},"s":[418.599,-1094.226],"t":282},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.844},"s":[418.136,-1093.155],"t":283},{"o":{"x":0.167,"y":0.173},"i":{"x":0.833,"y":0.842},"s":[417.859,-1092.142],"t":284},{"o":{"x":0.167,"y":0.174},"i":{"x":0.833,"y":0.843},"s":[417.767,-1091.202],"t":285},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.842},"s":[417.859,-1090.351],"t":286},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.836},"s":[418.134,-1089.602],"t":287},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.838},"s":[418.54,-1088.874],"t":288},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.84},"s":[418.985,-1088.007],"t":289},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.841},"s":[419.463,-1087.027],"t":290},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.843},"s":[419.967,-1085.961],"t":291},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.845},"s":[420.492,-1084.834],"t":292},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.847},"s":[421.034,-1083.673],"t":293},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.849},"s":[421.587,-1082.502],"t":294},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.851},"s":[422.144,-1081.347],"t":295},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.854},"s":[422.702,-1080.23],"t":296},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.857},"s":[423.255,-1079.175],"t":297},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.862},"s":[423.798,-1078.203],"t":298},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.869},"s":[424.326,-1077.335],"t":299},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.878},"s":[424.835,-1076.591],"t":300},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.893},"s":[425.319,-1075.99],"t":301},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.912},"s":[425.775,-1075.55],"t":302},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.913},"s":[426.198,-1075.286],"t":303},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.868},"s":[426.585,-1075.215],"t":304},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.826},"s":[426.933,-1075.351],"t":305},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.815},"s":[427.239,-1075.706],"t":306},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.814},"s":[427.499,-1076.293],"t":307},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.816},"s":[427.694,-1077.12],"t":308},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.819},"s":[427.79,-1078.187],"t":309},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.821},"s":[427.783,-1079.49],"t":310},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.824},"s":[427.675,-1081.022],"t":311},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.826},"s":[427.464,-1082.779],"t":312},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.828},"s":[427.153,-1084.757],"t":313},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.829},"s":[426.744,-1086.949],"t":314},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.83},"s":[426.239,-1089.352],"t":315},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.831},"s":[425.642,-1091.958],"t":316},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.832},"s":[424.959,-1094.762],"t":317},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.833},"s":[424.196,-1097.758],"t":318},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.834},"s":[423.359,-1100.936],"t":319},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.834},"s":[422.455,-1104.29],"t":320},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[421.495,-1107.81],"t":321},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[420.488,-1111.484],"t":322},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[419.445,-1115.303],"t":323},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.836},"s":[418.377,-1119.252],"t":324},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[417.298,-1123.318],"t":325},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[416.222,-1127.484],"t":326},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.836},"s":[415.165,-1131.733],"t":327},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.836},"s":[414.141,-1136.046],"t":328},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.836},"s":[413.17,-1140.402],"t":329},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.836},"s":[412.269,-1144.777],"t":330},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.836},"s":[411.459,-1149.146],"t":331},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.836},"s":[410.759,-1153.482],"t":332},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.837},"s":[410.193,-1157.755],"t":333},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.837},"s":[409.783,-1161.932],"t":334},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.838},"s":[409.554,-1165.979],"t":335},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.837},"s":[409.532,-1169.858],"t":336},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.834},"s":[409.743,-1173.529],"t":337},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.835},"s":[410.211,-1177.058],"t":338},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.837},"s":[410.928,-1180.622],"t":339},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.839},"s":[411.871,-1184.213],"t":340},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[413.018,-1187.824],"t":341},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.843},"s":[414.347,-1191.446],"t":342},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.845},"s":[415.835,-1195.07],"t":343},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.846},"s":[417.463,-1198.69],"t":344},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.848},"s":[419.208,-1202.296],"t":345},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.849},"s":[421.051,-1205.882],"t":346},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.851},"s":[422.971,-1209.439],"t":347},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.852},"s":[424.949,-1212.961],"t":348},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.853},"s":[426.966,-1216.439],"t":349},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.853},"s":[429.003,-1219.868],"t":350},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.854},"s":[431.042,-1223.24],"t":351},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.854},"s":[433.065,-1226.547],"t":352},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.854},"s":[435.056,-1229.782],"t":353},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.855},"s":[436.998,-1232.939],"t":354},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.855},"s":[438.875,-1236.009],"t":355},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.854},"s":[440.673,-1238.986],"t":356},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.854},"s":[442.377,-1241.861],"t":357},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.85},"s":[443.974,-1244.627],"t":358},{"s":[445.449,-1247.277],"t":359}],"ix":6},"g":{"p":3,"k":{"a":0,"k":[0,0.7490196078431373,0.9176470588235294,0.984313725490196,0.5,0.8745098039215686,0.9607843137254902,0.9921568627450981,1,1,1,1,0,1,0.5,0.5,1,0],"ix":9}},"t":2,"a":{"a":0,"k":0,"ix":8},"h":{"a":0,"k":0,"ix":7},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.894},"s":[-528.727,565.945],"t":0},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.894},"s":[-488.391,542.359],"t":1},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.895},"s":[-448.821,519.401],"t":2},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.895},"s":[-410.074,497.071],"t":3},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.895},"s":[-372.218,475.392],"t":4},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-335.312,454.369],"t":5},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-299.402,433.997],"t":6},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-264.535,414.27],"t":7},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-230.762,395.195],"t":8},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-198.128,376.77],"t":9},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-166.669,358.985],"t":10},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-136.413,341.823],"t":11},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.896},"s":[-107.391,325.269],"t":12},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.895},"s":[-79.631,309.316],"t":13},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.895},"s":[-53.157,293.948],"t":14},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.894},"s":[-27.986,279.146],"t":15},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.894},"s":[-4.128,264.883],"t":16},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.893},"s":[18.412,251.133],"t":17},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.891},"s":[39.631,237.867],"t":18},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.89},"s":[59.53,225.059],"t":19},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.888},"s":[78.113,212.681],"t":20},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.886},"s":[95.391,200.698],"t":21},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.884},"s":[111.382,189.076],"t":22},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.881},"s":[126.109,177.775],"t":23},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.878},"s":[139.6,166.753],"t":24},{"o":{"x":0.167,"y":0.13},"i":{"x":0.833,"y":0.874},"s":[151.888,155.969],"t":25},{"o":{"x":0.167,"y":0.134},"i":{"x":0.833,"y":0.87},"s":[163.008,145.381],"t":26},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.866},"s":[173.006,134.944],"t":27},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.861},"s":[181.929,124.61],"t":28},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.856},"s":[189.834,114.328],"t":29},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.851},"s":[196.782,104.046],"t":30},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.846},"s":[202.839,93.712],"t":31},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.842},"s":[208.077,83.272],"t":32},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.839},"s":[212.576,72.669],"t":33},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.836},"s":[216.42,61.844],"t":34},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.834},"s":[219.7,50.737],"t":35},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[222.516,39.285],"t":36},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.839},"s":[224.972,27.425],"t":37},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.84},"s":[227.253,15.461],"t":38},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.84},"s":[229.558,4.021],"t":39},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.84},"s":[231.917,-6.908],"t":40},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.841},"s":[234.359,-17.35],"t":41},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.842},"s":[236.911,-27.329],"t":42},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.843},"s":[239.599,-36.87],"t":43},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.844},"s":[242.45,-45.996],"t":44},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.845},"s":[245.492,-54.727],"t":45},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.847},"s":[248.749,-63.086],"t":46},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.849},"s":[252.246,-71.093],"t":47},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.852},"s":[256.007,-78.767],"t":48},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.855},"s":[260.057,-86.129],"t":49},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.858},"s":[264.416,-93.195],"t":50},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.862},"s":[269.106,-99.985],"t":51},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.865},"s":[274.147,-106.515],"t":52},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.869},"s":[279.558,-112.801],"t":53},{"o":{"x":0.167,"y":0.128},"i":{"x":0.833,"y":0.874},"s":[285.355,-118.859],"t":54},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.878},"s":[291.554,-124.705],"t":55},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.881},"s":[298.168,-130.353],"t":56},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[305.211,-135.817],"t":57},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[312.694,-141.114],"t":58},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[320.626,-146.257],"t":59},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.895},"s":[329.013,-151.259],"t":60},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.897},"s":[337.86,-156.134],"t":61},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.899},"s":[347.17,-160.892],"t":62},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.902},"s":[356.941,-165.547],"t":63},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.903},"s":[367.173,-170.112],"t":64},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.905},"s":[377.861,-174.599],"t":65},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.906},"s":[388.999,-179.021],"t":66},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.907},"s":[400.576,-183.388],"t":67},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.908},"s":[412.579,-187.712],"t":68},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.909},"s":[424.991,-192.002],"t":69},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.91},"s":[437.795,-196.271],"t":70},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.91},"s":[450.97,-200.531],"t":71},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[464.492,-204.795],"t":72},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[478.333,-209.074],"t":73},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.911},"s":[492.46,-213.379],"t":74},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.912},"s":[506.838,-217.718],"t":75},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.912},"s":[521.427,-222.102],"t":76},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[536.187,-226.546],"t":77},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[551.072,-231.062],"t":78},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[566.034,-235.663],"t":79},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.911},"s":[581.017,-240.359],"t":80},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.91},"s":[595.961,-245.158],"t":81},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.909},"s":[610.805,-250.077],"t":82},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.908},"s":[625.486,-255.131],"t":83},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.907},"s":[639.933,-260.334],"t":84},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.905},"s":[654.068,-265.695],"t":85},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.904},"s":[667.809,-271.225],"t":86},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.901},"s":[681.077,-276.946],"t":87},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.898},"s":[693.781,-282.871],"t":88},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.907},"s":[705.825,-289.011],"t":89},{"o":{"x":0.167,"y":0.178},"i":{"x":0.833,"y":0.858},"s":[717.112,-295.385],"t":90},{"o":{"x":0.167,"y":0.175},"i":{"x":0.833,"y":0.846},"s":[719.206,-289.49],"t":91},{"o":{"x":0.167,"y":0.176},"i":{"x":0.833,"y":0.844},"s":[720.516,-284.143],"t":92},{"o":{"x":0.167,"y":0.176},"i":{"x":0.833,"y":0.845},"s":[720.928,-279.361],"t":93},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.855},"s":[720.321,-275.161],"t":94},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.875},"s":[718.569,-271.563],"t":95},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.89},"s":[715.543,-268.585],"t":96},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.899},"s":[711.105,-266.247],"t":97},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.907},"s":[704.81,-264.307],"t":98},{"o":{"x":0.167,"y":0.074},"i":{"x":0.833,"y":0.911},"s":[696.13,-262.307],"t":99},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.914},"s":[685.197,-260.246],"t":100},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.916},"s":[672.153,-258.132],"t":101},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.917},"s":[657.136,-255.966],"t":102},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.918},"s":[640.275,-253.75],"t":103},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.918},"s":[621.7,-251.479],"t":104},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.919},"s":[601.532,-249.149],"t":105},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.919},"s":[579.891,-246.75],"t":106},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[556.89,-244.272],"t":107},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[532.641,-241.7],"t":108},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[507.248,-239.02],"t":109},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[480.814,-236.214],"t":110},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[453.439,-233.264],"t":111},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.92},"s":[425.217,-230.15],"t":112},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.92},"s":[396.239,-226.851],"t":113},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.92},"s":[366.594,-223.343],"t":114},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[336.367,-219.605],"t":115},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[305.639,-215.613],"t":116},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[274.489,-211.342],"t":117},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[242.994,-206.77],"t":118},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[211.226,-201.872],"t":119},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.918},"s":[179.255,-196.624],"t":120},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.918},"s":[147.15,-191.004],"t":121},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.917},"s":[114.977,-184.99],"t":122},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.917},"s":[82.797,-178.559],"t":123},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.916},"s":[50.673,-171.692],"t":124},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.915},"s":[18.663,-164.37],"t":125},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.914},"s":[-13.175,-156.576],"t":126},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.913},"s":[-44.787,-148.295],"t":127},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-76.118,-139.512],"t":128},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.91},"s":[-107.117,-130.217],"t":129},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.909},"s":[-137.733,-120.4],"t":130},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.907},"s":[-167.917,-110.057],"t":131},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.906},"s":[-197.621,-99.182],"t":132},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.904},"s":[-226.798,-87.776],"t":133},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.902},"s":[-255.402,-75.842],"t":134},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.9},"s":[-283.387,-63.386],"t":135},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.898},"s":[-310.71,-50.419],"t":136},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.896},"s":[-337.327,-36.954],"t":137},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.893},"s":[-363.194,-23.009],"t":138},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-388.269,-8.608],"t":139},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.888},"s":[-412.508,6.224],"t":140},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[-435.869,21.455],"t":141},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.883},"s":[-458.31,37.047],"t":142},{"o":{"x":0.167,"y":0.12},"i":{"x":0.833,"y":0.88},"s":[-479.788,52.959],"t":143},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.878},"s":[-500.26,69.144],"t":144},{"o":{"x":0.167,"y":0.126},"i":{"x":0.833,"y":0.875},"s":[-519.682,85.549],"t":145},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.872},"s":[-538.011,102.116],"t":146},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.869},"s":[-555.203,118.78],"t":147},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.867},"s":[-571.212,135.47],"t":148},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.864},"s":[-585.993,152.109],"t":149},{"o":{"x":0.167,"y":0.143},"i":{"x":0.833,"y":0.861},"s":[-599.498,168.613],"t":150},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.858},"s":[-611.68,184.892],"t":151},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.855},"s":[-622.49,200.848],"t":152},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.852},"s":[-631.877,216.375],"t":153},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.849},"s":[-639.789,231.362],"t":154},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.847},"s":[-646.173,245.687],"t":155},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.843},"s":[-650.973,259.224],"t":156},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.837},"s":[-654.133,271.835],"t":157},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.835},"s":[-655.715,283.66],"t":158},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.835},"s":[-655.985,295.191],"t":159},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.836},"s":[-655.068,306.517],"t":160},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.837},"s":[-653.078,317.711],"t":161},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.84},"s":[-650.126,328.838],"t":162},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.842},"s":[-646.316,339.962],"t":163},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.844},"s":[-641.748,351.138],"t":164},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.846},"s":[-636.516,362.417],"t":165},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.847},"s":[-630.71,373.848],"t":166},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.848},"s":[-624.415,385.472],"t":167},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[-617.71,397.327],"t":168},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[-610.671,409.446],"t":169},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[-603.369,421.86],"t":170},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[-595.872,434.592],"t":171},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.848},"s":[-588.242,447.664],"t":172},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.848},"s":[-580.538,461.095],"t":173},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.847},"s":[-572.815,474.899],"t":174},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.846},"s":[-565.125,489.086],"t":175},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.845},"s":[-557.516,503.664],"t":176},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.844},"s":[-550.032,518.639],"t":177},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.838},"s":[-542.714,534.012],"t":178},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.799},"s":[-535.601,549.781],"t":179},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.894},"s":[-528.727,565.945],"t":180},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.894},"s":[-488.391,542.359],"t":181},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.895},"s":[-448.821,519.401],"t":182},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.895},"s":[-410.074,497.071],"t":183},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.895},"s":[-372.218,475.392],"t":184},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-335.312,454.369],"t":185},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-299.402,433.997],"t":186},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-264.535,414.27],"t":187},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-230.762,395.195],"t":188},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-198.128,376.77],"t":189},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-166.669,358.985],"t":190},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.896},"s":[-136.413,341.823],"t":191},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.896},"s":[-107.391,325.269],"t":192},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.895},"s":[-79.631,309.316],"t":193},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.895},"s":[-53.157,293.948],"t":194},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.894},"s":[-27.986,279.146],"t":195},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.894},"s":[-4.128,264.883],"t":196},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.893},"s":[18.412,251.133],"t":197},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.891},"s":[39.631,237.867],"t":198},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.89},"s":[59.53,225.059],"t":199},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.888},"s":[78.113,212.681],"t":200},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.886},"s":[95.391,200.698],"t":201},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.884},"s":[111.382,189.076],"t":202},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.881},"s":[126.109,177.775],"t":203},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.878},"s":[139.6,166.753],"t":204},{"o":{"x":0.167,"y":0.13},"i":{"x":0.833,"y":0.874},"s":[151.888,155.969],"t":205},{"o":{"x":0.167,"y":0.134},"i":{"x":0.833,"y":0.87},"s":[163.008,145.381],"t":206},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.866},"s":[173.006,134.944],"t":207},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.861},"s":[181.929,124.61],"t":208},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.856},"s":[189.834,114.328],"t":209},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.851},"s":[196.782,104.046],"t":210},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.846},"s":[202.839,93.712],"t":211},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.842},"s":[208.077,83.272],"t":212},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.839},"s":[212.576,72.669],"t":213},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.836},"s":[216.42,61.844],"t":214},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.834},"s":[219.7,50.737],"t":215},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[222.516,39.285],"t":216},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.839},"s":[224.972,27.425],"t":217},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.84},"s":[227.253,15.461],"t":218},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.84},"s":[229.558,4.021],"t":219},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.84},"s":[231.917,-6.908],"t":220},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.841},"s":[234.359,-17.35],"t":221},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.842},"s":[236.911,-27.329],"t":222},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.843},"s":[239.599,-36.87],"t":223},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.844},"s":[242.45,-45.996],"t":224},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.845},"s":[245.492,-54.727],"t":225},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.847},"s":[248.749,-63.086],"t":226},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.849},"s":[252.246,-71.093],"t":227},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.852},"s":[256.007,-78.767],"t":228},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.855},"s":[260.057,-86.129],"t":229},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.858},"s":[264.416,-93.195],"t":230},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.862},"s":[269.106,-99.985],"t":231},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.865},"s":[274.147,-106.515],"t":232},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.869},"s":[279.558,-112.801],"t":233},{"o":{"x":0.167,"y":0.128},"i":{"x":0.833,"y":0.874},"s":[285.355,-118.859],"t":234},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.878},"s":[291.554,-124.705],"t":235},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.881},"s":[298.168,-130.353],"t":236},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[305.211,-135.817],"t":237},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[312.694,-141.114],"t":238},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[320.626,-146.257],"t":239},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.895},"s":[329.013,-151.259],"t":240},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.897},"s":[337.86,-156.134],"t":241},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.899},"s":[347.17,-160.892],"t":242},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.902},"s":[356.941,-165.547],"t":243},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.903},"s":[367.173,-170.112],"t":244},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.905},"s":[377.861,-174.599],"t":245},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.906},"s":[388.999,-179.021],"t":246},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.907},"s":[400.576,-183.388],"t":247},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.908},"s":[412.579,-187.712],"t":248},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.909},"s":[424.991,-192.002],"t":249},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.91},"s":[437.795,-196.271],"t":250},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.91},"s":[450.97,-200.531],"t":251},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[464.492,-204.795],"t":252},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[478.333,-209.074],"t":253},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.911},"s":[492.46,-213.379],"t":254},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.912},"s":[506.838,-217.718],"t":255},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.912},"s":[521.427,-222.102],"t":256},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[536.187,-226.546],"t":257},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[551.072,-231.062],"t":258},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.911},"s":[566.034,-235.663],"t":259},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.911},"s":[581.017,-240.359],"t":260},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.91},"s":[595.961,-245.158],"t":261},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.909},"s":[610.805,-250.077],"t":262},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.908},"s":[625.486,-255.131],"t":263},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.907},"s":[639.933,-260.334],"t":264},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.905},"s":[654.068,-265.695],"t":265},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.904},"s":[667.809,-271.225],"t":266},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.901},"s":[681.077,-276.946],"t":267},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.898},"s":[693.781,-282.871],"t":268},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.907},"s":[705.825,-289.011],"t":269},{"o":{"x":0.167,"y":0.178},"i":{"x":0.833,"y":0.858},"s":[717.112,-295.385],"t":270},{"o":{"x":0.167,"y":0.175},"i":{"x":0.833,"y":0.846},"s":[719.206,-289.49],"t":271},{"o":{"x":0.167,"y":0.176},"i":{"x":0.833,"y":0.844},"s":[720.516,-284.143],"t":272},{"o":{"x":0.167,"y":0.176},"i":{"x":0.833,"y":0.845},"s":[720.928,-279.361],"t":273},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.855},"s":[720.321,-275.161],"t":274},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.875},"s":[718.569,-271.563],"t":275},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.89},"s":[715.543,-268.585],"t":276},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.899},"s":[711.105,-266.247],"t":277},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.907},"s":[704.81,-264.307],"t":278},{"o":{"x":0.167,"y":0.074},"i":{"x":0.833,"y":0.911},"s":[696.13,-262.307],"t":279},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.914},"s":[685.197,-260.246],"t":280},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.916},"s":[672.153,-258.132],"t":281},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.917},"s":[657.136,-255.966],"t":282},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.918},"s":[640.275,-253.75],"t":283},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.918},"s":[621.7,-251.479],"t":284},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.919},"s":[601.532,-249.149],"t":285},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.919},"s":[579.891,-246.75],"t":286},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[556.89,-244.272],"t":287},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[532.641,-241.7],"t":288},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[507.248,-239.02],"t":289},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[480.814,-236.214],"t":290},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[453.439,-233.264],"t":291},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.92},"s":[425.217,-230.15],"t":292},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.92},"s":[396.239,-226.851],"t":293},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.92},"s":[366.594,-223.343],"t":294},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[336.367,-219.605],"t":295},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[305.639,-215.613],"t":296},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[274.489,-211.342],"t":297},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[242.994,-206.77],"t":298},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[211.226,-201.872],"t":299},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.918},"s":[179.255,-196.624],"t":300},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.918},"s":[147.15,-191.004],"t":301},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.917},"s":[114.977,-184.99],"t":302},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.917},"s":[82.797,-178.559],"t":303},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.916},"s":[50.673,-171.692],"t":304},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.915},"s":[18.663,-164.37],"t":305},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.914},"s":[-13.175,-156.576],"t":306},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.913},"s":[-44.787,-148.295],"t":307},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-76.118,-139.512],"t":308},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.91},"s":[-107.117,-130.217],"t":309},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.909},"s":[-137.733,-120.4],"t":310},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.907},"s":[-167.917,-110.057],"t":311},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.906},"s":[-197.621,-99.182],"t":312},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.904},"s":[-226.798,-87.776],"t":313},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.902},"s":[-255.402,-75.842],"t":314},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.9},"s":[-283.387,-63.386],"t":315},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.898},"s":[-310.71,-50.419],"t":316},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.896},"s":[-337.327,-36.954],"t":317},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.893},"s":[-363.194,-23.009],"t":318},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-388.269,-8.608],"t":319},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.888},"s":[-412.508,6.224],"t":320},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[-435.869,21.455],"t":321},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.883},"s":[-458.31,37.047],"t":322},{"o":{"x":0.167,"y":0.12},"i":{"x":0.833,"y":0.88},"s":[-479.788,52.959],"t":323},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.878},"s":[-500.26,69.144],"t":324},{"o":{"x":0.167,"y":0.126},"i":{"x":0.833,"y":0.875},"s":[-519.682,85.549],"t":325},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.872},"s":[-538.011,102.116],"t":326},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.869},"s":[-555.203,118.78],"t":327},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.867},"s":[-571.212,135.47],"t":328},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.864},"s":[-585.993,152.109],"t":329},{"o":{"x":0.167,"y":0.143},"i":{"x":0.833,"y":0.861},"s":[-599.498,168.613],"t":330},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.858},"s":[-611.68,184.892],"t":331},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.855},"s":[-622.49,200.848],"t":332},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.852},"s":[-631.877,216.375],"t":333},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.849},"s":[-639.789,231.362],"t":334},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.847},"s":[-646.173,245.687],"t":335},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.843},"s":[-650.973,259.224],"t":336},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.837},"s":[-654.133,271.835],"t":337},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.835},"s":[-655.715,283.66],"t":338},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.835},"s":[-655.985,295.191],"t":339},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.836},"s":[-655.068,306.517],"t":340},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.837},"s":[-653.078,317.711],"t":341},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.84},"s":[-650.126,328.838],"t":342},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.842},"s":[-646.316,339.962],"t":343},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.844},"s":[-641.748,351.138],"t":344},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.846},"s":[-636.516,362.417],"t":345},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.847},"s":[-630.71,373.848],"t":346},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.848},"s":[-624.415,385.472],"t":347},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[-617.71,397.327],"t":348},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[-610.671,409.446],"t":349},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[-603.369,421.86],"t":350},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[-595.872,434.592],"t":351},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.848},"s":[-588.242,447.664],"t":352},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.848},"s":[-580.538,461.095],"t":353},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.847},"s":[-572.815,474.899],"t":354},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.846},"s":[-565.125,489.086],"t":355},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.845},"s":[-557.516,503.664],"t":356},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.844},"s":[-550.032,518.639],"t":357},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.845},"s":[-542.714,534.012],"t":358},{"s":[-535.601,549.781],"t":359}],"ix":5},"r":1,"o":{"a":0,"k":100,"ix":10}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[3.056,-531.6],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":1},{"ty":4,"nm":"gradient4","sr":1,"st":0,"op":360,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[558.944,1749.6,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Rectangle 1","ix":1,"cix":2,"np":2,"it":[{"ty":"rc","bm":0,"hd":false,"mn":"ADBE Vector Shape - Rect","nm":"Rectangle Path 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"s":{"a":0,"k":[1125,2436],"ix":2}},{"ty":"gf","bm":0,"hd":false,"mn":"ADBE Vector Graphic - G-Fill","nm":"Gradient Fill 1","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[-889.202,-600.237],"t":0},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.922},"s":[-923.981,-597.426],"t":1},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.922},"s":[-957.735,-594.064],"t":2},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.921},"s":[-990.443,-590.14],"t":3},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.92},"s":[-1022.07,-585.653],"t":4},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.919},"s":[-1052.596,-580.6],"t":5},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.917},"s":[-1082.013,-574.968],"t":6},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.915},"s":[-1110.309,-568.735],"t":7},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.913},"s":[-1137.471,-561.896],"t":8},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.911},"s":[-1163.492,-554.447],"t":9},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.908},"s":[-1188.376,-546.383],"t":10},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.905},"s":[-1212.129,-537.702],"t":11},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.901},"s":[-1234.748,-528.413],"t":12},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.897},"s":[-1256.238,-518.524],"t":13},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.893},"s":[-1276.607,-508.045],"t":14},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.889},"s":[-1295.87,-496.987],"t":15},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.884},"s":[-1314.044,-485.363],"t":16},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.879},"s":[-1331.145,-473.191],"t":17},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.875},"s":[-1347.188,-460.492],"t":18},{"o":{"x":0.167,"y":0.128},"i":{"x":0.833,"y":0.87},"s":[-1362.194,-447.289],"t":19},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.866},"s":[-1376.186,-433.605],"t":20},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.861},"s":[-1389.189,-419.466],"t":21},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.858},"s":[-1401.233,-404.899],"t":22},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.854},"s":[-1412.349,-389.931],"t":23},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.851},"s":[-1422.564,-374.596],"t":24},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.848},"s":[-1431.911,-358.927],"t":25},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.845},"s":[-1440.422,-342.959],"t":26},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.843},"s":[-1448.134,-326.727],"t":27},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-1455.083,-310.268],"t":28},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.84},"s":[-1461.31,-293.619],"t":29},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.838},"s":[-1466.853,-276.821],"t":30},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.837},"s":[-1471.751,-259.913],"t":31},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.837},"s":[-1476.045,-242.938],"t":32},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.836},"s":[-1479.778,-225.938],"t":33},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.836},"s":[-1482.993,-208.956],"t":34},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.835},"s":[-1485.735,-192.034],"t":35},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[-1488.048,-175.216],"t":36},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[-1489.978,-158.547],"t":37},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[-1491.57,-142.072],"t":38},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.835},"s":[-1492.868,-125.836],"t":39},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.836},"s":[-1493.92,-109.885],"t":40},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[-1494.771,-94.263],"t":41},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[-1495.468,-79.016],"t":42},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[-1496.058,-64.189],"t":43},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.837},"s":[-1496.587,-49.826],"t":44},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.837},"s":[-1497.101,-35.971],"t":45},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.838},"s":[-1497.647,-22.67],"t":46},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.838},"s":[-1498.271,-9.963],"t":47},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.839},"s":[-1499.017,2.105],"t":48},{"o":{"x":0.167,"y":0.172},"i":{"x":0.833,"y":0.837},"s":[-1499.931,13.492],"t":49},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.83},"s":[-1501.056,24.159],"t":50},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.831},"s":[-1502.466,34.396],"t":51},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.832},"s":[-1504.261,45.161],"t":52},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.834},"s":[-1506.423,56.441],"t":53},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[-1508.924,68.154],"t":54},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[-1511.738,80.22],"t":55},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.837},"s":[-1514.838,92.567],"t":56},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.838},"s":[-1518.195,105.126],"t":57},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.838},"s":[-1521.782,117.83],"t":58},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-1525.571,130.619],"t":59},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.84},"s":[-1529.529,143.435],"t":60},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.841},"s":[-1533.628,156.225],"t":61},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.841},"s":[-1537.834,168.94],"t":62},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[-1542.116,181.536],"t":63},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[-1546.443,193.972],"t":64},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.843},"s":[-1550.781,206.214],"t":65},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-1555.095,218.228],"t":66},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-1559.348,229.986],"t":67},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-1563.503,241.465],"t":68},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.843},"s":[-1567.526,252.645],"t":69},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.843},"s":[-1571.379,263.513],"t":70},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-1575.023,274.056],"t":71},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.842},"s":[-1578.417,284.269],"t":72},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.841},"s":[-1581.52,294.147],"t":73},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.84},"s":[-1584.286,303.692],"t":74},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.839},"s":[-1586.673,312.908],"t":75},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.838},"s":[-1588.639,321.808],"t":76},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.837},"s":[-1590.139,330.407],"t":77},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[-1591.127,338.722],"t":78},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.835},"s":[-1591.552,346.776],"t":79},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.836},"s":[-1591.362,354.592],"t":80},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.837},"s":[-1590.55,362.206],"t":81},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.839},"s":[-1589.226,369.642],"t":82},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.842},"s":[-1587.4,376.888],"t":83},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.845},"s":[-1585.076,383.93],"t":84},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.849},"s":[-1582.252,390.753],"t":85},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.854},"s":[-1578.937,397.348],"t":86},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.858},"s":[-1575.141,403.707],"t":87},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.861},"s":[-1570.87,409.82],"t":88},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.851},"s":[-1566.125,415.675],"t":89},{"o":{"x":0.167,"y":0.07},"i":{"x":0.833,"y":0.921},"s":[-1560.919,421.268],"t":90},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-1544.187,419.745],"t":91},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-1527.26,418.1],"t":92},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-1510.149,416.326],"t":93},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-1492.865,414.42],"t":94},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[-1475.419,412.377],"t":95},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[-1457.824,410.194],"t":96},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[-1440.09,407.867],"t":97},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[-1422.233,405.394],"t":98},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[-1404.264,402.773],"t":99},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[-1386.197,400.003],"t":100},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[-1368.048,397.081],"t":101},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.919},"s":[-1349.829,394.007],"t":102},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.918},"s":[-1331.557,390.782],"t":103},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.918},"s":[-1313.246,387.403],"t":104},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.917},"s":[-1294.911,383.872],"t":105},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.917},"s":[-1276.569,380.189],"t":106},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.917},"s":[-1258.234,376.354],"t":107},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.916},"s":[-1239.923,372.368],"t":108},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.916},"s":[-1221.651,368.231],"t":109},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.915},"s":[-1203.435,363.944],"t":110},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.915},"s":[-1185.292,359.508],"t":111},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.914},"s":[-1167.236,354.923],"t":112},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.914},"s":[-1149.285,350.191],"t":113},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.913},"s":[-1131.454,345.313],"t":114},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-1113.76,340.287],"t":115},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-1096.219,335.116],"t":116},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.911},"s":[-1078.846,329.8],"t":117},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.91},"s":[-1061.658,324.338],"t":118},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.909},"s":[-1044.669,318.729],"t":119},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.908},"s":[-1027.895,312.975],"t":120},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.907},"s":[-1011.351,307.073],"t":121},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.906},"s":[-995.052,301.023],"t":122},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.905},"s":[-979.012,294.823],"t":123},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.904},"s":[-963.246,288.47],"t":124},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.902},"s":[-947.766,281.963],"t":125},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.898},"s":[-932.678,275.232],"t":126},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.895},"s":[-918.265,268.085],"t":127},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-904.535,260.535],"t":128},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.887},"s":[-891.474,252.609],"t":129},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.883},"s":[-879.071,244.328],"t":130},{"o":{"x":0.167,"y":0.12},"i":{"x":0.833,"y":0.879},"s":[-867.311,235.713],"t":131},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.875},"s":[-856.18,226.779],"t":132},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.871},"s":[-845.664,217.542],"t":133},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.867},"s":[-835.748,208.012],"t":134},{"o":{"x":0.167,"y":0.135},"i":{"x":0.833,"y":0.863},"s":[-826.415,198.197],"t":135},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.86},"s":[-817.651,188.105],"t":136},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.856},"s":[-809.438,177.737],"t":137},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.853},"s":[-801.758,167.097],"t":138},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.85},"s":[-794.595,156.181],"t":139},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.847},"s":[-787.93,144.988],"t":140},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.845},"s":[-781.742,133.511],"t":141},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.843},"s":[-776.014,121.742],"t":142},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.841},"s":[-770.723,109.672],"t":143},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.839},"s":[-765.849,97.288],"t":144},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.837},"s":[-761.37,84.577],"t":145},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.836},"s":[-757.264,71.524],"t":146},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[-753.506,58.111],"t":147},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.834},"s":[-750.072,44.318],"t":148},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.833},"s":[-746.938,30.126],"t":149},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.833},"s":[-744.077,15.512],"t":150},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.832},"s":[-741.463,0.453],"t":151},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.832},"s":[-739.068,-15.077],"t":152},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.832},"s":[-736.864,-31.105],"t":153},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.831},"s":[-734.821,-47.657],"t":154},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.831},"s":[-732.909,-64.763],"t":155},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.831},"s":[-731.361,-82.459],"t":156},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.831},"s":[-730.94,-100.763],"t":157},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.832},"s":[-731.631,-119.633],"t":158},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[-733.362,-139.027],"t":159},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.834},"s":[-736.061,-158.902],"t":160},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[-739.655,-179.217],"t":161},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[-744.074,-199.931],"t":162},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.837},"s":[-749.247,-221.006],"t":163},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-755.105,-242.403],"t":164},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[-761.579,-264.084],"t":165},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.84},"s":[-768.601,-286.012],"t":166},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.84},"s":[-776.104,-308.152],"t":167},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-784.023,-330.467],"t":168},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-792.292,-352.924],"t":169},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.842},"s":[-800.847,-375.489],"t":170},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.842},"s":[-809.628,-398.129],"t":171},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.843},"s":[-818.572,-420.812],"t":172},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.843},"s":[-827.62,-443.507],"t":173},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.843},"s":[-836.714,-466.183],"t":174},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.843},"s":[-845.798,-488.812],"t":175},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.843},"s":[-854.817,-511.362],"t":176},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.843},"s":[-863.717,-533.808],"t":177},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.842},"s":[-872.447,-556.119],"t":178},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.865},"s":[-880.958,-578.271],"t":179},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.922},"s":[-889.202,-600.237],"t":180},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.922},"s":[-923.981,-597.426],"t":181},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.922},"s":[-957.735,-594.064],"t":182},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.921},"s":[-990.443,-590.14],"t":183},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.92},"s":[-1022.07,-585.653],"t":184},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.919},"s":[-1052.596,-580.6],"t":185},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.917},"s":[-1082.013,-574.968],"t":186},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.915},"s":[-1110.309,-568.735],"t":187},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.913},"s":[-1137.471,-561.896],"t":188},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.911},"s":[-1163.492,-554.447],"t":189},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.908},"s":[-1188.376,-546.383],"t":190},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.905},"s":[-1212.129,-537.702],"t":191},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.901},"s":[-1234.748,-528.413],"t":192},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.897},"s":[-1256.238,-518.524],"t":193},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.893},"s":[-1276.607,-508.045],"t":194},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.889},"s":[-1295.87,-496.987],"t":195},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.884},"s":[-1314.044,-485.363],"t":196},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.879},"s":[-1331.145,-473.191],"t":197},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.875},"s":[-1347.188,-460.492],"t":198},{"o":{"x":0.167,"y":0.128},"i":{"x":0.833,"y":0.87},"s":[-1362.194,-447.289],"t":199},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.866},"s":[-1376.186,-433.605],"t":200},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.861},"s":[-1389.189,-419.466],"t":201},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.858},"s":[-1401.233,-404.899],"t":202},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.854},"s":[-1412.349,-389.931],"t":203},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.851},"s":[-1422.564,-374.596],"t":204},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.848},"s":[-1431.911,-358.927],"t":205},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.845},"s":[-1440.422,-342.959],"t":206},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.843},"s":[-1448.134,-326.727],"t":207},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-1455.083,-310.268],"t":208},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.84},"s":[-1461.31,-293.619],"t":209},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.838},"s":[-1466.853,-276.821],"t":210},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.837},"s":[-1471.751,-259.913],"t":211},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.837},"s":[-1476.045,-242.938],"t":212},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.836},"s":[-1479.778,-225.938],"t":213},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.836},"s":[-1482.993,-208.956],"t":214},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.835},"s":[-1485.735,-192.034],"t":215},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[-1488.048,-175.216],"t":216},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[-1489.978,-158.547],"t":217},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[-1491.57,-142.072],"t":218},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.835},"s":[-1492.868,-125.836],"t":219},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.836},"s":[-1493.92,-109.885],"t":220},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[-1494.771,-94.263],"t":221},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[-1495.468,-79.016],"t":222},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[-1496.058,-64.189],"t":223},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.837},"s":[-1496.587,-49.826],"t":224},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.837},"s":[-1497.101,-35.971],"t":225},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.838},"s":[-1497.647,-22.67],"t":226},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.838},"s":[-1498.271,-9.963],"t":227},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.839},"s":[-1499.017,2.105],"t":228},{"o":{"x":0.167,"y":0.172},"i":{"x":0.833,"y":0.837},"s":[-1499.931,13.492],"t":229},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.83},"s":[-1501.056,24.159],"t":230},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.831},"s":[-1502.466,34.396],"t":231},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.832},"s":[-1504.261,45.161],"t":232},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.834},"s":[-1506.423,56.441],"t":233},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[-1508.924,68.154],"t":234},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[-1511.738,80.22],"t":235},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.837},"s":[-1514.838,92.567],"t":236},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.838},"s":[-1518.195,105.126],"t":237},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.838},"s":[-1521.782,117.83],"t":238},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-1525.571,130.619],"t":239},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.84},"s":[-1529.529,143.435],"t":240},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.841},"s":[-1533.628,156.225],"t":241},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.841},"s":[-1537.834,168.94],"t":242},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[-1542.116,181.536],"t":243},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[-1546.443,193.972],"t":244},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.843},"s":[-1550.781,206.214],"t":245},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-1555.095,218.228],"t":246},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-1559.348,229.986],"t":247},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-1563.503,241.465],"t":248},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.843},"s":[-1567.526,252.645],"t":249},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.843},"s":[-1571.379,263.513],"t":250},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-1575.023,274.056],"t":251},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.842},"s":[-1578.417,284.269],"t":252},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.841},"s":[-1581.52,294.147],"t":253},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.84},"s":[-1584.286,303.692],"t":254},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.839},"s":[-1586.673,312.908],"t":255},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.838},"s":[-1588.639,321.808],"t":256},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.837},"s":[-1590.139,330.407],"t":257},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[-1591.127,338.722],"t":258},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.835},"s":[-1591.552,346.776],"t":259},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.836},"s":[-1591.362,354.592],"t":260},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.837},"s":[-1590.55,362.206],"t":261},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.839},"s":[-1589.226,369.642],"t":262},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.842},"s":[-1587.4,376.888],"t":263},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.845},"s":[-1585.076,383.93],"t":264},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.849},"s":[-1582.252,390.753],"t":265},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.854},"s":[-1578.937,397.348],"t":266},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.858},"s":[-1575.141,403.707],"t":267},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.861},"s":[-1570.87,409.82],"t":268},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.851},"s":[-1566.125,415.675],"t":269},{"o":{"x":0.167,"y":0.07},"i":{"x":0.833,"y":0.921},"s":[-1560.919,421.268],"t":270},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-1544.187,419.745],"t":271},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-1527.26,418.1],"t":272},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-1510.149,416.326],"t":273},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-1492.865,414.42],"t":274},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[-1475.419,412.377],"t":275},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[-1457.824,410.194],"t":276},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[-1440.09,407.867],"t":277},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[-1422.233,405.394],"t":278},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[-1404.264,402.773],"t":279},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[-1386.197,400.003],"t":280},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.919},"s":[-1368.048,397.081],"t":281},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.919},"s":[-1349.829,394.007],"t":282},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.918},"s":[-1331.557,390.782],"t":283},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.918},"s":[-1313.246,387.403],"t":284},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.917},"s":[-1294.911,383.872],"t":285},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.917},"s":[-1276.569,380.189],"t":286},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.917},"s":[-1258.234,376.354],"t":287},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.916},"s":[-1239.923,372.368],"t":288},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.916},"s":[-1221.651,368.231],"t":289},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.915},"s":[-1203.435,363.944],"t":290},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.915},"s":[-1185.292,359.508],"t":291},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.914},"s":[-1167.236,354.923],"t":292},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.914},"s":[-1149.285,350.191],"t":293},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.913},"s":[-1131.454,345.313],"t":294},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-1113.76,340.287],"t":295},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-1096.219,335.116],"t":296},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.911},"s":[-1078.846,329.8],"t":297},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.91},"s":[-1061.658,324.338],"t":298},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.909},"s":[-1044.669,318.729],"t":299},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.908},"s":[-1027.895,312.975],"t":300},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.907},"s":[-1011.351,307.073],"t":301},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.906},"s":[-995.052,301.023],"t":302},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.905},"s":[-979.012,294.823],"t":303},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.904},"s":[-963.246,288.47],"t":304},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.902},"s":[-947.766,281.963],"t":305},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.898},"s":[-932.678,275.232],"t":306},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.895},"s":[-918.265,268.085],"t":307},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-904.535,260.535],"t":308},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.887},"s":[-891.474,252.609],"t":309},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.883},"s":[-879.071,244.328],"t":310},{"o":{"x":0.167,"y":0.12},"i":{"x":0.833,"y":0.879},"s":[-867.311,235.713],"t":311},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.875},"s":[-856.18,226.779],"t":312},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.871},"s":[-845.664,217.542],"t":313},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.867},"s":[-835.748,208.012],"t":314},{"o":{"x":0.167,"y":0.135},"i":{"x":0.833,"y":0.863},"s":[-826.415,198.197],"t":315},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.86},"s":[-817.651,188.105],"t":316},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.856},"s":[-809.438,177.737],"t":317},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.853},"s":[-801.758,167.097],"t":318},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.85},"s":[-794.595,156.181],"t":319},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.847},"s":[-787.93,144.988],"t":320},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.845},"s":[-781.742,133.511],"t":321},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.843},"s":[-776.014,121.742],"t":322},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.841},"s":[-770.723,109.672],"t":323},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.839},"s":[-765.849,97.288],"t":324},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.837},"s":[-761.37,84.577],"t":325},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.836},"s":[-757.264,71.524],"t":326},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[-753.506,58.111],"t":327},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.834},"s":[-750.072,44.318],"t":328},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.833},"s":[-746.938,30.126],"t":329},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.833},"s":[-744.077,15.512],"t":330},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.832},"s":[-741.463,0.453],"t":331},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.832},"s":[-739.068,-15.077],"t":332},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.832},"s":[-736.864,-31.105],"t":333},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.831},"s":[-734.821,-47.657],"t":334},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.831},"s":[-732.909,-64.763],"t":335},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.831},"s":[-731.361,-82.459],"t":336},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.831},"s":[-730.94,-100.763],"t":337},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.832},"s":[-731.631,-119.633],"t":338},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[-733.362,-139.027],"t":339},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.834},"s":[-736.061,-158.902],"t":340},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[-739.655,-179.217],"t":341},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[-744.074,-199.931],"t":342},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.837},"s":[-749.247,-221.006],"t":343},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-755.105,-242.403],"t":344},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[-761.579,-264.084],"t":345},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.84},"s":[-768.601,-286.012],"t":346},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.84},"s":[-776.104,-308.152],"t":347},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-784.023,-330.467],"t":348},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-792.292,-352.924],"t":349},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.842},"s":[-800.847,-375.489],"t":350},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.842},"s":[-809.628,-398.129],"t":351},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.843},"s":[-818.572,-420.812],"t":352},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.843},"s":[-827.62,-443.507],"t":353},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.843},"s":[-836.714,-466.183],"t":354},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.843},"s":[-845.798,-488.812],"t":355},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.843},"s":[-854.817,-511.362],"t":356},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.843},"s":[-863.717,-533.808],"t":357},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.842},"s":[-872.447,-556.119],"t":358},{"s":[-880.958,-578.271],"t":359}],"ix":6},"g":{"p":3,"k":{"a":0,"k":[0,0.7019607843137254,0.615686274509804,0.8588235294117647,0.5,0.8196078431372549,0.7686274509803922,0.9137254901960784,1,1,1,1,0,1,0.5,1,1,0],"ix":9}},"t":2,"a":{"a":0,"k":0,"ix":8},"h":{"a":0,"k":0,"ix":7},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.834},"s":[1198.71,1316.215],"t":0},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[1190.208,1276.733],"t":1},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[1180.57,1236.399],"t":2},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[1169.778,1195.196],"t":3},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.837},"s":[1157.821,1153.115],"t":4},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[1144.694,1110.154],"t":5},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.839},"s":[1130.395,1066.318],"t":6},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.84},"s":[1114.93,1021.617],"t":7},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.84},"s":[1098.31,976.072],"t":8},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.841},"s":[1080.548,929.702],"t":9},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.842},"s":[1061.668,882.544],"t":10},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.843},"s":[1041.692,834.631],"t":11},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.844},"s":[1020.65,786.007],"t":12},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.844},"s":[998.579,736.722],"t":13},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.845},"s":[975.514,686.826],"t":14},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.846},"s":[951.501,636.384],"t":15},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.846},"s":[926.587,585.461],"t":16},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.847},"s":[900.821,534.126],"t":17},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.848},"s":[874.258,482.452],"t":18},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.849},"s":[846.96,430.526],"t":19},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.849},"s":[818.985,378.429],"t":20},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.85},"s":[790.401,326.25],"t":21},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.85},"s":[761.276,274.088],"t":22},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.851},"s":[731.683,222.041],"t":23},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.852},"s":[701.696,170.209],"t":24},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.852},"s":[671.393,118.7],"t":25},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.853},"s":[640.855,67.627],"t":26},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.854},"s":[610.167,17.106],"t":27},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.854},"s":[579.413,-32.744],"t":28},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.855},"s":[548.68,-81.804],"t":29},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.856},"s":[518.059,-129.95],"t":30},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.856},"s":[487.643,-177.053],"t":31},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.857},"s":[457.525,-222.978],"t":32},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.858},"s":[427.803,-267.592],"t":33},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.859},"s":[398.574,-310.757],"t":34},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.858},"s":[369.936,-352.334],"t":35},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.859},"s":[341.912,-392.234],"t":36},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.861},"s":[313.49,-431.099],"t":37},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.862},"s":[284.464,-469.149],"t":38},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.863},"s":[255.001,-506.373],"t":39},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.865},"s":[225.263,-542.76],"t":40},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.866},"s":[195.406,-578.303],"t":41},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.866},"s":[165.578,-612.993],"t":42},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.867},"s":[135.923,-646.827],"t":43},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[106.576,-679.8],"t":44},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[77.669,-711.909],"t":45},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[49.326,-743.152],"t":46},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[21.665,-773.53],"t":47},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[-5.203,-803.042],"t":48},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[-31.172,-831.69],"t":49},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.868},"s":[-56.143,-859.476],"t":50},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.867},"s":[-80.023,-886.404],"t":51},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.866},"s":[-102.725,-912.477],"t":52},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.865},"s":[-124.169,-937.7],"t":53},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.864},"s":[-144.281,-962.077],"t":54},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.862},"s":[-162.994,-985.614],"t":55},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.861},"s":[-180.247,-1008.316],"t":56},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.858},"s":[-195.986,-1030.193],"t":57},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.856},"s":[-210.164,-1051.253],"t":58},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.853},"s":[-222.74,-1071.507],"t":59},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.851},"s":[-233.68,-1090.963],"t":60},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.848},"s":[-242.955,-1109.63],"t":61},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.844},"s":[-250.545,-1127.516],"t":62},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.841},"s":[-256.433,-1144.63],"t":63},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.839},"s":[-260.613,-1160.984],"t":64},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.838},"s":[-263.084,-1176.591],"t":65},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.838},"s":[-263.853,-1191.462],"t":66},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.84},"s":[-262.932,-1205.607],"t":67},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.843},"s":[-260.338,-1219.036],"t":68},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.849},"s":[-256.099,-1231.756],"t":69},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.857},"s":[-250.247,-1243.782],"t":70},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.865},"s":[-242.823,-1255.126],"t":71},{"o":{"x":0.167,"y":0.13},"i":{"x":0.833,"y":0.873},"s":[-233.874,-1265.798],"t":72},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.881},"s":[-223.453,-1275.805],"t":73},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-211.62,-1285.161],"t":74},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.895},"s":[-198.445,-1293.876],"t":75},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.9},"s":[-184.001,-1301.956],"t":76},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.905},"s":[-168.371,-1309.414],"t":77},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.909},"s":[-151.643,-1316.258],"t":78},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-133.913,-1322.494],"t":79},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.914},"s":[-115.285,-1328.134],"t":80},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.917},"s":[-95.869,-1333.18],"t":81},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.918},"s":[-75.782,-1337.643],"t":82},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.92},"s":[-55.15,-1341.527],"t":83},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.921},"s":[-34.104,-1344.835],"t":84},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.922},"s":[-12.784,-1347.576],"t":85},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[8.666,-1349.746],"t":86},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[30.089,-1351.356],"t":87},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.92},"s":[51.322,-1352.411],"t":88},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.837},"s":[72.201,-1352.898],"t":89},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.874},"s":[92.548,-1352.825],"t":90},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.873},"s":[121.897,-1325.796],"t":91},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.871},"s":[150.121,-1298.795],"t":92},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.868},"s":[177.021,-1271.817],"t":93},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.866},"s":[202.393,-1244.858],"t":94},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.863},"s":[226.022,-1217.913],"t":95},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.863},"s":[247.788,-1191.006],"t":96},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.863},"s":[268.953,-1164.628],"t":97},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.864},"s":[289.861,-1138.912],"t":98},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.864},"s":[310.395,-1113.823],"t":99},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.863},"s":[330.443,-1089.328],"t":100},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.863},"s":[349.905,-1065.393],"t":101},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.862},"s":[368.692,-1041.982],"t":102},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.861},"s":[386.721,-1019.062],"t":103},{"o":{"x":0.167,"y":0.143},"i":{"x":0.833,"y":0.86},"s":[403.92,-996.596],"t":104},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.858},"s":[420.227,-974.552],"t":105},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.857},"s":[435.585,-952.895],"t":106},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.855},"s":[449.95,-931.589],"t":107},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.853},"s":[463.282,-910.6],"t":108},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.85},"s":[475.55,-889.895],"t":109},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.848},"s":[486.732,-869.439],"t":110},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.846},"s":[496.812,-849.197],"t":111},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.843},"s":[505.781,-829.137],"t":112},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.841},"s":[513.638,-809.225],"t":113},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.839},"s":[520.389,-789.427],"t":114},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.837},"s":[526.043,-769.712],"t":115},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.835},"s":[530.62,-750.045],"t":116},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.834},"s":[534.141,-730.396],"t":117},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[536.638,-710.732],"t":118},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[538.144,-691.022],"t":119},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[538.699,-671.236],"t":120},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[538.347,-651.342],"t":121},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.833},"s":[537.14,-631.312],"t":122},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.834},"s":[535.129,-611.117],"t":123},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.834},"s":[532.375,-590.726],"t":124},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.835},"s":[528.938,-570.114],"t":125},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.835},"s":[524.886,-549.252],"t":126},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.836},"s":[520.287,-528.114],"t":127},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[515.215,-506.675],"t":128},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[509.744,-484.908],"t":129},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[503.955,-462.791],"t":130},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[497.928,-440.299],"t":131},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[491.747,-417.41],"t":132},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[485.497,-394.103],"t":133},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[479.266,-370.356],"t":134},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[473.143,-346.15],"t":135},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[467.218,-321.467],"t":136},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.834},"s":[461.583,-296.288],"t":137},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.834},"s":[456.331,-270.597],"t":138},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[451.555,-244.379],"t":139},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.833},"s":[447.347,-217.619],"t":140},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.832},"s":[443.802,-190.303],"t":141},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[441.013,-162.419],"t":142},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[439.074,-133.958],"t":143},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[438.077,-104.908],"t":144},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[438.114,-75.263],"t":145},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[439.276,-45.014],"t":146},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.833},"s":[441.652,-14.156],"t":147},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[445.331,17.316],"t":148},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.834},"s":[450.399,49.403],"t":149},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[456.941,82.108],"t":150},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[465.037,115.43],"t":151},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.838},"s":[474.768,149.369],"t":152},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.84},"s":[486.211,183.92],"t":153},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.842},"s":[499.44,219.08],"t":154},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.842},"s":[514.525,254.841],"t":155},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.844},"s":[531.544,291.247],"t":156},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.846},"s":[550.649,328.978],"t":157},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.848},"s":[571.721,368.125],"t":158},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[594.573,408.53],"t":159},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.851},"s":[619.021,450.038],"t":160},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.852},"s":[644.885,492.5],"t":161},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.853},"s":[671.989,535.77],"t":162},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.854},"s":[700.162,579.709],"t":163},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.854},"s":[729.235,624.179],"t":164},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.855},"s":[759.045,669.052],"t":165},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.855},"s":[789.433,714.199],"t":166},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.856},"s":[820.246,759.5],"t":167},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.856},"s":[851.334,804.838],"t":168},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.857},"s":[882.553,850.102],"t":169},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.857},"s":[913.764,895.185],"t":170},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.857},"s":[944.832,939.985],"t":171},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.857},"s":[975.629,984.406],"t":172},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.857},"s":[1006.031,1028.355],"t":173},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.857},"s":[1035.923,1071.748],"t":174},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.857},"s":[1065.191,1114.501],"t":175},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.857},"s":[1093.731,1156.541],"t":176},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.856},"s":[1121.443,1197.794],"t":177},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.856},"s":[1148.234,1238.197],"t":178},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.855},"s":[1174.016,1277.689],"t":179},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.836},"s":[1198.71,1316.215],"t":180},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[1190.208,1276.733],"t":181},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[1180.57,1236.399],"t":182},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[1169.778,1195.196],"t":183},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.837},"s":[1157.821,1153.115],"t":184},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[1144.694,1110.154],"t":185},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.839},"s":[1130.395,1066.318],"t":186},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.84},"s":[1114.93,1021.617],"t":187},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.84},"s":[1098.31,976.072],"t":188},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.841},"s":[1080.548,929.702],"t":189},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.842},"s":[1061.668,882.544],"t":190},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.843},"s":[1041.692,834.631],"t":191},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.844},"s":[1020.65,786.007],"t":192},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.844},"s":[998.579,736.722],"t":193},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.845},"s":[975.514,686.826],"t":194},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.846},"s":[951.501,636.384],"t":195},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.846},"s":[926.587,585.461],"t":196},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.847},"s":[900.821,534.126],"t":197},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.848},"s":[874.258,482.452],"t":198},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.849},"s":[846.96,430.526],"t":199},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.849},"s":[818.985,378.429],"t":200},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.85},"s":[790.401,326.25],"t":201},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.85},"s":[761.276,274.088],"t":202},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.851},"s":[731.683,222.041],"t":203},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.852},"s":[701.696,170.209],"t":204},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.852},"s":[671.393,118.7],"t":205},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.853},"s":[640.855,67.627],"t":206},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.854},"s":[610.167,17.106],"t":207},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.854},"s":[579.413,-32.744],"t":208},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.855},"s":[548.68,-81.804],"t":209},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.856},"s":[518.059,-129.95],"t":210},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.856},"s":[487.643,-177.053],"t":211},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.857},"s":[457.525,-222.978],"t":212},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.858},"s":[427.803,-267.592],"t":213},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.859},"s":[398.574,-310.757],"t":214},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.858},"s":[369.936,-352.334],"t":215},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.859},"s":[341.912,-392.234],"t":216},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.861},"s":[313.49,-431.099],"t":217},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.862},"s":[284.464,-469.149],"t":218},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.863},"s":[255.001,-506.373],"t":219},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.865},"s":[225.263,-542.76],"t":220},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.866},"s":[195.406,-578.303],"t":221},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.866},"s":[165.578,-612.993],"t":222},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.867},"s":[135.923,-646.827],"t":223},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[106.576,-679.8],"t":224},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[77.669,-711.909],"t":225},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[49.326,-743.152],"t":226},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[21.665,-773.53],"t":227},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[-5.203,-803.042],"t":228},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.868},"s":[-31.172,-831.69],"t":229},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.868},"s":[-56.143,-859.476],"t":230},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.867},"s":[-80.023,-886.404],"t":231},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.866},"s":[-102.725,-912.477],"t":232},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.865},"s":[-124.169,-937.7],"t":233},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.864},"s":[-144.281,-962.077],"t":234},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.862},"s":[-162.994,-985.614],"t":235},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.861},"s":[-180.247,-1008.316],"t":236},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.858},"s":[-195.986,-1030.193],"t":237},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.856},"s":[-210.164,-1051.253],"t":238},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.853},"s":[-222.74,-1071.507],"t":239},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.851},"s":[-233.68,-1090.963],"t":240},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.848},"s":[-242.955,-1109.63],"t":241},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.844},"s":[-250.545,-1127.516],"t":242},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.841},"s":[-256.433,-1144.63],"t":243},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.839},"s":[-260.613,-1160.984],"t":244},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.838},"s":[-263.084,-1176.591],"t":245},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.838},"s":[-263.853,-1191.462],"t":246},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.84},"s":[-262.932,-1205.607],"t":247},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.843},"s":[-260.338,-1219.036],"t":248},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.849},"s":[-256.099,-1231.756],"t":249},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.857},"s":[-250.247,-1243.782],"t":250},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.865},"s":[-242.823,-1255.126],"t":251},{"o":{"x":0.167,"y":0.13},"i":{"x":0.833,"y":0.873},"s":[-233.874,-1265.798],"t":252},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.881},"s":[-223.453,-1275.805],"t":253},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-211.62,-1285.161],"t":254},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.895},"s":[-198.445,-1293.876],"t":255},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.9},"s":[-184.001,-1301.956],"t":256},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.905},"s":[-168.371,-1309.414],"t":257},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.909},"s":[-151.643,-1316.258],"t":258},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-133.913,-1322.494],"t":259},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.914},"s":[-115.285,-1328.134],"t":260},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.917},"s":[-95.869,-1333.18],"t":261},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.918},"s":[-75.782,-1337.643],"t":262},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.92},"s":[-55.15,-1341.527],"t":263},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.921},"s":[-34.104,-1344.835],"t":264},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.922},"s":[-12.784,-1347.576],"t":265},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[8.666,-1349.746],"t":266},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[30.089,-1351.356],"t":267},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.92},"s":[51.322,-1352.411],"t":268},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.837},"s":[72.201,-1352.898],"t":269},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.874},"s":[92.548,-1352.825],"t":270},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.873},"s":[121.897,-1325.796],"t":271},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.871},"s":[150.121,-1298.795],"t":272},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.868},"s":[177.021,-1271.817],"t":273},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.866},"s":[202.393,-1244.858],"t":274},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.863},"s":[226.022,-1217.913],"t":275},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.863},"s":[247.788,-1191.006],"t":276},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.863},"s":[268.953,-1164.628],"t":277},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.864},"s":[289.861,-1138.912],"t":278},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.864},"s":[310.395,-1113.823],"t":279},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.863},"s":[330.443,-1089.328],"t":280},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.863},"s":[349.905,-1065.393],"t":281},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.862},"s":[368.692,-1041.982],"t":282},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.861},"s":[386.721,-1019.062],"t":283},{"o":{"x":0.167,"y":0.143},"i":{"x":0.833,"y":0.86},"s":[403.92,-996.596],"t":284},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.858},"s":[420.227,-974.552],"t":285},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.857},"s":[435.585,-952.895],"t":286},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.855},"s":[449.95,-931.589],"t":287},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.853},"s":[463.282,-910.6],"t":288},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.85},"s":[475.55,-889.895],"t":289},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.848},"s":[486.732,-869.439],"t":290},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.846},"s":[496.812,-849.197],"t":291},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.843},"s":[505.781,-829.137],"t":292},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.841},"s":[513.638,-809.225],"t":293},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.839},"s":[520.389,-789.427],"t":294},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.837},"s":[526.043,-769.712],"t":295},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.835},"s":[530.62,-750.045],"t":296},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.834},"s":[534.141,-730.396],"t":297},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[536.638,-710.732],"t":298},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[538.144,-691.022],"t":299},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[538.699,-671.236],"t":300},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[538.347,-651.342],"t":301},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.833},"s":[537.14,-631.312],"t":302},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.834},"s":[535.129,-611.117],"t":303},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.834},"s":[532.375,-590.726],"t":304},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.835},"s":[528.938,-570.114],"t":305},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.835},"s":[524.886,-549.252],"t":306},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.836},"s":[520.287,-528.114],"t":307},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[515.215,-506.675],"t":308},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[509.744,-484.908],"t":309},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[503.955,-462.791],"t":310},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[497.928,-440.299],"t":311},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[491.747,-417.41],"t":312},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[485.497,-394.103],"t":313},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.836},"s":[479.266,-370.356],"t":314},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[473.143,-346.15],"t":315},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[467.218,-321.467],"t":316},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.834},"s":[461.583,-296.288],"t":317},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.834},"s":[456.331,-270.597],"t":318},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[451.555,-244.379],"t":319},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.833},"s":[447.347,-217.619],"t":320},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.832},"s":[443.802,-190.303],"t":321},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[441.013,-162.419],"t":322},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[439.074,-133.958],"t":323},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[438.077,-104.908],"t":324},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[438.114,-75.263],"t":325},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.832},"s":[439.276,-45.014],"t":326},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.833},"s":[441.652,-14.156],"t":327},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[445.331,17.316],"t":328},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.834},"s":[450.399,49.403],"t":329},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.835},"s":[456.941,82.108],"t":330},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[465.037,115.43],"t":331},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.838},"s":[474.768,149.369],"t":332},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.84},"s":[486.211,183.92],"t":333},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.842},"s":[499.44,219.08],"t":334},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.842},"s":[514.525,254.841],"t":335},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.844},"s":[531.544,291.247],"t":336},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.846},"s":[550.649,328.978],"t":337},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.848},"s":[571.721,368.125],"t":338},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.849},"s":[594.573,408.53],"t":339},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.851},"s":[619.021,450.038],"t":340},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.852},"s":[644.885,492.5],"t":341},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.853},"s":[671.989,535.77],"t":342},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.854},"s":[700.162,579.709],"t":343},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.854},"s":[729.235,624.179],"t":344},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.855},"s":[759.045,669.052],"t":345},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.855},"s":[789.433,714.199],"t":346},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.856},"s":[820.246,759.5],"t":347},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.856},"s":[851.334,804.838],"t":348},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.857},"s":[882.553,850.102],"t":349},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.857},"s":[913.764,895.185],"t":350},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.857},"s":[944.832,939.985],"t":351},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.857},"s":[975.629,984.406],"t":352},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.857},"s":[1006.031,1028.355],"t":353},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.857},"s":[1035.923,1071.748],"t":354},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.857},"s":[1065.191,1114.501],"t":355},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.857},"s":[1093.731,1156.541],"t":356},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.856},"s":[1121.443,1197.794],"t":357},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.854},"s":[1148.234,1238.197],"t":358},{"s":[1174.016,1277.689],"t":359}],"ix":5},"r":1,"o":{"a":0,"k":100,"ix":10}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[3.056,-531.6],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":2},{"ty":4,"nm":"gradient3","sr":1,"st":0,"op":360,"ip":0,"hd":true,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[558.944,1749.6,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Rectangle 1","ix":1,"cix":2,"np":2,"it":[{"ty":"rc","bm":0,"hd":false,"mn":"ADBE Vector Shape - Rect","nm":"Rectangle Path 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"s":{"a":0,"k":[1125,2436],"ix":2}},{"ty":"gf","bm":0,"hd":false,"mn":"ADBE Vector Graphic - G-Fill","nm":"Gradient Fill 1","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[567.691,1503.187],"t":0},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.888},"s":[506.996,1462.77],"t":1},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.888},"s":[446.853,1422.506],"t":2},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[387.089,1382.759],"t":3},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[327.711,1343.593],"t":4},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[268.764,1304.997],"t":5},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[210.289,1266.96],"t":6},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[152.326,1229.471],"t":7},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[94.918,1192.521],"t":8},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[38.092,1156.095],"t":9},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-18.109,1120.19],"t":10},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-73.658,1084.79],"t":11},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-128.524,1049.891],"t":12},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-182.675,1015.484],"t":13},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-236.093,981.556],"t":14},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-288.745,948.106],"t":15},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-340.609,915.125],"t":16},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-391.668,882.606],"t":17},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-441.907,850.539],"t":18},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-491.302,818.923],"t":19},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-539.833,787.757],"t":20},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-587.483,757.038],"t":21},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[-634.242,726.762],"t":22},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-680.1,696.925],"t":23},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-725.053,667.522],"t":24},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-769.091,638.553],"t":25},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-812.2,610.022],"t":26},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.888},"s":[-854.375,581.931],"t":27},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.888},"s":[-895.611,554.281],"t":28},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.888},"s":[-935.905,527.075],"t":29},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.888},"s":[-975.259,500.314],"t":30},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.887},"s":[-1013.668,474.005],"t":31},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.887},"s":[-1051.141,448.15],"t":32},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.887},"s":[-1087.756,422.712],"t":33},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.886},"s":[-1123.54,397.682],"t":34},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.886},"s":[-1158.506,373.058],"t":35},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.886},"s":[-1192.669,348.84],"t":36},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.886},"s":[-1226.044,325.026],"t":37},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.885},"s":[-1258.644,301.618],"t":38},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.885},"s":[-1290.484,278.616],"t":39},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.885},"s":[-1321.58,256.022],"t":40},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.885},"s":[-1351.948,233.837],"t":41},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.884},"s":[-1381.607,212.061],"t":42},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.884},"s":[-1410.576,190.695],"t":43},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.884},"s":[-1438.873,169.74],"t":44},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.884},"s":[-1466.518,149.197],"t":45},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1493.534,129.067],"t":46},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1519.939,109.351],"t":47},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1545.756,90.05],"t":48},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1571.007,71.165],"t":49},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1595.713,52.697],"t":50},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.885},"s":[-1619.896,34.648],"t":51},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.887},"s":[-1643.582,17.069],"t":52},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.888},"s":[-1666.803,0.308],"t":53},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.889},"s":[-1689.551,-15.59],"t":54},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.891},"s":[-1711.806,-30.655],"t":55},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.892},"s":[-1733.555,-44.914],"t":56},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.893},"s":[-1754.783,-58.397],"t":57},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.895},"s":[-1775.478,-71.133],"t":58},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.896},"s":[-1795.625,-83.148],"t":59},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.897},"s":[-1815.211,-94.468],"t":60},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.898},"s":[-1834.221,-105.118],"t":61},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.899},"s":[-1852.641,-115.122],"t":62},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.9},"s":[-1870.456,-124.501],"t":63},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.902},"s":[-1887.656,-133.281],"t":64},{"o":{"x":0.167,"y":0.103},"i":{"x":0.833,"y":0.903},"s":[-1904.231,-141.487],"t":65},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.904},"s":[-1920.169,-149.139],"t":66},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.905},"s":[-1935.458,-156.261],"t":67},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.906},"s":[-1950.084,-162.871],"t":68},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.907},"s":[-1964.034,-168.99],"t":69},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.908},"s":[-1977.301,-174.638],"t":70},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.909},"s":[-1989.875,-179.839],"t":71},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.91},"s":[-2001.746,-184.611],"t":72},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.91},"s":[-2012.9,-188.972],"t":73},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.911},"s":[-2023.332,-192.943],"t":74},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.912},"s":[-2033.032,-196.544],"t":75},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.913},"s":[-2041.986,-199.791],"t":76},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.914},"s":[-2050.19,-202.705],"t":77},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.914},"s":[-2057.64,-205.309],"t":78},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.915},"s":[-2064.328,-207.621],"t":79},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.916},"s":[-2070.242,-209.658],"t":80},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.918},"s":[-2075.369,-211.436],"t":81},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.923},"s":[-2079.725,-212.95],"t":82},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.928},"s":[-2083.428,-214.023],"t":83},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.931},"s":[-2086.503,-214.635],"t":84},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.921},"s":[-2088.957,-214.811],"t":85},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.885},"s":[-2090.784,-214.566],"t":86},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.831},"s":[-2091.998,-213.929],"t":87},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.707},"s":[-2092.603,-212.921],"t":88},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.534},"s":[-2092.59,-211.554],"t":89},{"o":{"x":0.167,"y":0.065},"i":{"x":0.833,"y":0.886},"s":[-2091.968,-209.85],"t":90},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.886},"s":[-2061.381,-188.3],"t":91},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.886},"s":[-2030.849,-166.887],"t":92},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[-2000.375,-145.628],"t":93},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.887},"s":[-1969.96,-124.54],"t":94},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.887},"s":[-1939.607,-103.638],"t":95},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.887},"s":[-1909.318,-82.934],"t":96},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.888},"s":[-1879.093,-62.443],"t":97},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.888},"s":[-1848.934,-42.177],"t":98},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[-1818.842,-22.145],"t":99},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[-1788.817,-2.359],"t":100},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-1758.86,17.173],"t":101},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-1728.987,36.442],"t":102},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-1699.316,55.446],"t":103},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-1669.865,74.2],"t":104},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-1640.625,92.721],"t":105},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.891},"s":[-1611.589,111.023],"t":106},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1582.745,129.123],"t":107},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1554.085,147.032],"t":108},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1525.597,164.766],"t":109},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1497.269,182.337],"t":110},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1469.09,199.756],"t":111},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1441.046,217.037],"t":112},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1413.124,234.191],"t":113},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1385.308,251.23],"t":114},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1357.585,268.167],"t":115},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1329.937,285.013],"t":116},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.892},"s":[-1302.347,301.78],"t":117},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.892},"s":[-1274.799,318.482],"t":118},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.892},"s":[-1247.273,335.133],"t":119},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1219.75,351.746],"t":120},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1192.21,368.335],"t":121},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1164.633,384.918],"t":122},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1136.996,401.51],"t":123},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1109.276,418.129],"t":124},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1081.45,434.794],"t":125},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1053.494,451.526],"t":126},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-1025.383,468.346],"t":127},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-997.089,485.278],"t":128},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-968.586,502.346],"t":129},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-939.847,519.577],"t":130},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-910.841,537.001],"t":131},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-881.544,554.636],"t":132},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-851.98,572.42],"t":133},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-822.168,590.34],"t":134},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-792.124,608.395],"t":135},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-761.86,626.586],"t":136},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-731.393,644.911],"t":137},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-700.737,663.37],"t":138},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-669.907,681.959],"t":139},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-638.918,700.677],"t":140},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-607.785,719.521],"t":141},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-576.524,738.487],"t":142},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-545.148,757.572],"t":143},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-513.674,776.77],"t":144},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-482.117,796.079],"t":145},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-450.491,815.493],"t":146},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.89},"s":[-418.813,835.007],"t":147},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.89},"s":[-387.096,854.615],"t":148},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.89},"s":[-355.356,874.313],"t":149},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.89},"s":[-323.608,894.095],"t":150},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.89},"s":[-291.866,913.955],"t":151},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.89},"s":[-260.152,933.885],"t":152},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[-228.532,953.865],"t":153},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[-197.028,973.898],"t":154},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[-165.648,993.986],"t":155},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[-134.403,1014.135],"t":156},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[-103.3,1034.347],"t":157},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[-72.347,1054.622],"t":158},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[-41.55,1074.962],"t":159},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.887},"s":[-10.914,1095.366],"t":160},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.887},"s":[19.556,1115.83],"t":161},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.887},"s":[49.857,1136.353],"t":162},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[79.986,1156.928],"t":163},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[109.941,1177.549],"t":164},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[139.722,1198.21],"t":165},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[169.329,1218.901],"t":166},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[198.762,1239.612],"t":167},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[228.023,1260.331],"t":168},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[257.116,1281.046],"t":169},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[286.043,1301.74],"t":170},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[314.81,1322.398],"t":171},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[343.423,1343.002],"t":172},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[371.886,1363.532],"t":173},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[400.208,1383.968],"t":174},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[428.397,1404.285],"t":175},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[456.462,1424.461],"t":176},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[484.412,1444.468],"t":177},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.883},"s":[512.259,1464.278],"t":178},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.828},"s":[540.015,1483.862],"t":179},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.888},"s":[567.691,1503.187],"t":180},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.888},"s":[506.996,1462.77],"t":181},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.888},"s":[446.853,1422.506],"t":182},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[387.089,1382.759],"t":183},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[327.711,1343.593],"t":184},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[268.764,1304.997],"t":185},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[210.289,1266.96],"t":186},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[152.326,1229.471],"t":187},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[94.918,1192.521],"t":188},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[38.092,1156.095],"t":189},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-18.109,1120.19],"t":190},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-73.658,1084.79],"t":191},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-128.524,1049.891],"t":192},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-182.675,1015.484],"t":193},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-236.093,981.556],"t":194},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-288.745,948.106],"t":195},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-340.609,915.125],"t":196},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-391.668,882.606],"t":197},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-441.907,850.539],"t":198},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-491.302,818.923],"t":199},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-539.833,787.757],"t":200},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-587.483,757.038],"t":201},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[-634.242,726.762],"t":202},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-680.1,696.925],"t":203},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-725.053,667.522],"t":204},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-769.091,638.553],"t":205},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.889},"s":[-812.2,610.022],"t":206},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.888},"s":[-854.375,581.931],"t":207},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.888},"s":[-895.611,554.281],"t":208},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.888},"s":[-935.905,527.075],"t":209},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.888},"s":[-975.259,500.314],"t":210},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.887},"s":[-1013.668,474.005],"t":211},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.887},"s":[-1051.141,448.15],"t":212},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.887},"s":[-1087.756,422.712],"t":213},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.886},"s":[-1123.54,397.682],"t":214},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.886},"s":[-1158.506,373.058],"t":215},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.886},"s":[-1192.669,348.84],"t":216},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.886},"s":[-1226.044,325.026],"t":217},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.885},"s":[-1258.644,301.618],"t":218},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.885},"s":[-1290.484,278.616],"t":219},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.885},"s":[-1321.58,256.022],"t":220},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.885},"s":[-1351.948,233.837],"t":221},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.884},"s":[-1381.607,212.061],"t":222},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.884},"s":[-1410.576,190.695],"t":223},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.884},"s":[-1438.873,169.74],"t":224},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.884},"s":[-1466.518,149.197],"t":225},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1493.534,129.067],"t":226},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1519.939,109.351],"t":227},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1545.756,90.05],"t":228},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1571.007,71.165],"t":229},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.884},"s":[-1595.713,52.697],"t":230},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.885},"s":[-1619.896,34.648],"t":231},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.887},"s":[-1643.582,17.069],"t":232},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.888},"s":[-1666.803,0.308],"t":233},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.889},"s":[-1689.551,-15.59],"t":234},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.891},"s":[-1711.806,-30.655],"t":235},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.892},"s":[-1733.555,-44.914],"t":236},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.893},"s":[-1754.783,-58.397],"t":237},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.895},"s":[-1775.478,-71.133],"t":238},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.896},"s":[-1795.625,-83.148],"t":239},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.897},"s":[-1815.211,-94.468],"t":240},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.898},"s":[-1834.221,-105.118],"t":241},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.899},"s":[-1852.641,-115.122],"t":242},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.9},"s":[-1870.456,-124.501],"t":243},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.902},"s":[-1887.656,-133.281],"t":244},{"o":{"x":0.167,"y":0.103},"i":{"x":0.833,"y":0.903},"s":[-1904.231,-141.487],"t":245},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.904},"s":[-1920.169,-149.139],"t":246},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.905},"s":[-1935.458,-156.261],"t":247},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.906},"s":[-1950.084,-162.871],"t":248},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.907},"s":[-1964.034,-168.99],"t":249},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.908},"s":[-1977.301,-174.638],"t":250},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.909},"s":[-1989.875,-179.839],"t":251},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.91},"s":[-2001.746,-184.611],"t":252},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.91},"s":[-2012.9,-188.972],"t":253},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.911},"s":[-2023.332,-192.943],"t":254},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.912},"s":[-2033.032,-196.544],"t":255},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.913},"s":[-2041.986,-199.791],"t":256},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.914},"s":[-2050.19,-202.705],"t":257},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.914},"s":[-2057.64,-205.309],"t":258},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.915},"s":[-2064.328,-207.621],"t":259},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.916},"s":[-2070.242,-209.658],"t":260},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.918},"s":[-2075.369,-211.436],"t":261},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.923},"s":[-2079.725,-212.95],"t":262},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.928},"s":[-2083.428,-214.023],"t":263},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.931},"s":[-2086.503,-214.635],"t":264},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.921},"s":[-2088.957,-214.811],"t":265},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.885},"s":[-2090.784,-214.566],"t":266},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.831},"s":[-2091.998,-213.929],"t":267},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.707},"s":[-2092.603,-212.921],"t":268},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.534},"s":[-2092.59,-211.554],"t":269},{"o":{"x":0.167,"y":0.065},"i":{"x":0.833,"y":0.886},"s":[-2091.968,-209.85],"t":270},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.886},"s":[-2061.381,-188.3],"t":271},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.886},"s":[-2030.849,-166.887],"t":272},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[-2000.375,-145.628],"t":273},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.887},"s":[-1969.96,-124.54],"t":274},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.887},"s":[-1939.607,-103.638],"t":275},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.887},"s":[-1909.318,-82.934],"t":276},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.888},"s":[-1879.093,-62.443],"t":277},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.888},"s":[-1848.934,-42.177],"t":278},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[-1818.842,-22.145],"t":279},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.889},"s":[-1788.817,-2.359],"t":280},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.89},"s":[-1758.86,17.173],"t":281},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-1728.987,36.442],"t":282},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-1699.316,55.446],"t":283},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-1669.865,74.2],"t":284},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.89},"s":[-1640.625,92.721],"t":285},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.891},"s":[-1611.589,111.023],"t":286},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1582.745,129.123],"t":287},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1554.085,147.032],"t":288},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1525.597,164.766],"t":289},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1497.269,182.337],"t":290},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.891},"s":[-1469.09,199.756],"t":291},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1441.046,217.037],"t":292},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1413.124,234.191],"t":293},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1385.308,251.23],"t":294},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1357.585,268.167],"t":295},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-1329.937,285.013],"t":296},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.892},"s":[-1302.347,301.78],"t":297},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.892},"s":[-1274.799,318.482],"t":298},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.892},"s":[-1247.273,335.133],"t":299},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1219.75,351.746],"t":300},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1192.21,368.335],"t":301},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1164.633,384.918],"t":302},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1136.996,401.51],"t":303},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1109.276,418.129],"t":304},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1081.45,434.794],"t":305},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.892},"s":[-1053.494,451.526],"t":306},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-1025.383,468.346],"t":307},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-997.089,485.278],"t":308},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-968.586,502.346],"t":309},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-939.847,519.577],"t":310},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-910.841,537.001],"t":311},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-881.544,554.636],"t":312},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-851.98,572.42],"t":313},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-822.168,590.34],"t":314},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-792.124,608.395],"t":315},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-761.86,626.586],"t":316},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-731.393,644.911],"t":317},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-700.737,663.37],"t":318},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-669.907,681.959],"t":319},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-638.918,700.677],"t":320},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-607.785,719.521],"t":321},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[-576.524,738.487],"t":322},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-545.148,757.572],"t":323},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-513.674,776.77],"t":324},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-482.117,796.079],"t":325},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.891},"s":[-450.491,815.493],"t":326},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.89},"s":[-418.813,835.007],"t":327},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.89},"s":[-387.096,854.615],"t":328},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.89},"s":[-355.356,874.313],"t":329},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.89},"s":[-323.608,894.095],"t":330},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.89},"s":[-291.866,913.955],"t":331},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.89},"s":[-260.152,933.885],"t":332},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[-228.532,953.865],"t":333},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[-197.028,973.898],"t":334},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[-165.648,993.986],"t":335},{"o":{"x":0.167,"y":0.111},"i":{"x":0.833,"y":0.889},"s":[-134.403,1014.135],"t":336},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[-103.3,1034.347],"t":337},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[-72.347,1054.622],"t":338},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.888},"s":[-41.55,1074.962],"t":339},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.887},"s":[-10.914,1095.366],"t":340},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.887},"s":[19.556,1115.83],"t":341},{"o":{"x":0.167,"y":0.113},"i":{"x":0.833,"y":0.887},"s":[49.857,1136.353],"t":342},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[79.986,1156.928],"t":343},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[109.941,1177.549],"t":344},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.886},"s":[139.722,1198.21],"t":345},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[169.329,1218.901],"t":346},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[198.762,1239.612],"t":347},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[228.023,1260.331],"t":348},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[257.116,1281.046],"t":349},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[286.043,1301.74],"t":350},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[314.81,1322.398],"t":351},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[343.423,1343.002],"t":352},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[371.886,1363.532],"t":353},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[400.208,1383.968],"t":354},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[428.397,1404.285],"t":355},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[456.462,1424.461],"t":356},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.885},"s":[484.412,1444.468],"t":357},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.885},"s":[512.259,1464.278],"t":358},{"s":[540.015,1483.862],"t":359}],"ix":6},"g":{"p":3,"k":{"a":0,"k":[0,0.6980392156862745,0.9215686274509803,0.9490196078431372,0.5,0.6980392156862745,0.9215686274509803,0.9490196078431372,1,1,1,1,0,1,0.5,1,1,0],"ix":9}},"t":2,"a":{"a":0,"k":0,"ix":8},"h":{"a":0,"k":0,"ix":7},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.844},"s":[355.873,13.269],"t":0},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.844},"s":[349.491,-4.906],"t":1},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.844},"s":[343.547,-22.324],"t":2},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.843},"s":[338.042,-38.996],"t":3},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.843},"s":[332.984,-54.923],"t":4},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.842},"s":[328.375,-70.115],"t":5},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.842},"s":[324.211,-84.591],"t":6},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.842},"s":[320.494,-98.36],"t":7},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.841},"s":[317.226,-111.427],"t":8},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.841},"s":[314.406,-123.807],"t":9},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.84},"s":[312.026,-135.519],"t":10},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.84},"s":[310.081,-146.581],"t":11},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.839},"s":[308.568,-157.005],"t":12},{"o":{"x":0.167,"y":0.172},"i":{"x":0.833,"y":0.837},"s":[307.482,-166.805],"t":13},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.836},"s":[306.83,-176.008],"t":14},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[306.886,-184.808],"t":15},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.837},"s":[307.679,-193.314],"t":16},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.839},"s":[309.118,-201.563],"t":17},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[311.115,-209.588],"t":18},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[313.584,-217.424],"t":19},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.844},"s":[316.44,-225.105],"t":20},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.845},"s":[319.601,-232.664],"t":21},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.846},"s":[322.987,-240.134],"t":22},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.847},"s":[326.522,-247.547],"t":23},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.846},"s":[330.133,-254.929],"t":24},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.846},"s":[333.75,-262.306],"t":25},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.845},"s":[337.305,-269.704],"t":26},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.843},"s":[340.731,-277.149],"t":27},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.841},"s":[343.963,-284.664],"t":28},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[346.941,-292.274],"t":29},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[349.604,-299.999],"t":30},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[351.895,-307.858],"t":31},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[353.761,-315.87],"t":32},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.832},"s":[355.148,-324.053],"t":33},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.831},"s":[356.005,-332.424],"t":34},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.831},"s":[356.282,-341],"t":35},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.832},"s":[355.931,-349.795],"t":36},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.833},"s":[354.907,-358.823],"t":37},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.835},"s":[353.167,-368.097],"t":38},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.837},"s":[350.667,-377.628],"t":39},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.84},"s":[347.369,-387.426],"t":40},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.844},"s":[343.232,-397.501],"t":41},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.847},"s":[338.22,-407.863],"t":42},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.851},"s":[332.295,-418.519],"t":43},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.855},"s":[325.425,-429.47],"t":44},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.859},"s":[317.622,-440.588],"t":45},{"o":{"x":0.167,"y":0.135},"i":{"x":0.833,"y":0.862},"s":[308.903,-451.808],"t":46},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.866},"s":[299.279,-463.113],"t":47},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.869},"s":[288.764,-474.486],"t":48},{"o":{"x":0.167,"y":0.126},"i":{"x":0.833,"y":0.872},"s":[277.376,-485.908],"t":49},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.875},"s":[265.136,-497.36],"t":50},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.878},"s":[252.068,-508.822],"t":51},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.88},"s":[238.197,-520.271],"t":52},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.883},"s":[223.555,-531.687],"t":53},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.885},"s":[208.174,-543.046],"t":54},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.887},"s":[192.088,-554.324],"t":55},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.889},"s":[175.336,-565.499],"t":56},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[157.955,-576.546],"t":57},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.893},"s":[139.988,-587.442],"t":58},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.895},"s":[121.478,-598.161],"t":59},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.897},"s":[102.471,-608.68],"t":60},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.898},"s":[83.015,-618.971],"t":61},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.9},"s":[63.159,-629.009],"t":62},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.902},"s":[42.953,-638.769],"t":63},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.903},"s":[22.448,-648.227],"t":64},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.905},"s":[1.697,-657.356],"t":65},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.906},"s":[-19.245,-666.133],"t":66},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.908},"s":[-40.323,-674.53],"t":67},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.909},"s":[-61.481,-682.522],"t":68},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.911},"s":[-82.662,-690.087],"t":69},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.912},"s":[-103.811,-697.202],"t":70},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.914},"s":[-124.87,-703.842],"t":71},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.915},"s":[-145.781,-709.985],"t":72},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.917},"s":[-166.485,-715.608],"t":73},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.918},"s":[-186.923,-720.686],"t":74},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.919},"s":[-207.037,-725.199],"t":75},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.921},"s":[-226.772,-729.13],"t":76},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.922},"s":[-246.071,-732.46],"t":77},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.923},"s":[-264.876,-735.17],"t":78},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.924},"s":[-283.131,-737.242],"t":79},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.925},"s":[-300.779,-738.657],"t":80},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.925},"s":[-317.765,-739.399],"t":81},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.924},"s":[-334.038,-739.458],"t":82},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.923},"s":[-349.546,-738.823],"t":83},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.921},"s":[-364.237,-737.479],"t":84},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.917},"s":[-378.06,-735.413],"t":85},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-390.966,-732.619],"t":86},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.905},"s":[-402.914,-729.093],"t":87},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.893},"s":[-413.857,-724.828],"t":88},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.825},"s":[-423.785,-719.813],"t":89},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.834},"s":[-433.36,-713.983],"t":90},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.833},"s":[-436.493,-697.542],"t":91},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[-439.547,-680.564],"t":92},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[-442.452,-663.096],"t":93},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.833},"s":[-445.143,-645.182],"t":94},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.833},"s":[-447.562,-626.87],"t":95},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.833},"s":[-449.653,-608.205],"t":96},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.833},"s":[-451.367,-589.235],"t":97},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[-452.659,-570.007],"t":98},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[-453.49,-550.567],"t":99},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[-453.825,-530.964],"t":100},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[-453.635,-511.245],"t":101},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.834},"s":[-452.896,-491.458],"t":102},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.834},"s":[-451.589,-471.654],"t":103},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.835},"s":[-449.698,-451.882],"t":104},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.836},"s":[-447.215,-432.193],"t":105},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-444.136,-412.637],"t":106},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.838},"s":[-440.462,-393.268],"t":107},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.839},"s":[-436.197,-374.138],"t":108},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.84},"s":[-431.354,-355.302],"t":109},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.842},"s":[-425.947,-336.814],"t":110},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-419.999,-318.731],"t":111},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.845},"s":[-413.534,-301.111],"t":112},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.847},"s":[-406.583,-284.013],"t":113},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.85},"s":[-399.184,-267.495],"t":114},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.852},"s":[-391.376,-251.621],"t":115},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.855},"s":[-383.205,-236.453],"t":116},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.858},"s":[-374.723,-222.055],"t":117},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.861},"s":[-365.986,-208.494],"t":118},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.864},"s":[-357.05,-195.831],"t":119},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.867},"s":[-347.892,-183.992],"t":120},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.871},"s":[-338.489,-172.923],"t":121},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.875},"s":[-328.844,-162.608],"t":122},{"o":{"x":0.167,"y":0.128},"i":{"x":0.833,"y":0.879},"s":[-318.963,-153.029],"t":123},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.884},"s":[-308.849,-144.169],"t":124},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.888},"s":[-298.51,-136.009],"t":125},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.893},"s":[-287.952,-128.527],"t":126},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.897},"s":[-277.184,-121.704],"t":127},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.901},"s":[-266.213,-115.517],"t":128},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.905},"s":[-255.051,-109.942],"t":129},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.908},"s":[-243.707,-104.956],"t":130},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.912},"s":[-232.191,-100.535],"t":131},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.914},"s":[-220.515,-96.652],"t":132},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.917},"s":[-208.691,-93.281],"t":133},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.918},"s":[-196.731,-90.397],"t":134},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.92},"s":[-184.646,-87.971],"t":135},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.921},"s":[-172.451,-85.975],"t":136},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.922},"s":[-160.157,-84.381],"t":137},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[-147.777,-83.16],"t":138},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-135.325,-82.283],"t":139},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-122.813,-81.721],"t":140},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-110.254,-81.442],"t":141},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-97.661,-81.418],"t":142},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-85.047,-81.617],"t":143},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-72.422,-82.009],"t":144},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-59.8,-82.564],"t":145},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[-47.191,-83.25],"t":146},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[-34.606,-84.038],"t":147},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[-22.055,-84.895],"t":148},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[-9.549,-85.792],"t":149},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[2.905,-86.699],"t":150},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[15.297,-87.584],"t":151},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[27.621,-88.419],"t":152},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[39.871,-89.173],"t":153},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[52.041,-89.816],"t":154},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[64.127,-90.321],"t":155},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[76.125,-90.658],"t":156},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[88.034,-90.799],"t":157},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[99.853,-90.716],"t":158},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[111.582,-90.384],"t":159},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[123.223,-89.775],"t":160},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[134.781,-88.864],"t":161},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[146.259,-87.626],"t":162},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.918},"s":[157.664,-86.036],"t":163},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.916},"s":[169.012,-84.074],"t":164},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.914},"s":[180.446,-81.753],"t":165},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.912},"s":[191.995,-79.054],"t":166},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.91},"s":[203.632,-75.941],"t":167},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.907},"s":[215.332,-72.385],"t":168},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.904},"s":[227.075,-68.356],"t":169},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.901},"s":[238.84,-63.826],"t":170},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.898},"s":[250.615,-58.773],"t":171},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.894},"s":[262.386,-53.172],"t":172},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.891},"s":[274.144,-47.006],"t":173},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.887},"s":[285.883,-40.254],"t":174},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.884},"s":[297.599,-32.903],"t":175},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.88},"s":[309.29,-24.938],"t":176},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.877},"s":[320.958,-16.347],"t":177},{"o":{"x":0.167,"y":0.12},"i":{"x":0.833,"y":0.872},"s":[332.607,-7.121],"t":178},{"o":{"x":0.167,"y":0.122},"i":{"x":0.833,"y":0.84},"s":[344.243,2.749],"t":179},{"o":{"x":0.167,"y":0.131},"i":{"x":0.833,"y":0.845},"s":[355.873,13.269],"t":180},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.844},"s":[349.491,-4.906],"t":181},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.844},"s":[343.547,-22.324],"t":182},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.843},"s":[338.042,-38.996],"t":183},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.843},"s":[332.984,-54.923],"t":184},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.842},"s":[328.375,-70.115],"t":185},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.842},"s":[324.211,-84.591],"t":186},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.842},"s":[320.494,-98.36],"t":187},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.841},"s":[317.226,-111.427],"t":188},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.841},"s":[314.406,-123.807],"t":189},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.84},"s":[312.026,-135.519],"t":190},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.84},"s":[310.081,-146.581],"t":191},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.839},"s":[308.568,-157.005],"t":192},{"o":{"x":0.167,"y":0.172},"i":{"x":0.833,"y":0.837},"s":[307.482,-166.805],"t":193},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.836},"s":[306.83,-176.008],"t":194},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.836},"s":[306.886,-184.808],"t":195},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.837},"s":[307.679,-193.314],"t":196},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.839},"s":[309.118,-201.563],"t":197},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[311.115,-209.588],"t":198},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[313.584,-217.424],"t":199},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.844},"s":[316.44,-225.105],"t":200},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.845},"s":[319.601,-232.664],"t":201},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.846},"s":[322.987,-240.134],"t":202},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.847},"s":[326.522,-247.547],"t":203},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.846},"s":[330.133,-254.929],"t":204},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.846},"s":[333.75,-262.306],"t":205},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.845},"s":[337.305,-269.704],"t":206},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.843},"s":[340.731,-277.149],"t":207},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.841},"s":[343.963,-284.664],"t":208},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[346.941,-292.274],"t":209},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[349.604,-299.999],"t":210},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.835},"s":[351.895,-307.858],"t":211},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[353.761,-315.87],"t":212},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.832},"s":[355.148,-324.053],"t":213},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.831},"s":[356.005,-332.424],"t":214},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.831},"s":[356.282,-341],"t":215},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.832},"s":[355.931,-349.795],"t":216},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.833},"s":[354.907,-358.823],"t":217},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.835},"s":[353.167,-368.097],"t":218},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.837},"s":[350.667,-377.628],"t":219},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.84},"s":[347.369,-387.426],"t":220},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.844},"s":[343.232,-397.501],"t":221},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.847},"s":[338.22,-407.863],"t":222},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.851},"s":[332.295,-418.519],"t":223},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.855},"s":[325.425,-429.47],"t":224},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.859},"s":[317.622,-440.588],"t":225},{"o":{"x":0.167,"y":0.135},"i":{"x":0.833,"y":0.862},"s":[308.903,-451.808],"t":226},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.866},"s":[299.279,-463.113],"t":227},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.869},"s":[288.764,-474.486],"t":228},{"o":{"x":0.167,"y":0.126},"i":{"x":0.833,"y":0.872},"s":[277.376,-485.908],"t":229},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.875},"s":[265.136,-497.36],"t":230},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.878},"s":[252.068,-508.822],"t":231},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.88},"s":[238.197,-520.271],"t":232},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.883},"s":[223.555,-531.687],"t":233},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.885},"s":[208.174,-543.046],"t":234},{"o":{"x":0.167,"y":0.112},"i":{"x":0.833,"y":0.887},"s":[192.088,-554.324],"t":235},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.889},"s":[175.336,-565.499],"t":236},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.891},"s":[157.955,-576.546],"t":237},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.893},"s":[139.988,-587.442],"t":238},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.895},"s":[121.478,-598.161],"t":239},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.897},"s":[102.471,-608.68],"t":240},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.898},"s":[83.015,-618.971],"t":241},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.9},"s":[63.159,-629.009],"t":242},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.902},"s":[42.953,-638.769],"t":243},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.903},"s":[22.448,-648.227],"t":244},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.905},"s":[1.697,-657.356],"t":245},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.906},"s":[-19.245,-666.133],"t":246},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.908},"s":[-40.323,-674.53],"t":247},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.909},"s":[-61.481,-682.522],"t":248},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.911},"s":[-82.662,-690.087],"t":249},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.912},"s":[-103.811,-697.202],"t":250},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.914},"s":[-124.87,-703.842],"t":251},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.915},"s":[-145.781,-709.985],"t":252},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.917},"s":[-166.485,-715.608],"t":253},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.918},"s":[-186.923,-720.686],"t":254},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.919},"s":[-207.037,-725.199],"t":255},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.921},"s":[-226.772,-729.13],"t":256},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.922},"s":[-246.071,-732.46],"t":257},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.923},"s":[-264.876,-735.17],"t":258},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.924},"s":[-283.131,-737.242],"t":259},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.925},"s":[-300.779,-738.657],"t":260},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.925},"s":[-317.765,-739.399],"t":261},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.924},"s":[-334.038,-739.458],"t":262},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.923},"s":[-349.546,-738.823],"t":263},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.921},"s":[-364.237,-737.479],"t":264},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.917},"s":[-378.06,-735.413],"t":265},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[-390.966,-732.619],"t":266},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.905},"s":[-402.914,-729.093],"t":267},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.893},"s":[-413.857,-724.828],"t":268},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.825},"s":[-423.785,-719.813],"t":269},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.834},"s":[-433.36,-713.983],"t":270},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.833},"s":[-436.493,-697.542],"t":271},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[-439.547,-680.564],"t":272},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.833},"s":[-442.452,-663.096],"t":273},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.833},"s":[-445.143,-645.182],"t":274},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.833},"s":[-447.562,-626.87],"t":275},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.833},"s":[-449.653,-608.205],"t":276},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.833},"s":[-451.367,-589.235],"t":277},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[-452.659,-570.007],"t":278},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[-453.49,-550.567],"t":279},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[-453.825,-530.964],"t":280},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.833},"s":[-453.635,-511.245],"t":281},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.834},"s":[-452.896,-491.458],"t":282},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.834},"s":[-451.589,-471.654],"t":283},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.835},"s":[-449.698,-451.882],"t":284},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.836},"s":[-447.215,-432.193],"t":285},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-444.136,-412.637],"t":286},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.838},"s":[-440.462,-393.268],"t":287},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.839},"s":[-436.197,-374.138],"t":288},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.84},"s":[-431.354,-355.302],"t":289},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.842},"s":[-425.947,-336.814],"t":290},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-419.999,-318.731],"t":291},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.845},"s":[-413.534,-301.111],"t":292},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.847},"s":[-406.583,-284.013],"t":293},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.85},"s":[-399.184,-267.495],"t":294},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.852},"s":[-391.376,-251.621],"t":295},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.855},"s":[-383.205,-236.453],"t":296},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.858},"s":[-374.723,-222.055],"t":297},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.861},"s":[-365.986,-208.494],"t":298},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.864},"s":[-357.05,-195.831],"t":299},{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.867},"s":[-347.892,-183.992],"t":300},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.871},"s":[-338.489,-172.923],"t":301},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.875},"s":[-328.844,-162.608],"t":302},{"o":{"x":0.167,"y":0.128},"i":{"x":0.833,"y":0.879},"s":[-318.963,-153.029],"t":303},{"o":{"x":0.167,"y":0.123},"i":{"x":0.833,"y":0.884},"s":[-308.849,-144.169],"t":304},{"o":{"x":0.167,"y":0.118},"i":{"x":0.833,"y":0.888},"s":[-298.51,-136.009],"t":305},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.893},"s":[-287.952,-128.527],"t":306},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.897},"s":[-277.184,-121.704],"t":307},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.901},"s":[-266.213,-115.517],"t":308},{"o":{"x":0.167,"y":0.1},"i":{"x":0.833,"y":0.905},"s":[-255.051,-109.942],"t":309},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.908},"s":[-243.707,-104.956],"t":310},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.912},"s":[-232.191,-100.535],"t":311},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.914},"s":[-220.515,-96.652],"t":312},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.917},"s":[-208.691,-93.281],"t":313},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.918},"s":[-196.731,-90.397],"t":314},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.92},"s":[-184.646,-87.971],"t":315},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.921},"s":[-172.451,-85.975],"t":316},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.922},"s":[-160.157,-84.381],"t":317},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[-147.777,-83.16],"t":318},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-135.325,-82.283],"t":319},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-122.813,-81.721],"t":320},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-110.254,-81.442],"t":321},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-97.661,-81.418],"t":322},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-85.047,-81.617],"t":323},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-72.422,-82.009],"t":324},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[-59.8,-82.564],"t":325},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[-47.191,-83.25],"t":326},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[-34.606,-84.038],"t":327},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[-22.055,-84.895],"t":328},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[-9.549,-85.792],"t":329},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[2.905,-86.699],"t":330},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[15.297,-87.584],"t":331},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[27.621,-88.419],"t":332},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[39.871,-89.173],"t":333},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[52.041,-89.816],"t":334},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[64.127,-90.321],"t":335},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[76.125,-90.658],"t":336},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[88.034,-90.799],"t":337},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[99.853,-90.716],"t":338},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[111.582,-90.384],"t":339},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.922},"s":[123.223,-89.775],"t":340},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[134.781,-88.864],"t":341},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.92},"s":[146.259,-87.626],"t":342},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.918},"s":[157.664,-86.036],"t":343},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.916},"s":[169.012,-84.074],"t":344},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.914},"s":[180.446,-81.753],"t":345},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.912},"s":[191.995,-79.054],"t":346},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.91},"s":[203.632,-75.941],"t":347},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.907},"s":[215.332,-72.385],"t":348},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.904},"s":[227.075,-68.356],"t":349},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.901},"s":[238.84,-63.826],"t":350},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.898},"s":[250.615,-58.773],"t":351},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.894},"s":[262.386,-53.172],"t":352},{"o":{"x":0.167,"y":0.104},"i":{"x":0.833,"y":0.891},"s":[274.144,-47.006],"t":353},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.887},"s":[285.883,-40.254],"t":354},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.884},"s":[297.599,-32.903],"t":355},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.88},"s":[309.29,-24.938],"t":356},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.877},"s":[320.958,-16.347],"t":357},{"o":{"x":0.167,"y":0.12},"i":{"x":0.833,"y":0.877},"s":[332.607,-7.121],"t":358},{"s":[344.243,2.749],"t":359}],"ix":5},"r":1,"o":{"a":0,"k":100,"ix":10}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[3.056,-531.6],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":3},{"ty":4,"nm":"gradient2","sr":1,"st":0,"op":360,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[558.944,1749.6,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"shapes":[{"ty":"gr","bm":0,"hd":false,"mn":"ADBE Vector Group","nm":"Rectangle 1","ix":1,"cix":2,"np":2,"it":[{"ty":"rc","bm":0,"hd":false,"mn":"ADBE Vector Shape - Rect","nm":"Rectangle Path 1","d":1,"p":{"a":0,"k":[0,0],"ix":3},"r":{"a":0,"k":0,"ix":4},"s":{"a":0,"k":[1125,2436],"ix":2}},{"ty":"gf","bm":0,"hd":false,"mn":"ADBE Vector Graphic - G-Fill","nm":"Gradient Fill 1","e":{"a":1,"k":[{"o":{"x":0.167,"y":0.14},"i":{"x":0.833,"y":0.859},"s":[965.71,492.365],"t":0},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.857},"s":[959.904,499.912],"t":1},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.856},"s":[954.236,507.6],"t":2},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.856},"s":[948.695,515.423],"t":3},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.855},"s":[943.271,523.337],"t":4},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.855},"s":[937.949,531.3],"t":5},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.855},"s":[932.712,539.277],"t":6},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.855},"s":[927.55,547.232],"t":7},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.855},"s":[922.454,555.131],"t":8},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.855},"s":[917.415,562.942],"t":9},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.856},"s":[912.419,570.64],"t":10},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.857},"s":[907.458,578.198],"t":11},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.857},"s":[902.525,585.592],"t":12},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.859},"s":[897.615,592.801],"t":13},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.86},"s":[892.722,599.804],"t":14},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.861},"s":[887.839,606.585],"t":15},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.863},"s":[882.961,613.128],"t":16},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.864},"s":[878.083,619.421],"t":17},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.866},"s":[873.204,625.45],"t":18},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.869},"s":[868.323,631.205],"t":19},{"o":{"x":0.167,"y":0.135},"i":{"x":0.833,"y":0.871},"s":[863.437,636.677],"t":20},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.873},"s":[858.545,641.861],"t":21},{"o":{"x":0.167,"y":0.13},"i":{"x":0.833,"y":0.876},"s":[853.645,646.751],"t":22},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.879},"s":[848.737,651.344],"t":23},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.883},"s":[843.821,655.637],"t":24},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.886},"s":[838.901,659.628],"t":25},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.89},"s":[833.977,663.318],"t":26},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.893},"s":[829.054,666.708],"t":27},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.897},"s":[824.135,669.8],"t":28},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.901},"s":[819.223,672.596],"t":29},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.904},"s":[814.322,675.103],"t":30},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.907},"s":[809.443,677.332],"t":31},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.91},"s":[804.611,679.314],"t":32},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.913},"s":[799.829,681.062],"t":33},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.915},"s":[795.102,682.589],"t":34},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.917},"s":[790.431,683.909],"t":35},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.919},"s":[785.82,685.038],"t":36},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.92},"s":[781.273,685.991],"t":37},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.921},"s":[776.796,686.785],"t":38},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.922},"s":[772.392,687.438],"t":39},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.923},"s":[768.066,687.97],"t":40},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[763.824,688.399],"t":41},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[759.669,688.747],"t":42},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.924},"s":[755.608,689.035],"t":43},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.924},"s":[751.645,689.286],"t":44},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[747.786,689.523],"t":45},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[744.038,689.77],"t":46},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[740.407,690.053],"t":47},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.921},"s":[736.9,690.395],"t":48},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.92},"s":[733.524,690.824],"t":49},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.918},"s":[730.287,691.365],"t":50},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[727.103,691.933],"t":51},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.92},"s":[723.695,692.208],"t":52},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.92},"s":[720.062,692.199],"t":53},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.918},"s":[716.222,691.937],"t":54},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.917},"s":[712.192,691.453],"t":55},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.916},"s":[707.99,690.776],"t":56},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.915},"s":[703.632,689.932],"t":57},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.914},"s":[699.135,688.947],"t":58},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.914},"s":[694.515,687.847],"t":59},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.913},"s":[689.789,686.655],"t":60},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.913},"s":[684.973,685.391],"t":61},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.913},"s":[680.085,684.077],"t":62},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.914},"s":[675.14,682.732],"t":63},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.914},"s":[670.158,681.374],"t":64},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.915},"s":[665.155,680.02],"t":65},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.915},"s":[660.15,678.686],"t":66},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.916},"s":[655.16,677.388],"t":67},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.917},"s":[650.204,676.14],"t":68},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.918},"s":[645.3,674.957],"t":69},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.919},"s":[640.466,673.851],"t":70},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.92},"s":[635.722,672.835],"t":71},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.921},"s":[631.088,671.92],"t":72},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.922},"s":[626.586,671.118],"t":73},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.923},"s":[622.238,670.44],"t":74},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.924},"s":[618.066,669.894],"t":75},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.925},"s":[614.093,669.493],"t":76},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.926},"s":[610.34,669.245],"t":77},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.926},"s":[606.834,669.16],"t":78},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.924},"s":[603.601,669.248],"t":79},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.919},"s":[600.668,669.515],"t":80},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[598.051,669.981],"t":81},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.901},"s":[595.728,670.669],"t":82},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.887},"s":[593.699,671.564],"t":83},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.872},"s":[591.965,672.649],"t":84},{"o":{"x":0.167,"y":0.134},"i":{"x":0.833,"y":0.858},"s":[590.528,673.903],"t":85},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.846},"s":[589.386,675.309],"t":86},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.837},"s":[588.537,676.848],"t":87},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.829},"s":[587.98,678.498],"t":88},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.832},"s":[587.713,680.237],"t":89},{"o":{"x":0.167,"y":0.072},"i":{"x":0.833,"y":0.921},"s":[587.734,682.044],"t":90},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[592.49,681.924],"t":91},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[597.426,681.871],"t":92},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[602.537,681.865],"t":93},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[607.817,681.881],"t":94},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[613.263,681.897],"t":95},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[618.866,681.89],"t":96},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[624.622,681.838],"t":97},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[630.522,681.717],"t":98},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[636.559,681.505],"t":99},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[642.726,681.179],"t":100},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[648.955,680.759],"t":101},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[655.062,680.338],"t":102},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[661.036,679.889],"t":103},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.922},"s":[666.879,679.378],"t":104},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.921},"s":[672.591,678.774],"t":105},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.919},"s":[678.174,678.049],"t":106},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.918},"s":[683.631,677.177],"t":107},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.915},"s":[688.96,676.134],"t":108},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.912},"s":[694.165,674.9],"t":109},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.908},"s":[699.246,673.456],"t":110},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.904},"s":[704.203,671.787],"t":111},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.899},"s":[709.038,669.879],"t":112},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.894},"s":[713.751,667.721],"t":113},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.889},"s":[718.342,665.305],"t":114},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.884},"s":[722.812,662.625],"t":115},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.879},"s":[727.16,659.675],"t":116},{"o":{"x":0.167,"y":0.12},"i":{"x":0.833,"y":0.874},"s":[731.386,656.455],"t":117},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.869},"s":[735.491,652.965],"t":118},{"o":{"x":0.167,"y":0.128},"i":{"x":0.833,"y":0.865},"s":[739.473,649.208],"t":119},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.862},"s":[743.332,645.187],"t":120},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.858},"s":[747.066,640.912],"t":121},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.855},"s":[750.674,636.389],"t":122},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.853},"s":[754.155,631.63],"t":123},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.85},"s":[757.506,626.649],"t":124},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.848},"s":[760.727,621.46],"t":125},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.847},"s":[763.813,616.081],"t":126},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.845},"s":[766.764,610.529],"t":127},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.844},"s":[769.574,604.827],"t":128},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.842},"s":[772.242,598.996],"t":129},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.838},"s":[774.764,593.062],"t":130},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.836},"s":[777.094,586.982],"t":131},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[779.121,580.591],"t":132},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.834},"s":[780.874,573.924],"t":133},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.834},"s":[782.388,567.032],"t":134},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.834},"s":[783.699,559.966],"t":135},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.834},"s":[784.844,552.776],"t":136},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.835},"s":[785.857,545.51],"t":137},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.835},"s":[786.771,538.216],"t":138},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[787.619,530.94],"t":139},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.836},"s":[788.434,523.728],"t":140},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.837},"s":[789.245,516.624],"t":141},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.837},"s":[790.084,509.671],"t":142},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.838},"s":[790.978,502.908],"t":143},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.839},"s":[791.955,496.375],"t":144},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.84},"s":[793.041,490.108],"t":145},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.842},"s":[794.261,484.141],"t":146},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.844},"s":[795.637,478.508],"t":147},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.847},"s":[797.192,473.238],"t":148},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.851},"s":[798.945,468.358],"t":149},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.855},"s":[800.915,463.893],"t":150},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.861},"s":[803.102,459.848],"t":151},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.868},"s":[805.47,456.194],"t":152},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.877},"s":[808.034,452.946],"t":153},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.887},"s":[810.81,450.12],"t":154},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.897},"s":[813.812,447.728],"t":155},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.907},"s":[817.052,445.778],"t":156},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.915},"s":[820.543,444.276],"t":157},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.919},"s":[824.293,443.222],"t":158},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[828.31,442.616],"t":159},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.919},"s":[832.598,442.451],"t":160},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.916},"s":[837.162,442.719],"t":161},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.912},"s":[842.003,443.407],"t":162},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.909},"s":[847.121,444.501],"t":163},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.906},"s":[852.514,445.979],"t":164},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.904},"s":[858.176,447.82],"t":165},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.902},"s":[864.102,449.996],"t":166},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.901},"s":[870.282,452.479],"t":167},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.9},"s":[876.706,455.234],"t":168},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.899},"s":[883.361,458.223],"t":169},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.899},"s":[890.231,461.407],"t":170},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.899},"s":[897.299,464.741],"t":171},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.9},"s":[904.544,468.176],"t":172},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.901},"s":[911.945,471.662],"t":173},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.903},"s":[919.476,475.142],"t":174},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.905},"s":[927.111,478.559],"t":175},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.907},"s":[934.82,481.849],"t":176},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.91},"s":[942.57,484.947],"t":177},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.911},"s":[950.327,487.781],"t":178},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.874},"s":[958.054,490.28],"t":179},{"o":{"x":0.167,"y":0.107},"i":{"x":0.833,"y":0.861},"s":[965.71,492.365],"t":180},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.857},"s":[959.904,499.912],"t":181},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.856},"s":[954.236,507.6],"t":182},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.856},"s":[948.695,515.423],"t":183},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.855},"s":[943.271,523.337],"t":184},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.855},"s":[937.949,531.3],"t":185},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.855},"s":[932.712,539.277],"t":186},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.855},"s":[927.55,547.232],"t":187},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.855},"s":[922.454,555.131],"t":188},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.855},"s":[917.415,562.942],"t":189},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.856},"s":[912.419,570.64],"t":190},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.857},"s":[907.458,578.198],"t":191},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.857},"s":[902.525,585.592],"t":192},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.859},"s":[897.615,592.801],"t":193},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.86},"s":[892.722,599.804],"t":194},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.861},"s":[887.839,606.585],"t":195},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.863},"s":[882.961,613.128],"t":196},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.864},"s":[878.083,619.421],"t":197},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.866},"s":[873.204,625.45],"t":198},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.869},"s":[868.323,631.205],"t":199},{"o":{"x":0.167,"y":0.135},"i":{"x":0.833,"y":0.871},"s":[863.437,636.677],"t":200},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.873},"s":[858.545,641.861],"t":201},{"o":{"x":0.167,"y":0.13},"i":{"x":0.833,"y":0.876},"s":[853.645,646.751],"t":202},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.879},"s":[848.737,651.344],"t":203},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.883},"s":[843.821,655.637],"t":204},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.886},"s":[838.901,659.628],"t":205},{"o":{"x":0.167,"y":0.117},"i":{"x":0.833,"y":0.89},"s":[833.977,663.318],"t":206},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.893},"s":[829.054,666.708],"t":207},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.897},"s":[824.135,669.8],"t":208},{"o":{"x":0.167,"y":0.106},"i":{"x":0.833,"y":0.901},"s":[819.223,672.596],"t":209},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.904},"s":[814.322,675.103],"t":210},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.907},"s":[809.443,677.332],"t":211},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.91},"s":[804.611,679.314],"t":212},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.913},"s":[799.829,681.062],"t":213},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.915},"s":[795.102,682.589],"t":214},{"o":{"x":0.167,"y":0.087},"i":{"x":0.833,"y":0.917},"s":[790.431,683.909],"t":215},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.919},"s":[785.82,685.038],"t":216},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.92},"s":[781.273,685.991],"t":217},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.921},"s":[776.796,686.785],"t":218},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.922},"s":[772.392,687.438],"t":219},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.923},"s":[768.066,687.97],"t":220},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[763.824,688.399],"t":221},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[759.669,688.747],"t":222},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.924},"s":[755.608,689.035],"t":223},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.924},"s":[751.645,689.286],"t":224},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[747.786,689.523],"t":225},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[744.038,689.77],"t":226},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[740.407,690.053],"t":227},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.921},"s":[736.9,690.395],"t":228},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.92},"s":[733.524,690.824],"t":229},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.918},"s":[730.287,691.365],"t":230},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[727.103,691.933],"t":231},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.92},"s":[723.695,692.208],"t":232},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.92},"s":[720.062,692.199],"t":233},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.918},"s":[716.222,691.937],"t":234},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.917},"s":[712.192,691.453],"t":235},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.916},"s":[707.99,690.776],"t":236},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.915},"s":[703.632,689.932],"t":237},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.914},"s":[699.135,688.947],"t":238},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.914},"s":[694.515,687.847],"t":239},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.913},"s":[689.789,686.655],"t":240},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.913},"s":[684.973,685.391],"t":241},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.913},"s":[680.085,684.077],"t":242},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.914},"s":[675.14,682.732],"t":243},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.914},"s":[670.158,681.374],"t":244},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.915},"s":[665.155,680.02],"t":245},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.915},"s":[660.15,678.686],"t":246},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.916},"s":[655.16,677.388],"t":247},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.917},"s":[650.204,676.14],"t":248},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.918},"s":[645.3,674.957],"t":249},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.919},"s":[640.466,673.851],"t":250},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.92},"s":[635.722,672.835],"t":251},{"o":{"x":0.167,"y":0.083},"i":{"x":0.833,"y":0.921},"s":[631.088,671.92],"t":252},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.922},"s":[626.586,671.118],"t":253},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.923},"s":[622.238,670.44],"t":254},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.924},"s":[618.066,669.894],"t":255},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.925},"s":[614.093,669.493],"t":256},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.926},"s":[610.34,669.245],"t":257},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.926},"s":[606.834,669.16],"t":258},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.924},"s":[603.601,669.248],"t":259},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.919},"s":[600.668,669.515],"t":260},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.912},"s":[598.051,669.981],"t":261},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.901},"s":[595.728,670.669],"t":262},{"o":{"x":0.167,"y":0.108},"i":{"x":0.833,"y":0.887},"s":[593.699,671.564],"t":263},{"o":{"x":0.167,"y":0.121},"i":{"x":0.833,"y":0.872},"s":[591.965,672.649],"t":264},{"o":{"x":0.167,"y":0.134},"i":{"x":0.833,"y":0.858},"s":[590.528,673.903],"t":265},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.846},"s":[589.386,675.309],"t":266},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.837},"s":[588.537,676.848],"t":267},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.829},"s":[587.98,678.498],"t":268},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.832},"s":[587.713,680.237],"t":269},{"o":{"x":0.167,"y":0.072},"i":{"x":0.833,"y":0.921},"s":[587.734,682.044],"t":270},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[592.49,681.924],"t":271},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[597.426,681.871],"t":272},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[602.537,681.865],"t":273},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[607.817,681.881],"t":274},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[613.263,681.897],"t":275},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[618.866,681.89],"t":276},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[624.622,681.838],"t":277},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[630.522,681.717],"t":278},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.922},"s":[636.559,681.505],"t":279},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.923},"s":[642.726,681.179],"t":280},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.923},"s":[648.955,680.759],"t":281},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[655.062,680.338],"t":282},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.923},"s":[661.036,679.889],"t":283},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.922},"s":[666.879,679.378],"t":284},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.921},"s":[672.591,678.774],"t":285},{"o":{"x":0.167,"y":0.081},"i":{"x":0.833,"y":0.919},"s":[678.174,678.049],"t":286},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.918},"s":[683.631,677.177],"t":287},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.915},"s":[688.96,676.134],"t":288},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.912},"s":[694.165,674.9],"t":289},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.908},"s":[699.246,673.456],"t":290},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.904},"s":[704.203,671.787],"t":291},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.899},"s":[709.038,669.879],"t":292},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.894},"s":[713.751,667.721],"t":293},{"o":{"x":0.167,"y":0.105},"i":{"x":0.833,"y":0.889},"s":[718.342,665.305],"t":294},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.884},"s":[722.812,662.625],"t":295},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.879},"s":[727.16,659.675],"t":296},{"o":{"x":0.167,"y":0.12},"i":{"x":0.833,"y":0.874},"s":[731.386,656.455],"t":297},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.869},"s":[735.491,652.965],"t":298},{"o":{"x":0.167,"y":0.128},"i":{"x":0.833,"y":0.865},"s":[739.473,649.208],"t":299},{"o":{"x":0.167,"y":0.132},"i":{"x":0.833,"y":0.862},"s":[743.332,645.187],"t":300},{"o":{"x":0.167,"y":0.136},"i":{"x":0.833,"y":0.858},"s":[747.066,640.912],"t":301},{"o":{"x":0.167,"y":0.139},"i":{"x":0.833,"y":0.855},"s":[750.674,636.389],"t":302},{"o":{"x":0.167,"y":0.142},"i":{"x":0.833,"y":0.853},"s":[754.155,631.63],"t":303},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.85},"s":[757.506,626.649],"t":304},{"o":{"x":0.167,"y":0.147},"i":{"x":0.833,"y":0.848},"s":[760.727,621.46],"t":305},{"o":{"x":0.167,"y":0.15},"i":{"x":0.833,"y":0.847},"s":[763.813,616.081],"t":306},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.845},"s":[766.764,610.529],"t":307},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.844},"s":[769.574,604.827],"t":308},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.842},"s":[772.242,598.996],"t":309},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.838},"s":[774.764,593.062],"t":310},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.836},"s":[777.094,586.982],"t":311},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[779.121,580.591],"t":312},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.834},"s":[780.874,573.924],"t":313},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.834},"s":[782.388,567.032],"t":314},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.834},"s":[783.699,559.966],"t":315},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.834},"s":[784.844,552.776],"t":316},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.835},"s":[785.857,545.51],"t":317},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.835},"s":[786.771,538.216],"t":318},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.835},"s":[787.619,530.94],"t":319},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.836},"s":[788.434,523.728],"t":320},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.837},"s":[789.245,516.624],"t":321},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.837},"s":[790.084,509.671],"t":322},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.838},"s":[790.978,502.908],"t":323},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.839},"s":[791.955,496.375],"t":324},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.84},"s":[793.041,490.108],"t":325},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.842},"s":[794.261,484.141],"t":326},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.844},"s":[795.637,478.508],"t":327},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.847},"s":[797.192,473.238],"t":328},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.851},"s":[798.945,468.358],"t":329},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.855},"s":[800.915,463.893],"t":330},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.861},"s":[803.102,459.848],"t":331},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.868},"s":[805.47,456.194],"t":332},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.877},"s":[808.034,452.946],"t":333},{"o":{"x":0.167,"y":0.127},"i":{"x":0.833,"y":0.887},"s":[810.81,450.12],"t":334},{"o":{"x":0.167,"y":0.115},"i":{"x":0.833,"y":0.897},"s":[813.812,447.728],"t":335},{"o":{"x":0.167,"y":0.102},"i":{"x":0.833,"y":0.907},"s":[817.052,445.778],"t":336},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.915},"s":[820.543,444.276],"t":337},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.919},"s":[824.293,443.222],"t":338},{"o":{"x":0.167,"y":0.077},"i":{"x":0.833,"y":0.92},"s":[828.31,442.616],"t":339},{"o":{"x":0.167,"y":0.075},"i":{"x":0.833,"y":0.919},"s":[832.598,442.451],"t":340},{"o":{"x":0.167,"y":0.076},"i":{"x":0.833,"y":0.916},"s":[837.162,442.719],"t":341},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.912},"s":[842.003,443.407],"t":342},{"o":{"x":0.167,"y":0.082},"i":{"x":0.833,"y":0.909},"s":[847.121,444.501],"t":343},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.906},"s":[852.514,445.979],"t":344},{"o":{"x":0.167,"y":0.088},"i":{"x":0.833,"y":0.904},"s":[858.176,447.82],"t":345},{"o":{"x":0.167,"y":0.091},"i":{"x":0.833,"y":0.902},"s":[864.102,449.996],"t":346},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.901},"s":[870.282,452.479],"t":347},{"o":{"x":0.167,"y":0.095},"i":{"x":0.833,"y":0.9},"s":[876.706,455.234],"t":348},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.899},"s":[883.361,458.223],"t":349},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.899},"s":[890.231,461.407],"t":350},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.899},"s":[897.299,464.741],"t":351},{"o":{"x":0.167,"y":0.099},"i":{"x":0.833,"y":0.9},"s":[904.544,468.176],"t":352},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.901},"s":[911.945,471.662],"t":353},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.903},"s":[919.476,475.142],"t":354},{"o":{"x":0.167,"y":0.097},"i":{"x":0.833,"y":0.905},"s":[927.111,478.559],"t":355},{"o":{"x":0.167,"y":0.096},"i":{"x":0.833,"y":0.907},"s":[934.82,481.849],"t":356},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.91},"s":[942.57,484.947],"t":357},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.911},"s":[950.327,487.781],"t":358},{"s":[958.054,490.28],"t":359}],"ix":6},"g":{"p":3,"k":{"a":0,"k":[0,1,0.9607843137254902,0.615686274509804,0.5,1,0.9764705882352941,0.7686274509803922,1,1,1,1,0,1,0.5,1,1,0],"ix":9}},"t":2,"a":{"a":0,"k":0,"ix":8},"h":{"a":0,"k":0,"ix":7},"s":{"a":1,"k":[{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-95.752,-1074.828],"t":0},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-106.916,-1045.698],"t":1},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.84},"s":[-117.763,-1016.31],"t":2},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[-128.352,-986.567],"t":3},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[-138.73,-956.407],"t":4},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-148.952,-925.76],"t":5},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-159.072,-894.554],"t":6},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-169.136,-862.753],"t":7},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.837},"s":[-179.191,-830.315],"t":8},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-189.278,-797.217],"t":9},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-199.436,-763.442],"t":10},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-209.703,-728.976],"t":11},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-220.107,-693.83],"t":12},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-230.671,-658.023],"t":13},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-241.414,-621.576],"t":14},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-252.352,-584.519],"t":15},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-263.496,-546.888],"t":16},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-274.844,-508.737],"t":17},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-286.387,-470.135],"t":18},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-298.11,-431.156],"t":19},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-309.989,-391.88],"t":20},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-321.993,-352.397],"t":21},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-334.083,-312.802],"t":22},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.84},"s":[-346.209,-273.201],"t":23},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[-358.308,-233.718],"t":24},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.842},"s":[-370.326,-194.552],"t":25},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-382.332,-156.335],"t":26},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.844},"s":[-394.331,-119.332],"t":27},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.845},"s":[-406.308,-83.725],"t":28},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.846},"s":[-418.243,-49.695],"t":29},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.848},"s":[-430.119,-17.425],"t":30},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.846},"s":[-441.913,12.898],"t":31},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[-453.604,41.087],"t":32},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.84},"s":[-464.697,68.392],"t":33},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[-474.739,96.254],"t":34},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.838},"s":[-483.856,124.533],"t":35},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.838},"s":[-492.17,153.096],"t":36},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.837},"s":[-499.795,181.821],"t":37},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.837},"s":[-506.839,210.594],"t":38},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.837},"s":[-513.405,239.312],"t":39},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-519.589,267.884],"t":40},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-525.482,296.225],"t":41},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-531.168,324.265],"t":42},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-536.727,351.941],"t":43},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.838},"s":[-542.232,379.2],"t":44},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.838},"s":[-547.75,405.998],"t":45},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.838},"s":[-553.343,432.303],"t":46},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.838},"s":[-559.067,458.088],"t":47},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.839},"s":[-564.971,483.339],"t":48},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.839},"s":[-571.101,508.05],"t":49},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.84},"s":[-577.495,532.221],"t":50},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.841},"s":[-584.186,555.866],"t":51},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[-591.201,579.002],"t":52},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.842},"s":[-598.562,601.658],"t":53},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.843},"s":[-606.285,623.869],"t":54},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.843},"s":[-614.381,645.68],"t":55},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.844},"s":[-622.855,667.145],"t":56},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.845},"s":[-631.708,688.326],"t":57},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.845},"s":[-640.933,709.292],"t":58},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.846},"s":[-650.521,730.122],"t":59},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.846},"s":[-660.454,750.899],"t":60},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.848},"s":[-670.711,771.716],"t":61},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.851},"s":[-681.264,792.67],"t":62},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.851},"s":[-691.734,813.324],"t":63},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.852},"s":[-701.702,833.034],"t":64},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.853},"s":[-711.135,851.7],"t":65},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.854},"s":[-720.003,869.224],"t":66},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.855},"s":[-728.277,885.514],"t":67},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.855},"s":[-735.931,900.482],"t":68},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.851},"s":[-742.939,914.052],"t":69},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-749.089,926.3],"t":70},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.839},"s":[-753.14,938.144],"t":71},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.838},"s":[-755.05,949.648],"t":72},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.84},"s":[-755.021,960.693],"t":73},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.845},"s":[-753.248,971.165],"t":74},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.854},"s":[-749.917,980.961],"t":75},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.863},"s":[-745.21,989.989],"t":76},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.874},"s":[-739.299,998.162],"t":77},{"o":{"x":0.167,"y":0.126},"i":{"x":0.833,"y":0.886},"s":[-732.35,1005.401],"t":78},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.897},"s":[-724.524,1011.64],"t":79},{"o":{"x":0.167,"y":0.103},"i":{"x":0.833,"y":0.907},"s":[-715.977,1016.824],"t":80},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.915},"s":[-706.86,1020.906],"t":81},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.92},"s":[-697.321,1023.854],"t":82},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.922},"s":[-687.503,1025.645],"t":83},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-677.547,1026.271],"t":84},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.916},"s":[-667.593,1025.737],"t":85},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.908},"s":[-657.777,1024.062],"t":86},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.899},"s":[-648.234,1021.279],"t":87},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.888},"s":[-639.101,1017.435],"t":88},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.705},"s":[-630.512,1012.593],"t":89},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.849},"s":[-622.604,1006.835],"t":90},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.847},"s":[-607.529,979.1],"t":91},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.845},"s":[-593.589,951.133],"t":92},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.844},"s":[-580.925,923.064],"t":93},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[-569.68,895.045],"t":94},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.841},"s":[-560.002,867.242],"t":95},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.839},"s":[-552.04,839.845],"t":96},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.839},"s":[-545.952,813.06],"t":97},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.837},"s":[-541.896,787.115],"t":98},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.828},"s":[-540.04,762.262],"t":99},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.828},"s":[-540.483,738.36],"t":100},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.829},"s":[-542.796,712.89],"t":101},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.831},"s":[-546.753,685.526],"t":102},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.833},"s":[-552.195,656.451],"t":103},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.834},"s":[-558.959,625.851],"t":104},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[-566.882,593.907],"t":105},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-575.795,560.802],"t":106},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.84},"s":[-585.527,526.712],"t":107},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[-595.947,492.439],"t":108},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.842},"s":[-606.909,458.764],"t":109},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-618.215,425.721],"t":110},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-629.679,393.347],"t":111},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-641.13,361.68],"t":112},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-652.413,330.764],"t":113},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-663.381,300.639],"t":114},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.843},"s":[-673.902,271.35],"t":115},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-683.855,242.938],"t":116},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-693.133,215.445],"t":117},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.842},"s":[-701.636,188.91],"t":118},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.842},"s":[-709.279,163.369],"t":119},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.841},"s":[-715.985,138.855],"t":120},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.84},"s":[-721.688,115.394],"t":121},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.839},"s":[-726.33,93.009],"t":122},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.839},"s":[-729.865,71.717],"t":123},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.838},"s":[-732.255,51.525],"t":124},{"o":{"x":0.167,"y":0.172},"i":{"x":0.833,"y":0.838},"s":[-733.468,32.434],"t":125},{"o":{"x":0.167,"y":0.172},"i":{"x":0.833,"y":0.839},"s":[-733.484,14.437],"t":126},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.84},"s":[-732.288,-2.485],"t":127},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.842},"s":[-729.874,-18.358],"t":128},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.845},"s":[-726.241,-33.224],"t":129},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.849},"s":[-721.396,-47.133],"t":130},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.854},"s":[-715.352,-60.15],"t":131},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.86},"s":[-708.128,-72.354],"t":132},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.866},"s":[-699.748,-83.836],"t":133},{"o":{"x":0.167,"y":0.13},"i":{"x":0.833,"y":0.872},"s":[-690.24,-94.707],"t":134},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.876},"s":[-679.639,-105.089],"t":135},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.885},"s":[-667.983,-115.125],"t":136},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.895},"s":[-655.313,-124.975],"t":137},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.902},"s":[-641.992,-133.831],"t":138},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.908},"s":[-628.455,-140.887],"t":139},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.913},"s":[-614.796,-146.501],"t":140},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.915},"s":[-601.107,-151.015],"t":141},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.916},"s":[-587.474,-154.757],"t":142},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.916},"s":[-573.982,-158.04],"t":143},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.912},"s":[-560.708,-161.164],"t":144},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.91},"s":[-547.629,-164.435],"t":145},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.906},"s":[-534.151,-168.261],"t":146},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.902},"s":[-520.21,-172.85],"t":147},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.897},"s":[-505.868,-178.369],"t":148},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.891},"s":[-491.181,-184.977],"t":149},{"o":{"x":0.167,"y":0.103},"i":{"x":0.833,"y":0.885},"s":[-476.206,-192.829],"t":150},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.879},"s":[-460.996,-202.067],"t":151},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.873},"s":[-445.604,-212.826],"t":152},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.868},"s":[-430.081,-225.226],"t":153},{"o":{"x":0.167,"y":0.125},"i":{"x":0.833,"y":0.863},"s":[-414.476,-239.376],"t":154},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.858},"s":[-398.837,-255.366],"t":155},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.854},"s":[-383.209,-273.273],"t":156},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.851},"s":[-367.637,-293.151],"t":157},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.848},"s":[-352.165,-315.038],"t":158},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.846},"s":[-336.833,-338.947],"t":159},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.844},"s":[-321.683,-364.87],"t":160},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.842},"s":[-306.753,-392.772],"t":161},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.841},"s":[-292.081,-422.592],"t":162},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.84},"s":[-277.705,-454.242],"t":163},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.839},"s":[-263.658,-487.602],"t":164},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.839},"s":[-249.976,-522.523],"t":165},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.839},"s":[-236.692,-558.822],"t":166},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-223.838,-596.28],"t":167},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.839},"s":[-211.446,-634.644],"t":168},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-199.545,-673.621],"t":169},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.839},"s":[-188.165,-712.882],"t":170},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.84},"s":[-177.336,-752.054],"t":171},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.841},"s":[-167.084,-790.722],"t":172},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.841},"s":[-157.437,-828.428],"t":173},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.837},"s":[-148.421,-864.666],"t":174},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.836},"s":[-139.962,-899.217],"t":175},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.837},"s":[-131.418,-933.893],"t":176},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.837},"s":[-122.696,-968.941],"t":177},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.838},"s":[-113.824,-1004.213],"t":178},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.851},"s":[-104.834,-1039.558],"t":179},{"o":{"x":0.167,"y":0.174},"i":{"x":0.833,"y":0.842},"s":[-95.752,-1074.828],"t":180},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.841},"s":[-106.916,-1045.698],"t":181},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.84},"s":[-117.763,-1016.31],"t":182},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[-128.352,-986.567],"t":183},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[-138.73,-956.407],"t":184},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-148.952,-925.76],"t":185},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-159.072,-894.554],"t":186},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-169.136,-862.753],"t":187},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.837},"s":[-179.191,-830.315],"t":188},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-189.278,-797.217],"t":189},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-199.436,-763.442],"t":190},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-209.703,-728.976],"t":191},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-220.107,-693.83],"t":192},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-230.671,-658.023],"t":193},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.837},"s":[-241.414,-621.576],"t":194},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-252.352,-584.519],"t":195},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-263.496,-546.888],"t":196},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-274.844,-508.737],"t":197},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-286.387,-470.135],"t":198},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.838},"s":[-298.11,-431.156],"t":199},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-309.989,-391.88],"t":200},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-321.993,-352.397],"t":201},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-334.083,-312.802],"t":202},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.84},"s":[-346.209,-273.201],"t":203},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[-358.308,-233.718],"t":204},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.842},"s":[-370.326,-194.552],"t":205},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-382.332,-156.335],"t":206},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.844},"s":[-394.331,-119.332],"t":207},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.845},"s":[-406.308,-83.725],"t":208},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.846},"s":[-418.243,-49.695],"t":209},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.848},"s":[-430.119,-17.425],"t":210},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.846},"s":[-441.913,12.898],"t":211},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[-453.604,41.087],"t":212},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.84},"s":[-464.697,68.392],"t":213},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.839},"s":[-474.739,96.254],"t":214},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.838},"s":[-483.856,124.533],"t":215},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.838},"s":[-492.17,153.096],"t":216},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.837},"s":[-499.795,181.821],"t":217},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.837},"s":[-506.839,210.594],"t":218},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.837},"s":[-513.405,239.312],"t":219},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-519.589,267.884],"t":220},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-525.482,296.225],"t":221},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-531.168,324.265],"t":222},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.837},"s":[-536.727,351.941],"t":223},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.838},"s":[-542.232,379.2],"t":224},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.838},"s":[-547.75,405.998],"t":225},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.838},"s":[-553.343,432.303],"t":226},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.838},"s":[-559.067,458.088],"t":227},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.839},"s":[-564.971,483.339],"t":228},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.839},"s":[-571.101,508.05],"t":229},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.84},"s":[-577.495,532.221],"t":230},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.841},"s":[-584.186,555.866],"t":231},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[-591.201,579.002],"t":232},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.842},"s":[-598.562,601.658],"t":233},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.843},"s":[-606.285,623.869],"t":234},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.843},"s":[-614.381,645.68],"t":235},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.844},"s":[-622.855,667.145],"t":236},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.845},"s":[-631.708,688.326],"t":237},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.845},"s":[-640.933,709.292],"t":238},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.846},"s":[-650.521,730.122],"t":239},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.846},"s":[-660.454,750.899],"t":240},{"o":{"x":0.167,"y":0.152},"i":{"x":0.833,"y":0.848},"s":[-670.711,771.716],"t":241},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.851},"s":[-681.264,792.67],"t":242},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.851},"s":[-691.734,813.324],"t":243},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.852},"s":[-701.702,833.034],"t":244},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.853},"s":[-711.135,851.7],"t":245},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.854},"s":[-720.003,869.224],"t":246},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.855},"s":[-728.277,885.514],"t":247},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.855},"s":[-735.931,900.482],"t":248},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.851},"s":[-742.939,914.052],"t":249},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-749.089,926.3],"t":250},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.839},"s":[-753.14,938.144],"t":251},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.838},"s":[-755.05,949.648],"t":252},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.84},"s":[-755.021,960.693],"t":253},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.845},"s":[-753.248,971.165],"t":254},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.854},"s":[-749.917,980.961],"t":255},{"o":{"x":0.167,"y":0.148},"i":{"x":0.833,"y":0.863},"s":[-745.21,989.989],"t":256},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.874},"s":[-739.299,998.162],"t":257},{"o":{"x":0.167,"y":0.126},"i":{"x":0.833,"y":0.886},"s":[-732.35,1005.401],"t":258},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.897},"s":[-724.524,1011.64],"t":259},{"o":{"x":0.167,"y":0.103},"i":{"x":0.833,"y":0.907},"s":[-715.977,1016.824],"t":260},{"o":{"x":0.167,"y":0.093},"i":{"x":0.833,"y":0.915},"s":[-706.86,1020.906],"t":261},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.92},"s":[-697.321,1023.854],"t":262},{"o":{"x":0.167,"y":0.08},"i":{"x":0.833,"y":0.922},"s":[-687.503,1025.645],"t":263},{"o":{"x":0.167,"y":0.078},"i":{"x":0.833,"y":0.921},"s":[-677.547,1026.271],"t":264},{"o":{"x":0.167,"y":0.079},"i":{"x":0.833,"y":0.916},"s":[-667.593,1025.737],"t":265},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.908},"s":[-657.777,1024.062],"t":266},{"o":{"x":0.167,"y":0.092},"i":{"x":0.833,"y":0.899},"s":[-648.234,1021.279],"t":267},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.888},"s":[-639.101,1017.435],"t":268},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.705},"s":[-630.512,1012.593],"t":269},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.849},"s":[-622.604,1006.835],"t":270},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.847},"s":[-607.529,979.1],"t":271},{"o":{"x":0.167,"y":0.155},"i":{"x":0.833,"y":0.845},"s":[-593.589,951.133],"t":272},{"o":{"x":0.167,"y":0.158},"i":{"x":0.833,"y":0.844},"s":[-580.925,923.064],"t":273},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.842},"s":[-569.68,895.045],"t":274},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.841},"s":[-560.002,867.242],"t":275},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.839},"s":[-552.04,839.845],"t":276},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.839},"s":[-545.952,813.06],"t":277},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.837},"s":[-541.896,787.115],"t":278},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.828},"s":[-540.04,762.262],"t":279},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.828},"s":[-540.483,738.36],"t":280},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.829},"s":[-542.796,712.89],"t":281},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.831},"s":[-546.753,685.526],"t":282},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.833},"s":[-552.195,656.451],"t":283},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.834},"s":[-558.959,625.851],"t":284},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.835},"s":[-566.882,593.907],"t":285},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-575.795,560.802],"t":286},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.84},"s":[-585.527,526.712],"t":287},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.841},"s":[-595.947,492.439],"t":288},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.842},"s":[-606.909,458.764],"t":289},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-618.215,425.721],"t":290},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-629.679,393.347],"t":291},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-641.13,361.68],"t":292},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-652.413,330.764],"t":293},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.843},"s":[-663.381,300.639],"t":294},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.843},"s":[-673.902,271.35],"t":295},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-683.855,242.938],"t":296},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.843},"s":[-693.133,215.445],"t":297},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.842},"s":[-701.636,188.91],"t":298},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.842},"s":[-709.279,163.369],"t":299},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.841},"s":[-715.985,138.855],"t":300},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.84},"s":[-721.688,115.394],"t":301},{"o":{"x":0.167,"y":0.169},"i":{"x":0.833,"y":0.839},"s":[-726.33,93.009],"t":302},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.839},"s":[-729.865,71.717],"t":303},{"o":{"x":0.167,"y":0.171},"i":{"x":0.833,"y":0.838},"s":[-732.255,51.525],"t":304},{"o":{"x":0.167,"y":0.172},"i":{"x":0.833,"y":0.838},"s":[-733.468,32.434],"t":305},{"o":{"x":0.167,"y":0.172},"i":{"x":0.833,"y":0.839},"s":[-733.484,14.437],"t":306},{"o":{"x":0.167,"y":0.17},"i":{"x":0.833,"y":0.84},"s":[-732.288,-2.485],"t":307},{"o":{"x":0.167,"y":0.168},"i":{"x":0.833,"y":0.842},"s":[-729.874,-18.358],"t":308},{"o":{"x":0.167,"y":0.164},"i":{"x":0.833,"y":0.845},"s":[-726.241,-33.224],"t":309},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.849},"s":[-721.396,-47.133],"t":310},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.854},"s":[-715.352,-60.15],"t":311},{"o":{"x":0.167,"y":0.145},"i":{"x":0.833,"y":0.86},"s":[-708.128,-72.354],"t":312},{"o":{"x":0.167,"y":0.138},"i":{"x":0.833,"y":0.866},"s":[-699.748,-83.836],"t":313},{"o":{"x":0.167,"y":0.13},"i":{"x":0.833,"y":0.872},"s":[-690.24,-94.707],"t":314},{"o":{"x":0.167,"y":0.124},"i":{"x":0.833,"y":0.876},"s":[-679.639,-105.089],"t":315},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.885},"s":[-667.983,-115.125],"t":316},{"o":{"x":0.167,"y":0.116},"i":{"x":0.833,"y":0.895},"s":[-655.313,-124.975],"t":317},{"o":{"x":0.167,"y":0.11},"i":{"x":0.833,"y":0.902},"s":[-641.992,-133.831],"t":318},{"o":{"x":0.167,"y":0.101},"i":{"x":0.833,"y":0.908},"s":[-628.455,-140.887],"t":319},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.913},"s":[-614.796,-146.501],"t":320},{"o":{"x":0.167,"y":0.089},"i":{"x":0.833,"y":0.915},"s":[-601.107,-151.015],"t":321},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.916},"s":[-587.474,-154.757],"t":322},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.916},"s":[-573.982,-158.04],"t":323},{"o":{"x":0.167,"y":0.085},"i":{"x":0.833,"y":0.912},"s":[-560.708,-161.164],"t":324},{"o":{"x":0.167,"y":0.084},"i":{"x":0.833,"y":0.91},"s":[-547.629,-164.435],"t":325},{"o":{"x":0.167,"y":0.086},"i":{"x":0.833,"y":0.906},"s":[-534.151,-168.261],"t":326},{"o":{"x":0.167,"y":0.09},"i":{"x":0.833,"y":0.902},"s":[-520.21,-172.85],"t":327},{"o":{"x":0.167,"y":0.094},"i":{"x":0.833,"y":0.897},"s":[-505.868,-178.369],"t":328},{"o":{"x":0.167,"y":0.098},"i":{"x":0.833,"y":0.891},"s":[-491.181,-184.977],"t":329},{"o":{"x":0.167,"y":0.103},"i":{"x":0.833,"y":0.885},"s":[-476.206,-192.829],"t":330},{"o":{"x":0.167,"y":0.109},"i":{"x":0.833,"y":0.879},"s":[-460.996,-202.067],"t":331},{"o":{"x":0.167,"y":0.114},"i":{"x":0.833,"y":0.873},"s":[-445.604,-212.826],"t":332},{"o":{"x":0.167,"y":0.119},"i":{"x":0.833,"y":0.868},"s":[-430.081,-225.226],"t":333},{"o":{"x":0.167,"y":0.125},"i":{"x":0.833,"y":0.863},"s":[-414.476,-239.376],"t":334},{"o":{"x":0.167,"y":0.129},"i":{"x":0.833,"y":0.858},"s":[-398.837,-255.366],"t":335},{"o":{"x":0.167,"y":0.133},"i":{"x":0.833,"y":0.854},"s":[-383.209,-273.273],"t":336},{"o":{"x":0.167,"y":0.137},"i":{"x":0.833,"y":0.851},"s":[-367.637,-293.151],"t":337},{"o":{"x":0.167,"y":0.141},"i":{"x":0.833,"y":0.848},"s":[-352.165,-315.038],"t":338},{"o":{"x":0.167,"y":0.144},"i":{"x":0.833,"y":0.846},"s":[-336.833,-338.947],"t":339},{"o":{"x":0.167,"y":0.146},"i":{"x":0.833,"y":0.844},"s":[-321.683,-364.87],"t":340},{"o":{"x":0.167,"y":0.149},"i":{"x":0.833,"y":0.842},"s":[-306.753,-392.772],"t":341},{"o":{"x":0.167,"y":0.151},"i":{"x":0.833,"y":0.841},"s":[-292.081,-422.592],"t":342},{"o":{"x":0.167,"y":0.153},"i":{"x":0.833,"y":0.84},"s":[-277.705,-454.242],"t":343},{"o":{"x":0.167,"y":0.154},"i":{"x":0.833,"y":0.839},"s":[-263.658,-487.602],"t":344},{"o":{"x":0.167,"y":0.156},"i":{"x":0.833,"y":0.839},"s":[-249.976,-522.523],"t":345},{"o":{"x":0.167,"y":0.157},"i":{"x":0.833,"y":0.839},"s":[-236.692,-558.822],"t":346},{"o":{"x":0.167,"y":0.159},"i":{"x":0.833,"y":0.838},"s":[-223.838,-596.28],"t":347},{"o":{"x":0.167,"y":0.16},"i":{"x":0.833,"y":0.839},"s":[-211.446,-634.644],"t":348},{"o":{"x":0.167,"y":0.161},"i":{"x":0.833,"y":0.839},"s":[-199.545,-673.621],"t":349},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.839},"s":[-188.165,-712.882],"t":350},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.84},"s":[-177.336,-752.054],"t":351},{"o":{"x":0.167,"y":0.165},"i":{"x":0.833,"y":0.841},"s":[-167.084,-790.722],"t":352},{"o":{"x":0.167,"y":0.166},"i":{"x":0.833,"y":0.841},"s":[-157.437,-828.428],"t":353},{"o":{"x":0.167,"y":0.167},"i":{"x":0.833,"y":0.837},"s":[-148.421,-864.666],"t":354},{"o":{"x":0.167,"y":0.163},"i":{"x":0.833,"y":0.836},"s":[-139.962,-899.217],"t":355},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.837},"s":[-131.418,-933.893],"t":356},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.837},"s":[-122.696,-968.941],"t":357},{"o":{"x":0.167,"y":0.162},"i":{"x":0.833,"y":0.837},"s":[-113.824,-1004.213],"t":358},{"s":[-104.834,-1039.558],"t":359}],"ix":5},"r":1,"o":{"a":0,"k":100,"ix":10}},{"ty":"tr","a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"sk":{"a":0,"k":0,"ix":4},"p":{"a":0,"k":[3.056,-531.6],"ix":2},"r":{"a":0,"k":0,"ix":6},"sa":{"a":0,"k":0,"ix":5},"o":{"a":0,"k":100,"ix":7}}]}],"ind":4},{"ty":1,"nm":"bg","sr":1,"st":0,"op":360,"ip":0,"hd":false,"ddd":0,"bm":0,"hasMask":false,"ao":0,"ks":{"a":{"a":0,"k":[562.5,1218,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6},"sk":{"a":0,"k":0},"p":{"a":0,"k":[562.5,1218,0],"ix":2},"r":{"a":0,"k":0,"ix":10},"sa":{"a":0,"k":0},"o":{"a":0,"k":100,"ix":11}},"ef":[],"sc":"#80deea","sh":2436,"sw":1125,"ind":5}],"v":"5.9.0","fr":18,"op":181,"ip":0,"assets":[]} \ No newline at end of file diff --git a/home/src/main/res/values/nav_ids.xml b/home/src/main/res/values/nav_ids.xml new file mode 100644 index 0000000..097db5d --- /dev/null +++ b/home/src/main/res/values/nav_ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/home/src/main/res/values/strings.xml b/home/src/main/res/values/strings.xml new file mode 100644 index 0000000..55f2d3b --- /dev/null +++ b/home/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + Home + Відсканувати QR-код + + Вітаємо,\n%s + Вітаємо + \ No newline at end of file diff --git a/home/src/test/java/ua/gov/diia/home/MainDispatcherRule.kt b/home/src/test/java/ua/gov/diia/home/MainDispatcherRule.kt new file mode 100644 index 0000000..e963d68 --- /dev/null +++ b/home/src/test/java/ua/gov/diia/home/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.home + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/home/src/test/java/ua/gov/diia/home/ui/HomeScreenComposeMapperImplTest.kt b/home/src/test/java/ua/gov/diia/home/ui/HomeScreenComposeMapperImplTest.kt new file mode 100644 index 0000000..59d9b13 --- /dev/null +++ b/home/src/test/java/ua/gov/diia/home/ui/HomeScreenComposeMapperImplTest.kt @@ -0,0 +1,51 @@ +package ua.gov.diia.home.ui + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.MockitoAnnotations +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.tab.TabItemMoleculeData + +class HomeScreenComposeMapperImplTest { + val mapper: HomeScreenComposeMapperImpl = HomeScreenComposeMapperImpl() + + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + } + + @Test + fun `test mapping of TabItemMoleculeData list to TabBarOrganismData`() { + val mutableList = mutableListOf() + val firstObj = TabItemMoleculeData( + actionKey = "action_first", + id = "first_item_id", + label = "First Item", + iconSelected = UiText.DynamicString("iconSelected"), + iconUnselected = UiText.DynamicString("iconUnselected"), + iconSelectedWithBadge = UiText.DynamicString("iconSelectedWithBadge"), + iconUnselectedWithBadge = UiText.DynamicString("iconUnselectedWithBadge"), + showBadge = true, + selectionState = UIState.Selection.Selected + ) + mutableList.add(firstObj) + + val mappedData = mapper.mapTabItemMoleculeDataToTabBarOrganismData(mutableList) + + val firstMappedItem = mappedData.tabs[0] + + assertEquals(firstObj.id, firstMappedItem.id) + assertEquals(firstObj.actionKey, firstMappedItem.actionKey) + assertEquals(firstObj.label, firstMappedItem.label) + assertEquals(firstObj.iconSelected, firstMappedItem.iconSelected) + assertEquals(firstObj.iconUnselected, firstMappedItem.iconUnselected) + assertEquals(firstObj.iconSelectedWithBadge, firstMappedItem.iconSelectedWithBadge) + assertEquals(firstObj.iconUnselectedWithBadge, firstMappedItem.iconUnselectedWithBadge) + assertEquals(firstObj.showBadge, firstMappedItem.showBadge) + assertEquals(UIState.Selection.Unselected, firstMappedItem.selectionState) + + } +} \ No newline at end of file diff --git a/home/src/test/java/ua/gov/diia/home/ui/HomeVMTest.kt b/home/src/test/java/ua/gov/diia/home/ui/HomeVMTest.kt new file mode 100644 index 0000000..b4ca948 --- /dev/null +++ b/home/src/test/java/ua/gov/diia/home/ui/HomeVMTest.kt @@ -0,0 +1,555 @@ +package ua.gov.diia.home.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.Observer +import androidx.navigation.NavDirections +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doThrow +import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.times +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.stubbing.Answer +import ua.gov.diia.core.controller.DeeplinkProcessor +import ua.gov.diia.core.controller.NotificationController +import ua.gov.diia.core.controller.PromoController +import ua.gov.diia.core.models.deeplink.DeepLinkAction +import ua.gov.diia.core.models.deeplink.DeepLinkActionViewDocument +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.models.dialogs.TemplateDialogModelWithProcessCode +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.delegation.WithDeeplinkHandling +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.home.MainDispatcherRule +import ua.gov.diia.home.model.HomeMenuItem +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.organism.bottom.TabBarOrganismData + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class HomeVMTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + @Mock + lateinit var promoController: PromoController + + @Mock + lateinit var notificationController: NotificationController + + @Mock + lateinit var itnDataSource: ua.gov.diia.diia_storage.store.datasource.itn.ItnDataRepository + + @Mock + lateinit var dispatcherProvider: DispatcherProvider + + lateinit var allowAuthorizedLinksFlow: MutableSharedFlow> + + lateinit var globalActionDocLoadingIndicator: MutableSharedFlow> + + lateinit var globalActionConfirmDocumentRemoval: MutableStateFlow?> + + lateinit var globalActionFocusOnDocument: MutableStateFlow?> + + lateinit var globalActionSelectedMenuItem: MutableStateFlow?> + + @Mock + lateinit var withRetryLastAction: WithRetryLastAction + + @Mock + lateinit var errorHandlingDelegate: WithErrorHandling + + @Mock + lateinit var deepLinkDelegate: WithDeeplinkHandling + + var composeMapper: HomeScreenComposeMapper = HomeScreenComposeMapperImpl() + + @Mock + lateinit var deeplinkProcessor: DeeplinkProcessor + + @Mock + lateinit var withCrashlytics: WithCrashlytics + + lateinit var viewModel: HomeVM + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + allowAuthorizedLinksFlow = MutableSharedFlow() + globalActionDocLoadingIndicator = MutableSharedFlow() + globalActionConfirmDocumentRemoval = MutableStateFlow(null) + globalActionFocusOnDocument = MutableStateFlow(null) + globalActionSelectedMenuItem = MutableStateFlow(null) + + `when`(dispatcherProvider.ioDispatcher()).thenReturn(UnconfinedTestDispatcher()) + + } + + fun createVM() { + + viewModel = HomeVM( + promoController, + notificationController, + itnDataSource, + dispatcherProvider, + allowAuthorizedLinksFlow, + globalActionDocLoadingIndicator, + globalActionConfirmDocumentRemoval, + globalActionFocusOnDocument, + globalActionSelectedMenuItem, + withRetryLastAction, + errorHandlingDelegate, + deepLinkDelegate, + composeMapper, + deeplinkProcessor, + withCrashlytics + ) + } + + @Test + fun `test selectedMenuItem react on changes in globalActionSelectedMenuItem`() { + runBlocking { + val homeMenuItemMock = mock() + var result: HomeMenuItemConstructor? = null + val observer = Observer?>() { + result = it!!.peekContent() + } + + createVM() + viewModel.selectedMenuItem.observeForever(observer) + globalActionSelectedMenuItem.emit(UiDataEvent(homeMenuItemMock)) + + assertEquals(homeMenuItemMock, result) + viewModel.selectedMenuItem.removeObserver(observer) + } + } + + @Test + fun `test isLoadIndicatorHomeScreen react on changes in globalActionDocLoadingIndicator`() { + runBlocking { + var result = true + val observer = Observer() { + result =it + } + createVM() + viewModel.isLoadIndicatorHomeScreen.observeForever(observer) + + globalActionDocLoadingIndicator.emit(UiDataEvent(false)) + assertFalse(result) + + globalActionDocLoadingIndicator.emit(UiDataEvent(true)) + assertTrue(result) + + + val uiDataEvent = mock>() + `when`(uiDataEvent.getContentIfNotHandled()).thenReturn(null) + globalActionDocLoadingIndicator.emit(uiDataEvent) + assertFalse(result) + + viewModel.isLoadIndicatorHomeScreen.removeObserver(observer) + } + } + @Test + fun `test check calling of check promo`() { + runBlocking { + createVM() + + verify(promoController, times(1)).checkPromo(any()) + } + } + + @Test + fun `test triggering sendNonFatalError if checkPromo throw error`() { + runBlocking { + val exception = RuntimeException() + + `when`(promoController.checkPromo(any())).doThrow(exception) + createVM() + + verify(withCrashlytics, times(1)).sendNonFatalError(exception) + + } + } + + + @Test + fun `test show corresponded promo template`() { + runBlocking { + var callback: ((template: TemplateDialogModelWithProcessCode) -> Unit)? = null + + `when`(promoController.checkPromo(any())).thenAnswer(Answer { + callback = (it.getArguments() + .get(0) as ((template: TemplateDialogModelWithProcessCode) -> Unit)) + }) + + createVM() + + val templateDialogModel = mock() + val template = TemplateDialogModelWithProcessCode(1, templateDialogModel) + + callback!!(template) + + Assert.assertEquals(templateDialogModel, viewModel.showTemplate.value!!.peekContent()) + } + } + + @Test + fun `test changing of promo code from callback`() { + runBlocking { + var callback: ((template: TemplateDialogModelWithProcessCode) -> Unit)? = null + + `when`(promoController.checkPromo(any())).thenAnswer(Answer { + callback = (it.getArguments() + .get(0) as ((template: TemplateDialogModelWithProcessCode) -> Unit)) + }) + + createVM() + + val templateDialogModel = mock() + val template = TemplateDialogModelWithProcessCode(10, templateDialogModel) + + callback!!(template) + + viewModel.updatePromoProcessCode() + verify(promoController, times(1)).updatePromoProcessCode(10) + } + } + + @Test + fun `test invalidateDataSource trigger invalidation of notification and itn data sources`() { + runBlocking { + createVM() + verify(notificationController, times(1)).invalidateNotificationDataSource() + verify(itnDataSource, times(1)).invalidate() + } + } + + @Test + fun `test invalidateDataSource trigger sendNonFatalError if notificationController throw exception`() { + runBlocking { + val exception = RuntimeException() + + `when`(notificationController.invalidateNotificationDataSource()).doThrow(exception) + createVM() + verify(withCrashlytics, times(1)).sendNonFatalError(exception) + } + } + + @Test + fun `test invalidateDataSource trigger sendNonFatalError if itnDataSource throw exception`() { + runBlocking { + val exception = RuntimeException() + + `when`(itnDataSource.invalidate()).doThrow(exception) + createVM() + verify(withCrashlytics, times(1)).sendNonFatalError(exception) + } + } + + @Test + fun `test creation of bottom data`() { + runBlocking { + createVM() + + val data = (viewModel.bottomData[0] as TabBarOrganismData) + Assert.assertEquals(4, data.tabs.size) + } + } + + @Test + fun `test select feed tab on initialization`() { + runBlocking { + createVM() + Assert.assertEquals( + HomeMenuItem.FEED, + globalActionSelectedMenuItem.value!!.peekContent() + ) + val tabs = (viewModel.bottomData[0] as TabBarOrganismData).tabs + Assert.assertEquals(UIState.Selection.Selected, tabs[0].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[1].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[2].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[3].selectionState) + } + } + @Test + fun `test setting showBadges if hasUnreadNotifications was set`() { + runBlocking { + `when`( + notificationController.collectUnreadNotificationCounts(any()) + ).thenAnswer { invocation -> + (invocation.arguments[0] as (amount: Int) -> Unit).invoke(10) + null + } + viewModel = HomeVM( + promoController, + notificationController, + itnDataSource, + dispatcherProvider, + allowAuthorizedLinksFlow, + globalActionDocLoadingIndicator, + globalActionConfirmDocumentRemoval, + globalActionFocusOnDocument, + globalActionSelectedMenuItem, + withRetryLastAction, + errorHandlingDelegate, + deepLinkDelegate, + composeMapper, + deeplinkProcessor, + withCrashlytics + ) + + val tabs = (viewModel.bottomData[0] as TabBarOrganismData).tabs + Assert.assertEquals(true, tabs[3].showBadge) + } + } + @Test + fun `test check sync of push token`() { + runBlocking { + createVM() + verify(notificationController, times(1)).checkPushTokenInSync() + } + } + + @Test + fun `test send nonfatal if check push token throw exception`() { + runBlocking { + val exception = RuntimeException() + + `when`(notificationController.checkPushTokenInSync()).doThrow(exception) + createVM() + verify(withCrashlytics, times(1)).sendNonFatalError(exception) + } + } + + @Test + fun `test collecting empty unread notifications`() { + runBlocking { + var callback: ((amount: Int) -> Unit)? = null + + `when`(notificationController.collectUnreadNotificationCounts(any())).thenAnswer(Answer { + callback = (it.getArguments().get(0) as ((amount: Int) -> Unit)) + }) + createVM() + callback!!(0) + val data = (viewModel.bottomData[0] as TabBarOrganismData) + Assert.assertEquals(false, viewModel.hasUnreadNotifications.value) + Assert.assertEquals(4, data.tabs.size) + } + } + + @Test + fun `test onUIAction select correct tab`() { + runBlocking { + createVM() + + viewModel.onUIAction(UIAction(HomeActions.HOME_DOCUMENTS.toString())) + Assert.assertEquals( + HomeMenuItem.DOCUMENTS, + globalActionSelectedMenuItem.value!!.peekContent() + ) + var tabs = (viewModel.bottomData[0] as TabBarOrganismData).tabs + + Assert.assertEquals(UIState.Selection.Selected, tabs[1].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[0].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[2].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[3].selectionState) + + viewModel.onUIAction(UIAction(HomeActions.HOME_FEED.toString())) + Assert.assertEquals( + HomeMenuItem.FEED, + globalActionSelectedMenuItem.value!!.peekContent() + ) + tabs = (viewModel.bottomData[0] as TabBarOrganismData).tabs + Assert.assertEquals(UIState.Selection.Selected, tabs[0].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[1].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[2].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[3].selectionState) + + viewModel.onUIAction(UIAction(HomeActions.HOME_MENU.toString())) + Assert.assertEquals( + HomeMenuItem.MENU, + globalActionSelectedMenuItem.value!!.peekContent() + ) + tabs = (viewModel.bottomData[0] as TabBarOrganismData).tabs + Assert.assertEquals(UIState.Selection.Selected, tabs[3].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[1].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[2].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[0].selectionState) + + viewModel.onUIAction(UIAction(HomeActions.HOME_SERVICES.toString())) + Assert.assertEquals( + HomeMenuItem.SERVICES, + globalActionSelectedMenuItem.value!!.peekContent() + ) + tabs = (viewModel.bottomData[0] as TabBarOrganismData).tabs + Assert.assertEquals(UIState.Selection.Selected, tabs[2].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[1].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[0].selectionState) + Assert.assertEquals(UIState.Selection.Unselected, tabs[3].selectionState) + } + } + + @Test + fun `test confirmation of removing doc from gallery`() { + runBlocking { + createVM() + val docName = "doc_name" + viewModel.confirmRemoveDocFromGallery(docName) + + Assert.assertEquals(docName, globalActionConfirmDocumentRemoval.value!!.peekContent()) + } + } + + @Test + fun `test show data loading indicator`() { + runTest { + + createVM() + globalActionDocLoadingIndicator.test { + viewModel.showDataLoadingIndicator(true) + Assert.assertEquals(true, awaitItem().peekContent()) + } + } + } + + @Test + fun `test subscribe to beta by code`() { + runTest { + var callback: ((template: TemplateDialogModelWithProcessCode) -> Unit)? = null + + `when`(promoController.checkPromo(any())).thenAnswer(Answer { + callback = (it.getArguments() + .get(0) as ((template: TemplateDialogModelWithProcessCode) -> Unit)) + }) + + createVM() + val templateDialogModel = mock() + val template = TemplateDialogModelWithProcessCode(1, templateDialogModel) + callback!!(template) + + viewModel.subscribeToBetaByCode() + verify(promoController, times(1)).subscribeToBetaByCode(any()) + } + } + + @Test + fun `test subscribe to beta by code throw exception`() { + runBlocking { + val exception = RuntimeException() + + createVM() + `when`(promoController.subscribeToBetaByCode(null)).doThrow(exception) + viewModel.subscribeToBetaByCode() + verify(withCrashlytics, times(1)).sendNonFatalError(exception) + } + } + + @Test + fun `test allowAuthorizedDeepLinks emin flow with true value`() { + runTest { + createVM() + allowAuthorizedLinksFlow.test { + viewModel.allowAuthorizedDeepLinks() + Assert.assertEquals(true, awaitItem().peekContent()) + } + } + } + + @Test + fun `test checkNotificationsRequested pass value to notifications requested LiveData`() { + runBlocking { + createVM() + + `when`(notificationController.checkNotificationsRequested()).thenReturn(true) + viewModel.checkNotificationsRequested() + + Assert.assertEquals(true, viewModel.notificationsRequested.value!!.peekContent()) + } + } + + @Test + fun `test allow notifications through notification controller`() { + runBlocking { + createVM() + + viewModel.allowNotifications() + + verify(notificationController, times(1)).allowNotifications() + } + } + + @Test + fun `test deny notifications through notification controller`() { + runBlocking { + createVM() + + viewModel.denyNotifications() + + verify(notificationController, times(1)).denyNotifications() + } + } + + @Test + fun `test focusOnDocumentType pass global focus doc action`() { + runBlocking { + createVM() + + val docType = "doc_type" + viewModel.focusOnDocumentType(docType) + Assert.assertEquals(docType, globalActionFocusOnDocument.value!!.peekContent()) + } + } + + @Test + fun `test handleDeepLinks collect deeplinks and handle them`() { + runTest { + val route = mock() + val deepLinkAction = DeepLinkActionViewDocument("", "", "") + val deeplinkFlow = + MutableStateFlow?>(UiDataEvent(deepLinkAction)) + + `when`(deepLinkDelegate.deeplinkFlow).thenReturn(deeplinkFlow) + `when`(deeplinkProcessor.handleDeepLinkAction(deepLinkAction)).thenReturn(route) + + createVM() + val job = launch { + viewModel.handleDeepLinks() + } + advanceUntilIdle() + + verify(deeplinkProcessor, times(1)).handleDeepLinkAction(deepLinkAction) + Assert.assertEquals(route, viewModel.processNavigation.value!!.peekContent()) + job.cancel() + } + } + +} \ No newline at end of file diff --git a/jacoco.gradle b/jacoco.gradle new file mode 100644 index 0000000..bbea076 --- /dev/null +++ b/jacoco.gradle @@ -0,0 +1,139 @@ +apply plugin: 'jacoco' + +jacoco { + toolVersion = "0.8.7" + // Custom reports directory can be specfied like this: + reportsDir = file("$buildDir/jacoco-report") +} + +tasks.withType(Test) { + jacoco.includeNoLocationClasses = true + jacoco.excludes = ['jdk.internal.*'] + // see related issue https://github.com/gradle/gradle/issues/5184#issuecomment-457865951 +} + +project.afterEvaluate { + + (android.hasProperty('applicationVariants') + ? android.'applicationVariants' + : android.'libraryVariants') + .all { variant -> + def variantName = variant.name + def unitTestTask = "test${variantName.capitalize()}UnitTest" + + + tasks.create(name: "${unitTestTask}Coverage", type: JacocoReport, dependsOn: [ + "$unitTestTask", + // "$androidTestCoverageTask" + ]) { + group = "Reporting" + description = "Generate Jacoco coverage reports for the ${variantName.capitalize()} build" + + reports { + html.enabled = true + xml.enabled = true + csv.enabled = true + } + + def mainExcludes = [ + // data binding + 'android/databinding/**/*.class', + '**/android/databinding/*Binding.class', + '**/android/databinding/*', + '**/androidx/databinding/*', + 'androidx/databinding/*', + '**/databinding/*', + '**/BR.*', + '**/DataBindingTriggerClass.*', + // android + '**/R.class', + '**/R$*.class', + '**/BuildConfig.*', + '**/Manifest*.*', + '**/*Test*.*', + 'android/**/*.*', + '**/androidx/navigation/*', + // dagger + 'dagger/hilt/**/*.*', + '**/hilt_aggregated_deps/*', + '**/*_MembersInjector.class', + '**/Dagger*Component.class', + '**/Dagger*Component$Builder.class', + '**/*Module_*Factory.class', + '**/di/module/*', + '**/*_Factory*.*', + '**/*Module*.*', + '**/*Dagger*.*', + '**/*Hilt*.*', + '**/*_Generated*.*', + // kotlin + '**/*MapperImpl*.*', + '**/*$ViewInjector*.*', + '**/*$ViewBinder*.*', + '**/BuildConfig.*', + '**/*Component*.*', + '**/*BR*.*', + '**/Manifest*.*', + '**/*$Lambda$*.*', + '**/*Companion*.*', + '**/*Module*.*', + '**/*Dagger*.*', + '**/*Hilt*.*', + '**/*MembersInjector*.*', + '**/*_MembersInjector.class', + '**/*_Factory*.*', + '**/*_Provide*Factory*.*', + '**/*Extensions*.*', + '**/*_AssistedFactory*.*', + // sealed and data classes + '**/*$Result.*', + '**/*$Result$*.*', + // Models + '**/models/**/*.*', + '**/model/**/*.*', + //UI + //views + '**/ui/views/**/*.*', + '**/com/bumptech/glide/*', + // moshi + '**/*JsonAdapter.*', + 'ua/gov/diia/**/*FDirections.*', + 'ua/gov/diia/**/*Directions$*.*', + 'ua/gov/diia/**/*FArgs.*', + 'ua/gov/diia/**/*FCompose.*' + ] + + def excludes = mainExcludes + + allprojects.forEach { + try { + def homeRules = file("../${it.name}/excludes.jacoco") + println("../${it.name}/excludes.jacoco") + def homeExcludes = homeRules.getText('UTF-8').split('\n') + excludes.addAll(homeExcludes) + } catch (Exception e) { + println("No excludes.jacoco file in ${it.name}") + } + } + + def javaClasses = fileTree(dir: variant.javaCompileProvider.get().destinationDir, + excludes: excludes) + def kotlinClasses = fileTree(dir: "${buildDir}/tmp/kotlin-classes/${variantName}", + excludes: excludes) + + def excludingFiles = files([ + javaClasses, + kotlinClasses + ]) + + classDirectories.setFrom(excludingFiles) + + def variantSourceSets = variant.sourceSets.java.srcDirs.collect { it.path }.flatten() + sourceDirectories.setFrom(project.files(variantSourceSets)) + executionData(files([ + "$project.buildDir/outputs/unit_test_code_coverage/${variantName}UnitTest/${unitTestTask}.exec", + ])) + } + + } +} \ No newline at end of file diff --git a/login/.gitignore b/login/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/login/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/login/README.md b/login/README.md new file mode 100644 index 0000000..ce33668 --- /dev/null +++ b/login/README.md @@ -0,0 +1,47 @@ +# Description + +This is module responsible for user authorization. + +# How to install + +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':login') +``` + +2. Module requires next modules to work + +```groovy +implementation project(':core') +implementation project(':web') +implementation project(':pin') +implementation project(':verification') +implementation project(':ui_base') +implementation project(':diia_storage') +``` + +3. Add next nav graphs to main navigation graph + +```xml + +``` + +4. nav_ids file describe all ids that this module requires. Entry point should implement all those ids. + +`./src/main/res/values/nav_ids.xml` + +## Implementing login hook (optional) + +To create a new action that will be executed after success login you have to implement +the `PostLoginAction` interface and provide it via di: + +```kotlin +@Binds +@IntoSet +fun bindPostLoginAction( + impl: PostLoginActionImpl +): PostLoginAction +``` + +You can provide multiple actions in this way. \ No newline at end of file diff --git a/login/build.gradle b/login/build.gradle new file mode 100644 index 0000000..cbd3623 --- /dev/null +++ b/login/build.gradle @@ -0,0 +1,134 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + id 'androidx.navigation.safeargs.kotlin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.login' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation project(':core') + implementation project(':web') + implementation project(':pin') + implementation project(':verification') + implementation project(':ui_base') + implementation project(':diia_storage') + implementation deps.fragment_ktx + implementation deps.appcompat + implementation deps.material + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + kapt deps.hilt_compiler + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + //navigation + implementation deps.navigation_fragment_ktx + implementation deps.navigation_ui_ktx + //Compose + implementation deps.activity_compose + implementation deps.compose_ui + implementation deps.compose_material + implementation deps.compose_ui_tooling + implementation deps.compose_ui_tooling_preview + + //test + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.hamcrest_library + testImplementation deps.json + testImplementation deps.turbine + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/login/consumer-rules.pro b/login/consumer-rules.pro new file mode 100644 index 0000000..7211705 --- /dev/null +++ b/login/consumer-rules.pro @@ -0,0 +1,3 @@ +-keep enum * { *; } + +-keep public class ua.gov.diia.pin.model.CreatePinFlowType \ No newline at end of file diff --git a/login/excludes.jacoco b/login/excludes.jacoco new file mode 100644 index 0000000..579393b --- /dev/null +++ b/login/excludes.jacoco @@ -0,0 +1,3 @@ +ua/gov/diia/login/ui/**/*F.* +ua/gov/diia/login/**/*$*.* +ua/gov/diia/login/ui/**/compose/*.* \ No newline at end of file diff --git a/login/proguard-rules.pro b/login/proguard-rules.pro new file mode 100644 index 0000000..7211705 --- /dev/null +++ b/login/proguard-rules.pro @@ -0,0 +1,3 @@ +-keep enum * { *; } + +-keep public class ua.gov.diia.pin.model.CreatePinFlowType \ No newline at end of file diff --git a/login/src/main/AndroidManifest.xml b/login/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a1da445 --- /dev/null +++ b/login/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/login/src/main/java/ua/gov/diia/login/di/LoginModule.kt b/login/src/main/java/ua/gov/diia/login/di/LoginModule.kt new file mode 100644 index 0000000..415e303 --- /dev/null +++ b/login/src/main/java/ua/gov/diia/login/di/LoginModule.kt @@ -0,0 +1,29 @@ +package ua.gov.diia.login.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.Multibinds +import retrofit2.Retrofit +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.login.network.ApiLogin +import ua.gov.diia.login.ui.PostLoginAction + +@Module +@InstallIn(SingletonComponent::class) +interface LoginModule { + + @Multibinds + fun providePostLoginActions(): Set<@JvmSuppressWildcards PostLoginAction> + + companion object { + + + @Provides + @UnauthorizedClient + fun provideApiLogin( + @UnauthorizedClient retrofit: Retrofit + ): ApiLogin = retrofit.create(ApiLogin::class.java) + } +} \ No newline at end of file diff --git a/login/src/main/java/ua/gov/diia/login/model/LoginToken.kt b/login/src/main/java/ua/gov/diia/login/model/LoginToken.kt new file mode 100644 index 0000000..0a4c9c4 --- /dev/null +++ b/login/src/main/java/ua/gov/diia/login/model/LoginToken.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.login.model + + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class LoginToken( + @Json(name = "token") + val token: String +) \ No newline at end of file diff --git a/login/src/main/java/ua/gov/diia/login/network/ApiLogin.kt b/login/src/main/java/ua/gov/diia/login/network/ApiLogin.kt new file mode 100644 index 0000000..4c25db3 --- /dev/null +++ b/login/src/main/java/ua/gov/diia/login/network/ApiLogin.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.login.network + +import retrofit2.http.GET +import retrofit2.http.Query +import ua.gov.diia.core.network.annotation.Analytics +import ua.gov.diia.login.model.LoginToken + +interface ApiLogin { + + @Analytics("getAuthenticationToken") + @GET("api/v3/auth/token") + suspend fun getAuthenticationToken( + @Query("processId") processId: String + ): LoginToken +} \ No newline at end of file diff --git a/login/src/main/java/ua/gov/diia/login/ui/LoginConst.kt b/login/src/main/java/ua/gov/diia/login/ui/LoginConst.kt new file mode 100644 index 0000000..1b6ddd6 --- /dev/null +++ b/login/src/main/java/ua/gov/diia/login/ui/LoginConst.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.login.ui + +internal object LoginConst { + + const val ACTION_NAVIGATE_TO_POLICY = "action_navigate_to_policy" + const val ACTION_CHECKBOX_CHECKED = "action_checkbox_checked" + +} \ No newline at end of file diff --git a/login/src/main/java/ua/gov/diia/login/ui/LoginF.kt b/login/src/main/java/ua/gov/diia/login/ui/LoginF.kt new file mode 100644 index 0000000..5839d4b --- /dev/null +++ b/login/src/main/java/ua/gov/diia/login/ui/LoginF.kt @@ -0,0 +1,121 @@ +package ua.gov.diia.login.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.pin.model.CreatePinFlowType +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.delegation.WithAppConfig +import ua.gov.diia.core.util.extensions.fragment.currentDestinationId +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResultOnce +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.login.ui.compose.LoginScreen +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.verification.ui.controller.VerificationControllerOnFlowF +import ua.gov.diia.web.util.extensions.fragment.navigateToWebView +import javax.inject.Inject + +@AndroidEntryPoint +class LoginF : VerificationControllerOnFlowF() { + + override val verificationVM: LoginVM by viewModels() + private var composeView: ComposeView? = null + + @Inject + lateinit var appConfig: WithAppConfig + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + registerForNavigationResultOnce(RESULT_KEY_CREATE_PIN, verificationVM::setPinCode) + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> verificationVM.retryLastAction() + } + } + composeView?.setContent { + verificationVM.apply { + val isLoading = verificationVM.isLoading.collectAsState( + initial = Pair( + UIActionKeysCompose.PAGE_LOADING_TRIDENT, + true + ) + ) + navigation.collectAsEffect { navigation -> + when (navigation) { + is LoginVM.Navigation.ToPolicy -> { + navigateToWebView(appConfig.getAppPolicyUrl()) + } + + is LoginVM.Navigation.ToPinCreation -> { + navigateToPinCreation() + } + + is LoginVM.Navigation.ToHome -> { + navigateToHomeScreen() + } + } + } + + showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + + LoginScreen( + toolbar = verificationVM.toolbarData, + body = verificationVM.bodyData, + screenNavigationProcessing = verificationVM.progressState, + isLoading = isLoading.value, + onEvent = { verificationVM.onUIAction(it) }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun navigateToPinCreation() { + navigate( + LoginFDirections.actionLoginToCreatePin( + resultDestinationId = currentDestinationId ?: return, + resultKey = RESULT_KEY_CREATE_PIN, + flowType = CreatePinFlowType.AUTHORIZATION + ) + ) + } + + private fun navigateToHomeScreen() { + navigate(LoginFDirections.actionDestinationLoginToHomeF()) + } + + private companion object { + const val RESULT_KEY_CREATE_PIN = "create_pin" + } +} \ No newline at end of file diff --git a/login/src/main/java/ua/gov/diia/login/ui/LoginVM.kt b/login/src/main/java/ua/gov/diia/login/ui/LoginVM.kt new file mode 100644 index 0000000..1c0c22d --- /dev/null +++ b/login/src/main/java/ua/gov/diia/login/ui/LoginVM.kt @@ -0,0 +1,304 @@ +package ua.gov.diia.login.ui + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import ua.gov.diia.core.di.actions.GlobalActionLazy +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.core.models.ActionDataLazy +import ua.gov.diia.core.network.apis.ApiAuth +import ua.gov.diia.core.util.CommonConst.BUILD_TYPE_DEBUG +import ua.gov.diia.core.util.CommonConst.BUILD_TYPE_STAGE +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.consumeEvent +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.core.util.system.application.ApplicationLauncher +import ua.gov.diia.core.util.system.application.InstalledApplicationInfoProvider +import ua.gov.diia.core.util.system.service.SystemServiceProvider +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.login.R +import ua.gov.diia.login.network.ApiLogin +import ua.gov.diia.pin.repository.LoginPinRepository +import ua.gov.diia.ui_base.components.atom.space.SpacerAtmData +import ua.gov.diia.ui_base.components.atom.space.SpacerAtmType +import ua.gov.diia.ui_base.components.atom.text.textwithparameter.TextParameter +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addAllIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.checkbox.CheckboxBorderedMlcData +import ua.gov.diia.ui_base.components.molecule.checkbox.CheckboxSquareMlcData +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData +import ua.gov.diia.verification.model.VerificationFlowResult +import ua.gov.diia.verification.model.VerificationMethodsData +import ua.gov.diia.verification.model.VerificationResult +import ua.gov.diia.verification.network.ApiVerification +import ua.gov.diia.verification.ui.VerificationSchema +import ua.gov.diia.verification.ui.controller.VerificationControllerConst +import ua.gov.diia.verification.ui.controller.VerificationControllerOnFlowVM +import ua.gov.diia.verification.ui.methods.VerificationMethod +import javax.inject.Inject + +@HiltViewModel +class LoginVM @Inject constructor( + @GlobalActionLazy private val actionLazy: MutableSharedFlow>, + @UnauthorizedClient private val apiAuth: ApiAuth, + @UnauthorizedClient apiVerification: ApiVerification, + @UnauthorizedClient private val apiLogin: ApiLogin, + private val loginPinRepository: LoginPinRepository, + private val authorizationRepository: AuthorizationRepository, + private val postLoginActions: Set<@JvmSuppressWildcards PostLoginAction>, + clientAlertDialogsFactory: ClientAlertDialogsFactory, + retryErrorBehavior: WithRetryLastAction, + errorHandlingBehaviour: WithErrorHandlingOnFlow, + applicationInfoProvider: InstalledApplicationInfoProvider, + systemServiceProvider: SystemServiceProvider, + applicationLauncher: ApplicationLauncher, + private val verificationMethods: Map, + private val withCrashlytics: WithCrashlytics, + withBuildConfig: WithBuildConfig, +) : VerificationControllerOnFlowVM( + apiVerification, + clientAlertDialogsFactory, + applicationInfoProvider, + systemServiceProvider, + applicationLauncher, + retryErrorBehavior, + errorHandlingBehaviour, + verificationMethods +), WithBuildConfig by withBuildConfig { + + private var authToken: String? = null + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _loadingIndicatorKey = MutableStateFlow(UIActionKeysCompose.PAGE_LOADING_TRIDENT) + private val _dataLoading = MutableStateFlow(value = false) + val isLoading = combine(_dataLoading, verifyingUser) { dataLoadingState, verifyingUserState -> + if (verifyingUserState) { + _loadingIndicatorKey.emit(UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_UI_BLOCKING) + } + dataLoadingState || verifyingUserState + }.combine(_loadingIndicatorKey) { value, key -> + key to value + } + private val _screenProgressState = mutableStateOf(value = false) + val progressState: State = _screenProgressState + + private val _toolbarData = mutableStateListOf() + val toolbarData: SnapshotStateList = _toolbarData + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + init { + if (getBuildType() == BUILD_TYPE_STAGE || getBuildType() == BUILD_TYPE_DEBUG) { + + fun loadToken(data: ActionDataLazy) { + viewModelScope.launch { + try { + authToken = apiAuth.getTestToken(data.hard, data.hardMap).token + _navigation.tryEmit(Navigation.ToPinCreation) + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + } + + viewModelScope.launch { + actionLazy.collectLatest { dataEvent -> + dataEvent.consumeEvent(::loadToken) + } + } + } + + loadAuthMethodData() + } + + private fun displayStaticUI() { + _toolbarData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.StringResource(R.string.login_screen_title_text), + label = UiText.StringResource( + R.string.login_screen_title_subtext, + getVersionName(), + ) + ) + ) + ) + + _bodyData.addAllIfNotNull( + TextLabelMlcData( + actionKey = LoginConst.ACTION_NAVIGATE_TO_POLICY, + text = UiText.StringResource(R.string.login_screen_description_text), + parameters = listOf( + TextParameter( + type = "link", + data = TextParameter.Data( + name = UiText.StringResource(R.string.login_screen_description_param_type), + alt = UiText.StringResource(R.string.login_screen_description_param_text), + resource = UiText.StringResource(R.string.login_screen_description_param_link), + ) + ) + ) + ), + CheckboxBorderedMlcData( + actionKey = LoginConst.ACTION_CHECKBOX_CHECKED, + data = CheckboxSquareMlcData( + actionKey = LoginConst.ACTION_CHECKBOX_CHECKED, + title = UiText.StringResource(R.string.login_screen_checkbox_text), + interactionState = UIState.Interaction.Enabled, + selectionState = UIState.Selection.Selected, + contentDescription = UiText.StringResource(R.string.accessibility_login_screen_checkbox_text) + ) + ) + ) + } + + private fun loadAuthMethodData() { + executeActionOnFlow( + progressIndicator = _dataLoading.also { + _loadingIndicatorKey.tryEmit(UIActionKeysCompose.PAGE_LOADING_TRIDENT) + }, + templateKey = VerificationControllerConst.VERIFICATION_ALERT_DIALOG_ACTION + ) { + val data = doVerificationMethodsApiCall(VerificationSchema.AUTHORIZATION) + processVerificationRequestData(data) + } + } + + private fun processVerificationRequestData(verificationRequestData: VerificationMethodsData) { + verificationRequestData.doOnVerificationMethodsApproved { methods, _ -> + displayStaticUI() + val state = + if (isCheckBoxSelected()) UIState.Interaction.Enabled else UIState.Interaction.Disabled + val verificationMethods = methods.mapNotNull { m -> + verificationMethods[m]?.toListItemAtomData(state) + } + val list = SnapshotStateList() + list.addAll(verificationMethods) + _bodyData.add( + ListItemGroupOrgData( + title = UiText.StringResource(R.string.login_screen_bank_list_title), + itemsList = list + ) + ) + _bodyData.add(SpacerAtmData(SpacerAtmType.SPACER_32)) + } + } + + private fun VerificationMethod.toListItemAtomData( + state: UIState.Interaction + ): ListItemMlcData? = if (isAvailableForAuth) { + ListItemMlcData( + id = name, + label = UiText.StringResource(titleResId), + logoLeft = UiIcon.DrawableResInt(iconResId), + interactionState = state, + logoLeftContentDescription = UiText.StringResource(descriptionResId), + ) + } else { + null + } + + override fun doOnVerificationCompleted(result: VerificationResult) { + if (result is VerificationResult.Common) { + getToken(result.processId) + } + } + + private fun getToken(processId: String) { + executeActionOnFlow(progressIndicator = _dataLoading) { + authToken = apiLogin.getAuthenticationToken(processId).token + _navigation.tryEmit(Navigation.ToPinCreation) + } + } + + fun setPinCode(pin: String) { + executeActionOnFlow { + authorizationRepository.setToken(authToken!!) + loginPinRepository.setUserAuthorized(pin) + postLoginActions.forEach { action -> + launch { + runCatching { + action.onPostLogin() + }.onFailure(withCrashlytics::sendNonFatalError) + } + } + _navigation.tryEmit(Navigation.ToHome) + } + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + LoginConst.ACTION_NAVIGATE_TO_POLICY -> { + _navigation.tryEmit(Navigation.ToPolicy) + } + + UIActionKeysCompose.LIST_ITEM_GROUP_ORG -> { + val code = uiAction.data ?: return + cleanUpAndLaunchVerificationMethod( + VerificationSchema.AUTHORIZATION, + VerificationFlowResult.VerificationMethod(code) + ) + } + + LoginConst.ACTION_CHECKBOX_CHECKED -> { + + _bodyData.findAndChangeFirstByInstance { + val result = it.onOptionsCheckChanged() + val isSelected = result.data.selectionState == UIState.Selection.Selected + _bodyData.findAndChangeFirstByInstance { listV2 -> + val updatedList = SnapshotStateList() + listV2.itemsList.forEach { e -> + updatedList.add(e.copy(interactionState = if (isSelected) UIState.Interaction.Enabled else UIState.Interaction.Disabled)) + } + listV2.copy(itemsList = updatedList) + } + result + } + } + } + } + + @VisibleForTesting + fun isCheckBoxSelected() = bodyData.findLast { it is CheckboxBorderedMlcData } + ?.let { (it as? CheckboxBorderedMlcData)?.data?.selectionState == UIState.Selection.Selected } + ?: false + + sealed class Navigation : NavigationPath { + object ToPolicy : Navigation() + object ToPinCreation : Navigation() + object ToHome : Navigation() + } +} \ No newline at end of file diff --git a/login/src/main/java/ua/gov/diia/login/ui/PostLoginAction.kt b/login/src/main/java/ua/gov/diia/login/ui/PostLoginAction.kt new file mode 100644 index 0000000..4ea6b7a --- /dev/null +++ b/login/src/main/java/ua/gov/diia/login/ui/PostLoginAction.kt @@ -0,0 +1,9 @@ +package ua.gov.diia.login.ui + +interface PostLoginAction { + + /** + * This action will be performed after successful user authorization + */ + suspend fun onPostLogin() +} \ No newline at end of file diff --git a/login/src/main/java/ua/gov/diia/login/ui/compose/LoginScreen.kt b/login/src/main/java/ua/gov/diia/login/ui/compose/LoginScreen.kt new file mode 100644 index 0000000..d631fd8 --- /dev/null +++ b/login/src/main/java/ua/gov/diia/login/ui/compose/LoginScreen.kt @@ -0,0 +1,220 @@ +package ua.gov.diia.login.ui.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.paint +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import ua.gov.diia.ui_base.R +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.atom.text.textwithparameter.TextParameter +import ua.gov.diia.ui_base.components.molecule.checkbox.CheckboxSquareMlcData +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.checkbox.CheckboxBorderedMlc +import ua.gov.diia.ui_base.components.molecule.checkbox.CheckboxBorderedMlcData +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import ua.gov.diia.ui_base.components.molecule.loading.FullScreenLoadingMolecule +import ua.gov.diia.ui_base.components.molecule.loading.TridentLoaderMolecule +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlc +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrg +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrg +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.subatomic.loader.TridentLoaderWithUIBlocking +import ua.gov.diia.ui_base.components.theme.BlackAlpha30 + +@Composable +fun LoginScreen( + modifier: Modifier = Modifier, + toolbar: SnapshotStateList, + body: SnapshotStateList, + screenNavigationProcessing: State, + isLoading: Pair, + diiaResourceIconProvider: DiiaResourceIconProvider, + onEvent: (UIAction) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = modifier + .paint( + painterResource(id = R.drawable.bg_blue_yellow_gradient), + contentScale = ContentScale.FillBounds + ) + .fillMaxSize() + .safeDrawingPadding() + ) { + if (screenNavigationProcessing.value) { + FullScreenLoadingMolecule( + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {} + ) + .fillMaxSize(), + backgroundColor = BlackAlpha30 + ) + } + Column { + toolbar.forEach { item -> + when (item) { + is TopGroupOrgData -> { + TopGroupOrg( + data = item, + onUIAction = onEvent, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + } + } + } + + val scrollState = rememberScrollState() + Column( + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .verticalScroll(scrollState), + ) { + val listVisible = remember { mutableStateOf(false) } + body.forEach { element -> + when (element) { + is TextLabelMlcData -> { + TextLabelMlc( + data = element, + onUIAction = onEvent + ) + } + + is CheckboxBorderedMlcData -> { + CheckboxBorderedMlc( + data = element, + onUIAction = onEvent + ) + } + + is ListItemGroupOrgData -> { + ListItemGroupOrg( + data = element, + onUIAction = onEvent, + modifier = modifier + .fillMaxWidth() + .wrapContentSize() + .onGloballyPositioned { + //making list visible only after it was positioned + if (!listVisible.value) { + listVisible.value = true + } + } + .alpha(if (listVisible.value) 1f else 0f), + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + } + } + } + } + if (isLoading.first == UIActionKeysCompose.PAGE_LOADING_TRIDENT && isLoading.second) { + TridentLoaderMolecule() + } + TridentLoaderWithUIBlocking(contentLoaded = isLoading.first to !isLoading.second) + } +} + +@Preview +@Composable +fun LoginScreenPreview( +) { + val _toolbarData = remember { mutableStateListOf() } + val _bodyData = remember { mutableStateListOf() } + _toolbarData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.DynamicString("Авторизація"), + label = UiText.DynamicString("Версія Дії: 3.0.78.1722") + ) + ) + ) + + val linkText = + UiText.DynamicString("Увійдіть за допомогою BankID Національного банку України, використовуючи свій інтернет-банкінг, або за допомогою NFC-чипу у біометричних документах.\n\nДля авторизації ознайомтесь зі змістом.\n{details}") + val linkParameter = TextParameter( + type = "link", + data = TextParameter.Data( + name = UiText.DynamicString("details"), + alt = UiText.DynamicString("Повідомлення про обробку персональних даних"), + resource = UiText.DynamicString("https://diia.gov.ua/app_policy"), + ) + ) + _bodyData.add( + TextLabelMlcData( + actionKey = "ACTION_NAVIGATE_TO_POLICY", + text = linkText, + parameters = listOf(linkParameter) + ) + ) + + _bodyData.add( + CheckboxBorderedMlcData( + actionKey = "ACTION_CHECKBOX_CHECKED", + data = CheckboxSquareMlcData( + actionKey = "LoginConst.ACTION_CHECKBOX_CHECKED", + title = UiText.DynamicString("Я ознайомився зі змістом Повідомлення про обробку персональних даних."), + interactionState = UIState.Interaction.Enabled, + selectionState = UIState.Selection.Unselected + ) + ) + ) + val list = SnapshotStateList().also { + for (i in 1..4) { + it.add( + ListItemMlcData( + id = i.toString(), + label = UiText.DynamicString("BankID $i"), + interactionState = UIState.Interaction.Disabled + ) + ) + } + } + _bodyData.add( + ListItemGroupOrgData( + title = UiText.StringResource(R.string.login_screen_bank_list_title), + itemsList = list + ) + ) + + + val navState = remember { mutableStateOf(value = false) } + LoginScreen( + toolbar = _toolbarData, + body = _bodyData, + screenNavigationProcessing = navState, + isLoading = "" to false, + diiaResourceIconProvider = DiiaResourceIconProvider.forPreview() + ) {} +} \ No newline at end of file diff --git a/login/src/main/res/navigation/nav_login.xml b/login/src/main/res/navigation/nav_login.xml new file mode 100644 index 0000000..4360579 --- /dev/null +++ b/login/src/main/res/navigation/nav_login.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/login/src/main/res/values/nav_ids.xml b/login/src/main/res/values/nav_ids.xml new file mode 100644 index 0000000..943ffea --- /dev/null +++ b/login/src/main/res/values/nav_ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/login/src/main/res/values/strings.xml b/login/src/main/res/values/strings.xml new file mode 100644 index 0000000..7196dbd --- /dev/null +++ b/login/src/main/res/values/strings.xml @@ -0,0 +1,16 @@ + + + Авторизація + Версія Дії: %s + + Увійдіть за допомогою BankID Національного банку України, використовуючи свій інтернет-банкінг, або за допомогою NFC-чипу у біометричних документах.\n\nДля авторизації ознайомтесь зі змістом.\n{details} + details + Повідомлення про обробку персональних даних + https://diia.gov.ua/app_policy + + Я ознайомився зі змістом Повідомлення про обробку персональних даних. + Авторизуватись через: + NFC + Приват24 + monobank + \ No newline at end of file diff --git a/login/src/test/java/ua/gov/diia/login/rules/MainDispatcherRule.kt b/login/src/test/java/ua/gov/diia/login/rules/MainDispatcherRule.kt new file mode 100644 index 0000000..91c0cc6 --- /dev/null +++ b/login/src/test/java/ua/gov/diia/login/rules/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.login.rules + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/login/src/test/java/ua/gov/diia/login/ui/LoginVMTest.kt b/login/src/test/java/ua/gov/diia/login/ui/LoginVMTest.kt new file mode 100644 index 0000000..c301cf4 --- /dev/null +++ b/login/src/test/java/ua/gov/diia/login/ui/LoginVMTest.kt @@ -0,0 +1,301 @@ +package ua.gov.diia.login.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.clearInvocations +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.times +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.core.models.ActionDataLazy +import ua.gov.diia.core.models.Token +import ua.gov.diia.core.models.dialogs.TemplateDialogModelWithProcessCode +import ua.gov.diia.core.network.apis.ApiAuth +import ua.gov.diia.core.util.CommonConst +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.system.application.ApplicationLauncher +import ua.gov.diia.core.util.system.application.InstalledApplicationInfoProvider +import ua.gov.diia.core.util.system.service.SystemServiceProvider +import ua.gov.diia.login.model.LoginToken +import ua.gov.diia.login.network.ApiLogin +import ua.gov.diia.login.rules.MainDispatcherRule +import ua.gov.diia.pin.repository.LoginPinRepository +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.molecule.checkbox.CheckboxBorderedMlcData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData +import ua.gov.diia.verification.model.VerificationMethodsData +import ua.gov.diia.verification.model.VerificationUrl +import ua.gov.diia.verification.network.ApiVerification +import ua.gov.diia.verification.ui.methods.VerificationMethod +import ua.gov.diia.verification.ui.methods.VerificationRequest + + +@RunWith(MockitoJUnitRunner::class) +class LoginVMTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val actionLazy = MutableSharedFlow>() + + @Mock + lateinit var apiAuth: ApiAuth + + @Mock + lateinit var apiLogin: ApiLogin + + @Mock + lateinit var loginPinRepository: LoginPinRepository + + @Mock + lateinit var authorizationRepository: ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository + + @Mock + lateinit var clientAlertDialogsFactory: ClientAlertDialogsFactory + + @Mock + lateinit var retryErrorBehavior: WithRetryLastAction + + @Mock + lateinit var errorHandlingBehaviour: WithErrorHandlingOnFlow + + @Mock + lateinit var applicationInfoProvider: InstalledApplicationInfoProvider + + @Mock + lateinit var systemServiceProvider: SystemServiceProvider + + @Mock + lateinit var applicationLauncher: ApplicationLauncher + + @Mock + lateinit var withCrashlytics: WithCrashlytics + + @Mock + lateinit var withBuildConfig: WithBuildConfig + + private lateinit var verificationMethod: VerificationMethod + private lateinit var unavailableVerificationMethod: VerificationMethod + private lateinit var apiVerification: ApiVerification + private lateinit var viewModel: LoginVM + + @Before + fun setUp() { + verificationMethod = StubVerificationMethod( + name = "test", + isUsesUrl = false, + isAvailableForAuth = true + ) + unavailableVerificationMethod = StubVerificationMethod( + name = "unavailable", + isUsesUrl = true, + isAvailableForAuth = false, + ) + apiVerification = StubApiVerification() + viewModel = LoginVM( + actionLazy = actionLazy, + apiAuth = apiAuth, + apiVerification = apiVerification, + apiLogin = apiLogin, + loginPinRepository = loginPinRepository, + authorizationRepository = authorizationRepository, + postLoginActions = emptySet(), + clientAlertDialogsFactory = clientAlertDialogsFactory, + retryErrorBehavior = retryErrorBehavior, + errorHandlingBehaviour = errorHandlingBehaviour, + applicationInfoProvider = applicationInfoProvider, + systemServiceProvider = systemServiceProvider, + applicationLauncher = applicationLauncher, + verificationMethods = mapOf( + verificationMethod.name to verificationMethod, + unavailableVerificationMethod.name to unavailableVerificationMethod, + ), + withCrashlytics = withCrashlytics, + withBuildConfig = withBuildConfig, + ) + } + + @Test + fun `open policy`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction(UIAction(LoginConst.ACTION_NAVIGATE_TO_POLICY)) + Assert.assertEquals(LoginVM.Navigation.ToPolicy, awaitItem()) + } + } + + @Test + fun `policy check box`() = runTest { + Assert.assertFalse(viewModel.isCheckBoxSelected()) + viewModel.isLoading.first { !it.second } + viewModel.onUIAction(UIAction(LoginConst.ACTION_CHECKBOX_CHECKED)) + Assert.assertEquals( + UIState.Selection.Unselected, + viewModel.bodyData.firstNotNullOf { it as? CheckboxBorderedMlcData }.data.selectionState, + ) + viewModel.bodyData.firstNotNullOf { it as? ListItemGroupOrgData }.itemsList.forEach { + Assert.assertEquals(UIState.Interaction.Disabled, it.interactionState) + } + Assert.assertFalse(viewModel.isCheckBoxSelected()) + viewModel.onUIAction(UIAction(LoginConst.ACTION_CHECKBOX_CHECKED)) + Assert.assertEquals( + UIState.Selection.Selected, + viewModel.bodyData.firstNotNullOf { it as? CheckboxBorderedMlcData }.data.selectionState, + ) + viewModel.bodyData.firstNotNullOf { it as? ListItemGroupOrgData }.itemsList.forEach { + Assert.assertEquals(UIState.Interaction.Enabled, it.interactionState) + } + Assert.assertTrue(viewModel.isCheckBoxSelected()) + } + + @Test + fun `full login flow`() = runTest { + whenever(apiLogin.getAuthenticationToken(any())).thenReturn(LoginToken("testtoken")) + viewModel.navigateToVerification.test { + viewModel.onUIAction( + UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, data = verificationMethod.name) + ) + awaitItem() + } + viewModel.navigation.test { + viewModel.completeVerification() + Assert.assertEquals(LoginVM.Navigation.ToPinCreation, awaitItem()) + } + // get pin code + viewModel.navigation.test { + viewModel.setPinCode("1234") + Assert.assertEquals(LoginVM.Navigation.ToHome, awaitItem()) + } + verify(loginPinRepository).setUserAuthorized("1234") + } + + @Test + fun `test user auth`() = runTest { + whenever(apiAuth.getTestToken(any(), any())).thenReturn(Token("token", null)) + val data = ActionDataLazy( + hard = "senectus", + hardMap = mapOf("name" to "lkfore") + ) + val buildTypes = listOf(CommonConst.BUILD_TYPE_DEBUG, CommonConst.BUILD_TYPE_STAGE, CommonConst.BUILD_TYPE_RELEASE) + for (type in buildTypes) { + whenever(withBuildConfig.getBuildType()).thenReturn(type) + clearInvocations(apiAuth) + val actionLazy = MutableSharedFlow>() + val vm = LoginVM( + actionLazy = actionLazy, + apiAuth = apiAuth, + apiVerification = apiVerification, + apiLogin = apiLogin, + loginPinRepository = loginPinRepository, + authorizationRepository = authorizationRepository, + postLoginActions = emptySet(), + clientAlertDialogsFactory = clientAlertDialogsFactory, + retryErrorBehavior = retryErrorBehavior, + errorHandlingBehaviour = errorHandlingBehaviour, + applicationInfoProvider = applicationInfoProvider, + systemServiceProvider = systemServiceProvider, + applicationLauncher = applicationLauncher, + verificationMethods = mapOf( + verificationMethod.name to verificationMethod, + unavailableVerificationMethod.name to unavailableVerificationMethod, + ), + withCrashlytics = withCrashlytics, + withBuildConfig = withBuildConfig + ) + vm.isLoading.first { !it.second } + vm.navigation.test { + actionLazy.emit(UiDataEvent(data)) + if (type == CommonConst.BUILD_TYPE_RELEASE) { + expectNoEvents() + } else { + Assert.assertEquals(LoginVM.Navigation.ToPinCreation, awaitItem()) + } + } + val verificationMode = if (type == CommonConst.BUILD_TYPE_RELEASE) { + never() + } else { + times(1) + } + verify(apiAuth, verificationMode).getTestToken(data.hard, data.hardMap) + } + } + + private class StubVerificationMethod( + override val name: String, + private val isUsesUrl: Boolean, + override val isAvailableForAuth: Boolean, + ) : VerificationMethod() { + + override val isAvailable = true + override val iconResId = 0 + override val titleResId = 0 + override val descriptionResId = 0 + + override suspend fun getVerificationRequest( + verificationSchema: String, + processId: String + ) = VerificationRequest( + navRequest = { _, _ -> mock() }, + url = if (isUsesUrl) { + VerificationUrl("http://test.com", "tk2", null) + } else { + null + }, + shouldLaunchUrl = isUsesUrl, + ) + } + + private class StubApiVerification : ApiVerification { + + override suspend fun getVerificationMethods( + schema: String, + processId: String? + ) = VerificationMethodsData( + title = "Test", + methods = listOf("test", "unavailable"), + actionButton = null, + processId = "dl4lee", + skipAuthMethods = null, + template = null, + ) + + override suspend fun getAuthUrl( + verificationMethodCode: String, + processId: String, + bankCode: String? + ): VerificationUrl = VerificationUrl( + authUrl = null, + token = "testtoken", + template = null, + ) + + override suspend fun completeVerificationStep( + verificationMethodCode: String, + requestId: String, + processId: String, + bankCode: String? + ): TemplateDialogModelWithProcessCode = mock() + } +} \ No newline at end of file diff --git a/menu/.gitignore b/menu/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/menu/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/menu/README.md b/menu/README.md new file mode 100644 index 0000000..541beb0 --- /dev/null +++ b/menu/README.md @@ -0,0 +1,111 @@ +# Description + +This is module responsible for representing menu of the application + +# How to install +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':menu') +``` + +2. Module requires next modules to work +```groovy + implementation project(':core') + implementation project(':diia_storage') + implementation project(':ui_base') + implementation project(':web') +``` +3. nav_id file describe all ids that this module requires. Entry point should implement all those ids. + +`./src/main/res/values/nav_ids.xml` + +4. Add next nav graphs to main navigation graph + +```xml + +``` + +5. The following action should be added into the root navigation graph otherwise navigation actions won't work + +```xml + + + + + + + + + + + + + + + + + + +``` + diff --git a/menu/build.gradle b/menu/build.gradle new file mode 100644 index 0000000..e4a33f0 --- /dev/null +++ b/menu/build.gradle @@ -0,0 +1,121 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'androidx.navigation.safeargs.kotlin' + id 'kotlin-kapt' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'dagger.hilt.android.plugin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.menu' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + + implementation project(path: ':ui_base') + implementation project(':core') + implementation project(':diia_storage') + implementation project(path: ':web') + + implementation deps.fragment_ktx + implementation deps.appcompat + implementation deps.constraint_layout + implementation deps.recyclerview + implementation deps.viewpager + //compose + implementation deps.activity_compose + //lifecycle + implementation deps.lifecycle_livedata_ktx + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + implementation deps.hilt_work + kapt deps.hilt_compiler + + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.turbine +} +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/menu/consumer-rules.pro b/menu/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/menu/excludes.jacoco b/menu/excludes.jacoco new file mode 100644 index 0000000..13895c1 --- /dev/null +++ b/menu/excludes.jacoco @@ -0,0 +1,4 @@ +ua/gov/diia/menu/ui/**/*F.* +ua/gov/diia/menu/ui/**/*FCompose.* +ua/gov/diia/menu/**/*$*.* +ua/gov/diia/menu/MenuContentController.* \ No newline at end of file diff --git a/menu/proguard-rules.pro b/menu/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/menu/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/menu/src/main/AndroidManifest.xml b/menu/src/main/AndroidManifest.xml new file mode 100644 index 0000000..72a934f --- /dev/null +++ b/menu/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/menu/src/main/java/ua/gov/diia/menu/MenuContentController.kt b/menu/src/main/java/ua/gov/diia/menu/MenuContentController.kt new file mode 100644 index 0000000..b07044d --- /dev/null +++ b/menu/src/main/java/ua/gov/diia/menu/MenuContentController.kt @@ -0,0 +1,148 @@ +package ua.gov.diia.menu + +import androidx.compose.runtime.mutableStateListOf +import ua.gov.diia.menu.ui.MenuActionsKey +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.atom.button.BtnPrimaryDefaultAtmData +import ua.gov.diia.ui_base.components.atom.space.SpacerAtmData +import ua.gov.diia.ui_base.components.atom.space.SpacerAtmType +import ua.gov.diia.ui_base.components.atom.text.textwithparameter.TextWithParametersConstants +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addAllIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.toDynamicString +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData +import ua.gov.diia.ui_base.components.atom.text.textwithparameter.TextParameter +import javax.inject.Inject + +class MenuContentController @Inject constructor() { + + fun configureBody(isShowBadge: Boolean): List { + + val menuUser: List = listOf( + ListItemMlcData( + id = MenuActionsKey.OPEN_NOTIFICATION, + label = UiText.StringResource(R.string.settings_notifications), + iconLeft = UiIcon.DrawableResource( + if (isShowBadge) { + CommonDiiaResourceIcon.NEW_MESSAGE.code + } else { + CommonDiiaResourceIcon.NOTIFICATION_MESSAGE.code + } + ), + action = DataActionWrapper( + type = MenuActionsKey.OPEN_NOTIFICATION + ) + ) + ) + + val menuSigning: List = listOf( + ListItemMlcData( + id = MenuActionsKey.OPEN_DIIA_ID, + label = UiText.StringResource(R.string.settings_diia_id), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.KEY.code), + action = DataActionWrapper( + type = MenuActionsKey.OPEN_DIIA_ID + ) + ), + ListItemMlcData( + id = MenuActionsKey.OPEN_SIGNE_HISTORY, + label = UiText.StringResource(R.string.settings_signing_history), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.SOME_DOCS.code), + action = DataActionWrapper( + type = MenuActionsKey.OPEN_SIGNE_HISTORY + ) + ) + ) + + val menuSettings: List = listOf( + ListItemMlcData( + id = MenuActionsKey.OPEN_SETTINGS, + label = UiText.StringResource(R.string.menu_settings), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.SETTINGS.code), + action = DataActionWrapper( + type = MenuActionsKey.OPEN_SETTINGS + ) + ), + ListItemMlcData( + id = MenuActionsKey.OPEN_PLAY_MARKET, + label = UiText.StringResource(R.string.settings_set_estimate_label), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.REFRESH.code), + action = DataActionWrapper( + type = MenuActionsKey.OPEN_PLAY_MARKET + ) + ), + ListItemMlcData( + id = MenuActionsKey.OPEN_APP_SESSIONS, + label = UiText.StringResource(R.string.app_session_header), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.DEVICE.code), + action = DataActionWrapper( + type = MenuActionsKey.OPEN_APP_SESSIONS + ) + ) + ) + + val menuSupport: List = listOf( + ListItemMlcData( + id = MenuActionsKey.OPEN_SUPPORT, + label = UiText.StringResource(R.string.settings_support_label), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.MESSAGE.code), + action = DataActionWrapper( + type = MenuActionsKey.OPEN_SUPPORT + ) + ), + ListItemMlcData( + id = MenuActionsKey.COPY_DEVICE_UID, + label = UiText.StringResource(R.string.settings_copy_device_id_label), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.COPY.code), + action = DataActionWrapper( + type = MenuActionsKey.COPY_DEVICE_UID + ) + ), + ListItemMlcData( + id = MenuActionsKey.OPEN_FAQ, + label = UiText.StringResource(R.string.settings_faq_label), + iconLeft = UiIcon.DrawableResource(CommonDiiaResourceIcon.FAQ.code), + action = DataActionWrapper( + type = MenuActionsKey.OPEN_FAQ + ) + ) + ) + val linkText = "{app_policy}" + val linkParameter = + TextParameter( + type = TextWithParametersConstants.TYPE_LINK, + data = TextParameter.Data( + name = UiText.DynamicString("app_policy"), + alt = UiText.DynamicString("Повідомлення про обробку персональних даних"), + resource = UiText.DynamicString("https://diia.gov.ua/app_policy"), + ) + ) + val bodyData = mutableStateListOf() + bodyData.addAllIfNotNull( + ListItemGroupOrgData(itemsList = menuUser), + ListItemGroupOrgData(itemsList = menuSigning), + ListItemGroupOrgData(itemsList = menuSettings), + ListItemGroupOrgData(itemsList = menuSupport), + SpacerAtmData(SpacerAtmType.SPACER_16), + BtnPrimaryDefaultAtmData( + id = MenuActionsKey.LOGOUT, + actionKey = MenuActionsKey.LOGOUT, + title = UiText.DynamicString("Вийти") + ), + SpacerAtmData(SpacerAtmType.SPACER_8), + TextLabelMlcData( + text = linkText.toDynamicString(), + parameters = listOf(linkParameter), + actionKey = MenuActionsKey.OPEN_POLICY + ), + SpacerAtmData(SpacerAtmType.SPACER_32) + ) + return bodyData + } + +} \ No newline at end of file diff --git a/menu/src/main/java/ua/gov/diia/menu/models/EventType.kt b/menu/src/main/java/ua/gov/diia/menu/models/EventType.kt new file mode 100644 index 0000000..9cdefd9 --- /dev/null +++ b/menu/src/main/java/ua/gov/diia/menu/models/EventType.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.menu.models + + +enum class EventType { + OPEN_ABOUT, OPEN_POLICY, LOGOUT +} \ No newline at end of file diff --git a/menu/src/main/java/ua/gov/diia/menu/ui/MenuAction.kt b/menu/src/main/java/ua/gov/diia/menu/ui/MenuAction.kt new file mode 100644 index 0000000..f00b844 --- /dev/null +++ b/menu/src/main/java/ua/gov/diia/menu/ui/MenuAction.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.menu.ui + + +sealed class MenuAction { + object OpenPlayMarketAction : MenuAction() + object OpenHelpAction : MenuAction() + object OpenDiiaId : MenuAction() + object OpenSignHistory : MenuAction() + object OpenAppSessions : MenuAction() + object OpenSupportAction : MenuAction() + object OpenFAQAction : MenuAction() + object ShareApp : MenuAction() + object OpenSettings : MenuAction() + object Logout : MenuAction() + object AboutDiia : MenuAction() + object CopyDeviceUid : MenuAction() + object OpenPolicyLink : MenuAction() + data class DoCopyDeviceUid(val deviceUid: String) : MenuAction() +} + diff --git a/menu/src/main/java/ua/gov/diia/menu/ui/MenuActionsKey.kt b/menu/src/main/java/ua/gov/diia/menu/ui/MenuActionsKey.kt new file mode 100644 index 0000000..b389eb0 --- /dev/null +++ b/menu/src/main/java/ua/gov/diia/menu/ui/MenuActionsKey.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.menu.ui + +object MenuActionsKey { + const val OPEN_PLAY_MARKET = "OpenPlayMarket" + const val OPEN_HELP = "OpenHelpAction" + const val OPEN_DIIA_ID = "OpenDiiaId" + const val OPEN_SIGNE_HISTORY = "OpenSignHistory" + const val OPEN_APP_SESSIONS = "OpenAppSessions" + const val OPEN_SUPPORT = "OpenSupportAction" + const val OPEN_FAQ = "OpenFAQAction" + const val SHARE_APP = "ShareApp" + const val OPEN_SETTINGS = "OpenSettings" + const val LOGOUT = "Logout" + const val OPEN_ABOUT_DIIA = "AboutDiia" + const val OPEN_POLICY = "OpenPolicyLink" + const val COPY_DEVICE_UID = "CopyDeviceUid" + const val OPEN_NOTIFICATION = "navToNotifications" +} \ No newline at end of file diff --git a/menu/src/main/java/ua/gov/diia/menu/ui/MenuComposeVM.kt b/menu/src/main/java/ua/gov/diia/menu/ui/MenuComposeVM.kt new file mode 100644 index 0000000..cb1663a --- /dev/null +++ b/menu/src/main/java/ua/gov/diia/menu/ui/MenuComposeVM.kt @@ -0,0 +1,233 @@ +package ua.gov.diia.menu.ui + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import ua.gov.diia.core.controller.NotificationController +import ua.gov.diia.core.di.actions.GlobalActionDocLoadingIndicator +import ua.gov.diia.core.di.actions.GlobalActionLogout +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.menu.MenuContentController +import ua.gov.diia.menu.R +import ua.gov.diia.menu.ui.MenuActionsKey.COPY_DEVICE_UID +import ua.gov.diia.menu.ui.MenuActionsKey.LOGOUT +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_ABOUT_DIIA +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_APP_SESSIONS +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_DIIA_ID +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_FAQ +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_HELP +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_NOTIFICATION +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_PLAY_MARKET +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_POLICY +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_SETTINGS +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_SIGNE_HISTORY +import ua.gov.diia.menu.ui.MenuActionsKey.OPEN_SUPPORT +import ua.gov.diia.menu.ui.MenuActionsKey.SHARE_APP +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData +import javax.inject.Inject + +@HiltViewModel +class MenuComposeVM @Inject constructor( + @GlobalActionLogout private val actionLogout: MutableLiveData, + @GlobalActionDocLoadingIndicator val globalActionDocLoadingIndicator: MutableSharedFlow>, + private val errorHandling: WithErrorHandlingOnFlow, + private val retryLastAction: WithRetryLastAction, + private val diiaStorage: DiiaStorage, + private val withBuildConfig: WithBuildConfig, + private val menuContentController: MenuContentController, + private val notificationController: NotificationController, +) : WithErrorHandlingOnFlow by errorHandling, WithRetryLastAction by retryLastAction, + ViewModel() { + + private val _topBarData = mutableStateListOf() + val topBarData: SnapshotStateList = _topBarData + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + private val _hasUnreadNotifications = MutableStateFlow(false) + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _settingsAction = MutableSharedFlow>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + val settingsAction = _settingsAction.asSharedFlow() + + init { + viewModelScope.launch { + notificationController.collectUnreadNotificationCounts { + val hasUnreadNotification = it != 0 + val previousBadgeValue = _hasUnreadNotifications.value + _hasUnreadNotifications.value = hasUnreadNotification + + if (hasUnreadNotification != previousBadgeValue) { + updateMenuMessageItemData(hasUnreadNotification) + } + } + } + } + + fun logoutApprove() { + actionLogout.value = UiEvent() + } + + private suspend fun selectMenuAction(menuAction: MenuAction) { + if (menuAction == MenuAction.CopyDeviceUid) { + copyDeviceUid() + } else { + _settingsAction.emit(UiDataEvent(menuAction)) + } + } + + fun onUIAction(event: UIAction) { + executeActionOnFlow { + when (event.actionKey) { + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + UIActionKeysCompose.LIST_ITEM_GROUP_ORG -> { + event.action?.type?.let { + when (it) { + OPEN_NOTIFICATION -> { + _navigation.tryEmit(MenuTabNavigation.NavigateToNotifications) + } + + else -> { + getMenuActionForString(it)?.let { menuAction -> + selectMenuAction(menuAction) + } + } + } + } + } + + LOGOUT -> { + selectMenuAction(MenuAction.Logout) + } + } + } + } + + private fun copyDeviceUid() { + viewModelScope.launch { + val uid = diiaStorage.getMobileUuid() + _settingsAction.emit(UiDataEvent(MenuAction.DoCopyDeviceUid(uid))) + } + } + + fun configureTopBar() { + val toolbar = TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.DynamicString("Меню"), + label = UiText.StringResource(R.string.version_diia, withBuildConfig.getVersionName()) + ) + ) + _topBarData.addIfNotNull(toolbar) + } + + private fun getMenuActionForString( + actionKey: String + ): MenuAction? { + return when (actionKey) { + OPEN_PLAY_MARKET -> MenuAction.OpenPlayMarketAction + OPEN_HELP -> MenuAction.OpenHelpAction + OPEN_DIIA_ID -> MenuAction.OpenDiiaId + OPEN_SIGNE_HISTORY -> MenuAction.OpenSignHistory + OPEN_APP_SESSIONS -> MenuAction.OpenAppSessions + OPEN_SUPPORT -> MenuAction.OpenSupportAction + OPEN_FAQ -> MenuAction.OpenFAQAction + SHARE_APP -> MenuAction.ShareApp + OPEN_SETTINGS -> MenuAction.OpenSettings + LOGOUT -> MenuAction.Logout + OPEN_ABOUT_DIIA -> MenuAction.AboutDiia + OPEN_POLICY -> MenuAction.OpenPolicyLink + COPY_DEVICE_UID -> MenuAction.CopyDeviceUid + else -> null + } + } + + fun configureBody() { + _bodyData.addAll(menuContentController.configureBody(_hasUnreadNotifications.value)) + } + + fun showDataLoadingIndicator(load: Boolean) { + viewModelScope.launch { + globalActionDocLoadingIndicator.emit(UiDataEvent(load)) + } + } + + private fun updateMenuMessageItemData(hasUnreadNotification: Boolean) { + bodyData.forEachIndexed { rootIndex, item -> + if (item is ListItemGroupOrgData) { + item.itemsList.forEachIndexed { indexItemList, listItem -> + if (listItem.id == OPEN_NOTIFICATION) { + updateBodyByIndex(rootIndex, indexItemList, hasUnreadNotification) + return + } + } + } + } + } + + private fun updateBodyByIndex( + rootIndex: Int, + indexItemList: Int, + hasUnreadNotification: Boolean + ) { + (bodyData.getOrNull(rootIndex) as? ListItemGroupOrgData)?.let { + val list = it.itemsList.toMutableList() + val item = list[indexItemList].copy(iconLeft = UiIcon.DrawableResource( + if (hasUnreadNotification) { + CommonDiiaResourceIcon.NEW_MESSAGE.code + } else { + CommonDiiaResourceIcon.NOTIFICATION_MESSAGE.code + } + )) + list.removeAt(indexItemList) + list.add(indexItemList, item) + val organism = it.copy(itemsList = SnapshotStateList().apply { + addAll(list) + }) + bodyData.removeAt(rootIndex) + bodyData.add(rootIndex, organism) + } + } +} + +sealed class MenuTabNavigation : NavigationPath { + object NavigateToNotifications : MenuTabNavigation() +} \ No newline at end of file diff --git a/menu/src/main/java/ua/gov/diia/menu/ui/MenuFCompose.kt b/menu/src/main/java/ua/gov/diia/menu/ui/MenuFCompose.kt new file mode 100644 index 0000000..e3b89aa --- /dev/null +++ b/menu/src/main/java/ua/gov/diia/menu/ui/MenuFCompose.kt @@ -0,0 +1,280 @@ +package ua.gov.diia.menu.ui + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ui_base.NavSystemDialogDirections +import ua.gov.diia.core.models.SystemDialog +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.extensions.fragment.collapseApp +import ua.gov.diia.core.util.extensions.fragment.findNavControllerById +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.core.util.extensions.fragment.openPlayMarket +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ui_base.util.view.showCopyDeviceUuidClipedSnackBar +import ua.gov.diia.menu.NavMenuActionsDirections +import ua.gov.diia.menu.NavMenuActionsDirections.Companion.actionGlobalNotificationFCompose +import ua.gov.diia.menu.NavMenuActionsDirections.Companion.actionGlobalToNavFaq +import ua.gov.diia.menu.NavMenuActionsDirections.Companion.actionHomeFToDiiaId +import ua.gov.diia.menu.NavMenuActionsDirections.Companion.actionHomeFToHelpF +import ua.gov.diia.menu.NavMenuActionsDirections.Companion.actionHomeFToNavAppSessions +import ua.gov.diia.menu.NavMenuActionsDirections.Companion.actionHomeFToSettingsF +import ua.gov.diia.menu.NavMenuActionsDirections.Companion.actionHomeFToSignHistory +import ua.gov.diia.menu.R +import ua.gov.diia.menu.models.EventType +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.HomeScreenTab +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.fragments.dialog.system.DiiaSystemDFVM +import ua.gov.diia.web.util.extensions.fragment.navigateToWebView +import javax.inject.Inject + +@AndroidEntryPoint +class MenuFCompose : Fragment() { + + @Inject + lateinit var withCrashlytics: WithCrashlytics + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private var composeView: ComposeView? = null + private val viewModel: MenuComposeVM by viewModels() + private lateinit var event: EventType + private val dialogVM: DiiaSystemDFVM by activityViewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.configureTopBar() + viewModel.configureBody() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + composeView?.setContent { + val topBar = viewModel.topBarData + val body = viewModel.bodyData + viewModel.navigation.collectAsEffect { navigation -> + when (navigation) { + is BaseNavigation.Back -> { + activity?.collapseApp() + } + + is MenuTabNavigation.NavigateToNotifications -> { + navigateToNotifications() + } + } + } + viewModel.settingsAction.collectAsEffect { + + when (val action = it.getContentIfNotHandled()) { + + MenuAction.OpenPlayMarketAction -> { + openPlayMarket(withCrashlytics) + } + + MenuAction.OpenSupportAction -> { + navigate( + NavMenuActionsDirections.actionHomeFToSupportF(), + findNavControllerById(R.id.nav_host) + ) + } + + MenuAction.OpenHelpAction -> { + navigate( + actionHomeFToHelpF(true), + findNavControllerById(R.id.nav_host) + ) + } + + MenuAction.OpenSettings -> { + navigate( + actionHomeFToSettingsF(), + findNavControllerById(R.id.nav_host) + ) + } + + MenuAction.ShareApp -> { + shareApp() + } + + MenuAction.OpenFAQAction -> { + navigate( + actionGlobalToNavFaq(), + findNavControllerById(R.id.nav_host) + ) + } + + MenuAction.OpenPolicyLink -> { + openLink(EventType.OPEN_POLICY) + } + + MenuAction.Logout -> { + event = EventType.LOGOUT + navigate( + NavSystemDialogDirections.actionGlobalToSystemDialog( + SystemDialog( + getString(R.string.settings_dialog_title_logout), + getString(R.string.settings_dialog_logout_text), + getString(R.string.settings_dialog_logout_stay), + getString(R.string.settings_dialog_logout_leave), + ) + ), + findNavControllerById(R.id.nav_host) + ) + } + + MenuAction.AboutDiia -> { + openLink(EventType.OPEN_ABOUT) + } + + MenuAction.OpenDiiaId -> { + navigate( + actionHomeFToDiiaId(), + findNavControllerById(R.id.nav_host), + ) + } + + MenuAction.OpenSignHistory -> { + navigate( + actionHomeFToSignHistory(), + findNavControllerById(R.id.nav_host), + ) + } + + MenuAction.CopyDeviceUid -> { + } + + MenuAction.OpenAppSessions -> navigate( + actionHomeFToNavAppSessions(), + findNavControllerById(R.id.nav_host) + ) + + else -> { + action?.let { + action as MenuAction.DoCopyDeviceUid + composeView?.showCopyDeviceUuidClipedSnackBar( + action.deviceUid, + topPadding = 40f, + bottomPadding = 0f + ) + + } + } + } + } + + dialogVM.action.observe(viewLifecycleOwner) { + when (it.getContentIfNotHandled()) { + DiiaSystemDFVM.Action.NEGATIVE -> { + if (event == EventType.LOGOUT) { + viewModel.logoutApprove() + } + } + DiiaSystemDFVM.Action.POSITIVE -> { + when (event) { + EventType.OPEN_ABOUT -> + navigateToWebView(getString(R.string.url_about_diia)) + + EventType.OPEN_POLICY -> + navigateToWebView(getString(R.string.url_app_policy)) + + else -> { + } + } + } + + else -> {} + } + + } + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.DIALOG_ACTION_CODE_CLOSE -> viewModel.showDataLoadingIndicator( + false + ) + } + } + + HomeScreenTab( + topBar = topBar, + body = body, + onEvent = { + viewModel.onUIAction(it) + }, + diiaResourceIconProvider = diiaResourceIconProvider + ) + } + } + + private fun shareApp() { + val sendIntent: Intent = Intent().apply { + action = Intent.ACTION_SEND + putExtra( + Intent.EXTRA_TEXT, getString(R.string.share_text_title) + + "\n${getString(R.string.share_text_description)}" + + "\n${getString(R.string.share_text_link)}" + ) + type = TEXT_PLAIN + } + startActivity(Intent.createChooser(sendIntent, null)) + } + + private fun navigateToNotifications() { + navigate( + actionGlobalNotificationFCompose(), + findNavControllerById(R.id.nav_host) + ) + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun openLink(eventType: EventType) { + event = eventType + navigate( + NavSystemDialogDirections.actionGlobalToSystemDialog( + SystemDialog( + getString( + R.string.settings_dialog_title_faq, + getString(R.string.browser) + ), + null, + getString(R.string.settings_dialog_support_open), + getString(R.string.settings_dialog_support_cancel), + ) + ), + findNavControllerById(R.id.nav_host) + ) + } + + companion object { + private const val TEXT_PLAIN = "text/plain" + } +} + diff --git a/menu/src/main/res/navigation/nav_menu_actions.xml b/menu/src/main/res/navigation/nav_menu_actions.xml new file mode 100644 index 0000000..db1e7ff --- /dev/null +++ b/menu/src/main/res/navigation/nav_menu_actions.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/menu/src/main/res/values/config.xml b/menu/src/main/res/values/config.xml new file mode 100644 index 0000000..cb6185a --- /dev/null +++ b/menu/src/main/res/values/config.xml @@ -0,0 +1,5 @@ + + + https://diia.gov.ua/app_policy + https://presentation.diia.gov.ua + \ No newline at end of file diff --git a/menu/src/main/res/values/nav_ids.xml b/menu/src/main/res/values/nav_ids.xml new file mode 100644 index 0000000..1d676b8 --- /dev/null +++ b/menu/src/main/res/values/nav_ids.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/menu/src/main/res/values/strings.xml b/menu/src/main/res/values/strings.xml new file mode 100644 index 0000000..e0438c8 --- /dev/null +++ b/menu/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + Version %s + Версія Дії: %s + + + Служба підтримки + Дія.Підпис + Повідомлення + Історія підписань + Питання та відповіді + Копіювати номер пристрою + Про Дію + Розповісти друзям + Оновити застосунок + Налаштування + Підключені пристрої + Завантажуй застосунок Дія + Електронне водійське посвідчення та свідоцтво про реєстрацію авто вже доступні у твоєму смартфоні. + https://go.diia.app/ + + \ No newline at end of file diff --git a/menu/src/test/java/ua/gov/diia/menu/MainDispatcherRule.kt b/menu/src/test/java/ua/gov/diia/menu/MainDispatcherRule.kt new file mode 100644 index 0000000..254bcdc --- /dev/null +++ b/menu/src/test/java/ua/gov/diia/menu/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.menu + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/menu/src/test/java/ua/gov/diia/menu/ui/MenuComposeVMTest.kt b/menu/src/test/java/ua/gov/diia/menu/ui/MenuComposeVMTest.kt new file mode 100644 index 0000000..74f2d4d --- /dev/null +++ b/menu/src/test/java/ua/gov/diia/menu/ui/MenuComposeVMTest.kt @@ -0,0 +1,260 @@ +package ua.gov.diia.menu.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.MutableLiveData +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import org.mockito.stubbing.Answer +import ua.gov.diia.core.controller.NotificationController +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.menu.MainDispatcherRule +import ua.gov.diia.menu.MenuContentController +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData + + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class MenuComposeVMTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var actionLogout: MutableLiveData + @Mock + lateinit var errorHandling: WithErrorHandlingOnFlow + @Mock + lateinit var retryLastAction: WithRetryLastAction + @Mock + lateinit var diiaStorage: DiiaStorage + @Mock + lateinit var withBuildConfig: WithBuildConfig + @Mock + lateinit var notificationController: NotificationController + @Mock + lateinit var menuContentController: MenuContentController + lateinit var globalActionDocLoadingIndicator: MutableSharedFlow> + + lateinit var menuComposeVM: MenuComposeVM + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + actionLogout = MutableLiveData() + globalActionDocLoadingIndicator = MutableSharedFlow() + + menuComposeVM = MenuComposeVM(actionLogout, globalActionDocLoadingIndicator, errorHandling, retryLastAction, + diiaStorage, withBuildConfig, menuContentController, notificationController) + } + + @Test + fun `test logout approve call logout action`() { + menuComposeVM.logoutApprove() + Assert.assertNotNull(actionLogout.value) + } + + @Test + fun `test emin back navigation on ui action`() { + runTest { + menuComposeVM.navigation.test { + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK)) + Assert.assertEquals(BaseNavigation.Back, awaitItem()) + } + } + } + + @Test + fun `test emin open notification on ui action`() { + runTest { + menuComposeVM.navigation.test { + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_NOTIFICATION))) + Assert.assertEquals(MenuTabNavigation.NavigateToNotifications, awaitItem()) + } + } + } + @Test + fun `test emin menu setting action on ui action function`() { + runTest { + menuComposeVM.settingsAction.test { + menuComposeVM.onUIAction(UIAction(MenuActionsKey.LOGOUT)) + Assert.assertEquals(MenuAction.Logout, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_PLAY_MARKET))) + Assert.assertEquals(MenuAction.OpenPlayMarketAction, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_HELP))) + Assert.assertEquals(MenuAction.OpenHelpAction, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_DIIA_ID))) + Assert.assertEquals(MenuAction.OpenDiiaId, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_SIGNE_HISTORY))) + Assert.assertEquals(MenuAction.OpenSignHistory, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_APP_SESSIONS))) + Assert.assertEquals(MenuAction.OpenAppSessions, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_SUPPORT))) + Assert.assertEquals(MenuAction.OpenSupportAction, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_FAQ))) + Assert.assertEquals(MenuAction.OpenFAQAction, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.SHARE_APP))) + Assert.assertEquals(MenuAction.ShareApp, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_SETTINGS))) + Assert.assertEquals(MenuAction.OpenSettings, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_ABOUT_DIIA))) + Assert.assertEquals(MenuAction.AboutDiia, awaitItem().peekContent()) + + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.OPEN_POLICY))) + Assert.assertEquals(MenuAction.OpenPolicyLink, awaitItem().peekContent()) + } + } + } + + @Test + fun `test ui action pass device id in setting actions for coping`() { + runTest { + menuComposeVM.settingsAction.test { + val mobileUuid = "mobile_id" + `when`(diiaStorage.getMobileUuid()).thenReturn(mobileUuid) + menuComposeVM.onUIAction(UIAction(UIActionKeysCompose.LIST_ITEM_GROUP_ORG, action = DataActionWrapper(MenuActionsKey.COPY_DEVICE_UID))) + + val result = awaitItem().peekContent() + Assert.assertTrue(result is MenuAction.DoCopyDeviceUid) + Assert.assertEquals(mobileUuid, (result as MenuAction.DoCopyDeviceUid).deviceUid) + } + } + } + @Test + fun `test configureTopBar`() { + runTest { + menuComposeVM.configureTopBar() + val topBarData = menuComposeVM.topBarData[0] + assertTrue(topBarData is TopGroupOrgData) + val result = topBarData as TopGroupOrgData + assertEquals(result.titleGroupMlcData!!.heroText, UiText.DynamicString("Меню")) + } + } + + @Test + fun `test updateMenuMessageItemData updates body show badge value if notification has OPEN_NOTIFICATION id`() { + runTest { + var notificationCallback: ((amount: Int) -> Unit)? = null + `when`( + notificationController.collectUnreadNotificationCounts(any()) + ).thenAnswer( + Answer { invocation -> + notificationCallback = (invocation.arguments[0] as (amount: Int) -> Unit) + null + }) + menuComposeVM = MenuComposeVM(actionLogout, globalActionDocLoadingIndicator, errorHandling, retryLastAction, + diiaStorage, withBuildConfig, menuContentController, notificationController) + val menuList = mutableListOf() + val itemLabel = UiText.DynamicString("some_label") + val listItemMlcData = ListItemMlcData(id = MenuActionsKey.OPEN_NOTIFICATION, label = itemLabel) + val snapshotList = SnapshotStateList() + snapshotList.add(listItemMlcData) + menuList.add(ListItemGroupOrgData(MenuActionsKey.OPEN_NOTIFICATION, itemsList = snapshotList)) + + `when`(menuContentController.configureBody(any())).thenReturn(menuList) + menuComposeVM.configureBody() + notificationCallback!!.invoke(10) + + assertEquals(itemLabel, (menuComposeVM.bodyData[0] as ListItemGroupOrgData).itemsList[0].label) + } + } + + @Test + fun `test updateMenuMessageItemData not update body show badge value if notification has OPEN_NOTIFICATION id`() { + runTest { + var notificationCallback: ((amount: Int) -> Unit)? = null + `when`( + notificationController.collectUnreadNotificationCounts(any()) + ).thenAnswer( + Answer { invocation -> + notificationCallback = (invocation.arguments[0] as (amount: Int) -> Unit) + null + }) + menuComposeVM = MenuComposeVM(actionLogout, globalActionDocLoadingIndicator, errorHandling, retryLastAction, + diiaStorage, withBuildConfig, menuContentController, notificationController) + val menuList = mutableListOf() + val itemLabel = UiText.DynamicString("some_label") + val listItemMlcData = ListItemMlcData(id = MenuActionsKey.OPEN_FAQ, label = itemLabel) + val snapshotList = SnapshotStateList() + snapshotList.add(listItemMlcData) + menuList.add(ListItemGroupOrgData(MenuActionsKey.OPEN_FAQ, itemsList = snapshotList)) + + `when`(menuContentController.configureBody(any())).thenReturn(menuList) + menuComposeVM.configureBody() + notificationCallback!!.invoke(10) + + assertEquals(itemLabel, (menuComposeVM.bodyData[0] as ListItemGroupOrgData).itemsList[0].label) + } + } + + + @Test + fun `test updateMenuMessageItemData update left icon if no unread notification and notification has OPEN_NOTIFICATION id`() { + runTest { + var notificationCallback: ((amount: Int) -> Unit)? = null + `when`( + notificationController.collectUnreadNotificationCounts(any()) + ).thenAnswer( + Answer { invocation -> + notificationCallback = (invocation.arguments[0] as (amount: Int) -> Unit) + null + }) + menuComposeVM = MenuComposeVM(actionLogout, globalActionDocLoadingIndicator, errorHandling, retryLastAction, + diiaStorage, withBuildConfig, menuContentController, notificationController) + val menuList = mutableListOf() + val itemLabel = UiText.DynamicString("some_label") + val listItemMlcData = ListItemMlcData(id = MenuActionsKey.OPEN_NOTIFICATION, label = itemLabel) + val snapshotList = SnapshotStateList() + snapshotList.add(listItemMlcData) + menuList.add(ListItemGroupOrgData(MenuActionsKey.OPEN_FAQ, itemsList = snapshotList)) + + `when`(menuContentController.configureBody(any())).thenReturn(menuList) + menuComposeVM.configureBody() + notificationCallback!!.invoke(10) + notificationCallback!!.invoke(0) + + assertEquals(CommonDiiaResourceIcon.NOTIFICATION_MESSAGE.code, (menuComposeVM.bodyData[0] as ListItemGroupOrgData).itemsList[0].iconLeft!!.code) + } + } +} \ No newline at end of file diff --git a/notifications/.gitignore b/notifications/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/notifications/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/notifications/README.md b/notifications/README.md new file mode 100644 index 0000000..1e6c726 --- /dev/null +++ b/notifications/README.md @@ -0,0 +1,75 @@ +# Description + +This is module responsible for notification receiving and representation. The module implements functionality for google play and hauwey platforms. + +# How to install +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':notifications') +``` + +2. Module requires next modules to work +3. +```groovy + implementation project(':core') + implementation project(':analytics') + implementation project(':diia_storage') + implementation project(path: ':ui_base') +``` + +3. nav_id file describe all ids that this module requires. Entry point should implement all those ids. + +`./src/main/res/values/nav_ids.xml` + +4. Enter point should implement next interfaces and provide them through Hilt DI: + +`./src/main/java/ua/gov/diia/notifications/helper/NotificationHelper.kt` + +5. Add next nav graphs to main navigation graph + +```xml + + + +``` + +6. The following action should be added into the root navigation graph otherwise navigation actions won't work + +```xml + +``` + +7. Create PushNotificationActionModule with function that provide all notification types that will be processed by the app. and add all your notification actions into + +```kotlin +@Module +@InstallIn(SingletonComponent::class) +class PushNotificationActionModule { + + @Provides + fun providePushActionTypes(): List<@JvmSuppressWildcards BasePushNotificationAction> { + return listOf( + DocumentSharingPushNotificationAction(), + PushAccessibilityNotificationAction(), + //Other your actions + ) + } +} +``` + +## Add new notification type + +To implement a new notification type a class should implement BasePushNotificationAction with notification type passed in constructor as an id + +```kotlin +class AppSessionNotificationAction() : BasePushNotificationAction("notification_type") { + + override fun getNavigationDirection(item: PullNotificationItemSelection): NavDirections = + NavMainDirections + .actionNavigateSomewhere(ConsumableItem(item)) +} +``` + diff --git a/notifications/build.gradle b/notifications/build.gradle new file mode 100644 index 0000000..f54f820 --- /dev/null +++ b/notifications/build.gradle @@ -0,0 +1,156 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'androidx.navigation.safeargs.kotlin' + id 'kotlin-kapt' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'dagger.hilt.android.plugin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.notifications' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + + implementation project(':core') + implementation project(':analytics') + + implementation project(':diia_storage') + implementation deps.activity_ktx + implementation deps.fragment_ktx + implementation deps.appcompat + implementation project(path: ':ui_base') + //Compose + implementation deps.activity_compose + //cardview + implementation deps.navigation_ui_ktx + + implementation deps.constraint_layout + implementation deps.recyclerview + implementation deps.viewpager + + //lifecycle + implementation deps.lifecycle_livedata_ktx + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + implementation deps.hilt_work + kapt deps.hilt_compiler + + implementation deps.paging_runtime_ktx + + implementation deps.retrofit + // Moshi + implementation deps.moshi + implementation deps.moshi_kotlin + + //ExoPlayer + implementation deps.exoplayer_core + implementation deps.exoplayer_ui + implementation deps.exoplayer_hls + + implementation deps.shortcut_badger + implementation deps.glide + + implementation deps.work_runtime_ktx + + gplayImplementation deps.gplay_firebase_bom + gplayImplementation deps.gplay_firebase_messaging + //Huawei SDK + huaweiImplementation deps.huawei_push + + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.hamcrest_library + testImplementation deps.mockwebserver + testImplementation deps.json + testImplementation deps.turbine + testImplementation deps.mockk_android + testImplementation deps.mockk_agent + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/notifications/consumer-rules.pro b/notifications/consumer-rules.pro new file mode 100644 index 0000000..4944a39 --- /dev/null +++ b/notifications/consumer-rules.pro @@ -0,0 +1,3 @@ +-keep enum * { *; } + +-keep public class ua.gov.diia.notifications.models.notification.pull.MessageIdentification \ No newline at end of file diff --git a/notifications/excludes.jacoco b/notifications/excludes.jacoco new file mode 100644 index 0000000..beb5e3b --- /dev/null +++ b/notifications/excludes.jacoco @@ -0,0 +1,8 @@ +ua/gov/diia/notifications/ui/**/*F.* +ua/gov/diia/notifications/ui/**/*FCompose.* +ua/gov/diia/notifications/**/*$*.* +ua/gov/diia/notifications/ui/fragments/notifications/NotificationVideoPlayerView.* +ua/gov/diia/notifications/ui/fragments/notifications/NotificationFullAdapter.* +ua/gov/diia/notifications/service/FCMS.* +ua/gov/diia/notifications/ui/**/*Args.* +ua/gov/diia/notifications/work/SendPushTokenWork.* \ No newline at end of file diff --git a/notifications/proguard-rules.pro b/notifications/proguard-rules.pro new file mode 100644 index 0000000..d768b80 --- /dev/null +++ b/notifications/proguard-rules.pro @@ -0,0 +1,25 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-keep enum * { *; } + +-keep public class ua.gov.diia.notifications.models.notification.pull.MessageIdentification diff --git a/notifications/src/gplay/AndroidManifest.xml b/notifications/src/gplay/AndroidManifest.xml new file mode 100644 index 0000000..62eb600 --- /dev/null +++ b/notifications/src/gplay/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/notifications/src/gplay/java/ua/gov/diia/notifications/service/FCMS.kt b/notifications/src/gplay/java/ua/gov/diia/notifications/service/FCMS.kt new file mode 100644 index 0000000..6b3d48f --- /dev/null +++ b/notifications/src/gplay/java/ua/gov/diia/notifications/service/FCMS.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.notifications.service + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class FCMS : FirebaseMessagingService() { + + @Inject + lateinit var pushService: PushService + + override fun onNewToken(token: String) { + super.onNewToken(token) + pushService.onNewToken(token) + } + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + super.onMessageReceived(remoteMessage) + remoteMessage.data[PAYLOAD]?.let { + pushService.processNotification(it) + } + } + + companion object { + private const val PAYLOAD = "payload" + } + +} \ No newline at end of file diff --git a/notifications/src/gplay/java/ua/gov/diia/notifications/util/notification/push/CloudPushTokenProvider.kt b/notifications/src/gplay/java/ua/gov/diia/notifications/util/notification/push/CloudPushTokenProvider.kt new file mode 100644 index 0000000..cc4613a --- /dev/null +++ b/notifications/src/gplay/java/ua/gov/diia/notifications/util/notification/push/CloudPushTokenProvider.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.notifications.util.notification.push + +import androidx.annotation.WorkerThread +import com.google.android.gms.tasks.Tasks +import com.google.firebase.messaging.FirebaseMessaging +import javax.inject.Inject + +class CloudPushTokenProvider @Inject constructor() : PushTokenProvider { + + @WorkerThread + @Throws(java.util.concurrent.ExecutionException::class) + override fun requestCurrentPushToken(forceRefresh: Boolean): String { + return Tasks.await(FirebaseMessaging.getInstance().token) + } +} \ No newline at end of file diff --git a/notifications/src/huawei/AndroidManifest.xml b/notifications/src/huawei/AndroidManifest.xml new file mode 100644 index 0000000..a33e94d --- /dev/null +++ b/notifications/src/huawei/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/notifications/src/huawei/java/ua/gov/diia/notifications/service/HCMS.kt b/notifications/src/huawei/java/ua/gov/diia/notifications/service/HCMS.kt new file mode 100644 index 0000000..31ee68f --- /dev/null +++ b/notifications/src/huawei/java/ua/gov/diia/notifications/service/HCMS.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.notifications.service + +import com.huawei.hms.push.HmsMessageService +import com.huawei.hms.push.RemoteMessage +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class HCMS : HmsMessageService() { + + @Inject + lateinit var pushService: PushService + + override fun onNewToken(token: String) { + super.onNewToken(token) + pushService.onNewToken(token) + } + + override fun onMessageReceived(remoteMessage: RemoteMessage?) { + super.onMessageReceived(remoteMessage) + + remoteMessage?.data?.let { + pushService.processNotification(it) + } + } +} \ No newline at end of file diff --git a/notifications/src/huawei/java/ua/gov/diia/notifications/util/notification/push/CloudPushTokenProvider.kt b/notifications/src/huawei/java/ua/gov/diia/notifications/util/notification/push/CloudPushTokenProvider.kt new file mode 100644 index 0000000..fe1b113 --- /dev/null +++ b/notifications/src/huawei/java/ua/gov/diia/notifications/util/notification/push/CloudPushTokenProvider.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.notifications.util.notification.push + +import android.content.Context +import com.huawei.agconnect.config.AGConnectServicesConfig +import com.huawei.hms.aaid.HmsInstanceId +import dagger.hilt.android.qualifiers.ApplicationContext +import ua.gov.diia.notifications.util.notification.push.PushTokenProvider +import javax.inject.Inject + + +class CloudPushTokenProvider @Inject constructor( + @ApplicationContext private val context: Context +) : PushTokenProvider { + + override fun requestCurrentPushToken(forceRefresh: Boolean): String { + val appId = AGConnectServicesConfig.fromContext(context).getString("client/app_id") + return HmsInstanceId.getInstance(context).getToken(appId, "HCM") + } +} \ No newline at end of file diff --git a/notifications/src/main/AndroidManifest.xml b/notifications/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c6c1921 --- /dev/null +++ b/notifications/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/NotificationControllerImpl.kt b/notifications/src/main/java/ua/gov/diia/notifications/NotificationControllerImpl.kt new file mode 100644 index 0000000..d87273e --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/NotificationControllerImpl.kt @@ -0,0 +1,70 @@ +package ua.gov.diia.notifications + +import android.os.Build +import androidx.work.WorkManager +import ua.gov.diia.core.controller.NotificationController +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSource +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager +import ua.gov.diia.notifications.util.notification.push.PushTokenProvider +import ua.gov.diia.notifications.work.SendPushTokenWork + +class NotificationControllerImpl( + private val workManager: WorkManager, + private val notificationsDataSource: NotificationDataRepository, + private val notificationManager: DiiaNotificationManager, + private val pushTokenProvider: PushTokenProvider, + private val keyValueSource: KeyValueNotificationDataSource, +): NotificationController { + + override suspend fun markAsRead(resId: String?) { + if (resId != null && resId != Preferences.DEF) { + notificationsDataSource.markNotificationAsRead(resId) + } + } + + override fun invalidateNotificationDataSource() { + notificationsDataSource.invalidate() + } + + override suspend fun getNotificationsInitial() { + notificationsDataSource.loadDataFromNetwork(0, 5) {} + + } + + override fun checkPushTokenInSync() { + val pushTokenSynced: Boolean = keyValueSource.isPushTokenSynced() + if (!pushTokenSynced) { + val token = pushTokenProvider.requestCurrentPushToken() + if (token.isNotEmpty()) { + SendPushTokenWork.enqueue(workManager, token) + } + } + } + + override suspend fun collectUnreadNotificationCounts(callback: (amount: Int) -> Unit) { + notificationsDataSource.unreadCount.collect { + notificationManager.setBadeNumber(it) + callback(it) + } + } + + override suspend fun allowNotifications() { + keyValueSource.allowNotifications() + } + + override fun denyNotifications() { + keyValueSource.denyNotifications() + } + + override suspend fun checkNotificationsRequested(): Boolean? { + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return keyValueSource.isNotificationRequested() + } else { + allowNotifications() + } + return null + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/NotificationsConst.kt b/notifications/src/main/java/ua/gov/diia/notifications/NotificationsConst.kt new file mode 100644 index 0000000..afd9828 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/NotificationsConst.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.notifications + +object NotificationsConst { + const val PUSH_NOTIFICATION_RECEIVED = "ua.gov.diia.app.PUSH_NOTIFICATION_RECEIVED" + + const val WORK_NAME_PUSH_TOKEN_UPDATE = "sendPushTokenWork" +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/action/ActionConstants.kt b/notifications/src/main/java/ua/gov/diia/notifications/action/ActionConstants.kt new file mode 100644 index 0000000..7ef51da --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/action/ActionConstants.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.notifications.action + +object ActionConstants { + const val NOTIFICATION_TYPE_DOCUMENTS_SHARING = "documentsSharing" + const val NOTIFICATION_TYPE_PUSH_ACCESSIBILITY = "pushAccessibility" +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/action/DocumentSharingPushNotificationAction.kt b/notifications/src/main/java/ua/gov/diia/notifications/action/DocumentSharingPushNotificationAction.kt new file mode 100644 index 0000000..f8da879 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/action/DocumentSharingPushNotificationAction.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.notifications.action + +import androidx.navigation.NavDirections +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.push.BasePushNotificationAction +import ua.gov.diia.notifications.action.ActionConstants.NOTIFICATION_TYPE_DOCUMENTS_SHARING + +class DocumentSharingPushNotificationAction: BasePushNotificationAction(NOTIFICATION_TYPE_DOCUMENTS_SHARING) { + + override fun getNavigationDirection(item: PullNotificationItemSelection): NavDirections? = null +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/action/PushAccessibilityNotificationAction.kt b/notifications/src/main/java/ua/gov/diia/notifications/action/PushAccessibilityNotificationAction.kt new file mode 100644 index 0000000..f9d71ca --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/action/PushAccessibilityNotificationAction.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.notifications.action + +import androidx.navigation.NavDirections +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.push.BasePushNotificationAction +import ua.gov.diia.notifications.action.ActionConstants.NOTIFICATION_TYPE_PUSH_ACCESSIBILITY + +class PushAccessibilityNotificationAction: BasePushNotificationAction(NOTIFICATION_TYPE_PUSH_ACCESSIBILITY) { + + override fun getNavigationDirection(item: PullNotificationItemSelection): NavDirections? = null + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/data/data_source/network/api/notification/ApiNotifications.kt b/notifications/src/main/java/ua/gov/diia/notifications/data/data_source/network/api/notification/ApiNotifications.kt new file mode 100644 index 0000000..b115fd7 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/data/data_source/network/api/notification/ApiNotifications.kt @@ -0,0 +1,45 @@ +package ua.gov.diia.notifications.data.data_source.network.api.notification + +import retrofit2.http.* +import ua.gov.diia.notifications.models.notification.SubscribeResponse +import ua.gov.diia.notifications.models.notification.Subscriptions +import ua.gov.diia.notifications.models.notification.pull.PullNotificationsResponse +import ua.gov.diia.notifications.models.notification.pull.PullNotificationsToModify +import ua.gov.diia.notifications.models.notification.pull.UpdatePullNotificationResponse +import ua.gov.diia.core.models.notification.pull.message.NotificationFull +import ua.gov.diia.core.network.annotation.Analytics + +interface ApiNotifications { + + @Analytics("getNotifications") + @GET("api/v3/notification/notifications") + suspend fun getNotifications(@Query("skip") skip: Int): PullNotificationsResponse + + @Analytics("getNotificationByID") + @GET("api/v4/notification/notification/{notificationId}") + suspend fun getPullNotification(@Path("notificationId") notificationId: String): NotificationFull + + @Analytics("getNotificationByMessageId") + @GET("api/v4/notification/message/{messageId}") + suspend fun getNotificationByMessageId(@Path("messageId") messageId: String?): NotificationFull + + @Analytics("markNotificationsAsRead") + @PUT("api/v2/notification/notifications/read") + suspend fun markNotificationsAsRead(@Body pullNotificationsToModify: PullNotificationsToModify): UpdatePullNotificationResponse + + @Analytics("deleteNotifications") + @PUT("api/v2/notification/notifications/delete") + suspend fun deleteNotifications(@Body pullNotificationsToModify: PullNotificationsToModify): UpdatePullNotificationResponse + + @Analytics("getSubscriptions") + @GET("api/v1/user/subscriptions") + suspend fun getSubscriptions(): Subscriptions + + @Analytics("subscribe") + @POST("api/v1/user/subscription/{code}") + suspend fun subscribe(@Path("code") code: String): SubscribeResponse + + @Analytics("unsubscribe") + @DELETE("api/v1/user/subscription/{code}") + suspend fun unsubscribe(@Path("code") code: String): SubscribeResponse +} diff --git a/notifications/src/main/java/ua/gov/diia/notifications/di/NotificationModule.kt b/notifications/src/main/java/ua/gov/diia/notifications/di/NotificationModule.kt new file mode 100644 index 0000000..74cde15 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/di/NotificationModule.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.notifications.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.notifications.data.data_source.network.api.notification.ApiNotifications + +@Module +@InstallIn(SingletonComponent::class) +object NotificationModule { + + @Provides + @UnauthorizedClient + fun provideNotificationApi( + @UnauthorizedClient retrofit: Retrofit + ): ApiNotifications = retrofit.create(ApiNotifications::class.java) + + @Provides + @AuthorizedClient + fun provideApiNotifications( + @AuthorizedClient retrofit: Retrofit + ): ApiNotifications = retrofit.create(ApiNotifications::class.java) +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/di/PushTokenProviderModule.kt b/notifications/src/main/java/ua/gov/diia/notifications/di/PushTokenProviderModule.kt new file mode 100644 index 0000000..0c2dae1 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/di/PushTokenProviderModule.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.notifications.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.notifications.util.notification.push.CloudPushTokenProvider +import ua.gov.diia.notifications.util.notification.push.PushTokenProvider + +@Module +@InstallIn(SingletonComponent::class) +interface PushTokenProviderModule { + + @Binds + fun bindTokenProvider(impl: CloudPushTokenProvider): PushTokenProvider +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationDataSourceModule.kt b/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationDataSourceModule.kt new file mode 100644 index 0000000..49e0798 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationDataSourceModule.kt @@ -0,0 +1,108 @@ +package ua.gov.diia.notifications.di.legacy + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.work.WorkManager +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import ua.gov.diia.analytics.DiiaAnalytics +import ua.gov.diia.core.di.actions.GlobalActionNotificationRead +import ua.gov.diia.core.di.actions.GlobalActionNotificationReceived +import ua.gov.diia.core.util.deeplink.DeepLinkActionFactory +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.notifications.helper.NotificationHelper +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.service.PushService +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSource +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSourceImpl +import ua.gov.diia.notifications.store.datasource.notifications.NetworkNotificationDataSource +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepositoryImpl +import ua.gov.diia.notifications.util.notification.manager.DiiaAndroidNotificationManager +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager +import java.util.concurrent.Executors +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object NotificationDataSourceModule { + + @Provides + @Singleton + fun provideListPullNotificationJsonAdapter(): JsonAdapter> { + val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build() + return moshi.adapter( + Types.newParameterizedType( + MutableList::class.java, + PullNotification::class.java + ) + ) + } + + @Provides + @Singleton + fun bind( + keyValueStore: DiiaStorage, + withCrashlytics: WithCrashlytics, + jsonAdapter: JsonAdapter> + ): KeyValueNotificationDataSource { + return KeyValueNotificationDataSourceImpl(keyValueStore, withCrashlytics, jsonAdapter) + } + + @Provides + @Singleton + fun bindNotificationDataRepo( + keyValueSource: KeyValueNotificationDataSource, + diiaNotificationManager: DiiaNotificationManager, + networkSource: NetworkNotificationDataSource, + @GlobalActionNotificationRead actionNotificationRead: MutableLiveData>, + withCrashlytics: WithCrashlytics + ): NotificationDataRepository { + val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + val applicationScope = CoroutineScope(SupervisorJob() + dispatcher) + return NotificationDataRepositoryImpl( + applicationScope, + keyValueSource, + diiaNotificationManager, + networkSource, + actionNotificationRead, + withCrashlytics + ) + } + + @Provides + fun providePushService( + @ApplicationContext context: Context, + notificationHelper: NotificationHelper, + deepLinkActionFactory: DeepLinkActionFactory, + @GlobalActionNotificationReceived globalActionNotificationReceived: MutableLiveData, + diiaAndroidNotificationManager: DiiaAndroidNotificationManager, + analytics: DiiaAnalytics, + diiaStorage: DiiaStorage, + workManager: WorkManager + ): PushService { + return PushService( + context, + notificationHelper, + deepLinkActionFactory, + analytics, + globalActionNotificationReceived, + diiaStorage, + workManager, + diiaAndroidNotificationManager + ) + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationEnabledCheckerModule.kt b/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationEnabledCheckerModule.kt new file mode 100644 index 0000000..11fc5fa --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationEnabledCheckerModule.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.notifications.di.legacy + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.notifications.util.push.notification.AndroidNotificationEnabledChecker +import ua.gov.diia.notifications.util.push.notification.NotificationEnabledChecker + +@Module +@InstallIn(SingletonComponent::class) +interface NotificationEnabledCheckerModule { + + @Binds + fun bindChecker(impl: AndroidNotificationEnabledChecker): NotificationEnabledChecker +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationManagementModule.kt b/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationManagementModule.kt new file mode 100644 index 0000000..8c87550 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/di/legacy/NotificationManagementModule.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.notifications.di.legacy + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import ua.gov.diia.notifications.util.notification.manager.DiiaAndroidNotificationManager +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager + +@ExperimentalCoroutinesApi +@FlowPreview +@Module +@InstallIn(SingletonComponent::class) +interface NotificationManagementModule { + + @Binds + fun bindNotificationManager(impl: DiiaAndroidNotificationManager): DiiaNotificationManager +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/helper/NotificationHelper.kt b/notifications/src/main/java/ua/gov/diia/notifications/helper/NotificationHelper.kt new file mode 100644 index 0000000..584a44d --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/helper/NotificationHelper.kt @@ -0,0 +1,47 @@ +package ua.gov.diia.notifications.helper + +import android.content.Intent +import androidx.navigation.NavDirections +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.models.notification.push.PushNotification + +interface NotificationHelper { + + /** + * Validate notification type and return true if type is message notification type + * @return true if type is message notification type + * */ + fun isMessageNotification(resourceType: String): Boolean + + /** + * Provide navigation direction for further navigation + * @return NavDirections of document or null if not require further navigation + * */ + suspend fun navigateToDocument(item: PullNotificationItemSelection): NavDirections? + + /** + * @return String that represent last update date of document in ISO8601 + * */ + suspend fun getLastDocumentUpdate(): String? + + /** + * @return String that represent last active date in ISO8601 + * */ + suspend fun getLastActiveDate(): String? + + /** + * @return Intent to the main activity of the current application + * */ + fun getMainActivityIntent(): Intent + + /** + * @return String that represent notification channel for this notification type + * */ + fun getNotificationChannel(notif: PushNotification): String + + /** + * Logging notifications data for debug purpose + * */ + fun log(data: String) + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/LoadingState.java b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/LoadingState.java new file mode 100644 index 0000000..816ce7d --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/LoadingState.java @@ -0,0 +1,7 @@ +package ua.gov.diia.notifications.models.notification; + +public enum LoadingState { + FIRST_PAGE_LOADING, + ADDITIONAL_PAGE_LOADING, + NOT_LOADING +} diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/SubscribeResponse.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/SubscribeResponse.kt new file mode 100644 index 0000000..c211a61 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/SubscribeResponse.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.notifications.models.notification + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class SubscribeResponse( + @Json(name = "success") + val success: Boolean?, + @Json(name = "template") + val template: TemplateDialogModel? +) \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/Subscription.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/Subscription.kt new file mode 100644 index 0000000..6e07909 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/Subscription.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.notifications.models.notification + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class Subscription( + @Json(name = "code") + val code: String, + @Json(name = "description") + val description: String, + @Json(name = "name") + val name: String, + @Json(name = "status") + val status: String +) : Parcelable { + + val switchState + get() = status == "active" + + val hideItem + get() = status == "blocked" +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/SubscriptionHash.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/SubscriptionHash.kt new file mode 100644 index 0000000..33c42ae --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/SubscriptionHash.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.notifications.models.notification + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class SubscriptionHash( + @Json(name = "name") + val name: String, + @Json(name = "hash") + val hash: String +) : Parcelable \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/Subscriptions.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/Subscriptions.kt new file mode 100644 index 0000000..466a43c --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/Subscriptions.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.notifications.models.notification + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@JsonClass(generateAdapter = true) +@Parcelize +data class Subscriptions( + @Json(name = "description") + val description: String, + @Json(name = "subscriptions") + val subscriptions: List +) : Parcelable \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/MessageIdentification.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/MessageIdentification.kt new file mode 100644 index 0000000..d6531ef --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/MessageIdentification.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.notifications.models.notification.pull + +import android.os.Parcelable +import androidx.annotation.Keep +import kotlinx.parcelize.Parcelize + +@Keep +@Parcelize +data class MessageIdentification( + val needAuth: Boolean, + val resourceId: String?, + val notificationId: String +) : Parcelable \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotification.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotification.kt new file mode 100644 index 0000000..fabf8ba --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotification.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.notifications.models.notification.pull + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PullNotification( + @Json(name = "notificationId") + val notificationId: String?, + @Json(name = "creationDate") + val creationDate: String?, + @Json(name = "isRead") + var isRead: Boolean?, + @Json(name = "message") + val pullNotificationMessage: PullNotificationMessage?, + @Json(name = "local_sync_action") + var syncAction: PullNotificationSyncAction? = PullNotificationSyncAction.NONE +) { + companion object { + const val TYPE_GREETING = "greeting" + const val TYPE_URGENT = "urgent" + const val TYPE_REMINDER = "reminder" + const val TYPE_ATTENTION = "attention" + const val TYPE_CONFIRMATION = "confirmation" + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationMessage.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationMessage.kt new file mode 100644 index 0000000..0c51e98 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationMessage.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.notifications.models.notification.pull + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.notification.push.PushAction + +@JsonClass(generateAdapter = true) +data class PullNotificationMessage( + @Json(name = "icon") + val icon: String, + @Json(name = "title") + val title: String, + @Json(name = "shortText") + val shortText: String, + @Json(name = "action") + val action: PushAction? +) \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationSyncAction.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationSyncAction.kt new file mode 100644 index 0000000..b9dbe5b --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationSyncAction.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.notifications.models.notification.pull + +enum class PullNotificationSyncAction { + READ, REMOVE, NONE +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationsResponse.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationsResponse.kt new file mode 100644 index 0000000..a73ea37 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationsResponse.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.notifications.models.notification.pull + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PullNotificationsResponse( + @Json(name = "notifications") + val notifications: List, + @Json(name = "total") + val total: Int, + @Json(name = "unread") + val unread: Int +) \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationsToModify.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationsToModify.kt new file mode 100644 index 0000000..555ee4a --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/PullNotificationsToModify.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.notifications.models.notification.pull + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class PullNotificationsToModify( + @Json(name = "notificationIds") + val messageIds: List +) \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/UpdatePullNotificationResponse.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/UpdatePullNotificationResponse.kt new file mode 100644 index 0000000..63f4da1 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/pull/UpdatePullNotificationResponse.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.notifications.models.notification.pull + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UpdatePullNotificationResponse( + @Json(name = "unread") + val unread: Int +) \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/push/DiiaNotificationChannel.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/push/DiiaNotificationChannel.kt new file mode 100644 index 0000000..d0f3e7d --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/push/DiiaNotificationChannel.kt @@ -0,0 +1,12 @@ +package ua.gov.diia.notifications.models.notification.push + +import androidx.annotation.StringRes +import ua.gov.diia.notifications.R + +enum class DiiaNotificationChannel(val id: String, @StringRes val label: Int) { + ACQUIRER("acquirer", R.string.notification_channel_acquirer), + MESSAGE("messages", R.string.notification_channel_messages), + PENALTIES("penalties", R.string.notification_channel_penalties), + DEBTS("debts", R.string.notification_channel_debts), + DEFAULT("diia", R.string.notification_channel_diia_default) +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/models/notification/push/PushNotification.kt b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/push/PushNotification.kt new file mode 100644 index 0000000..50395b1 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/models/notification/push/PushNotification.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.notifications.models.notification.push + +import ua.gov.diia.core.models.notification.push.PushNotification + +fun PushNotification.getNotificationKey(): Int { + notificationId?.let { + return if (it.isNotEmpty()) { + it.hashCode() + } else { + action.let { action -> action.resourceId?.hashCode() ?: action.type.hashCode() } + } + } + return -1 +} diff --git a/notifications/src/main/java/ua/gov/diia/notifications/service/PushService.kt b/notifications/src/main/java/ua/gov/diia/notifications/service/PushService.kt new file mode 100644 index 0000000..931b190 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/service/PushService.kt @@ -0,0 +1,155 @@ +package ua.gov.diia.notifications.service + +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.RingtoneManager +import android.net.Uri +import androidx.core.app.NotificationCompat +import androidx.lifecycle.MutableLiveData +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.WorkManager +import ua.gov.diia.analytics.DiiaAnalytics +import ua.gov.diia.core.ExcludeFromJacocoGeneratedReport +import ua.gov.diia.core.di.actions.GlobalActionNotificationReceived +import ua.gov.diia.core.models.notification.push.PushNotification +import ua.gov.diia.core.util.CommonConst.BUILD_TYPE_RELEASE +import ua.gov.diia.core.util.deeplink.DeepLinkActionFactory +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.context.isDiiaAppRunning +import ua.gov.diia.core.util.extensions.getPendingFlags +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.notifications.BuildConfig +import ua.gov.diia.notifications.NotificationsConst.PUSH_NOTIFICATION_RECEIVED +import ua.gov.diia.notifications.R +import ua.gov.diia.notifications.action.ActionConstants.NOTIFICATION_TYPE_DOCUMENTS_SHARING +import ua.gov.diia.notifications.action.ActionConstants.NOTIFICATION_TYPE_PUSH_ACCESSIBILITY +import ua.gov.diia.notifications.helper.NotificationHelper +import ua.gov.diia.notifications.models.notification.push.getNotificationKey +import ua.gov.diia.notifications.store.NotificationsPreferences +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager +import ua.gov.diia.notifications.util.push.MoshiPushParser +import ua.gov.diia.notifications.work.SendPushTokenWork +import ua.gov.diia.notifications.work.SilentPushWork + +class PushService( + private val context: Context, + private val notificationHelper: NotificationHelper, + private val deepLinkActionFactory: DeepLinkActionFactory, + private val analytics: DiiaAnalytics, + @GlobalActionNotificationReceived private val globalActionNotificationReceived: MutableLiveData, + val diiaStorage: DiiaStorage, + val workManager: WorkManager, + val notificationManager: DiiaNotificationManager, +) { + fun onNewToken(token: String) { + analytics.setPushToken(token) + diiaStorage.apply { + set(NotificationsPreferences.IsPushTokenSynced, false) + set(NotificationsPreferences.PushToken, token) + } + + SendPushTokenWork.enqueue(workManager, token) + } + + fun processNotification(notificationJson: String) { + if (BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE) { + notificationHelper.log(notificationJson) + } + onNotificationReceived() + analytics.notificationReceived(notificationJson) + globalActionNotificationReceived.postValue(UiEvent()) + MoshiPushParser().parsePushNotification(notificationJson)?.let { + + analytics.pushReceived(it.action.resourceId ?: "") + + when (it.action.type) { + NOTIFICATION_TYPE_DOCUMENTS_SHARING -> { + if (context.isDiiaAppRunning()) { + context.startActivity(getIntentForNotification(it)) + } else { + if (notificationsDisplayAllowed()) { + displayNotification(it) + } + } + } + + NOTIFICATION_TYPE_PUSH_ACCESSIBILITY -> { + SilentPushWork.enqueue(workManager) + } + else -> { + if (notificationsDisplayAllowed()) { + displayNotification(it) + } + } + } + } + } + + private fun notificationsDisplayAllowed(): Boolean { + return try { + diiaStorage.getBoolean( + NotificationsPreferences.AllowNotifications, + true + ) + } catch (e: Exception) { + true + } + } + + @ExcludeFromJacocoGeneratedReport + private fun displayNotification(pushNotification: PushNotification) { + + val channelId = notificationHelper.getNotificationChannel(pushNotification) + + val notificationKey = pushNotification.getNotificationKey() + + val notificationIntent = getIntentForNotification(pushNotification) + + val defaultSoundUri: Uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) + val notificationBuilder = NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_push) + .setContentTitle(pushNotification.title) + .setContentText(pushNotification.shortText) + .setAutoCancel(true) + .setSound(defaultSoundUri) + .setChannelId(channelId) + .setContentIntent( + PendingIntent.getActivity( + context, + 0, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or getPendingFlags() + ) + ) + + pushNotification.unread?.let { + notificationManager.setBadeNumber(it) + } + + notificationKey.let { + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.notify( + it, + notificationBuilder.build() + ) + } + analytics.pushShown(pushNotification.action.resourceId ?: "") + } + + private fun onNotificationReceived() { + LocalBroadcastManager + .getInstance(context) + .sendBroadcast(Intent(PUSH_NOTIFICATION_RECEIVED)) + } + + private fun getIntentForNotification(pushNotification: PushNotification): Intent { + return notificationHelper.getMainActivityIntent().apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + data = Uri.parse(deepLinkActionFactory.buildPathFromPushNotification(pushNotification)) + } + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/store/NotificationsPreferences.kt b/notifications/src/main/java/ua/gov/diia/notifications/store/NotificationsPreferences.kt new file mode 100644 index 0000000..7fab074 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/store/NotificationsPreferences.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.notifications.store + +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.diia_storage.model.PreferenceKey + +object NotificationsPreferences { + + object Scopes { + const val PUSH_SCOPE = "push" + const val NOTIFICATION = "pull_notification" + } + + open class PushKey(name: String, dataType: Class<*>) : + PreferenceKey(name, Scopes.PUSH_SCOPE, dataType) + object PushToken : PushKey("push_token", String::class.java) + object IsPushTokenSynced : PushKey("is_push_token_synced", Boolean::class.java) + + object AllowNotifications : UserDataKey("allow_notifications", Boolean::class.java) + + object NotificationsList : NotificationKey("notifications_list", String::class.java) + + open class UserDataKey(name: String, dataType: Class<*>) : + PreferenceKey(name, Preferences.Scopes.USER_SCOPE, dataType) + + open class NotificationKey(name: String, dataType: Class<*>) : + PreferenceKey(name, Scopes.NOTIFICATION, dataType) + object NotificationsUnreadCount : NotificationKey("notifications_unread", Int::class.java) + + object NotificationsRequested : Preferences.UserDataKey("notifications_requested", Boolean::class.java) +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/KeyValueNotificationDataSource.kt b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/KeyValueNotificationDataSource.kt new file mode 100644 index 0000000..c389f21 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/KeyValueNotificationDataSource.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.notifications.store.datasource.notifications + +import ua.gov.diia.notifications.models.notification.pull.PullNotification + +interface KeyValueNotificationDataSource { + suspend fun fetchData(): List + fun fetchUnreadCount(): Int + fun saveDataToStore(cachedData: List) + fun updateUnreadCount(newUnreadCount: Int) + + fun allowNotifications() + + fun isPushTokenSynced(): Boolean + + fun denyNotifications() + + fun isNotificationRequested(): Boolean + fun setPushToken(token: String) + fun setIsPushTokenSynced(synced: Boolean) + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/KeyValueNotificationDataSourceImpl.kt b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/KeyValueNotificationDataSourceImpl.kt new file mode 100644 index 0000000..3cc3118 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/KeyValueNotificationDataSourceImpl.kt @@ -0,0 +1,59 @@ +package ua.gov.diia.notifications.store.datasource.notifications + +import com.squareup.moshi.JsonAdapter +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.model.PreferenceKey +import ua.gov.diia.diia_storage.store.AbstractKeyValueDataSource +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.store.NotificationsPreferences + +class KeyValueNotificationDataSourceImpl constructor( + keyValueStore: DiiaStorage, + withCrashlytics: WithCrashlytics, + override val jsonAdapter: JsonAdapter> +) : AbstractKeyValueDataSource>(keyValueStore, withCrashlytics), + KeyValueNotificationDataSource { + + override val preferenceKey: PreferenceKey = NotificationsPreferences.NotificationsList + + override suspend fun fetchData(): List { + if (store.containsKey(preferenceKey)) { + return loadData() ?: emptyList() + } + return emptyList() + } + + override fun fetchUnreadCount(): Int { + return store.getInt(NotificationsPreferences.NotificationsUnreadCount, 0) + } + + override fun updateUnreadCount(unreadCount: Int) { + store.set(NotificationsPreferences.NotificationsUnreadCount, unreadCount) + } + + + override fun allowNotifications() { + store.set(NotificationsPreferences.AllowNotifications, true) + store.set(NotificationsPreferences.NotificationsRequested, true) + } + + + override fun isPushTokenSynced(): Boolean = + store.getBoolean(NotificationsPreferences.IsPushTokenSynced, false) + + override fun denyNotifications() { + store.set(NotificationsPreferences.NotificationsRequested, true) + } + + override fun isNotificationRequested(): Boolean = + store.getBoolean(NotificationsPreferences.NotificationsRequested, false) + + override fun setPushToken(token: String) { + store.set(NotificationsPreferences.PushToken, token) + } + + override fun setIsPushTokenSynced(synced: Boolean) { + store.set(NotificationsPreferences.IsPushTokenSynced, synced) + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NetworkNotificationDataSource.kt b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NetworkNotificationDataSource.kt new file mode 100644 index 0000000..6a41554 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NetworkNotificationDataSource.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.notifications.store.datasource.notifications + +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.notifications.data.data_source.network.api.notification.ApiNotifications +import ua.gov.diia.notifications.models.notification.pull.PullNotificationsResponse +import ua.gov.diia.notifications.models.notification.pull.PullNotificationsToModify +import ua.gov.diia.notifications.models.notification.pull.UpdatePullNotificationResponse +import javax.inject.Inject + +class NetworkNotificationDataSource @Inject constructor( + @AuthorizedClient private val apiNotifications: ApiNotifications +) { + + suspend fun markNotificationsAsRead(notificationsToModify: PullNotificationsToModify): UpdatePullNotificationResponse { + return apiNotifications.markNotificationsAsRead(notificationsToModify) + } + + suspend fun deleteNotifications(notificationsToModify: PullNotificationsToModify): UpdatePullNotificationResponse { + return apiNotifications.deleteNotifications(notificationsToModify) + } + + suspend fun getNotifications(skip: Int): PullNotificationsResponse { + return apiNotifications.getNotifications(skip) + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NotificationDataRepository.kt b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NotificationDataRepository.kt new file mode 100644 index 0000000..1ceab5c --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NotificationDataRepository.kt @@ -0,0 +1,76 @@ +package ua.gov.diia.notifications.store.datasource.notifications + +import kotlinx.coroutines.flow.Flow +import ua.gov.diia.diia_storage.store.datasource.DataSource +import ua.gov.diia.notifications.models.notification.pull.PullNotification + +interface NotificationDataRepository : DataSource> { + + val unreadCount: Flow + + /** + * Remove notification with specified :notificationId fom local store + */ + suspend fun removeNotification(notificationId: String) + + /** + * Set pull notification with id :notificationId isRead status to tru + */ + suspend fun markNotificationAsRead(notificationId: String) + + /** + * Mark local messages as read + */ + suspend fun updateWithLocal(remoteNotifications: List) + + /** + * Find pull notification with same :notificationId or null + */ + suspend fun getPullNotificationById(notificationId: String): PullNotification? + + /** + * Notify backend about removed and read messages + */ + suspend fun syncWithRemote() + + /** + * Append new messages to start of local list + */ + suspend fun appendItems(items: List, toStart: Boolean) + + /** + * Remove outdated messages from local list + */ + suspend fun removeItems(items: List) + + /** + * Get local pull notifications from :skip position to min(pageSize, totalSize) + */ + suspend fun getPage(skip: Int, pageSize: Int): List + + /** + * Get amount of items stored in local storage + */ + suspend fun getTotalSize(): Int + + /** + * Retrieve position of notification with :notificationId in current local store or -1 if notification not found + */ + suspend fun indexOf(notificationId: String): Int + + /** + * Update unread count of notifications + */ + suspend fun updateUnreadCount(newUnreadCount: Int) + + /** + * Find notification by resource id + */ + suspend fun findNotificationByResourceId(resourceId: String): PullNotification? + + /** + * Load notifications from network. Use [updateTotal] for view pagination + */ + suspend fun loadDataFromNetwork(skip: Int, pageSize: Int, updateTotal: (Int) -> Unit = {}) + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NotificationDataRepositoryImpl.kt b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NotificationDataRepositoryImpl.kt new file mode 100644 index 0000000..145b75c --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/store/datasource/notifications/NotificationDataRepositoryImpl.kt @@ -0,0 +1,369 @@ +package ua.gov.diia.notifications.store.datasource.notifications + +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import ua.gov.diia.core.di.actions.GlobalActionNotificationRead +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.models.notification.pull.PullNotificationSyncAction +import ua.gov.diia.notifications.models.notification.pull.PullNotificationsToModify +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager +import javax.inject.Inject + +class NotificationDataRepositoryImpl @Inject constructor( + private val scope: CoroutineScope, + private val keyValueSource: KeyValueNotificationDataSource, + private val diiaNotificationManager: DiiaNotificationManager, + private val networkSource: NetworkNotificationDataSource, + @GlobalActionNotificationRead private val actionNotificationRead: MutableLiveData>, + private val withCrashlytics: WithCrashlytics +) : NotificationDataRepository { + + private val _isDataLoading = MutableStateFlow(false) + override val isDataLoading: Flow + get() = _isDataLoading + + private val _data = MutableStateFlow>>( + DataSourceDataResult.failed() + ) + override val data: Flow>> + get() = _data + + + private val _unreadCount = MutableStateFlow(0) + override val unreadCount: Flow + get() = _unreadCount + + override fun invalidate() { + scope.launch { + _isDataLoading.value = true + + val data = keyValueSource.fetchData() + _data.emit(DataSourceDataResult.successful(data)) + _unreadCount.emit(keyValueSource.fetchUnreadCount()) + + syncWithRemote() + + _isDataLoading.value = false + } + } + + override suspend fun getPullNotificationById(notificationId: String): PullNotification? { + return _data.value.data?.find { + it.notificationId == notificationId + } + } + + override suspend fun removeNotification(notificationId: String) { + try { + val notification = PullNotificationsToModify(listOf(notificationId)) + updateMessageSyncStatus(notification, PullNotificationSyncAction.REMOVE) + + networkSource.deleteNotifications(notification) + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + + override suspend fun markNotificationAsRead(notificationId: String) { + try { + if (_data.value.data == null) { + PullNotificationsToModify(listOf(notificationId)).let { + networkSource.markNotificationsAsRead(it) + } + return + } + updateMessageSyncStatus( + PullNotificationsToModify(listOf(notificationId)), + PullNotificationSyncAction.READ + ) + actionNotificationRead.postValue(UiDataEvent(notificationId)) + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + + override suspend fun updateWithLocal(remoteNotifications: List) { + val cachedData = (_data.value.data ?: return).toMutableList() + remoteNotifications.forEach { messageToModify -> + val localMessage = + cachedData.find { it.notificationId == messageToModify.notificationId } + if (localMessage == null) { + cachedData += messageToModify + } else { + if (localMessage.isRead == false && localMessage.isRead != messageToModify.isRead) { + localMessage.isRead = messageToModify.isRead + } + } + } + keyValueSource.saveDataToStore(cachedData) + _data.emit(DataSourceDataResult.successful(cachedData)) + } + + override suspend fun appendItems(items: List, toStart: Boolean) { + val localData = (this._data.value.data ?: emptyList()) + val newData = if (toStart) { + items + localData + } else { + localData + items + } + keyValueSource.saveDataToStore(newData) + _data.emit(DataSourceDataResult.successful(newData)) + } + + private suspend fun insertJustAfter(position: Int, notifications: List) { + val localData = this._data.value.data?.toMutableList() ?: return + val newData = localData.subList(0, position) + notifications + localData.subList( + position, + localData.size + ) + _data.emit(DataSourceDataResult.successful(newData)) + } + + override suspend fun removeItems(items: List) { + val localData = this._data.value.data ?: return + + val itemsToRemoveId = items.map { it.notificationId } + val itemsToRemove = localData.filter { it.notificationId in itemsToRemoveId } + val newData = localData - itemsToRemove + keyValueSource.saveDataToStore(newData) + _data.emit(DataSourceDataResult.successful(newData)) + } + + private suspend fun updateMessageSyncStatus( + pullNotificationsToModify: PullNotificationsToModify, + action: PullNotificationSyncAction + ) { + val cachedData = _data.value.data ?: return + var localStorageModifications = 0 + + val notFoundLocalNotificationIds = mutableListOf() + + pullNotificationsToModify.messageIds.forEach { messageToModify -> + diiaNotificationManager.clearNotification(messageToModify) + val localNotification = cachedData.find { it.notificationId == messageToModify } + if (localNotification == null) { + notFoundLocalNotificationIds.add(messageToModify) + } else { + if (localNotification.syncAction != action) { + when (action) { + PullNotificationSyncAction.READ -> { + with(localNotification) { + if (isRead == false) { + isRead = true + syncAction = action + localStorageModifications++ + } + } + } + + PullNotificationSyncAction.REMOVE -> { + localNotification.syncAction = action + localStorageModifications++ + } + + else -> { + } + } + } + } + } + if (notFoundLocalNotificationIds.isNotEmpty()) { + try { + PullNotificationsToModify(notFoundLocalNotificationIds).let { + val updateResponse = networkSource.markNotificationsAsRead(it) + _unreadCount.emit(updateResponse.unread) + } + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + if (localStorageModifications > 0) { + syncWithRemote() + } + } + + override suspend fun syncWithRemote() { + var cachedData = _data.value.data ?: return + var unreadCount = _unreadCount.value + try { + val readMessages = cachedData.filter { + it.syncAction == PullNotificationSyncAction.READ + } + + val removedMessages = cachedData.filter { + it.syncAction == PullNotificationSyncAction.REMOVE + } + + unreadCount = + (unreadCount - readMessages.size + readMessages.count { it.isRead == false }) + .coerceAtLeast(0) + + if (readMessages.isNotEmpty()) { + try { + PullNotificationsToModify(readMessages.mapNotNull { it.notificationId }).let { + val updateResponse = networkSource.markNotificationsAsRead(it) + unreadCount = updateResponse.unread + readMessages.forEach { + it.syncAction = PullNotificationSyncAction.NONE + } + } + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + + if (removedMessages.isNotEmpty()) { + PullNotificationsToModify(removedMessages.mapNotNull { it.notificationId }).let { + val updateResponse = networkSource.markNotificationsAsRead(it) + unreadCount = updateResponse.unread + cachedData = cachedData - removedMessages + } + } + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } finally { + updateUnreadCount(unreadCount) + keyValueSource.saveDataToStore(cachedData) + _data.emit(DataSourceDataResult.successful(cachedData)) + } + } + + override suspend fun getPage(skip: Int, pageSize: Int): List { + val data = _data.value.data + return data?.subList(skip, (skip + pageSize).coerceAtMost(data.size)) ?: emptyList() + } + + override suspend fun getTotalSize(): Int { + return _data.value.data?.size ?: 0 + } + + override suspend fun indexOf(notificationId: String): Int { + return _data.value.data?.indexOfFirst { it.notificationId == notificationId } ?: NOT_FOUND + } + + override suspend fun updateUnreadCount(newUnreadCount: Int) { + if (_unreadCount.value != newUnreadCount) { + keyValueSource.updateUnreadCount(newUnreadCount) + _unreadCount.emit(newUnreadCount) + } + } + + override suspend fun findNotificationByResourceId(resourceId: String): PullNotification? { + return _data.value.data?.find { + it.pullNotificationMessage?.action?.resourceId == resourceId + } + } + + override suspend fun loadDataFromNetwork(skip: Int, pageSize: Int, updateTotal: (Int) -> Unit) { + + val notificationsResponse = try { + networkSource.getNotifications(skip) + } catch (e: Exception) { + return + } + + val networkTotal = notificationsResponse.total + updateTotal(networkTotal) + updateUnreadCount(notificationsResponse.unread) + val remoteNotificationsPage = notificationsResponse.notifications + + if (remoteNotificationsPage.isEmpty()) { + if (networkTotal < getTotalSize()) { + removeLocalNotificationsThatNotExistOnServerInRange(remoteNotificationsPage, skip, pageSize) + } + return + } else { + if (getTotalSize() == 0) { + appendItems(remoteNotificationsPage, true) + return + } + mergeRemoteNotificationsWithLocal(remoteNotificationsPage, skip, pageSize) + } + + return + } + + private suspend fun mergeRemoteNotificationsWithLocal( + remoteNotificationsPage: List, + skip: Int, + pageSize: Int + ) { + val headItem = remoteNotificationsPage.first().notificationId + val localPageTopPosition = if (headItem != null) { + indexOf(headItem) + } else { + NOT_FOUND + } + + if (localPageTopPosition == NOT_FOUND) { + processNotificationIfTopRemoteNotificationNotFound(remoteNotificationsPage, skip) + } else { + removeLocalNotificationsThatNotExistOnServerInRange(remoteNotificationsPage, skip, pageSize) + updateWithLocal(remoteNotificationsPage) + } + } + + private suspend fun processNotificationIfTopRemoteNotificationNotFound( + remoteNotificationsPage: List, + skip: Int + ) { + val remoteTopPositionMatchingLocal = + getIndexOfLocalTopNotificationInRemoveList(remoteNotificationsPage) + + if (remoteTopPositionMatchingLocal == NOT_FOUND) { + val toStart = skip < getTotalSize() + appendItems(remoteNotificationsPage, toStart) + } else { + val newItems = + remoteNotificationsPage.subList(0, remoteTopPositionMatchingLocal) + insertJustAfter(skip, newItems) + removeLocalNotificationsThatNotExistOnServerInRange(remoteNotificationsPage, + skip + remoteTopPositionMatchingLocal, + remoteNotificationsPage.size - remoteTopPositionMatchingLocal) + updateWithLocal(remoteNotificationsPage) + } + } + + private suspend fun getIndexOfLocalTopNotificationInRemoveList(remoteNotificationsPage: List): Int { + var remoteTopPositionMatchingLocal = -1 + for (i in remoteNotificationsPage.indices) { + val remoteId = remoteNotificationsPage[i].notificationId ?: continue + if (getPullNotificationById(remoteId) != null) { + remoteTopPositionMatchingLocal = i + break + } + } + return remoteTopPositionMatchingLocal + } + + /** + * Detect items that was removed remotely but present locally + * Remove local items until pages are the same + */ + private suspend fun removeLocalNotificationsThatNotExistOnServerInRange( + remoteChanges: List, + topIndex: Int, + pageSize: Int + ) { + val remoteMessagesIds = remoteChanges.mapNotNull { it.notificationId } + + var diff = getPage(topIndex, pageSize) + .filter { it.notificationId !in remoteMessagesIds } + + while (diff.isNotEmpty()) { + removeItems(diff) + diff = getPage(topIndex, pageSize) + .filter { it.notificationId !in remoteMessagesIds } + } + } + + companion object { + private const val NOT_FOUND = -1 + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/compose/mapper/media/MediaMapper.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/compose/mapper/media/MediaMapper.kt new file mode 100644 index 0000000..fdaa87b --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/compose/mapper/media/MediaMapper.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.notifications.ui.compose.mapper.media + +import ua.gov.diia.ui_base.components.atom.media.ArticlePicAtmData +import ua.gov.diia.ui_base.components.molecule.media.ArticleVideoMlcData + + +fun String?.toComposeArticlePic(): ArticlePicAtmData? { + if (this == null) return null + return ArticlePicAtmData(id = "", url = this) +} + +fun String?.toComposeArticleVideo(): ArticleVideoMlcData? { + if (this == null) return null + return ArticleVideoMlcData(url = this) +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/adapters/SubscriptionAdapter.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/adapters/SubscriptionAdapter.kt new file mode 100644 index 0000000..4f2fae0 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/adapters/SubscriptionAdapter.kt @@ -0,0 +1,46 @@ +package ua.gov.diia.notifications.ui.fragments.home.notifications.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ua.gov.diia.notifications.databinding.ItemSubscriptionBinding +import ua.gov.diia.notifications.models.notification.Subscription + +class SubscriptionAdapter(private val checkedListener: (String, Boolean) -> Unit) : + ListAdapter(DiffCallback) { + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: Subscription, newItem: Subscription): Boolean { + return oldItem.code == newItem.code + } + + override fun areContentsTheSame(oldItem: Subscription, newItem: Subscription): Boolean { + return oldItem == newItem + } + } + + class SubscriptionVH(private var binding: ItemSubscriptionBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(s: Subscription, checkedListener: (String, Boolean) -> Unit) { + with(binding){ + subscription = s + switchState.setOnCheckedChangeListener { buttonView, isChecked -> + checkedListener(s.code, binding.switchState.isChecked) + switchState.isChecked = s.switchState + } + executePendingBindings() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SubscriptionVH { + return SubscriptionVH(ItemSubscriptionBinding.inflate(LayoutInflater.from(parent.context))) + } + + override fun onBindViewHolder(holder: SubscriptionVH, position: Int) { + val subscription = getItem(position) + holder.bind(subscription, checkedListener) + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationComposeVM.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationComposeVM.kt new file mode 100644 index 0000000..0b5eb4a --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationComposeVM.kt @@ -0,0 +1,270 @@ +package ua.gov.diia.notifications.ui.fragments.home.notifications.compose + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.navigation.NavDirections +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.notifications.helper.NotificationHelper +import ua.gov.diia.notifications.models.notification.LoadingState +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsActionKey.OPEN_NOTIFICATION_SETTINGS +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsActionKey.REMOVE_NOTIFICATION +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsActionKey.SELECT_NOTIFICATION +import ua.gov.diia.ui_base.R +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose.PAGE_LOADING_LINEAR_PAGINATION +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose.PAGE_LOADING_TRIDENT +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.toDynamicString +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.message.MessageMoleculeData +import ua.gov.diia.ui_base.components.molecule.message.StubMessageMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.MessageListOrganismData +import ua.gov.diia.ui_base.navigation.BaseNavigation +import javax.inject.Inject + +@HiltViewModel +class NotificationComposeVM @Inject constructor( + private val notificationsDataSource: NotificationDataRepository, + private val dispatcherProvider: DispatcherProvider, + private val composeMapper: NotificationsMapperCompose, + private val retryLastAction: WithRetryLastAction, + private val errorHandling: WithErrorHandlingOnFlow, + private val notificationHelper: NotificationHelper +) : WithErrorHandlingOnFlow by errorHandling, WithRetryLastAction by retryLastAction, + NotificationsMapperCompose by composeMapper, ViewModel() { + + private val _topBarData = mutableStateListOf() + val topBarData: SnapshotStateList = _topBarData + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _navigateTo = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigateTo = _navigateTo.asSharedFlow() + private val _contentLoadedKey = MutableStateFlow("") + private val _contentLoaded = MutableStateFlow(false) + val contentLoaded: Flow> = + _contentLoaded.combine(_contentLoadedKey) { value, key -> + key to value + } + + private val _progressIndicatorKey = MutableStateFlow("") + private val _progressIndicator = MutableStateFlow(false) + val progressIndicator: Flow> = + _progressIndicator.combine(_progressIndicatorKey) { value, key -> + key to value + } + + private var _onMessageNotificationSelected = + MutableSharedFlow>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val onMessageNotificationSelected = _onMessageNotificationSelected.asSharedFlow() + + private var _openResource = MutableSharedFlow>( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val openResource = _openResource.asSharedFlow() + + fun onUIAction(event: UIAction) { + when (event.actionKey) { + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + UIActionKeysCompose.TITLE_GROUP_MLC -> { + event.action?.let { + when (it.type) { + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + OPEN_NOTIFICATION_SETTINGS -> { + _navigation.tryEmit(NotificationsNavigation.NavigateToNotificationsSettings) + } + + else -> {} + } + } + } + + REMOVE_NOTIFICATION -> { + event.data?.let { + removeNotification(it) + } + } + + SELECT_NOTIFICATION -> { + executeActionOnFlow(contentLoadedIndicator = _contentLoaded.also { + _contentLoadedKey.tryEmit(UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_BACK_NAVIGATION) + }) { + val notification = event.data?.let { + notificationsDataSource.getPullNotificationById(it) + } + val notificationItemSelection = + notification?.pullNotificationMessage?.action?.type?.let { + PullNotificationItemSelection( + notificationId = notification.notificationId, + resourceId = notification.pullNotificationMessage.action.resourceId, + resourceType = it, + resourceSubtype = notification.pullNotificationMessage.action.subtype + ) + } + notificationItemSelection?.let { + onNotificationSelected(it) + } + } + } + } + } + + fun configureTopBar() { + val topGroupOrgData = TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = "Повідомлення".toDynamicString(), + mediumIconRight = TitleGroupMlcData.MediumIconRight( + code = CommonDiiaResourceIcon.ELLIPSE_SETTINGS.code, + action = DataActionWrapper( + type = OPEN_NOTIFICATION_SETTINGS, + subtype = null, + resource = null + ) + ), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + action = DataActionWrapper( + type = UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK, + subtype = null, + resource = null + ) + ) + ) + ) + _topBarData.addIfNotNull(topGroupOrgData) + } + + fun configureBody() { + _bodyData.apply { + clear() + add( + MessageListOrganismData( + items = createPager(), + emptyData = StubMessageMlcData( + icon = UiText.StringResource(R.string.error_message_notifications_icon), + title = UiText.StringResource(R.string.error_message_notifications_empty) + ) + ) + ) + } + } + + private fun removeNotification(notificationId: String) { + viewModelScope.launch(dispatcherProvider.ioDispatcher()) { + notificationsDataSource.removeNotification(notificationId) + } + } + + private fun onNotificationSelected(pullNotificationItemSelection: PullNotificationItemSelection) { + viewModelScope.launch { + pullNotificationItemSelection.notificationId?.let { + notificationsDataSource.markNotificationAsRead(it) + } + + if (notificationHelper.isMessageNotification(pullNotificationItemSelection.resourceType)) { + _onMessageNotificationSelected.emit( + UiDataEvent(pullNotificationItemSelection) + ) + } else { + _openResource.emit(UiDataEvent(pullNotificationItemSelection)) + } + + } + } + + private fun createPager(): Flow> { + val pagingConfig = PagingConfig( + pageSize = 5, + prefetchDistance = 4, + enablePlaceholders = false, + initialLoadSize = 5 + ) + return Pager( + config = pagingConfig, + initialKey = 0, + pagingSourceFactory = { + NotificationPagingSourceCompose( + notificationDataSource = notificationsDataSource, + onContentLoadedStateChanged = { + when (it) { + LoadingState.FIRST_PAGE_LOADING -> { + _contentLoaded.value = false + _contentLoadedKey.value = PAGE_LOADING_TRIDENT + } + LoadingState.ADDITIONAL_PAGE_LOADING -> { + _contentLoaded.value = false + _contentLoadedKey.value = PAGE_LOADING_LINEAR_PAGINATION + } + + LoadingState.NOT_LOADING -> { + _contentLoaded.value = true + } + + } + }, + composeMapper = this + ) + } + ).flow.cachedIn(viewModelScope) + } + + + fun navigateToDirection(item: PullNotificationItemSelection) { + viewModelScope.launch { + val navigateToDoc = notificationHelper.navigateToDocument(item) + navigateToDoc?.let { _navigateTo.tryEmit(it) } + } + } + +} + +sealed class NotificationsNavigation : NavigationPath { + object NavigateToNotificationsSettings : NotificationsNavigation() +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationFCompose.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationFCompose.kt new file mode 100644 index 0000000..2840837 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationFCompose.kt @@ -0,0 +1,120 @@ +package ua.gov.diia.notifications.ui.fragments.home.notifications.compose + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.extensions.fragment.findNavControllerById +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.notifications.NavNotificationsDirections +import ua.gov.diia.notifications.R +import ua.gov.diia.notifications.models.notification.pull.MessageIdentification +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.ServiceScreen +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import javax.inject.Inject + +@AndroidEntryPoint +class NotificationFCompose : Fragment() { + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private var composeView: ComposeView? = null + val vm: NotificationComposeVM by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + vm.configureTopBar() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + vm.configureBody() + composeView = ComposeView(requireContext()) + return composeView + + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + composeView?.setContent { + val topBar = vm.topBarData + val body = vm.bodyData + val contentLoaded = vm.contentLoaded.collectAsState( + initial = Pair( + UIActionKeysCompose.PAGE_LOADING_LINEAR_PAGINATION, true + ) + ) + vm.navigateTo.collectAsEffect { navigation -> + navigate(navigation, findNavControllerById(R.id.nav_host)) + } + vm.navigation.collectAsEffect { navigation -> + when (navigation) { + is BaseNavigation.Back -> { + findNavController().popBackStack() + } + + is NotificationsNavigation.NavigateToNotificationsSettings -> { + navigateToSettings() + } + } + } + vm.openResource.collectAsEffect { + navigateToDirection(it.peekContent()) + } + vm.onMessageNotificationSelected.collectAsEffect { + val message = it.peekContent() + navigate( + NavNotificationsDirections.actionToNotificationFull( + messageId = MessageIdentification( + needAuth = true, + resourceId = message.resourceId ?: "", + notificationId = message.notificationId ?: "" + ) + ), + findNavControllerById(R.id.nav_host) + ) + } + + ServiceScreen( + toolbar = topBar, + body = body, + contentLoaded = contentLoaded.value, + onEvent = { + vm.onUIAction(it) + }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + } + + private fun navigateToSettings() { + navigate( + NavNotificationsDirections.actionGlobalToNotificationSettingsF(), + findNavControllerById(R.id.nav_host) + ) + } + + private fun navigateToDirection(item: PullNotificationItemSelection) { + vm.navigateToDirection(item) + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationPagingSourseCompose.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationPagingSourseCompose.kt new file mode 100644 index 0000000..5a81260 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationPagingSourseCompose.kt @@ -0,0 +1,86 @@ +package ua.gov.diia.notifications.ui.fragments.home.notifications.compose + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlinx.coroutines.runBlocking +import ua.gov.diia.notifications.models.notification.LoadingState +import ua.gov.diia.notifications.models.notification.pull.PullNotificationSyncAction +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import ua.gov.diia.ui_base.components.molecule.message.MessageMoleculeData + +class NotificationPagingSourceCompose( + private val notificationDataSource: NotificationDataRepository, + private val composeMapper: NotificationsMapperCompose, + private val onContentLoadedStateChanged: (LoadingState) -> Unit, + ) : PagingSource(), NotificationsMapperCompose by composeMapper { + + private var networkTotal = 0 + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val pageSize = params.loadSize + val key = params.key + val skip = key ?: 0 + + onContentLoadedStateChanged( + if (key == null || key == 0) { + LoadingState.FIRST_PAGE_LOADING + } else { + LoadingState.ADDITIONAL_PAGE_LOADING + } + ) + + notificationDataSource.loadDataFromNetwork(skip, pageSize, updateTotal = { + networkTotal = it + }) + + val total = notificationDataSource.getTotalSize() + + val response = notificationDataSource.getPage(skip, pageSize) + val nextPage = (skip + pageSize).coerceAtMost(notificationDataSource.getTotalSize()) + + val prevKey = if (key == null || key == 0) { + null + } else { + (key - pageSize).coerceAtLeast(0) + } + val nextKey = if (nextPage != networkTotal && skip != nextPage) nextPage else null + + val convertedData = mutableListOf().apply { + response.forEach { element -> + with(composeMapper) { + add(element.toComposeMessage()) + } + } + } + + val filteredConvertedData = convertedData.filter { + it.syncAction != PullNotificationSyncAction.REMOVE + } + onContentLoadedStateChanged(LoadingState.NOT_LOADING) + LoadResult.Page( + data = filteredConvertedData, + prevKey = prevKey, + nextKey = nextKey, + itemsBefore = skip, + itemsAfter = total - skip + pageSize + ) + } catch (e: Exception) { + LoadResult.Error(e) + } finally { + onContentLoadedStateChanged(LoadingState.NOT_LOADING) + } + + } + + override fun getRefreshKey(state: PagingState): Int? { + val atMost = runBlocking { + notificationDataSource.getTotalSize() + } + val anchor = state.anchorPosition?.coerceAtMost(atMost) ?: return null + val closestPage = state.closestPageToPosition(anchor) + return closestPage?.prevKey + } + +} + diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationsActionKey.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationsActionKey.kt new file mode 100644 index 0000000..bbdcf73 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationsActionKey.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.notifications.ui.fragments.home.notifications.compose + +object NotificationsActionKey { + const val REMOVE_NOTIFICATION = "removeNotification" + const val OPEN_NOTIFICATION_SETTINGS = "openNotificationSettings" + const val SELECT_NOTIFICATION = "selectNotification" + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationsMapperCompose.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationsMapperCompose.kt new file mode 100644 index 0000000..bf89434 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationsMapperCompose.kt @@ -0,0 +1,39 @@ +package ua.gov.diia.notifications.ui.fragments.home.notifications.compose + +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsActionKey.SELECT_NOTIFICATION +import ua.gov.diia.ui_base.components.molecule.message.MessageMoleculeData +import ua.gov.diia.ui_base.components.molecule.message.StubMessageMlcData +import javax.inject.Inject + +interface NotificationsMapperCompose { + + fun PullNotification.toComposeMessage(): MessageMoleculeData + + fun StubMessageMlcData.toComposeEmptyStateErrorMoleculeData(): StubMessageMlcData + +} + +class NotificationsMapperComposeImpl @Inject constructor() : + NotificationsMapperCompose { + + override fun PullNotification.toComposeMessage(): MessageMoleculeData { + return MessageMoleculeData( + actionKey = SELECT_NOTIFICATION, + title = this.pullNotificationMessage?.title, + shortText = this.pullNotificationMessage?.shortText, + creationDate = this.creationDate, + isRead = this.isRead, + notificationId = this.notificationId, + id = this.notificationId!!, + syncAction = this.syncAction + ) + } + + override fun StubMessageMlcData.toComposeEmptyStateErrorMoleculeData(): StubMessageMlcData { + return StubMessageMlcData( + icon = this.icon, + title = this.title + ) + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/di/NotificationsMapperModule.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/di/NotificationsMapperModule.kt new file mode 100644 index 0000000..eb2864a --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notifications/di/NotificationsMapperModule.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.notifications.ui.fragments.home.notifications.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsMapperCompose +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsMapperComposeImpl + +@Module +@InstallIn(ViewModelComponent::class) +interface NotificationsMapperModule { + + @Binds + fun bindNotificationsMapperCompose( + impl: NotificationsMapperComposeImpl + ): NotificationsMapperCompose +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsF.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsF.kt new file mode 100644 index 0000000..778b187 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsF.kt @@ -0,0 +1,75 @@ +package ua.gov.diia.notifications.ui.fragments.home.notificationsettings + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.notifications.databinding.FragmentNotificationSettingsBinding +import ua.gov.diia.notifications.ui.fragments.home.notifications.adapters.SubscriptionAdapter + +@AndroidEntryPoint +class NotificationSettingsF : Fragment() { + + + private var binding: FragmentNotificationSettingsBinding? = null + + val vm: NotificationSettingsVM by viewModels() + val adapter = SubscriptionAdapter { code, isChecked -> + if (isChecked) + vm.subscribe(code) + else + vm.unsubscribe(code) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentNotificationSettingsBinding.inflate(inflater, container, false) + + binding?.apply { + viewModel = vm + lifecycleOwner = viewLifecycleOwner + rvSubscriptions.adapter = adapter + ivBack.setOnClickListener { findNavController().popBackStack() } + } + + vm.subscriptions.observe(viewLifecycleOwner) { + adapter.submitList(it.subscriptions) + } + + vm.getSubs.observe(viewLifecycleOwner) { success -> + if (success == true) { + vm.getSubs() + } + } + + vm.error.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + DIALOG_ACTION_GET_SUBS -> vm.getSubs() + } + } + + return binding?.root + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private companion object { + const val DIALOG_ACTION_GET_SUBS = "getSubscriptions" + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsFVM.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsFVM.kt new file mode 100644 index 0000000..1260af7 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsFVM.kt @@ -0,0 +1,98 @@ +package ua.gov.diia.notifications.ui.fragments.home.notificationsettings + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.noInternetException +import ua.gov.diia.notifications.data.data_source.network.api.notification.ApiNotifications +import ua.gov.diia.notifications.models.notification.Subscriptions +import javax.inject.Inject + +@HiltViewModel +class NotificationSettingsVM @Inject constructor( + @AuthorizedClient private val apiNotifications: ApiNotifications, + private val clientAlertDialogsFactory: ClientAlertDialogsFactory +) : ViewModel() { + + private val _isDataLoading: MutableLiveData = MutableLiveData() + val isDataLoading = _isDataLoading.asLiveData() + + private val _subscriptions: MutableLiveData = MutableLiveData() + val subscriptions = _subscriptions.asLiveData() + + private val _getSubs: MutableLiveData = MutableLiveData() + val getSubs = _getSubs.asLiveData() + + private var _error = MutableLiveData>() + val error = _error.asLiveData() + + init { + getSubs() + } + + fun getSubs() { + viewModelScope.launch { + _isDataLoading.value = true + try { + val subscriptions = apiNotifications.getSubscriptions() + _subscriptions.value = subscriptions + } catch (e: Exception) { + consumeException(e) + } finally { + _isDataLoading.value = false + } + } + } + + fun subscribe(code: String) { + viewModelScope.launch { + _isDataLoading.value = true + try { + val response = apiNotifications.subscribe(code) + if (response.template != null) { + _error.value = UiDataEvent(response.template) + } else { + _getSubs.value = response.success + } + } catch (e: Exception) { + consumeException(e) + } finally { + _isDataLoading.value = false + } + } + } + + fun unsubscribe(code: String) { + viewModelScope.launch { + _isDataLoading.value = true + try { + val response = apiNotifications.unsubscribe(code) + if (response.template != null) { + _error.value = UiDataEvent(response.template) + } else { + _getSubs.value = response.success + } + } catch (e: Exception) { + consumeException(e) + } finally { + _isDataLoading.value = false + } + } + } + + private fun consumeException(e: Exception) { + if (e.noInternetException()) { + _error.postValue(UiDataEvent(clientAlertDialogsFactory.alertNoInternet())) + } else { + _error.postValue(UiDataEvent(clientAlertDialogsFactory.unknownErrorAlert(false, e = e))) + } + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/NotificationFullAdapter.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/NotificationFullAdapter.kt new file mode 100644 index 0000000..5e0548a --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/NotificationFullAdapter.kt @@ -0,0 +1,232 @@ +package ua.gov.diia.notifications.ui.fragments.notifications + +import android.graphics.Outline +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewOutlineProvider +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.RoundedCorners +import com.bumptech.glide.request.RequestOptions +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.SimpleExoPlayer +import com.google.android.exoplayer2.util.MimeTypes +import ua.gov.diia.core.models.notification.pull.message.MessageActions +import ua.gov.diia.core.models.notification.pull.message.MessageTypes +import ua.gov.diia.core.models.notification.pull.message.NotificationMessagesBody +import ua.gov.diia.notifications.R +import ua.gov.diia.notifications.databinding.ItemNotificationDividerBinding +import ua.gov.diia.notifications.databinding.ItemNotificationDownloadArrowedLinkBinding +import ua.gov.diia.notifications.databinding.ItemNotificationImageBinding +import ua.gov.diia.notifications.databinding.ItemNotificationInternalArrowedLinkBinding +import ua.gov.diia.notifications.databinding.ItemNotificationTextBinding +import ua.gov.diia.notifications.databinding.ItemNotificationVideoBinding + +class NotificationFullAdapter(private val linkSelected: (MessageActions, String) -> Unit) : + ListAdapter(DiffCallback) { + + var itemsVhs: HashSet = hashSetOf() + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return when(viewType) { + R.layout.item_notification_text -> { + NotificationTextVH(ItemNotificationTextBinding.inflate( + LayoutInflater.from(parent.context), parent, false), linkSelected ) + } + + R.layout.item_notification_image -> { + NotificationImageVH( + ItemNotificationImageBinding.inflate( + LayoutInflater.from(parent.context), parent, false)) + } + + R.layout.item_notification_video -> { + NotificationVideoVH( + ItemNotificationVideoBinding.inflate( + LayoutInflater.from(parent.context), parent, false), + ) + } + + R.layout.item_notification_divider -> { + NotificationSeparatorVH(ItemNotificationDividerBinding.inflate( + LayoutInflater.from(parent.context), parent, false )) + } + + R.layout.item_notification_download_arrowed_link -> { + NotificationDownloadArrowedLinkVH( + ItemNotificationDownloadArrowedLinkBinding.inflate( + LayoutInflater.from(parent.context), parent, false ), linkSelected) + } + + R.layout.item_notification_internal_arrowed_link -> { + NotificationInternalArrowedLinkVH( + ItemNotificationInternalArrowedLinkBinding.inflate( + LayoutInflater.from(parent.context), parent, false ), linkSelected) + } + + else -> { + NotificationTextVH( ItemNotificationTextBinding.inflate( + LayoutInflater.from(parent.context), parent, false ), linkSelected ) + } + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + override fun getItemViewType(position: Int): Int { + return when(getItem(position).type) { + MessageTypes.text -> R.layout.item_notification_text + MessageTypes.image -> R.layout.item_notification_image + MessageTypes.video -> R.layout.item_notification_video + MessageTypes.separator -> R.layout.item_notification_divider + MessageTypes.downloadArrowedLink -> R.layout.item_notification_download_arrowed_link + + MessageTypes.externalArrowedLink, + MessageTypes.internalArrowedLink -> R.layout.item_notification_internal_arrowed_link + + else -> R.layout.item_notification_text + } + } + + abstract inner class ViewHolder(val viewBinding: ViewBinding) : RecyclerView.ViewHolder(viewBinding.root) { + /** Initial click by item */ + abstract fun bind(notificationMessageBody: NotificationMessagesBody) + } + + inner class NotificationTextVH( + private val binding: ItemNotificationTextBinding, + private val linkSelected: (MessageActions, String) -> Unit + ) : ViewHolder(binding) { + + val onLinkClicked: (String) -> Unit = { link -> + val item = getItem(bindingAdapterPosition) + linkSelected.invoke(item.data?.action ?: MessageActions.default, link) + } + + override fun bind(notificationMessageBody: NotificationMessagesBody) { + binding.message = notificationMessageBody + binding.adapter = this + } + } + + inner class NotificationSeparatorVH(binding: ItemNotificationDividerBinding) : ViewHolder(binding) { + override fun bind(notificationMessageBody: NotificationMessagesBody) { + /* Logic implemented with dataBinding */ + } + } + + inner class NotificationDownloadArrowedLinkVH( + private val binding: ItemNotificationDownloadArrowedLinkBinding, + private val linkSelected: (MessageActions, String) -> Unit ) : ViewHolder(binding) { + + init { + viewBinding.root.setOnClickListener { + val item = getItem(bindingAdapterPosition) + linkSelected.invoke(item.data?.action ?: MessageActions.default, item.data?.link ?: "") + } + } + + override fun bind(notificationMessageBody: NotificationMessagesBody) { + binding.message = notificationMessageBody + } + } + + inner class NotificationInternalArrowedLinkVH( + private val binding: ItemNotificationInternalArrowedLinkBinding, + private val linkSelected: (MessageActions, String) -> Unit ) : ViewHolder(binding) { + + init { + viewBinding.root.setOnClickListener { + val item = getItem(bindingAdapterPosition) + linkSelected.invoke(item.data?.action ?: MessageActions.default, item.data?.link ?: "") + } + } + + override fun bind(notificationMessageBody: NotificationMessagesBody) { + binding.message = notificationMessageBody + } + } + + inner class NotificationImageVH( private val binding: ItemNotificationImageBinding ) : ViewHolder(binding) { + override fun bind(notificationMessageBody: NotificationMessagesBody) { + val image = notificationMessageBody.data?.link ?: "" + val requestOption = RequestOptions.bitmapTransform(RoundedCorners(32)) + with(binding) { + Glide.with(binding.root.context) + .load(image) + .apply(requestOption) + .into(ivMessagesImage) + } + } + } + + inner class NotificationVideoVH(binding: ItemNotificationVideoBinding) : ViewHolder(binding) { + /** PLAYER */ + var exoPlayer: SimpleExoPlayer? = null + + init { + exoPlayer = SimpleExoPlayer.Builder(binding.root.context).build() + + /* Makes rounded corners */ + with(binding) { + tvPlayer.player = exoPlayer + tvPlayer.outlineProvider = object:ViewOutlineProvider(){ + override fun getOutline(view: View?, outline: Outline?) { + outline?.setRoundRect( + 0, 0,view?.width ?: 0,view?.height ?: 0,32F) + } + } + tvPlayer.clipToOutline = true + } + + binding.tvPlayer.player = exoPlayer + itemsVhs.add(this) + } + + override fun bind(notificationMessageBody: NotificationMessagesBody) { + val url = notificationMessageBody.data?.link ?: "" + val mediaItem = MediaItem.Builder() + .setUri(url) + .setMimeType(MimeTypes.VIDEO_MP4) + .build() + exoPlayer?.apply { + setMediaItem(mediaItem) + prepare() + } + } + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: NotificationMessagesBody, newItem: NotificationMessagesBody): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: NotificationMessagesBody, newItem: NotificationMessagesBody): Boolean { + return if (newItem.type == MessageTypes.downloadArrowedLink) { + false + } else { + oldItem == newItem + } + } + } + + fun pauseVideoPlayer(pause: Boolean) { + itemsVhs.forEach { vh -> + if (pause) { vh.exoPlayer?.pause() } + else { vh.exoPlayer?.play() } + } + } + + fun releaseVideoPlayer() { + itemsVhs.forEach { vh -> + vh.exoPlayer?.stop() + vh.exoPlayer?.release() + } + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/NotificationVideoPlayerView.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/NotificationVideoPlayerView.kt new file mode 100644 index 0000000..5929563 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/NotificationVideoPlayerView.kt @@ -0,0 +1,259 @@ +package ua.gov.diia.notifications.ui.fragments.notifications + +import android.animation.AnimatorSet +import android.animation.ObjectAnimator +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.ProgressBar +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ui.PlayerView +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import ua.gov.diia.core.util.extensions.addFlagKeepScreen +import ua.gov.diia.core.util.extensions.clearFlagKeepScreen +import ua.gov.diia.core.util.extensions.lifecycle.lifecycleScope +import ua.gov.diia.notifications.R + +class NotificationVideoPlayerView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), + Player.Listener, + View.OnClickListener { + + private enum class PlaybackViewState { + BUFFERING, PAUSE, PLAY + } + + private companion object { + const val ANIM_DURATION_CONTROL_PANEL_VISIBILITY = 300L + const val DELAY_HIDE_CONTROL_PANEL = 3000L + } + + + private val playerView: PlayerView + private val videoDimmer: View + private val playbackButton: FrameLayout + private val viewBuffering: ProgressBar + private val viewPlay: ImageView + private val viewPause: ImageView + + private var currentPlaybackView: View + + private var hideControlPanelJob: Job? = null + + private var isPlaybackViewVisible = true + set(visible) { + field = visible + setupPlayerActionListeners(visible) + } + + var player: Player? = null + set(newPlayer) { + if (player == newPlayer) return + + this.player?.apply { + pause() + removeListener(this@NotificationVideoPlayerView) + } + + field = newPlayer + playerView.player = newPlayer + newPlayer?.addListener(this) + } + + init { + inflate(context, R.layout.view_message_video_player, this) + + playerView = findViewById(R.id.player_view_notification) + videoDimmer = findViewById(R.id.video_dimer) + playbackButton = findViewById(R.id.exo_custom_action) + viewBuffering = findViewById(R.id.exo_custom_buffering) + viewPlay = findViewById(R.id.exo_custom_play) + viewPause = findViewById(R.id.exo_custom_pause) + + playerView.resizeMode + currentPlaybackView = player.playbackActionView + + context.theme.obtainStyledAttributes( + attrs, + R.styleable.DiiaTvPlayerView, + defStyleAttr, + 0 + ).apply { + try { + playerView.resizeMode = getInteger(R.styleable.DiiaTvPlayerView_playerResizeMode, 1) + } finally { + recycle() + } + } + + setOnClickListener(this) + setupPlayerActionListeners(enable = true) + addFlagKeepScreen(context) + } + + fun pause() { + clearFlagKeepScreen(context) + player?.pause() + updatePlaybackControlVisibility(visible = true) + } + + private fun setupPlayerActionListeners(enable: Boolean) { + if (enable) { + playbackButton.setOnClickListener(this) + } else { + playbackButton.setOnClickListener(null) + } + } + + private fun updatePlaybackControlVisibility(visible: Boolean) { + isPlaybackViewVisible = visible + + val viewVisibilityAlpha = if (visible) 1f else 0f + updateControlPanelVisibility(viewVisibilityAlpha) + + if (visible) scheduleHideControlPanelEvent() + } + + private fun updateControlPanelVisibility(alpha: Float) { + AnimatorSet().apply { + playTogether( + ObjectAnimator.ofFloat(playbackButton, "alpha", alpha), + ObjectAnimator.ofFloat(videoDimmer, "alpha", alpha) + ) + duration = ANIM_DURATION_CONTROL_PANEL_VISIBILITY + start() + } + } + + private fun scheduleHideControlPanelEvent() { + var job = hideControlPanelJob + // cancel the previous job because we need to reschedule a new one with the + //updated timing + if (!job.isFinished) job?.cancel() + + job = lifecycleScope?.launch { + delay(DELAY_HIDE_CONTROL_PANEL) + if (player.allowHideControlPanel) { + updateControlPanelVisibility(0f) + isPlaybackViewVisible = false + } + } + hideControlPanelJob = job + } + + private fun updatePlaybackButton() { + val newView = player.playbackActionView + if (currentPlaybackView != newView) { + currentPlaybackView.visibility = View.GONE + currentPlaybackView = newView + currentPlaybackView.visibility = View.VISIBLE + scheduleHideControlPanelEvent() + } + } + + private fun executePlaybackAction() { + when (player.playbackViewState) { + //Starts paling content when the player is ready + PlaybackViewState.PLAY -> { + player?.playWhenReady = true + addFlagKeepScreen(context) + } + //Pauses the video stream + PlaybackViewState.PAUSE -> { + player?.pause() + clearFlagKeepScreen(context) + } + else -> { + } + } + } + + + ///////////////////////////////////////////////////////////////////////////////// + ///////////////////////////// View listeners //////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + + override fun onClick(view: View) { + when (view) { + //Updates the video control panel when the VideoView has been clicked + this@NotificationVideoPlayerView -> updatePlaybackControlVisibility(!isPlaybackViewVisible) + //Execute the playbackAction + playbackButton -> executePlaybackAction() + } + } + + override fun onEvents(player: Player, events: Player.Events) { + events.onPlaybackButtonChanged(::updatePlaybackButton) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + //shows control panel if video has been ended and control panel is not visible + if (playbackState == Player.STATE_ENDED && !isPlaybackViewVisible) { + updatePlaybackControlVisibility(visible = true) + } + } + + ///////////////////////////////////////////////////////////////////////////////// + ///////////////////////////////////// Util ////////////////////////////////////// + //////////////////////////////////////////////////////////////////////////////// + + private val Job?.isFinished: Boolean + get() = (this?.isCancelled ?: true) || (this?.isCompleted ?: true) + + private val Player?.isActive: Boolean + get() = this?.isPlaying == true || this?.isLoading == true + + + private val Player?.allowHideControlPanel: Boolean + get() { + val player = this ?: return false + val playbackState = player.playbackState + return isVisible && (playbackState != Player.STATE_ENDED && + (player.isPlaying || + playbackState == Player.STATE_BUFFERING || + playbackState == Player.STATE_IDLE + ) + ) + } + + private val Player?.playbackActionView: View + get() = if (this != null) { + when (playbackState) { + Player.STATE_BUFFERING -> viewBuffering + else -> if (isPlaying) viewPause else viewPlay + } + } else { + viewBuffering + } + + private val Player?.playbackViewState: PlaybackViewState + get() = if (this != null) { + when (playbackState) { + Player.STATE_BUFFERING -> PlaybackViewState.BUFFERING + else -> if (isPlaying) PlaybackViewState.PAUSE else PlaybackViewState.PLAY + } + } else { + PlaybackViewState.BUFFERING + } + + + private inline fun Player.Events.onPlaybackButtonChanged(todo: () -> Unit) { + if (containsAny( + Player.EVENT_PLAYBACK_STATE_CHANGED, + Player.EVENT_PLAY_WHEN_READY_CHANGED + ) + ) { + todo.invoke() + } + } +} + diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullComposeVM.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullComposeVM.kt new file mode 100644 index 0000000..6f86c4a --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullComposeVM.kt @@ -0,0 +1,196 @@ +package ua.gov.diia.notifications.ui.fragments.notifications.compose + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import ua.gov.diia.core.data.data_source.network.api.notification.ApiNotificationsPublic +import ua.gov.diia.core.di.actions.GlobalActionLogout +import ua.gov.diia.core.di.actions.GlobalActionNetworkState +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.notification.pull.message.MessageActions +import ua.gov.diia.core.models.notification.pull.message.NotificationFull +import ua.gov.diia.core.network.connectivity.ConnectivityObserver +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithDeeplinkHandling +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.notifications.data.data_source.network.api.notification.ApiNotifications +import ua.gov.diia.notifications.models.notification.pull.MessageIdentification +import ua.gov.diia.notifications.ui.compose.mapper.media.toComposeArticlePic +import ua.gov.diia.notifications.ui.compose.mapper.media.toComposeArticleVideo +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.addAllIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.addIfNotNull +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.util.toUiModel +import javax.inject.Inject + +@HiltViewModel +class NotificationFullComposeVM @Inject constructor( + @AuthorizedClient private val apiNotifications: ApiNotifications, + @AuthorizedClient private val apiNotificationsPublic: ApiNotificationsPublic, + @GlobalActionNetworkState private val connectivityObserver: ConnectivityObserver, + @GlobalActionLogout private val actionLogout: MutableLiveData, + private val errorHandling: WithErrorHandlingOnFlow, + private val retryLastAction: WithRetryLastAction, + private val deepLinkDelegate: WithDeeplinkHandling, +) : ViewModel(), WithRetryLastAction by retryLastAction, + WithErrorHandlingOnFlow by errorHandling, + WithDeeplinkHandling by deepLinkDelegate{ + + private val _topBarData = mutableStateListOf() + val topBarData: SnapshotStateList = _topBarData + + private val _bodyData = mutableStateListOf() + val bodyData: SnapshotStateList = _bodyData + + private val _bottomData = mutableStateListOf() + val bottomData: SnapshotStateList = _bottomData + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _openLink = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val openLink = _openLink.asSharedFlow() + + private val _openInternalLink = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val openInternalLink = _openInternalLink.asSharedFlow() + + private val _contentLoadedKey = + MutableStateFlow(UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_BACK_NAVIGATION) + private val _contentLoaded = MutableStateFlow(false) + val contentLoaded: Flow> = + _contentLoaded.combine(_contentLoadedKey) { value, key -> + key to value + } + + private val _progressIndicatorKey = MutableStateFlow("") + private val _progressIndicator = MutableStateFlow(false) + val progressIndicator: Flow> = + _progressIndicator.combine(_progressIndicatorKey) { value, key -> + key to value + } + + val isNetworkEnabled = connectivityObserver.observe() + + fun loadNotification(message: MessageIdentification) { + executeActionOnFlow( + contentLoadedIndicator = _contentLoaded, + ) { + _contentLoadedKey.value = UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_BACK_NAVIGATION + + val notification = if (message.needAuth) { + if (message.notificationId.isEmpty()) { + apiNotifications.getNotificationByMessageId(message.resourceId) + } else { + apiNotifications.getPullNotification(message.notificationId) + } + } else { + if (!message.resourceId.isNullOrEmpty()) { + apiNotificationsPublic.getMessage(message.resourceId) + } else { + NotificationFull(null, null, null) + } + } + notification.topGroup?.forEach { + _topBarData.addIfNotNull( + it.topGroupOrg?.toUiModel() + ) + } + + notification.body?.forEach { + _bodyData.addAllIfNotNull( + it.textLabelContainerMlc?.toUiModel(), + it.articlePicAtm?.image.toComposeArticlePic(), + it.articleVideoMlc?.source.toComposeArticleVideo(), + it.listItemGroupOrg?.toUiModel(), + ) + } + + notification.bottomGroup?.forEach { + _bottomData.addAllIfNotNull( + it.listItemGroupOrg?.toUiModel() + ) + } + } + } + + fun onUIAction(event: UIAction) { + executeActionOnFlow( + progressIndicator = _progressIndicator + ) { + when (event.actionKey) { + UIActionKeysCompose.TITLE_GROUP_MLC -> { + event.action?.type.let { + if (it == ActionsConst.ACTION_NAVIGATE_BACK) { + _navigation.tryEmit(BaseNavigation.Back) + } + } + } + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + + UIActionKeysCompose.LIST_ITEM_GROUP_ORG -> { + event.action?.let { + when (it.type) { + MessageActions.externalLink.name, + MessageActions.default.name, + MessageActions.downloadLink.name -> { + event.action?.resource?.let { + executeActionOnFlow( + progressIndicator = _progressIndicator + ) { + _progressIndicatorKey.tryEmit(event.data ?: "") + _openLink.tryEmit(event.action?.resource) + } + + } + } + + MessageActions.internalLink.name -> { + event.action?.resource?.let { + _openInternalLink.tryEmit(it) + } + } + + MessageActions.logout.name -> { + logout() + } + + else -> {} + } + } + } + + else -> {} + } + } + } + + private fun logout() { + actionLogout.postValue(UiEvent()) + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullFCompose.kt b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullFCompose.kt new file mode 100644 index 0000000..c7c7338 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullFCompose.kt @@ -0,0 +1,132 @@ +package ua.gov.diia.notifications.ui.fragments.notifications.compose + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.fragment.openLink +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.core.util.extensions.fragment.setNavigationResult +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.ServiceScreen +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import javax.inject.Inject + +@AndroidEntryPoint +class NotificationFullFCompose : Fragment() { + + @Inject + lateinit var withCrashlytics: WithCrashlytics + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private var composeView: ComposeView? = null + private val args: NotificationFullFComposeArgs by navArgs() + val viewModel: NotificationFullComposeVM by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.loadNotification(args.messageId) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + composeView?.setContent { + val topBar = viewModel.topBarData + val body = viewModel.bodyData + val bottom = viewModel.bottomData + val progressIndicator = + viewModel.progressIndicator.collectAsState( + initial = Pair( + "", + true + ) + ) + val contentLoaded = viewModel.contentLoaded.collectAsState( + initial = Pair( + UIActionKeysCompose.PAGE_LOADING_CIRCULAR, true + ) + ) + val connectivityState = + viewModel.isNetworkEnabled.collectAsState(initial = false) + viewModel.navigation.collectAsEffect { navigation -> + when (navigation) { + is BaseNavigation.Back -> { + findNavController().popBackStack() + } + + } + } + viewModel.showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + + viewModel.openLink.collectAsEffect { link -> + link?.let { + openLink(it, withCrashlytics) + } + } + viewModel.openInternalLink.collectAsEffect { link -> + link?.let { + navByDeepLink(it) + } + } + ServiceScreen( + contentLoaded = contentLoaded.value, + progressIndicator = progressIndicator.value, + connectivityState = connectivityState.value, + toolbar = topBar, + body = body, + bottom = bottom, + onEvent = { + viewModel.onUIAction(it) + }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.ERROR_DIALOG_DEAL_WITH_IT -> findNavController().popBackStack() + } + } + } + + private fun navByDeepLink(link: String) { + setNavigationResult( + key = ActionsConst.DEEP_LINK_ACTION, + data = UiDataEvent(link) + ) + findNavController().popBackStack() + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/util/notification/manager/DiiaAndroidNotificationManager.kt b/notifications/src/main/java/ua/gov/diia/notifications/util/notification/manager/DiiaAndroidNotificationManager.kt new file mode 100644 index 0000000..66fcc82 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/util/notification/manager/DiiaAndroidNotificationManager.kt @@ -0,0 +1,80 @@ +package ua.gov.diia.notifications.util.notification.manager + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import me.leolin.shortcutbadger.ShortcutBadger +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.notifications.models.notification.push.DiiaNotificationChannel +import javax.inject.Inject + +class DiiaAndroidNotificationManager @Inject constructor( + @ApplicationContext private val context: Context, + private val withCrashlytics: WithCrashlytics +) : DiiaNotificationManager { + + init { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + initializeNotificationChannels() + } + } + + private val notificationManager: NotificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + @RequiresApi(Build.VERSION_CODES.O) + private fun initializeNotificationChannels() { + GlobalScope.launch { + try { + DiiaNotificationChannel.values().forEach { + if (notificationManager.getNotificationChannel(it.id) == null) { + val channel = NotificationChannel( + it.id, + context.getString(it.label), + NotificationManager.IMPORTANCE_HIGH + ) + notificationManager.createNotificationChannel(channel) + } + } + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + } + + override fun clearNotification(notificationId: String) { + val intId = notificationId.hashCode() + notificationManager.cancel(intId) + } + + override fun setBadeNumber(number: Int) { + try { + ShortcutBadger.applyCount(context, number) + } catch (e: Exception) { + withCrashlytics.sendNonFatalError(e) + } + } + + /** + * In future we should avoid it and request documentAcquires from server side + * + * To make this solution completely work also implement notification channels on server side + */ + @RequiresApi(Build.VERSION_CODES.O) + override fun findDocumentAcquireInNotifications() { + notificationManager.activeNotifications.forEach { + if (it.packageName == context.packageName) { + if (it.notification.channelId == DiiaNotificationChannel.ACQUIRER.id) { + it.notification.contentIntent.send() + notificationManager.cancel(it.tag, it.id) + } + } + } + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/util/notification/manager/DiiaNotificationManager.kt b/notifications/src/main/java/ua/gov/diia/notifications/util/notification/manager/DiiaNotificationManager.kt new file mode 100644 index 0000000..6e368df --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/util/notification/manager/DiiaNotificationManager.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.notifications.util.notification.manager + +import android.os.Build +import androidx.annotation.RequiresApi + +interface DiiaNotificationManager { + + fun clearNotification(notificationId: String) + + fun setBadeNumber(number: Int) + + @RequiresApi(Build.VERSION_CODES.M) + fun findDocumentAcquireInNotifications() +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/util/notification/push/PushTokenProvider.kt b/notifications/src/main/java/ua/gov/diia/notifications/util/notification/push/PushTokenProvider.kt new file mode 100644 index 0000000..6418ca6 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/util/notification/push/PushTokenProvider.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.notifications.util.notification.push + +interface PushTokenProvider { + + fun requestCurrentPushToken(forceRefresh: Boolean = true): String + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/util/push/MoshiPushParser.kt b/notifications/src/main/java/ua/gov/diia/notifications/util/push/MoshiPushParser.kt new file mode 100644 index 0000000..853bbbe --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/util/push/MoshiPushParser.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.notifications.util.push + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import ua.gov.diia.core.models.notification.push.PushNotification + +class MoshiPushParser : PushParser { + + private val moshi: Moshi = Moshi.Builder().build() + + private fun parseJson(data: String, type: Class): T? { + val adapter: JsonAdapter = moshi.adapter(type) + return adapter.fromJson(data) + } + + override fun parsePushNotification(pushJson: String): PushNotification? { + return parseJson(pushJson, PushNotification::class.java) + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/util/push/PushParser.kt b/notifications/src/main/java/ua/gov/diia/notifications/util/push/PushParser.kt new file mode 100644 index 0000000..0da9088 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/util/push/PushParser.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.notifications.util.push + +import ua.gov.diia.core.models.notification.push.PushNotification + +interface PushParser { + + fun parsePushNotification(pushJson: String): PushNotification? + + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/util/push/notification/AndroidNotificationEnabledChecker.kt b/notifications/src/main/java/ua/gov/diia/notifications/util/push/notification/AndroidNotificationEnabledChecker.kt new file mode 100644 index 0000000..56e46e4 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/util/push/notification/AndroidNotificationEnabledChecker.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.notifications.util.push.notification + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class AndroidNotificationEnabledChecker @Inject constructor( + @ApplicationContext private val context: Context +) : NotificationEnabledChecker { + + override fun notificationEnabled(): Boolean { + return NotificationManagerCompat.from(context).areNotificationsEnabled() + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/util/push/notification/NotificationEnabledChecker.kt b/notifications/src/main/java/ua/gov/diia/notifications/util/push/notification/NotificationEnabledChecker.kt new file mode 100644 index 0000000..16658c8 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/util/push/notification/NotificationEnabledChecker.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.notifications.util.push.notification + +interface NotificationEnabledChecker { + + fun notificationEnabled(): Boolean + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/util/settings_action/PushTokenUpdateActionExecutor.kt b/notifications/src/main/java/ua/gov/diia/notifications/util/settings_action/PushTokenUpdateActionExecutor.kt new file mode 100644 index 0000000..24e6210 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/util/settings_action/PushTokenUpdateActionExecutor.kt @@ -0,0 +1,51 @@ +package ua.gov.diia.notifications.util.settings_action + +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.await +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.settings_action.SettingsActionExecutor +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.notifications.NotificationsConst.WORK_NAME_PUSH_TOKEN_UPDATE +import ua.gov.diia.notifications.store.NotificationsPreferences.PushToken +import ua.gov.diia.notifications.util.notification.push.PushTokenProvider +import ua.gov.diia.notifications.work.SendPushTokenWork +import javax.inject.Inject + +class PushTokenUpdateActionExecutor @Inject constructor( + private val pushTokenProvider: PushTokenProvider, + private val workManager: WorkManager, + private val diiaStorage: DiiaStorage, + private val withCrashlytics: WithCrashlytics +) : SettingsActionExecutor { + + override val actionKey: String + get() = "pushTokenUpdate" + + override suspend fun executeAction() { + val work = workManager.getWorkInfosForUniqueWork(WORK_NAME_PUSH_TOKEN_UPDATE).await() + if (!work.isRunning()) { + var pushToken = diiaStorage.get(PushToken, null) as? String + if (pushToken == null) { + //In case push token is not saved, we should trigger new token generation process + //PushService onNewToken() will be triggered + try { + pushToken = pushTokenProvider.requestCurrentPushToken(forceRefresh = false) + } catch (e: java.util.concurrent.ExecutionException) { + withCrashlytics.sendNonFatalError(e) + return + } + } + SendPushTokenWork.enqueue(workManager, pushToken) + } + } + + private fun List.isRunning(): Boolean { + return if (isNotEmpty()) { + this[0].state == WorkInfo.State.RUNNING || this[0].state == WorkInfo.State.ENQUEUED + } else { + false + } + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/work/SendPushTokenProcessor.kt b/notifications/src/main/java/ua/gov/diia/notifications/work/SendPushTokenProcessor.kt new file mode 100644 index 0000000..f6c5d08 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/work/SendPushTokenProcessor.kt @@ -0,0 +1,30 @@ +package ua.gov.diia.notifications.work + +import androidx.work.ListenableWorker +import ua.gov.diia.core.data.data_source.network.api.notification.ApiNotificationsPublic +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.PushToken +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSource +import javax.inject.Inject + +class SendPushTokenProcessor @Inject constructor( + @AuthorizedClient private val apiNotificationsPublic: ApiNotificationsPublic, + private val authorizationRepository: AuthorizationRepository, + private val keyValueSource: KeyValueNotificationDataSource, +) { + + suspend fun syncPushNotification(pushToken: String): ListenableWorker.Result { + keyValueSource.setPushToken(pushToken) + val authToken = authorizationRepository.getToken() + if (authToken == null) { + return ListenableWorker.Result.success() + } else { + apiNotificationsPublic.sendDeviceUserPushToken( + PushToken(pushToken) + ) + keyValueSource.setIsPushTokenSynced(synced = true) + } + return ListenableWorker.Result.success() + } +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/work/SendPushTokenWork.kt b/notifications/src/main/java/ua/gov/diia/notifications/work/SendPushTokenWork.kt new file mode 100644 index 0000000..6cdca93 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/work/SendPushTokenWork.kt @@ -0,0 +1,59 @@ +package ua.gov.diia.notifications.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import ua.gov.diia.notifications.NotificationsConst.WORK_NAME_PUSH_TOKEN_UPDATE +import java.util.concurrent.TimeUnit + +@HiltWorker +class SendPushTokenWork @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val sendPushTokenProcessor: SendPushTokenProcessor +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + return try { + val pushToken = inputData.getString(WORK_NAME_PUSH_TOKEN_UPDATE) ?: return Result.failure() + sendPushTokenProcessor.syncPushNotification(pushToken) + } catch (e: Exception) { + Result.retry() + } + } + + companion object { + private const val WORK_DELAY = 3L + + fun enqueue(workManager: WorkManager, pushToken: String) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val data = Data.Builder() + .putString(WORK_NAME_PUSH_TOKEN_UPDATE, pushToken) + .build() + + val sendPushTokenWork = OneTimeWorkRequest.Builder(SendPushTokenWork::class.java) + .setConstraints(constraints) + .setInputData(data) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + WORK_DELAY, + TimeUnit.SECONDS + ).build() + workManager.enqueueUniqueWork(WORK_NAME_PUSH_TOKEN_UPDATE, ExistingWorkPolicy.REPLACE, sendPushTokenWork) + } + } + +} \ No newline at end of file diff --git a/notifications/src/main/java/ua/gov/diia/notifications/work/SilentPushWork.kt b/notifications/src/main/java/ua/gov/diia/notifications/work/SilentPushWork.kt new file mode 100644 index 0000000..6c8d546 --- /dev/null +++ b/notifications/src/main/java/ua/gov/diia/notifications/work/SilentPushWork.kt @@ -0,0 +1,79 @@ +package ua.gov.diia.notifications.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import ua.gov.diia.core.data.data_source.network.api.notification.ApiNotificationsPublic +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.notifications.helper.NotificationHelper + +@HiltWorker +class SilentPushWork @AssistedInject constructor( + private val authorizationRepository: AuthorizationRepository, + private val notificationHelper: NotificationHelper, + @UnauthorizedClient private val notificationsPublic: ApiNotificationsPublic, + @Assisted appContext: Context, + @Assisted workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + return try { + coroutineScope { + val lastDocUpdate = async { notificationHelper.getLastDocumentUpdate() } + val activityDate = async { notificationHelper.getLastActiveDate() } + + val headers = async { + val headers = mutableMapOf() + val token = authorizationRepository.getToken() + if (token != null) headers[AUTH] = "$AUTH_BEARER $token" + headers + } + + notificationsPublic.sendAppStatus( + headers = headers.await(), + appStatus = ua.gov.diia.core.models.AppStatus( + lastActivityDate = activityDate.await(), + lastDocumentUpdate = lastDocUpdate.await() + ) + ) + } + Result.success() + } catch (e: Exception) { + Result.retry() + } + } + + companion object { + private const val AUTH = "Authorization" + private const val AUTH_BEARER = "Bearer" + private const val SILENT_PUSH_WORK = "silent_push_work" + + fun enqueue(workManager: WorkManager) { + val silentWorkConstraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val silentPushWork = OneTimeWorkRequest.Builder(SilentPushWork::class.java) + .setConstraints(silentWorkConstraints) + .build() + + workManager.enqueueUniqueWork( + SILENT_PUSH_WORK, + ExistingWorkPolicy.REPLACE, + silentPushWork + ) + } + } + +} \ No newline at end of file diff --git a/notifications/src/main/res/drawable-v21/ic_push.xml b/notifications/src/main/res/drawable-v21/ic_push.xml new file mode 100644 index 0000000..cbf7f9a --- /dev/null +++ b/notifications/src/main/res/drawable-v21/ic_push.xml @@ -0,0 +1,10 @@ + + + diff --git a/notifications/src/main/res/drawable/ic_notification_next.xml b/notifications/src/main/res/drawable/ic_notification_next.xml new file mode 100644 index 0000000..b868083 --- /dev/null +++ b/notifications/src/main/res/drawable/ic_notification_next.xml @@ -0,0 +1,10 @@ + + + diff --git a/notifications/src/main/res/drawable/ic_push.png b/notifications/src/main/res/drawable/ic_push.png new file mode 100644 index 0000000000000000000000000000000000000000..cd1c222a234fb792e973450cc4ecf99b11150d38 GIT binary patch literal 733 zcmV<30wVp1P)3m8mv zj1>|<;+Gg+Y>Z3R7{!>yA2ni(VUrwAYNSHDs7d%W8&EGVq=_A z2r0;gCIh}gE|hc+h&Nzz4cVAn$^+W+3cI&`pHP?bzzdhZ(K9|F59&z2iL_VT_Z>Zh z+`7#i--VpHdO@xS9DJe1@=ME@M;vYm@3H?Wys=zx8DfUS{LFcEx81Xfv=_nesFwQ_ z;;2(JePj|>EaIN)I_Re-lEKc2xm<907xcUEDzTs3pOJw}JVu|m z)Iqxn`cFn`{JU5$kd_0)+7lo4`FmTM^M>i#l5!v?gbbIHjC)*|xbWF{a};dEEzbA@ zD<^~=12H-GXv@a(o8?0=#$aaR_s!x+P< zLf(G-HA&_e_5^dy!c|Pd literal 0 HcmV?d00001 diff --git a/notifications/src/main/res/layout/fragment_notification_settings.xml b/notifications/src/main/res/layout/fragment_notification_settings.xml new file mode 100644 index 0000000..620337f --- /dev/null +++ b/notifications/src/main/res/layout/fragment_notification_settings.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/notifications/src/main/res/layout/item_notification_divider.xml b/notifications/src/main/res/layout/item_notification_divider.xml new file mode 100644 index 0000000..70a3144 --- /dev/null +++ b/notifications/src/main/res/layout/item_notification_divider.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/layout/item_notification_download_arrowed_link.xml b/notifications/src/main/res/layout/item_notification_download_arrowed_link.xml new file mode 100644 index 0000000..6bf83bf --- /dev/null +++ b/notifications/src/main/res/layout/item_notification_download_arrowed_link.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/layout/item_notification_image.xml b/notifications/src/main/res/layout/item_notification_image.xml new file mode 100644 index 0000000..1a7af95 --- /dev/null +++ b/notifications/src/main/res/layout/item_notification_image.xml @@ -0,0 +1,20 @@ + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/layout/item_notification_internal_arrowed_link.xml b/notifications/src/main/res/layout/item_notification_internal_arrowed_link.xml new file mode 100644 index 0000000..eb10073 --- /dev/null +++ b/notifications/src/main/res/layout/item_notification_internal_arrowed_link.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/layout/item_notification_text.xml b/notifications/src/main/res/layout/item_notification_text.xml new file mode 100644 index 0000000..29d0175 --- /dev/null +++ b/notifications/src/main/res/layout/item_notification_text.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/layout/item_notification_video.xml b/notifications/src/main/res/layout/item_notification_video.xml new file mode 100644 index 0000000..e411f3e --- /dev/null +++ b/notifications/src/main/res/layout/item_notification_video.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/layout/item_subscription.xml b/notifications/src/main/res/layout/item_subscription.xml new file mode 100644 index 0000000..d68885d --- /dev/null +++ b/notifications/src/main/res/layout/item_subscription.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/layout/view_message_video_player.xml b/notifications/src/main/res/layout/view_message_video_player.xml new file mode 100644 index 0000000..61a551e --- /dev/null +++ b/notifications/src/main/res/layout/view_message_video_player.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/notifications/src/main/res/navigation/nav_notification_details.xml b/notifications/src/main/res/navigation/nav_notification_details.xml new file mode 100644 index 0000000..05c3149 --- /dev/null +++ b/notifications/src/main/res/navigation/nav_notification_details.xml @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/navigation/nav_notification_settings.xml b/notifications/src/main/res/navigation/nav_notification_settings.xml new file mode 100644 index 0000000..b0ce376 --- /dev/null +++ b/notifications/src/main/res/navigation/nav_notification_settings.xml @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/navigation/nav_notifications.xml b/notifications/src/main/res/navigation/nav_notifications.xml new file mode 100644 index 0000000..a51007f --- /dev/null +++ b/notifications/src/main/res/navigation/nav_notifications.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/notifications/src/main/res/values/nav_ids.xml b/notifications/src/main/res/values/nav_ids.xml new file mode 100644 index 0000000..5c4cb29 --- /dev/null +++ b/notifications/src/main/res/values/nav_ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/notifications/src/main/res/values/strings.xml b/notifications/src/main/res/values/strings.xml new file mode 100644 index 0000000..da1aeed --- /dev/null +++ b/notifications/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + + + Запит на копії цифрових документів + Повідомлення + Борги + Штрафи + Інші сповіщення + + \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/MainDispatcherRule.kt b/notifications/src/test/java/ua/gov/diia/notifications/MainDispatcherRule.kt new file mode 100644 index 0000000..8a963f4 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.notifications + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + private val testDispatcher: TestDispatcher = UnconfinedTestDispatcher() +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/NotificationControllerImplTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/NotificationControllerImplTest.kt new file mode 100644 index 0000000..d1e24e8 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/NotificationControllerImplTest.kt @@ -0,0 +1,195 @@ +package ua.gov.diia.notifications + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.work.WorkManager +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSource +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager +import ua.gov.diia.notifications.util.notification.push.PushTokenProvider +import ua.gov.diia.notifications.work.SendPushTokenWork + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class NotificationControllerImplTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var workManager: WorkManager + lateinit var notificationsDataSource: NotificationDataRepository + lateinit var notificationManager: DiiaNotificationManager + lateinit var pushTokenProvider: PushTokenProvider + lateinit var keyValueSource: KeyValueNotificationDataSource + + lateinit var notificationControllerImpl: NotificationControllerImpl + + private val unreadCount = MutableStateFlow(0) + + @Before + fun setUp() { + + workManager = mockk(relaxed = true) + notificationsDataSource = mockk(relaxed = true) + notificationManager = mockk(relaxed = true) + pushTokenProvider = mockk(relaxed = true) + keyValueSource = mockk(relaxed = true) + + coEvery { notificationsDataSource.unreadCount } returns unreadCount + notificationControllerImpl = NotificationControllerImpl( + workManager, + notificationsDataSource, + notificationManager, + pushTokenProvider, + keyValueSource + ) + } + + @Test + fun `test markAsRead`() { + runBlocking { + val resId = "resId" + notificationControllerImpl.markAsRead(resId) + + coVerify(exactly = 1) { notificationsDataSource.markNotificationAsRead(resId) } + + //Not mark data if is not def + clearMocks(notificationsDataSource) + notificationControllerImpl.markAsRead(Preferences.DEF) + + coVerify(exactly = 0) { notificationsDataSource.markNotificationAsRead(resId) } + + //Not mark data if redId is null + clearMocks(notificationsDataSource) + notificationControllerImpl.markAsRead(null) + + coVerify(exactly = 0) { notificationsDataSource.markNotificationAsRead(resId) } + } + } + + @Test + fun `test invalidateNotificationDataSource calls invalidate in data source`() { + runBlocking { + notificationControllerImpl.invalidateNotificationDataSource() + + coVerify(exactly = 1) { notificationsDataSource.invalidate() } + } + } + + @Test + fun `test getNotificationsInitial calls loadDataFromNetwork in data source`() { + runBlocking { + notificationControllerImpl.getNotificationsInitial() + + coVerify(exactly = 1) { notificationsDataSource.loadDataFromNetwork(0, 5, any()) } + } + } + + @Test + fun `test checkPushTokenInSync return that token is not synced`() { + runBlocking { + val token = "token" + mockkObject(SendPushTokenWork) + justRun { SendPushTokenWork.enqueue(workManager, token) } + + every { keyValueSource.isPushTokenSynced() } returns false + every { pushTokenProvider.requestCurrentPushToken() } returns token + + notificationControllerImpl.checkPushTokenInSync() + + coVerify(exactly = 1) { pushTokenProvider.requestCurrentPushToken() } + coVerify(exactly = 1) { keyValueSource.isPushTokenSynced() } + verify { SendPushTokenWork.enqueue(workManager, token) } + } + } + + @Test + fun `test checkPushTokenInSync return that token is not synced but token is empty`() { + runBlocking { + val token = "" + mockkObject(SendPushTokenWork) + every { keyValueSource.isPushTokenSynced() } returns false + every { pushTokenProvider.requestCurrentPushToken() } returns token + + notificationControllerImpl.checkPushTokenInSync() + + coVerify(exactly = 1) { pushTokenProvider.requestCurrentPushToken() } + coVerify(exactly = 1) { keyValueSource.isPushTokenSynced() } + verify(exactly = 0) { SendPushTokenWork.enqueue(workManager, token) } + } + } + + @Test + fun `test checkPushTokenInSync not call sync if toke is synced`() { + runBlocking { + mockkObject(SendPushTokenWork) + every { keyValueSource.isPushTokenSynced() } returns true + + notificationControllerImpl.checkPushTokenInSync() + + coVerify(exactly = 1) { keyValueSource.isPushTokenSynced() } + coVerify(exactly = 0) { pushTokenProvider.requestCurrentPushToken() } + verify(exactly = 0) { SendPushTokenWork.enqueue(workManager, any()) } + } + } + + @Test + fun `test allowNotifications`() { + runBlocking { + notificationControllerImpl.allowNotifications() + + coVerify(exactly = 1) { keyValueSource.allowNotifications() } + } + } + + @Test + fun `test denyNotifications`() { + runBlocking { + notificationControllerImpl.denyNotifications() + + coVerify(exactly = 1) { keyValueSource.denyNotifications() } + } + } + + @Test + fun `test collectUnreadNotificationCounts`() { + runTest { + val sendValue = 10 + var callbackValue = -1 + unreadCount.emit(sendValue) + val job = launch { + notificationControllerImpl.collectUnreadNotificationCounts { + callbackValue = it + } + } + advanceUntilIdle() + + coVerify(exactly = 1) { notificationManager.setBadeNumber(10) } + assertEquals(sendValue, callbackValue) + job.cancel() + } + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/TestDispatcherProvider.kt b/notifications/src/test/java/ua/gov/diia/notifications/TestDispatcherProvider.kt new file mode 100644 index 0000000..c8ad0e2 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/TestDispatcherProvider.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.notifications + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.test.StandardTestDispatcher +import ua.gov.diia.core.util.DispatcherProvider + +class TestDispatcherProvider : DispatcherProvider { + + private val testDispatcher = StandardTestDispatcher() + + override fun ioDispatcher(): CoroutineDispatcher = StandardTestDispatcher() + override val main: CoroutineDispatcher = StandardTestDispatcher() + override val work: CoroutineDispatcher = StandardTestDispatcher() +} diff --git a/notifications/src/test/java/ua/gov/diia/notifications/service/PushServiceTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/service/PushServiceTest.kt new file mode 100644 index 0000000..bc14a75 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/service/PushServiceTest.kt @@ -0,0 +1,248 @@ +package ua.gov.diia.notifications.service + +import android.content.Context +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.WorkManager +import com.nhaarman.mockitokotlin2.mock +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.analytics.DiiaAnalytics +import ua.gov.diia.core.util.deeplink.DeepLinkActionFactory +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.context.isDiiaAppRunning +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.notifications.action.ActionConstants +import ua.gov.diia.notifications.helper.NotificationHelper +import ua.gov.diia.notifications.store.NotificationsPreferences +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager +import ua.gov.diia.notifications.work.SendPushTokenWork +import ua.gov.diia.notifications.work.SilentPushWork + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class PushServiceTest { + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var context: Context + + lateinit var notificationHelper: NotificationHelper + + lateinit var deepLinkActionFactory: DeepLinkActionFactory + + @Mock + lateinit var analytics: DiiaAnalytics + + lateinit var globalActionNotificationReceived: MutableLiveData + + @Mock + lateinit var diiaStorage: DiiaStorage + + @Mock + lateinit var workManager: WorkManager + + lateinit var notificationManager: DiiaNotificationManager + + lateinit var pushService: PushService + + @Before + fun setUp() { + context = mockk() + notificationManager = mockk(relaxed = true) + deepLinkActionFactory = mockk(relaxed = true) + notificationHelper = mockk(relaxed = true) + MockitoAnnotations.initMocks(this) + globalActionNotificationReceived = MutableLiveData() + pushService = PushService( + context, + notificationHelper, + deepLinkActionFactory, + analytics, + globalActionNotificationReceived, + diiaStorage, + workManager, + notificationManager + ) + } + + @Test + fun `test onNewToken`() { + val token = "token" + mockkObject(SendPushTokenWork) + justRun { SendPushTokenWork.enqueue(workManager, token) } + + pushService.onNewToken(token) + + Mockito.verify(diiaStorage, Mockito.times(1)).set(NotificationsPreferences.IsPushTokenSynced, false) + Mockito.verify(diiaStorage, Mockito.times(1)).set(NotificationsPreferences.PushToken, token) + + Mockito.verify(analytics, Mockito.times(1)) + .setPushToken(token) + verify { SendPushTokenWork.enqueue(workManager, token) } + } + + @Test + fun `test processNotification`() { + + mockkStatic(LocalBroadcastManager::class) + + val localBroadcastManager = mockk() + every { LocalBroadcastManager.getInstance(context) } returns localBroadcastManager + every { localBroadcastManager.sendBroadcast(any()) } returns false + `when`( + diiaStorage.getBoolean( + NotificationsPreferences.AllowNotifications, + true + ) + ).thenReturn(false) + + val notificationJson = "{" + + "\"action\": {" + + "\"resourceId\": \"resourceId\"," + + "\"type\": \"type\"," + + "\"subtype\": \"subtype\"" + + "}," + + "\"needAuth\": false," + + "\"notificationId\": \"1\"," + + "\"shortText\": \"shortText\"," + + "\"title\": \"title\"," + + "\"unread\": 1" + + "}" + + pushService.processNotification(notificationJson) + + Mockito.verify(analytics, Mockito.times(1)) + .notificationReceived(notificationJson) + Mockito.verify(analytics, Mockito.times(1)) + .pushReceived("resourceId") + + Mockito.verify(diiaStorage, Mockito.times(1)) + .getBoolean(NotificationsPreferences.AllowNotifications, true) + + } + + @Test + fun `test processNotification start SilentPushWork for PushAccessibility type`() { + + mockkStatic(LocalBroadcastManager::class) + val localBroadcastManager = mockk() + every { LocalBroadcastManager.getInstance(context) } returns localBroadcastManager + every { localBroadcastManager.sendBroadcast(any()) } returns false + + mockkObject(SilentPushWork) + justRun { SilentPushWork.enqueue(workManager) } + + val notificationJson = "{" + + "\"action\": {" + + "\"resourceId\": \"resourceId\"," + + "\"type\": \"${ActionConstants.NOTIFICATION_TYPE_PUSH_ACCESSIBILITY}\"," + + "\"subtype\": \"subtype\"" + + "}," + + "\"needAuth\": false," + + "\"notificationId\": \"1\"," + + "\"shortText\": \"shortText\"," + + "\"title\": \"title\"," + + "\"unread\": 1" + + "}" + + pushService.processNotification(notificationJson) + + verify { SilentPushWork.enqueue(workManager) } + } + + @Test + fun `test processNotification start activity for DocumentSharing type if app running`() { + + mockkStatic(Context::isDiiaAppRunning) + mockkStatic(Uri::class) + every { Uri.parse(any()) } returns mockk() + mockkStatic(LocalBroadcastManager::class) + val localBroadcastManager = mockk() + every { LocalBroadcastManager.getInstance(context) } returns localBroadcastManager + every { localBroadcastManager.sendBroadcast(any()) } returns false + + mockkObject(SilentPushWork) + justRun { SilentPushWork.enqueue(workManager) } + + every { context.isDiiaAppRunning() } returns true + justRun { context.startActivity(any()) } + every { notificationHelper.getMainActivityIntent()} returns mock() + every { deepLinkActionFactory.buildPathFromPushNotification(any()) } returns "result" + + val notificationJson = "{" + + "\"action\": {" + + "\"resourceId\": \"resourceId\"," + + "\"type\": \"documentsSharing\"," + + "\"subtype\": \"subtype\"" + + "}," + + "\"needAuth\": false," + + "\"notificationId\": \"1\"," + + "\"shortText\": \"shortText\"," + + "\"title\": \"title\"," + + "\"unread\": 1" + + "}" + + pushService.processNotification(notificationJson) + + verify(exactly = 1) { context.startActivity(any()) } + } + + @Test + fun `test processNotification displayNotification for DocumentSharing type if app is not running`() { + + mockkStatic(Context::isDiiaAppRunning) + + mockkStatic(LocalBroadcastManager::class) + val localBroadcastManager = mockk() + every { LocalBroadcastManager.getInstance(context) } returns localBroadcastManager + every { localBroadcastManager.sendBroadcast(any()) } returns false + + mockkObject(SilentPushWork) + justRun { SilentPushWork.enqueue(workManager) } + + every { context.isDiiaAppRunning() } returns false + justRun { context.startActivity(any()) } + + `when`( + diiaStorage.getBoolean(NotificationsPreferences.AllowNotifications, true) + ).thenReturn(false) + + val notificationJson = "{" + + "\"action\": {" + + "\"resourceId\": \"resourceId\"," + + "\"type\": \"documentsSharing\"," + + "\"subtype\": \"subtype\"" + + "}," + + "\"needAuth\": false," + + "\"notificationId\": \"1\"," + + "\"shortText\": \"shortText\"," + + "\"title\": \"title\"," + + "\"unread\": 1" + + "}" + + pushService.processNotification(notificationJson) + + Mockito.verify(diiaStorage, Mockito.times(1)) + .getBoolean(NotificationsPreferences.AllowNotifications, true) + } + +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/KeyValueNotificationDataSourceImplTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/KeyValueNotificationDataSourceImplTest.kt new file mode 100644 index 0000000..6a59d01 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/KeyValueNotificationDataSourceImplTest.kt @@ -0,0 +1,223 @@ +package ua.gov.diia.notifications.store.datasource + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.nhaarman.mockitokotlin2.doThrow +import com.squareup.moshi.JsonAdapter +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.models.notification.pull.PullNotificationSyncAction +import ua.gov.diia.notifications.store.NotificationsPreferences +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSourceImpl + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class KeyValueNotificationDataSourceImplTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + @Mock + lateinit var keyValueStore: DiiaStorage + + @Mock + lateinit var withCrashlytics: WithCrashlytics + + @Mock + lateinit var jsonAdapter: JsonAdapter> + + lateinit var keyValueNotificationDataSourceImpl: KeyValueNotificationDataSourceImpl + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + keyValueNotificationDataSourceImpl = + KeyValueNotificationDataSourceImpl(keyValueStore, withCrashlytics, jsonAdapter) + } + + @Test + fun `test save push token`() { + runBlocking { + val token = "token" + keyValueNotificationDataSourceImpl.setPushToken(token) + + Mockito.verify(keyValueStore, Mockito.times(1)) + .set(NotificationsPreferences.PushToken, token) + } + } + + @Test + fun `test set push token synced status`() { + runBlocking { + keyValueNotificationDataSourceImpl.setIsPushTokenSynced(true) + + Mockito.verify(keyValueStore, Mockito.times(1)) + .set(NotificationsPreferences.IsPushTokenSynced, true) + } + } + + @Test + fun `test isNotificationRequested call store for data`() { + runBlocking { + `when`( + keyValueStore.getBoolean( + NotificationsPreferences.NotificationsRequested, + false + ) + ).thenReturn(true) + val result = keyValueNotificationDataSourceImpl.isNotificationRequested() + + Mockito.verify(keyValueStore, Mockito.times(1)) + .getBoolean(NotificationsPreferences.NotificationsRequested, false) + assertEquals(true, result) + } + } + + @Test + fun `test denyNotifications should set NotificationsRequested as true`() { + runBlocking { + keyValueNotificationDataSourceImpl.denyNotifications() + + Mockito.verify(keyValueStore, Mockito.times(1)) + .set(NotificationsPreferences.NotificationsRequested, true) + } + } + + @Test + fun `test requesting isPushTokenSynced from store`() { + runBlocking { + `when`( + keyValueStore.getBoolean( + NotificationsPreferences.IsPushTokenSynced, + false + ) + ).thenReturn(true) + val result = keyValueNotificationDataSourceImpl.isPushTokenSynced() + + Mockito.verify(keyValueStore, Mockito.times(1)) + .getBoolean(NotificationsPreferences.IsPushTokenSynced, false) + assertEquals(true, result) + } + } + + @Test + fun `test allow notifications set AllowNotifications and NotificationsRequested data in store`() { + runBlocking { + keyValueNotificationDataSourceImpl.allowNotifications() + + Mockito.verify(keyValueStore, Mockito.times(1)) + .set(NotificationsPreferences.AllowNotifications, true) + Mockito.verify(keyValueStore, Mockito.times(1)) + .set(NotificationsPreferences.NotificationsRequested, true) + } + } + + @Test + fun `test updateUnreadCount save NotificationsUnreadCount in store`() { + runBlocking { + val unreadCount = 10 + keyValueNotificationDataSourceImpl.updateUnreadCount(unreadCount) + + Mockito.verify(keyValueStore, Mockito.times(1)) + .set(NotificationsPreferences.NotificationsUnreadCount, unreadCount) + } + } + + @Test + fun `test fetchUnreadCount save NotificationsUnreadCount to 0`() { + runBlocking { + keyValueNotificationDataSourceImpl.fetchUnreadCount() + + Mockito.verify(keyValueStore, Mockito.times(1)) + .getInt(NotificationsPreferences.NotificationsUnreadCount, 0) + } + } + + @Test + fun `test fetchData return empty store does not have data `() { + runBlocking { + `when`(keyValueStore.containsKey(NotificationsPreferences.NotificationsList)).thenReturn( + false + ) + val list = keyValueNotificationDataSourceImpl.fetchData() + + assertEquals(0, list.size) + } + } + + @Test + fun `test fetchData return store parsed store data`() { + runBlocking { + val result = mutableListOf() + result.add( + PullNotification( + "notid", + "createdate", + false, + null, + PullNotificationSyncAction.NONE + ) + ) + + val storedData = "stored_data"; + `when`(keyValueStore.containsKey(NotificationsPreferences.NotificationsList)).thenReturn( + true + ) + `when`( + keyValueStore.getString( + NotificationsPreferences.NotificationsList, + Preferences.DEF + ) + ).thenReturn(storedData) + `when`(jsonAdapter.fromJson(storedData)).thenReturn(result) + val list = keyValueNotificationDataSourceImpl.fetchData() + + assertEquals(1, list.size) + assertEquals(result[0], list[0]) + } + } + + + @Test + fun `test calling crashlytics and clear data if load data throw error`() { + runBlocking { + + val storedData = "stored_data"; + `when`(keyValueStore.containsKey(NotificationsPreferences.NotificationsList)).thenReturn( + true + ) + `when`( + keyValueStore.getString( + NotificationsPreferences.NotificationsList, + Preferences.DEF + ) + ).thenReturn(storedData) + val exception = RuntimeException() + `when`(jsonAdapter.fromJson(storedData)).doThrow(exception) + val list = keyValueNotificationDataSourceImpl.fetchData() + + assertEquals(0, list.size) + Mockito.verify(keyValueStore, Mockito.times(1)) + .set(NotificationsPreferences.NotificationsList, "") + Mockito.verify(withCrashlytics, Mockito.times(1)) + .sendNonFatalError(exception) + } + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/NetworkNotificationDataSourceTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/NetworkNotificationDataSourceTest.kt new file mode 100644 index 0000000..af4d83b --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/NetworkNotificationDataSourceTest.kt @@ -0,0 +1,63 @@ +package ua.gov.diia.notifications.store.datasource + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.nhaarman.mockitokotlin2.mock +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.data.data_source.network.api.notification.ApiNotifications +import ua.gov.diia.notifications.models.notification.pull.PullNotificationsToModify +import ua.gov.diia.notifications.store.datasource.notifications.NetworkNotificationDataSource + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class NetworkNotificationDataSourceTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + @Mock + lateinit var apiNotifications: ApiNotifications + + lateinit var networkNotificationDataSource: NetworkNotificationDataSource + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + networkNotificationDataSource = + NetworkNotificationDataSource(apiNotifications) + } + + @Test + fun `test markNotificationsAsRead call api method`() { + runBlocking { + val modify = mock< PullNotificationsToModify>() + networkNotificationDataSource.markNotificationsAsRead(modify) + + Mockito.verify(apiNotifications, Mockito.times(1)) + .markNotificationsAsRead(modify) + } + } + + @Test + fun `test deleteNotifications call api method`() { + runBlocking { + val modify = mock< PullNotificationsToModify>() + networkNotificationDataSource.deleteNotifications(modify) + + Mockito.verify(apiNotifications, Mockito.times(1)) + .deleteNotifications(modify) + } + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/NotificationDataRepositoryImplTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/NotificationDataRepositoryImplTest.kt new file mode 100644 index 0000000..fc95f36 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/store/datasource/NotificationDataRepositoryImplTest.kt @@ -0,0 +1,1023 @@ +package ua.gov.diia.notifications.store.datasource + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.mock +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.models.notification.push.PushAction +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.models.notification.pull.PullNotificationMessage +import ua.gov.diia.notifications.models.notification.pull.PullNotificationSyncAction +import ua.gov.diia.notifications.models.notification.pull.PullNotificationsResponse +import ua.gov.diia.notifications.models.notification.pull.PullNotificationsToModify +import ua.gov.diia.notifications.models.notification.pull.UpdatePullNotificationResponse +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSource +import ua.gov.diia.notifications.store.datasource.notifications.NetworkNotificationDataSource +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepositoryImpl +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class NotificationDataRepositoryImplTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + val notificationId = "notid" + + lateinit var keyValueSource: KeyValueNotificationDataSource + + @Mock + lateinit var diiaNotificationManager: DiiaNotificationManager + + lateinit var networkSource: NetworkNotificationDataSource + + lateinit var actionNotificationRead: MutableLiveData> + + @Mock + lateinit var withCrashlytics: WithCrashlytics + + lateinit var notificationDataRepositoryImpl: NotificationDataRepositoryImpl + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + networkSource = mockk() + keyValueSource = mockk(relaxed = true) + } + + fun initRepo(scope: CoroutineScope) { + actionNotificationRead = MutableLiveData>() + notificationDataRepositoryImpl = NotificationDataRepositoryImpl( + scope, + keyValueSource, + diiaNotificationManager, + networkSource, + actionNotificationRead, + withCrashlytics + ) + } + + suspend fun mainInitialization(scope: CoroutineScope): MutableList { + initRepo(scope) + + val data = mutableListOf() + data.add( + PullNotification( + notificationId, + "createdate", + false, + null, + PullNotificationSyncAction.NONE + ) + ) + + coEvery { keyValueSource.fetchData() } returns data + coEvery { keyValueSource.fetchUnreadCount() } returns 0 + return data + } + + @Test + fun `test invalidate`() { + runTest { + val data = mainInitialization(this) + + val unreadedCound = 5 + coEvery { keyValueSource.fetchUnreadCount()} returns unreadedCound + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + + coVerify(exactly = 1) { keyValueSource.fetchData() } + coVerify(exactly = 1) { keyValueSource.fetchUnreadCount() } + + notificationDataRepositoryImpl.data.test { + val item = awaitItem() + assertEquals(true, item.isSuccessful) + assertEquals(null, item.exception) + assertEquals(data, item.data) + } + + notificationDataRepositoryImpl.unreadCount.test { + val item = awaitItem() + Assert.assertEquals(unreadedCound, item) + } + + notificationDataRepositoryImpl.isDataLoading.test { + val item = awaitItem() + Assert.assertEquals(false, item) + } + } + } + + @Test + fun `test invalidate sync with read data`() { + runTest { + initRepo(this) + + val unreadedCound = 5 + val data = mutableListOf() + data.add( + PullNotification( + "notid_read", "createdate", false, + null, PullNotificationSyncAction.READ + ) + ) + + coEvery { keyValueSource.fetchData() } returns data + coEvery { keyValueSource.fetchUnreadCount() } returns unreadedCound + coEvery { networkSource.markNotificationsAsRead(any()) } returns UpdatePullNotificationResponse(10) + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + + + coVerify(exactly = 1) { networkSource.markNotificationsAsRead(any()) } + + coVerify(exactly = 1) { keyValueSource.updateUnreadCount(10) } + coVerify(exactly = 1) { keyValueSource.saveDataToStore(data) } + notificationDataRepositoryImpl.unreadCount.test { + val item = awaitItem() + Assert.assertEquals(10, item) + } + } + } + + @Test + fun `test invalidate sync with remove data`() { + runTest { + initRepo(this) + + val unreadedCound = 5 + val data = mutableListOf() + data.add( + PullNotification( + "notid_remove", "createdate", false, + null, PullNotificationSyncAction.REMOVE + ) + ) + + coEvery { keyValueSource.fetchData()} returns data + coEvery { keyValueSource.fetchUnreadCount() } returns unreadedCound + coEvery { networkSource.markNotificationsAsRead(any()) } returns UpdatePullNotificationResponse(10) + + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + + + coVerify(exactly = 1) { networkSource.markNotificationsAsRead(any()) } + coVerify(exactly = 1) { keyValueSource.updateUnreadCount(10) } + + data.removeFirst() + coVerify(exactly = 1) { keyValueSource.saveDataToStore(data) } + notificationDataRepositoryImpl.unreadCount.test { + val item = awaitItem() + Assert.assertEquals(10, item) + } + } + } + + @Test + fun `test invalidate sync with read and remove data`() { + runTest { + initRepo(this) + + val unreadedCound = 5 + val data = mutableListOf() + data.add( + PullNotification( + "notid_remove", "createdate", false, + null, PullNotificationSyncAction.REMOVE + ) + ) + data.add( + PullNotification( + "notid_read", "createdate", false, + null, PullNotificationSyncAction.READ + ) + ) + + coEvery { keyValueSource.fetchData() } returns data + coEvery { keyValueSource.fetchUnreadCount() } returns unreadedCound + coEvery { networkSource.markNotificationsAsRead(any()) } returns UpdatePullNotificationResponse(10) + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + + + coVerify(exactly = 2) { networkSource.markNotificationsAsRead(any()) } + coVerify(exactly = 1) { keyValueSource.updateUnreadCount(10) } + + data.removeFirst() + coVerify(exactly = 1) { keyValueSource.saveDataToStore(data) } + notificationDataRepositoryImpl.unreadCount.test { + val item = awaitItem() + Assert.assertEquals(10, item) + } + } + } + + @Test + fun `test invalidate sync with read data throw error and trigger crashlytics`() { + runTest { + initRepo(this) + + val exception = RuntimeException("read error") + val unreadedCound = 5 + val data = mutableListOf() + data.add( + PullNotification( + "notid_read", "createdate", false, + null, PullNotificationSyncAction.READ + ) + ) + + coEvery { keyValueSource.fetchData()} returns data + coEvery { keyValueSource.fetchUnreadCount()} returns unreadedCound + coEvery { networkSource.markNotificationsAsRead(any()) } throws exception + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + + + Mockito.verify(withCrashlytics, Mockito.times(1)) + .sendNonFatalError(exception) + } + } + + + @Test + fun `test invalidate sync with remove data throw error and trigger crashlytics`() { + runTest { + initRepo(this) + + val exception = RuntimeException("remove error") + val unreadedCound = 5 + val data = mutableListOf() + data.add( + PullNotification( + "notid_remove", "createdate", false, + null, PullNotificationSyncAction.REMOVE + ) + ) + + coEvery { keyValueSource.fetchData() } returns data + coEvery { keyValueSource.fetchUnreadCount() } returns unreadedCound + coEvery { networkSource.markNotificationsAsRead(any()) } throws exception + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + + Mockito.verify(withCrashlytics, Mockito.times(1)) + .sendNonFatalError(exception) + } + } + + @Test + fun `test getPullNotificationById return data from flow`() { + runTest { + val data = mainInitialization(this) + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + assertEquals( + data[0], + notificationDataRepositoryImpl.getPullNotificationById(notificationId) + ) + assertEquals( + null, + notificationDataRepositoryImpl.getPullNotificationById("someid") + ) + } + } + + @Test + fun `test getPullNotificationById return null if data is empty`() { + runTest { + initRepo(this) + + assertEquals( + null, + notificationDataRepositoryImpl.getPullNotificationById(notificationId) + ) + } + } + + @Test + fun `test removeNotification trigger sendNonFatalError if exception appears`() { + runTest { + val exception = RuntimeException("remove error") + mainInitialization(this) + + coEvery { networkSource.deleteNotifications(any()) } throws exception + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + notificationDataRepositoryImpl.removeNotification(notificationId) + + Mockito.verify(withCrashlytics, Mockito.times(1)) + .sendNonFatalError(exception) + } + } + + @Test + fun `test removeNotification call deleteNotifications method in network source`() { + runTest { + mainInitialization(this) + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + notificationDataRepositoryImpl.removeNotification("notificationId") + + coVerify (exactly = 1) { networkSource.deleteNotifications(any()) } + } + } + + @Test + fun `test removeNotification mark notification as read that is not in data flow`() { + runTest { + mainInitialization(this) + val updatePullNotificationResponse: UpdatePullNotificationResponse = mock() + val unread = 20 + Mockito.`when`(updatePullNotificationResponse.unread).thenReturn(unread) + coEvery { + networkSource.markNotificationsAsRead( + PullNotificationsToModify( + mutableListOf("notificationId") + ) + ) + } returns updatePullNotificationResponse + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + notificationDataRepositoryImpl.removeNotification("notificationId") + + coVerify(exactly = 1) { networkSource.markNotificationsAsRead(PullNotificationsToModify(mutableListOf("notificationId"))) } + coVerify(exactly = 1) { networkSource.deleteNotifications(any()) } + notificationDataRepositoryImpl.unreadCount.test { + Assert.assertEquals(unread, awaitItem()) + } + } + } + + @Test + fun `test removeNotification clear notification if data stores in data`() { + runTest { + initRepo(this) + + val notificationId = "notid" + val data = mutableListOf() + data.add( + PullNotification( + notificationId, + "createdate", + false, + null, + PullNotificationSyncAction.READ + ) + ) + + coEvery { keyValueSource.fetchData() } returns data + coEvery { keyValueSource.fetchUnreadCount() } returns 0 + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + notificationDataRepositoryImpl.removeNotification(notificationId) + + Mockito.verify(diiaNotificationManager, Mockito.times(1)) + .clearNotification(notificationId) + } + } + + @Test + fun `test removeNotification has conflict in syncAction and call syncWithRemote`() { + runTest { + mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + notificationDataRepositoryImpl.removeNotification(notificationId) + + coVerify(exactly = 1) {networkSource.markNotificationsAsRead(PullNotificationsToModify(mutableListOf(notificationId))) } + + } + } + + @Test + fun `test markNotificationAsRead call updateMessageSyncStatus with read status`() { + runTest { + mainInitialization(this) + + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + notificationDataRepositoryImpl.markNotificationAsRead(notificationId) + + coVerify(exactly = 1) { networkSource.markNotificationsAsRead(PullNotificationsToModify(mutableListOf(notificationId))) } + Assert.assertEquals(notificationId, actionNotificationRead.value!!.peekContent()) + } + } + + @Test + fun `test markNotificationAsRead call network if notification is not loaded yet`() { + runTest { + initRepo(this) + + notificationDataRepositoryImpl.markNotificationAsRead(notificationId) + + coVerify(exactly = 1) { networkSource.markNotificationsAsRead(PullNotificationsToModify(listOf(notificationId))) } + } + } + + @Test + fun `test updateWithLocal save data to store`() { + runTest { + val data = mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + notificationDataRepositoryImpl.updateWithLocal(data) + + coVerify(exactly = 1) { keyValueSource.saveDataToStore(data) } + } + } + + @Test + fun `test updateWithLocal emits new data in data flow`() { + runTest { + val data = mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + val deleteData = mutableListOf() + deleteData.add( + PullNotification( + "notificationId", + "createdate", + false, + null, + PullNotificationSyncAction.NONE + ) + ) + notificationDataRepositoryImpl.updateWithLocal(deleteData) + + notificationDataRepositoryImpl.data.test { + Assert.assertEquals(data + deleteData[0], awaitItem().data) + } + } + } + + @Test + fun `test updateWithLocal emits data with changed read status`() { + runTest { + val data = mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + val deleteData = mutableListOf() + deleteData.add( + PullNotification( + notificationId, + "createdate", + true, + null, + PullNotificationSyncAction.NONE + ) + ) + notificationDataRepositoryImpl.updateWithLocal(deleteData) + + notificationDataRepositoryImpl.data.test { + Assert.assertEquals(true, awaitItem().data!![0].isRead) + } + } + } + + @Test + fun `test appendItems to start`() { + runTest { + val data = mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + val addData = mutableListOf() + addData.add( + PullNotification( + notificationId, + "createdate", + false, + null, + PullNotificationSyncAction.NONE + ) + ) + notificationDataRepositoryImpl.appendItems(addData, true) + + coVerify(exactly = 1) { keyValueSource.saveDataToStore(addData + data) } + notificationDataRepositoryImpl.data.test { + val result = awaitItem().data!! + Assert.assertEquals(notificationId, result[0].notificationId) + Assert.assertEquals(data[0].notificationId, result[1].notificationId) + } + } + } + + @Test + fun `test appendItems to end`() { + runTest { + val data = mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + val addData = mutableListOf() + addData.add( + PullNotification( + notificationId, + "createdate", + false, + null, + PullNotificationSyncAction.NONE + ) + ) + notificationDataRepositoryImpl.appendItems(addData, false) + + coVerify(exactly = 1) { keyValueSource.saveDataToStore(data + addData) } + notificationDataRepositoryImpl.data.test { + val result = awaitItem().data!! + Assert.assertEquals(data[0].notificationId, result[0].notificationId) + Assert.assertEquals(notificationId, result[1].notificationId) + } + } + } + + @Test + fun `test removeItems not remove data if its not in the data list`() { + runTest { + val data = mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + val items = mutableListOf() + items.add( + PullNotification( + "notificationId", + "createdate", + false, + null, + PullNotificationSyncAction.NONE + ) + ) + notificationDataRepositoryImpl.removeItems(items) + + coVerify(exactly = 1) { keyValueSource.saveDataToStore(data) } + notificationDataRepositoryImpl.data.test { + Assert.assertEquals(data, awaitItem().data) + } + } + } + + @Test + fun `test removeItems removes passed items`() { + runTest { + val data = mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + val items = mutableListOf() + items.add( + PullNotification( + notificationId, + "createdate", + false, + null, + PullNotificationSyncAction.NONE + ) + ) + notificationDataRepositoryImpl.removeItems(items) + + coVerify(exactly = 1) { keyValueSource.saveDataToStore(data - items) } + notificationDataRepositoryImpl.data.test { + val item = awaitItem() + Assert.assertTrue(item.data!!.isEmpty()) + Assert.assertTrue(item.isSuccessful) + Assert.assertEquals(null, item.exception) + } + } + } + + @Test + fun `test getPage`() { + runTest { + initRepo(this) + + val data = mutableListOf() + data.add( + PullNotification( + notificationId, "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + data.add( + PullNotification( + "notid2", "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + data.add( + PullNotification( + "notid3", "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + + coEvery { keyValueSource.fetchData()} returns data + coEvery { keyValueSource.fetchUnreadCount() } returns 0 + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + val result = notificationDataRepositoryImpl.getPage(1, 2) + assertEquals(data[1], result[0]) + assertEquals(data[2], result[1]) + } + } + + @Test + fun `test getTotalSize`() { + runTest { + initRepo(this) + + val data = mutableListOf() + data.add( + PullNotification( + notificationId, "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + data.add( + PullNotification( + "notid2", "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + data.add( + PullNotification( + "notid3", "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + + coEvery { keyValueSource.fetchData() } returns data + coEvery { keyValueSource.fetchUnreadCount() } returns 0 + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + assertEquals(3, notificationDataRepositoryImpl.getTotalSize()) + } + } + + @Test + fun `test indexOf`() { + runTest { + initRepo(this) + + val data = mutableListOf() + data.add( + PullNotification( + notificationId, "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + data.add( + PullNotification( + "notid2", "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + data.add( + PullNotification( + "notid3", "createdate", false, + null, PullNotificationSyncAction.NONE + ) + ) + + coEvery { keyValueSource.fetchData() } returns data + coEvery { keyValueSource.fetchUnreadCount() } returns 0 + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + assertEquals(1, notificationDataRepositoryImpl.indexOf("notid2")) + } + } + + @Test + fun `test updateUnreadCount`() { + runTest { + val data = mainInitialization(this) + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + notificationDataRepositoryImpl.updateUnreadCount(10) + + coVerify(exactly = 1) { keyValueSource.updateUnreadCount(10) } + notificationDataRepositoryImpl.unreadCount.test { + Assert.assertEquals(10, awaitItem()) + } + clearMocks(keyValueSource) + + notificationDataRepositoryImpl.updateUnreadCount(10) + + coVerify(exactly = 0) { keyValueSource.updateUnreadCount(any()) } + notificationDataRepositoryImpl.unreadCount.test { + Assert.assertEquals(10, awaitItem()) + } + } + } + + @Test + fun `test findNotificationByResourceId`() { + runTest { + initRepo(this) + + val data = mutableListOf() + data.add( + PullNotification( + notificationId, "createdate", false, + PullNotificationMessage( + "icon", + "title", + "shortText", + PushAction("resourceIdfirstId", "type", "subtype") + ), PullNotificationSyncAction.NONE + ) + ) + data.add( + PullNotification( + "notid2", "createdate", false, + PullNotificationMessage( + "icon", + "title", + "shortText", + PushAction("resourceIdSecond", "type", "subtype") + ), PullNotificationSyncAction.NONE + ) + ) + data.add( + PullNotification( + "notid3", "createdate", false, + PullNotificationMessage( + "icon", + "title", + "shortText", + PushAction("resourceIdThird", "type", "subtype") + ), PullNotificationSyncAction.NONE + ) + ) + + coEvery { keyValueSource.fetchData() } returns data + coEvery { keyValueSource.fetchUnreadCount() } returns 0 + notificationDataRepositoryImpl.invalidate() + advanceUntilIdle() + clearMocks(keyValueSource) + + assertEquals( + data[1].notificationId, + notificationDataRepositoryImpl.findNotificationByResourceId("resourceIdSecond")!!.notificationId + ) + assertEquals( + null, + notificationDataRepositoryImpl.findNotificationByResourceId("resourceIdFourch") + ) + + } + } + + @Test + fun `test loadDataFromNetwork`() { + runTest { + initRepo(this) + val notification1 = PullNotification("notid1", "createdate", true, null) + val notification2 = PullNotification("notid2", "createdate", false, null) + coEvery { networkSource.getNotifications(0) } returns PullNotificationsResponse(listOf(notification1, notification2), 2, 1) + var networkTotal = -1 + notificationDataRepositoryImpl.loadDataFromNetwork(0, 50) { + networkTotal = it + } + + coVerify(exactly = 1) { networkSource.getNotifications(0) } + coVerify(exactly = 1) { keyValueSource.updateUnreadCount(1) } + assertEquals(2, networkTotal) + notificationDataRepositoryImpl.unreadCount.test { + assertEquals(1, awaitItem()) + } + } + } + + @Test + fun `test loadDataFromNetwork append remove notifications to local empty list`() { + runTest { + initRepo(this) + val notification1 = PullNotification("notid1", "createdate", true, null) + val notification2 = PullNotification("notid2", "createdate", false, null) + val notificationList = listOf(notification1, notification2) + coEvery { networkSource.getNotifications(0) } returns PullNotificationsResponse(notificationList, 2, 1) + + var networkTotal = -1 + notificationDataRepositoryImpl.loadDataFromNetwork(0, 50) { + networkTotal = it + } + + coVerify(exactly = 1) { keyValueSource.saveDataToStore(notificationList) } + assertEquals(2, networkTotal) + notificationDataRepositoryImpl.data.test { + val dataSourceResult = awaitItem() + assertEquals(notificationList, dataSourceResult.data) + assertTrue(dataSourceResult.isSuccessful) + assertEquals(null, dataSourceResult.exception) + } + } + } + @Test + fun `test loadDataFromNetwork append notification that not require merging processNotificationIfTopFoundNotFound`() { + runTest { + // Precondition + + initRepo(this) + var notification1 = PullNotification("notid1", "createdate", true, null) + var notification2 = PullNotification("notid2", "createdate", false, null) + var notificationList = listOf(notification2, notification1) + coEvery { networkSource.getNotifications(0) } returns PullNotificationsResponse(notificationList, 2, 1) + + notificationDataRepositoryImpl.loadDataFromNetwork(0, 50) + + // Condition + clearMocks(keyValueSource) + val notification3 = PullNotification("notid3", "createdate", true, null) + val notification4 = PullNotification("notid4", "createdate", false, null) + notificationList = listOf(notification4, notification3) + + coEvery { networkSource.getNotifications(2) } returns PullNotificationsResponse(notificationList, 4, 1) + + var networkTotal = -1 + notificationDataRepositoryImpl.loadDataFromNetwork(2, 50){ + networkTotal = it + } + + val newNotList = listOf(notification2, notification1, notification4, notification3) + coVerify(exactly = 1) { keyValueSource.saveDataToStore(newNotList) } + assertEquals(4, networkTotal) + notificationDataRepositoryImpl.data.test { + val dataSourceResult = awaitItem() + assertEquals(newNotList, dataSourceResult.data) + assertTrue(dataSourceResult.isSuccessful) + assertEquals(null, dataSourceResult.exception) + } + } + } + + @Test + fun `test loadDataFromNetwork merge notifications processNotificationIfTopFoundNotFound`() { + runTest { + // Precondition + + initRepo(this) + var notification1 = PullNotification("notid1", "createdate", true, null) + var notification2 = PullNotification("notid2", "createdate", false, null) + var notificationList = listOf(notification2, notification1) + coEvery { networkSource.getNotifications(0) } returns PullNotificationsResponse(notificationList, 2, 1) + + notificationDataRepositoryImpl.loadDataFromNetwork(0, 50) + + // Condition + clearMocks(keyValueSource) + + notification1 = PullNotification("notid1", "createdate", true, null) + notification2 = PullNotification("notid2", "createdate", false, null) + val notification3 = PullNotification("notid3", "createdate", true, null) + val notification4 = PullNotification("notid4", "createdate", false, null) + notificationList = listOf(notification4, notification3, notification2, notification1) + + coEvery { networkSource.getNotifications(0) } returns PullNotificationsResponse(notificationList, 4, 1) + var networkTotal = -1 + notificationDataRepositoryImpl.loadDataFromNetwork(0, 50){ + networkTotal = it + } + + assertEquals(4, networkTotal) + coVerify(exactly = 1) { keyValueSource.saveDataToStore(listOf(notification4, notification3, notification2, notification1)) } + notificationDataRepositoryImpl.data.test { + val dataSourceResult = awaitItem() + assertEquals(notificationList, dataSourceResult.data) + assertTrue(dataSourceResult.isSuccessful) + assertEquals(null, dataSourceResult.exception) + } + } + } + + @Test + fun `test loadDataFromNetwork merge notifications from bottom mergeRemoteNotificationsWithLocal`() { + runTest { + // Precondition + + initRepo(this) + var notification0 = PullNotification("notid0", "createdate", true, null) + var notification1 = PullNotification("notid1", "createdate", true, null) + var notification2 = PullNotification("notid2", "createdate", false, null) + var notificationList = listOf(notification0, notification2, notification1) + coEvery { networkSource.getNotifications(0) } returns PullNotificationsResponse(notificationList, 3, 1) + + notificationDataRepositoryImpl.loadDataFromNetwork(0, 50) + + // Condition + clearMocks(keyValueSource) + notification1 = PullNotification("notid1", "createdate", true, null) + notification2 = PullNotification("notid2", "createdate", true, null) + val notification3 = PullNotification("notid3", "createdate", true, null) + val notification4 = PullNotification("notid4", "createdate", false, null) + notificationList = listOf(notification2, notification1, notification4, notification3, ) + + coEvery { networkSource.getNotifications(1) } returns PullNotificationsResponse(notificationList, 5, 1) + + var networkTotal = -1 + notificationDataRepositoryImpl.loadDataFromNetwork(1, 50){ + networkTotal = it + } + + val expectedResultList = listOf(notification0, notification2, notification1, notification4, notification3) + assertEquals(5, networkTotal) + coVerify(exactly = 1) { keyValueSource.saveDataToStore(expectedResultList) } + notificationDataRepositoryImpl.data.test { + val dataSourceResult = awaitItem() + assertEquals(expectedResultList, dataSourceResult.data) + assertTrue(dataSourceResult.isSuccessful) + assertEquals(null, dataSourceResult.exception) + } + } + } + + @Test + fun `test loadDataFromNetwork merge empty notification in range`() { + runTest { + // Precondition + + initRepo(this) + val notification0 = PullNotification("notid0", "createdate", true, null) + val notification1 = PullNotification("notid1", "createdate", true, null) + val notification2 = PullNotification("notid2", "createdate", false, null) + val notificationList = listOf(notification0, notification2, notification1) + coEvery { networkSource.getNotifications(0) } returns PullNotificationsResponse(notificationList, 3, 1) + + notificationDataRepositoryImpl.loadDataFromNetwork(0, 50) + + // Condition + clearMocks(keyValueSource) + coEvery { networkSource.getNotifications(0) } returns PullNotificationsResponse(listOf(), 0, 0) + + var networkTotal = -1 + notificationDataRepositoryImpl.loadDataFromNetwork(0, 50) { + networkTotal = it + } + + assertEquals(0, networkTotal) + coVerify(exactly = 1) { keyValueSource.saveDataToStore(listOf()) } + notificationDataRepositoryImpl.data.test { + val dataSourceResult = awaitItem() + assertEquals(0, dataSourceResult.data!!.size) + assertTrue(dataSourceResult.isSuccessful) + assertEquals(null, dataSourceResult.exception) + } + } + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/ui/compose/mapper/media/MediaMapperTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/ui/compose/mapper/media/MediaMapperTest.kt new file mode 100644 index 0000000..1385308 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/ui/compose/mapper/media/MediaMapperTest.kt @@ -0,0 +1,38 @@ +package ua.gov.diia.notifications.ui.compose.mapper.media + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class MediaMapperTest { + + @Test + fun `test toComposeArticlePic`() { + val url = "url" + val result = url.toComposeArticlePic() + + assertEquals("", result!!.id) + assertEquals(url, result.url) + + val nullUrl: String? = null + val nullResult = nullUrl.toComposeArticlePic() + + assertEquals(null, nullResult) + } + + @Test + fun `test toComposeArticleVideo`() { + val url = "url" + val result = url.toComposeArticleVideo() + assertEquals(url, result!!.url) + + val nullUrl: String? = null + val nullResult = nullUrl.toComposeArticleVideo() + + assertEquals(null, nullResult) + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationComposeVMTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationComposeVMTest.kt new file mode 100644 index 0000000..1df3248 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/home/notifications/compose/NotificationComposeVMTest.kt @@ -0,0 +1,252 @@ +package ua.gov.diia.notifications.ui.fragments.home.notifications.compose + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavDirections +import app.cash.turbine.test +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.models.notification.push.PushAction +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.TestDispatcherProvider +import ua.gov.diia.notifications.data.data_source.network.api.notification.ApiNotifications +import ua.gov.diia.notifications.helper.NotificationHelper +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.models.notification.pull.PullNotificationMessage +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class NotificationComposeVMTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var notificationsDataSource: NotificationDataRepository + lateinit var apiNotifications: ApiNotifications + lateinit var dispatcherProvider: DispatcherProvider + lateinit var composeMapper: NotificationsMapperCompose + lateinit var retryLastAction: WithRetryLastAction + lateinit var errorHandling: WithErrorHandlingOnFlow + lateinit var actionNotificationReceived: MutableLiveData + lateinit var actionNotificationRead: MutableLiveData> + lateinit var notificationHelper: NotificationHelper + + lateinit var notificationComposeVM: NotificationComposeVM + + + @Before + fun setUp() { + notificationsDataSource = mockk(relaxed = true) + apiNotifications = mockk() + dispatcherProvider = TestDispatcherProvider() + composeMapper = NotificationsMapperComposeImpl() + errorHandling = mockk(relaxed = true) + retryLastAction = mockk(relaxed = true) + actionNotificationReceived = MutableLiveData() + actionNotificationRead = MutableLiveData>() + notificationHelper = mockk() + + notificationComposeVM = NotificationComposeVM( + notificationsDataSource, + dispatcherProvider, + composeMapper, + retryLastAction, + errorHandling, + notificationHelper + ) + } + + @Test + fun `test onUIAction navigate back`() { + runTest { + notificationComposeVM.navigation.test { + notificationComposeVM.onUIAction(UIAction(actionKey = UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK)) + advanceUntilIdle() + Assert.assertEquals(BaseNavigation.Back, awaitItem()) + } + } + } + + @Test + fun `test onUIAction remove notification`() { + runTest { + val notId = "notId" + notificationComposeVM.onUIAction( + UIAction( + actionKey = NotificationsActionKey.REMOVE_NOTIFICATION, + data = notId + ) + ) + advanceUntilIdle() + coVerify(exactly = 1) { notificationsDataSource.removeNotification(notId) } + } + } + + @Test + fun `test onUIAction open notification settings`() { + runTest { + notificationComposeVM.navigation.test { + notificationComposeVM.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.TITLE_GROUP_MLC, + action = DataActionWrapper(type = NotificationsActionKey.OPEN_NOTIFICATION_SETTINGS) + ) + ) + advanceUntilIdle() + assertEquals(NotificationsNavigation.NavigateToNotificationsSettings, awaitItem()) + } + } + } + + @Test + fun `test onUIAction select message notification`() { + runTest { + val notId = "notId" + val resourceId = "resourceId" + val type = "mesage" + val subtype = "subtype" + val pullNotification = PullNotification( + notId, null, null, + PullNotificationMessage( + "icon", "title", "shortText", + PushAction(resourceId, type, subtype) + ) + ) + + coEvery { notificationsDataSource.getPullNotificationById(notId) } returns pullNotification + coEvery { notificationHelper.isMessageNotification(type) } returns true + notificationComposeVM.onMessageNotificationSelected.test { + notificationComposeVM.onUIAction( + UIAction( + actionKey = NotificationsActionKey.SELECT_NOTIFICATION, + data = notId + ) + ) + + coVerify(exactly = 1) { notificationsDataSource.getPullNotificationById(notId) } + coVerify(exactly = 1) { notificationsDataSource.markNotificationAsRead(notId) } + + val messageItem = awaitItem().peekContent() + assertEquals(notId, messageItem.notificationId) + assertEquals(resourceId, messageItem.resourceId) + assertEquals(type, messageItem.resourceType) + assertEquals(subtype, messageItem.resourceSubtype) + } + } + } + + @Test + fun `test onUIAction select non message notification`() { + runTest { + val notId = "notId" + val resourceId = "resourceId" + val type = "sometype" + val subtype = "subtype" + val pullNotification = PullNotification( + notId, null, null, + PullNotificationMessage( + "icon", "title", "shortText", + PushAction(resourceId, type, subtype) + ) + ) + + coEvery { notificationsDataSource.getPullNotificationById(notId) } returns pullNotification + coEvery { notificationHelper.isMessageNotification(type) } returns false + notificationComposeVM.openResource.test { + notificationComposeVM.onUIAction( + UIAction( + actionKey = NotificationsActionKey.SELECT_NOTIFICATION, + data = notId + ) + ) + + coVerify(exactly = 1) { notificationsDataSource.getPullNotificationById(notId) } + coVerify(exactly = 1) { notificationsDataSource.markNotificationAsRead(notId) } + + val messageItem = awaitItem().peekContent() + assertEquals(notId, messageItem.notificationId) + assertEquals(resourceId, messageItem.resourceId) + assertEquals(type, messageItem.resourceType) + assertEquals(subtype, messageItem.resourceSubtype) + } + } + } + + @Test + fun `test configureTopBar`() { + notificationComposeVM.configureTopBar() + assertTrue(notificationComposeVM.topBarData[0] is TopGroupOrgData) + val titleGroupMlcData = notificationComposeVM.topBarData[0] as TopGroupOrgData + assertEquals(UiText.DynamicString("Повідомлення"), titleGroupMlcData.titleGroupMlcData!!.heroText) + } + + @Test + fun `test navigateToDirection`() { + runTest { + val notId = "notId" + val resourceId = "resourceId" + val type = "sometype" + val subtype = "subtype" + val item = PullNotificationItemSelection(notId, resourceId, type, subtype) + val navDirections = mockk() + + coEvery { notificationHelper.navigateToDocument(item) } returns navDirections + notificationComposeVM.navigateTo.test { + notificationComposeVM.navigateToDirection(item) + + coVerify(exactly = 1) { notificationHelper.navigateToDocument(item) } + + assertEquals(navDirections, awaitItem()) + } + } + } + + @Test + fun `test configureBody`() { + runTest { + val notId = "notId" + val resourceId = "resourceId" + val type = "sometype" + val subtype = "subtype" + val item = PullNotificationItemSelection(notId, resourceId, type, subtype) + val navDirections = mockk() + + coEvery { notificationHelper.navigateToDocument(item) } returns navDirections + notificationComposeVM.navigateTo.test { + notificationComposeVM.navigateToDirection(item) + + coVerify(exactly = 1) { notificationHelper.navigateToDocument(item) } + + assertEquals(navDirections, awaitItem()) + } + } + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsVMTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsVMTest.kt new file mode 100644 index 0000000..646c668 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/home/notificationsettings/NotificationSettingsVMTest.kt @@ -0,0 +1,173 @@ +package ua.gov.diia.notifications.ui.fragments.home.notificationsettings + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.data.data_source.network.api.notification.ApiNotifications +import ua.gov.diia.notifications.models.notification.SubscribeResponse +import ua.gov.diia.notifications.models.notification.Subscriptions +import java.net.ConnectException + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class NotificationSettingsVMTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var notificationSettingsVM: NotificationSettingsVM + lateinit var apiNotifications: ApiNotifications + lateinit var clientAlertDialogsFactory: ClientAlertDialogsFactory + lateinit var subscriptions: Subscriptions + + @Before + fun setUp() { + apiNotifications = mockk() + clientAlertDialogsFactory = mockk() + + subscriptions = mockk() + coEvery { apiNotifications.getSubscriptions() } returns subscriptions + notificationSettingsVM = NotificationSettingsVM(apiNotifications, clientAlertDialogsFactory) + } + + @Test + fun `test init get subs`() { + + coVerify(exactly = 1) { apiNotifications.getSubscriptions() } + assertEquals(subscriptions, notificationSettingsVM.subscriptions.value) + } + + @Test + fun `test consume exception on get subs with unknown error`() { + runBlocking { + val exception = RuntimeException("error") + val mockTemplate = mockk() + coEvery { apiNotifications.getSubscriptions() } throws exception + coEvery { clientAlertDialogsFactory.unknownErrorAlert(any(), e= any()) } returns mockTemplate + notificationSettingsVM.getSubs() + + coVerify(exactly = 1) { clientAlertDialogsFactory.unknownErrorAlert(false, e= exception) } + assertEquals(mockTemplate, notificationSettingsVM.error.value!!.peekContent()) + } + } + + @Test + fun `test consume exception on get subs with no internet error`() { + runBlocking { + val exception = ConnectException() + val mockTemplate = mockk() + coEvery { apiNotifications.getSubscriptions() } throws exception + coEvery { clientAlertDialogsFactory.alertNoInternet() } returns mockTemplate + notificationSettingsVM.getSubs() + + coVerify(exactly = 1) { clientAlertDialogsFactory.alertNoInternet() } + assertEquals(mockTemplate, notificationSettingsVM.error.value!!.peekContent()) + } + } + + @Test + fun `test success subscribe`() { + runBlocking { + val code = "code" + val subscribeResponse = mockk() + every { subscribeResponse.success } returns true + every { subscribeResponse.template } returns null + coEvery { apiNotifications.subscribe(code) } returns subscribeResponse + notificationSettingsVM.subscribe(code) + + coVerify(exactly = 1) { apiNotifications.subscribe(code) } + assertEquals(true, notificationSettingsVM.getSubs.value) + } + } + + @Test + fun `test error subscribe`() { + runBlocking { + val code = "code" + val subscribeResponse = mockk() + val mockTemplate = mockk() + every { subscribeResponse.template } returns mockTemplate + coEvery { apiNotifications.subscribe(code) } returns subscribeResponse + notificationSettingsVM.subscribe(code) + + coVerify(exactly = 1) { apiNotifications.subscribe(code) } + assertEquals(mockTemplate, notificationSettingsVM.error.value!!.peekContent()) + } + } + + @Test + fun `test subscribe throw exception`() { + runBlocking { + val code = "code" + val exception = RuntimeException() + val mockTemplate = mockk() + coEvery { apiNotifications.subscribe(code) } throws exception + coEvery { clientAlertDialogsFactory.unknownErrorAlert(false, e = exception) } returns mockTemplate + notificationSettingsVM.subscribe(code) + + coVerify(exactly = 1) { apiNotifications.subscribe(code) } + assertEquals(mockTemplate, notificationSettingsVM.error.value!!.peekContent()) + } + } + + @Test + fun `test success unsubscribe`() { + runBlocking { + val code = "code" + val subscribeResponse = mockk() + every { subscribeResponse.success } returns true + every { subscribeResponse.template } returns null + coEvery { apiNotifications.unsubscribe(code) } returns subscribeResponse + notificationSettingsVM.unsubscribe(code) + + coVerify(exactly = 1) { apiNotifications.unsubscribe(code) } + assertEquals(true, notificationSettingsVM.getSubs.value) + } + } + + @Test + fun `test error unsubscribe`() { + runBlocking { + val code = "code" + val subscribeResponse = mockk() + val mockTemplate = mockk() + every { subscribeResponse.template } returns mockTemplate + coEvery { apiNotifications.unsubscribe(code) } returns subscribeResponse + notificationSettingsVM.unsubscribe(code) + + coVerify(exactly = 1) { apiNotifications.unsubscribe(code) } + assertEquals(mockTemplate, notificationSettingsVM.error.value!!.peekContent()) + } + } + + @Test + fun `test unsubscribe throw exception`() { + runBlocking { + val code = "code" + val exception = RuntimeException() + val mockTemplate = mockk() + coEvery { apiNotifications.unsubscribe(code) } throws exception + coEvery { clientAlertDialogsFactory.unknownErrorAlert(false, e = exception) } returns mockTemplate + notificationSettingsVM.unsubscribe(code) + + coVerify(exactly = 1) { apiNotifications.unsubscribe(code) } + assertEquals(mockTemplate, notificationSettingsVM.error.value!!.peekContent()) + } + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullComposeVMTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullComposeVMTest.kt new file mode 100644 index 0000000..56d497f --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationFullComposeVMTest.kt @@ -0,0 +1,346 @@ +package ua.gov.diia.notifications.ui.fragments.notifications.compose + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import app.cash.turbine.test +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.data.data_source.network.api.notification.ApiNotificationsPublic +import ua.gov.diia.core.models.common.message.TextParameter +import ua.gov.diia.core.models.common_compose.general.Action +import ua.gov.diia.core.models.common_compose.general.Body +import ua.gov.diia.core.models.common_compose.general.BottomGroup +import ua.gov.diia.core.models.common_compose.general.TopGroup +import ua.gov.diia.core.models.common_compose.mlc.header.TitleGroupMlc +import ua.gov.diia.core.models.common_compose.mlc.list.ListItemMlc +import ua.gov.diia.core.models.common_compose.mlc.text.TextLabelContainerMlc +import ua.gov.diia.core.models.common_compose.org.header.ChipTabsOrg +import ua.gov.diia.core.models.common_compose.org.header.NavigationPanelMlc +import ua.gov.diia.core.models.common_compose.org.header.TopGroupOrg +import ua.gov.diia.core.models.common_compose.org.list.ListItemGroupOrg +import ua.gov.diia.core.models.notification.pull.message.ArticlePicAtm +import ua.gov.diia.core.models.notification.pull.message.ArticleVideoMlc +import ua.gov.diia.core.models.notification.pull.message.MessageActions +import ua.gov.diia.core.models.notification.pull.message.NotificationFull +import ua.gov.diia.core.network.connectivity.ConnectivityObserver +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.delegation.WithDeeplinkHandling +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.data.data_source.network.api.notification.ApiNotifications +import ua.gov.diia.notifications.models.notification.pull.MessageIdentification +import ua.gov.diia.ui_base.components.atom.media.ArticlePicAtmData +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.media.ArticleVideoMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelContainerMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.list.ListItemGroupOrgData + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class NotificationFullComposeVMTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var notificationFullComposeVM: NotificationFullComposeVM + + lateinit var apiNotifications: ApiNotifications + lateinit var apiNotificationsPublic: ApiNotificationsPublic + lateinit var connectivityObserver: ConnectivityObserver + lateinit var actionLogout: MutableLiveData + lateinit var errorHandling: WithErrorHandlingOnFlow + lateinit var retryLastAction: WithRetryLastAction + lateinit var deepLinkDelegate: WithDeeplinkHandling + + @Before + fun setUp() { + apiNotifications = mockk() + apiNotificationsPublic = mockk() + connectivityObserver = mockk() + actionLogout = mockk() + errorHandling = mockk(relaxed = true) + retryLastAction = mockk(relaxed = true) + deepLinkDelegate = mockk() +// composeMapper = NotificationFullComposeMapperImpl() + + every { connectivityObserver.observe() } returns MutableSharedFlow() + + notificationFullComposeVM = NotificationFullComposeVM( + apiNotifications, + apiNotificationsPublic, + connectivityObserver, + actionLogout, + errorHandling, + retryLastAction, + deepLinkDelegate + ) + } + + @Test + fun `test onUIAction call logout`() { + justRun { actionLogout.postValue(any()) } + notificationFullComposeVM.onUIAction(UIAction(actionKey = UIActionKeysCompose.LIST_ITEM_GROUP_ORG, + action = DataActionWrapper(MessageActions.logout.name))) + verify(exactly = 1) { actionLogout.postValue(any()) } + } + + @Test + fun `test onUIAction call open internal link`() { + runTest { + notificationFullComposeVM.openInternalLink.test { + + val url = "internallink" + notificationFullComposeVM.onUIAction(UIAction(actionKey = UIActionKeysCompose.LIST_ITEM_GROUP_ORG, + action = DataActionWrapper(MessageActions.internalLink.name, null, url))) + + advanceUntilIdle() + assertEquals(url, awaitItem()) + } + } + } + + @Test + fun `test onUIAction open link for externalLink, default, downloadLink actions`() { + runTest { + notificationFullComposeVM.openLink.test { + val data = "event_externalLink" + notificationFullComposeVM.onUIAction(UIAction(actionKey = UIActionKeysCompose.LIST_ITEM_GROUP_ORG, + action = DataActionWrapper(MessageActions.externalLink.name, null, data))) + + assertEquals(data, awaitItem()) + } + notificationFullComposeVM.openLink.test { + val data = "event_default" + notificationFullComposeVM.onUIAction(UIAction(actionKey = UIActionKeysCompose.LIST_ITEM_GROUP_ORG, + action = DataActionWrapper(MessageActions.default.name, null, data))) + + assertEquals(data, awaitItem()) + } + notificationFullComposeVM.openLink.test { + val data = "event_downloadLink" + notificationFullComposeVM.onUIAction(UIAction(actionKey = UIActionKeysCompose.LIST_ITEM_GROUP_ORG, + action = DataActionWrapper(MessageActions.downloadLink.name, null, data))) + + assertEquals(data, awaitItem()) + } + } + } + + @Test + fun `test onUIAction navigate back`() { + runTest { + notificationFullComposeVM.navigation.test { + notificationFullComposeVM.onUIAction(UIAction(actionKey = UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK)) + advanceUntilIdle() + assertEquals(BaseNavigation.Back, awaitItem()) + } + } + } + + @Test + fun `test loadNotification trigger pull notification api`() { + runTest { + val resId = "resid" + val notId = "notid" + + notificationFullComposeVM.loadNotification(MessageIdentification(true, resId, notId)) + + notificationFullComposeVM.contentLoaded.test { + val item = awaitItem() + assertEquals(UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_BACK_NAVIGATION, item.first) + } + coVerify(exactly = 1) { apiNotifications.getPullNotification(notId) } + coVerify(exactly = 0) { apiNotifications.getNotificationByMessageId(resId) } + coVerify(exactly = 0) { apiNotificationsPublic.getMessage(resId) } + + clearAllMocks() + notificationFullComposeVM.loadNotification(MessageIdentification(true, resId, "")) + coVerify(exactly = 1) { apiNotifications.getNotificationByMessageId(resId) } + coVerify(exactly = 0) { apiNotificationsPublic.getMessage(resId) } + coVerify(exactly = 0) { apiNotifications.getPullNotification(notId) } + + clearAllMocks() + notificationFullComposeVM.loadNotification(MessageIdentification(false, resId, "")) + coVerify(exactly = 1) { apiNotificationsPublic.getMessage(resId) } + coVerify(exactly = 0) { apiNotifications.getPullNotification(notId) } + coVerify(exactly = 0) { apiNotifications.getNotificationByMessageId(resId) } + + clearAllMocks() + notificationFullComposeVM.loadNotification(MessageIdentification(false, null, "")) + coVerify(exactly = 0) { apiNotificationsPublic.getMessage(resId) } + coVerify(exactly = 0) { apiNotifications.getPullNotification(notId) } + coVerify(exactly = 0) { apiNotifications.getNotificationByMessageId(resId) } + } + } + + @Test + fun `test loadNotification check mapping of top bar data`() { + runTest { + val topGroupOrg = TopGroup( + TopGroupOrg( + ChipTabsOrg(listOf(), "label", "preselectedCode"), + NavigationPanelMlc(listOf(), "label"), + TitleGroupMlc( + heroText = "heroText", + leftNavIcon = TitleGroupMlc.LeftNavIcon( + "accessibilityDescription", + Action("type", "subtype", "resource"), + "code" + ), + mediumIconRight = TitleGroupMlc.MediumIconRight( + action = Action( + "type", + "subtype", + "resourse" + ), + "code" + ), + label = "label" + ) + ), null + ) + coEvery { apiNotifications.getPullNotification(any()) } returns NotificationFull( + null, + null, + listOf(topGroupOrg) + ) + notificationFullComposeVM.loadNotification(MessageIdentification(true, "resId", "notId")) + advanceUntilIdle() + + //Check top bar data + val uiElemenet: UIElementData = notificationFullComposeVM.topBarData[0] + assertTrue(uiElemenet is TopGroupOrgData) + val titleGroupMlcData = uiElemenet as TopGroupOrgData + assertEquals(UiText.DynamicString(topGroupOrg.topGroupOrg!!.titleGroupMlc!!.heroText!!), titleGroupMlcData.titleGroupMlcData!!.heroText) + assertEquals(topGroupOrg.topGroupOrg!!.titleGroupMlc!!.mediumIconRight!!.action!!.resource!!, titleGroupMlcData.titleGroupMlcData!!.mediumIconRight!!.action!!.resource) + assertEquals(UiText.DynamicString(topGroupOrg.topGroupOrg!!.titleGroupMlc!!.label!!), titleGroupMlcData.titleGroupMlcData!!.label) + } + } + @Test + fun `test loadNotification check mapping of body data`() { + runTest { + val body = mutableListOf() + val textParamData = TextParameter.Data("alt", "name", "resource") + val textParam = TextParameter( + data = textParamData, + type = "type" + ) + val itemListViewOrgItem = ListItemMlc(Action("ResourceItemListViewOrgItem", "SubTypeItemListViewOrgItem", "resource"), + "DescriptionItemListViewOrgItem", ListItemMlc.IconLeft("IconLeftItemListViewOrgItem"), ListItemMlc.IconRight("IconRightItemListViewOrgItem"), "LabelItemListViewOrgItem", "LogoLeftItemListViewOrgItem", "StateItemListViewOrgItem", "state") + + body.add( + Body( + articlePicAtm = ArticlePicAtm("ArticlePicAtmImage"), + articleVideoMlc = ArticleVideoMlc("ArticleVideoAtm", null), + listItemGroupOrg = ListItemGroupOrg(listOf(itemListViewOrgItem), "title"), + textLabelContainerMlc = TextLabelContainerMlc("label", "text", listOf(textParam)), + btnIconRoundedGroupOrg = null, halvedCardCarouselOrg = null, + imageCardMlc = null, sectionTitleAtm = null, smallNotificationCarouselOrg = null, + verticalCardCarouselOrg = null, mediaTitleOrg = null, articlePicCarouselOrg = null, + whiteCardMlc = null + ) + ) + + coEvery { apiNotifications.getPullNotification(any()) } returns NotificationFull( + body, + null, + null + ) + notificationFullComposeVM.loadNotification(MessageIdentification(true, "resId", "notId")) + advanceUntilIdle() + + //Check body + val textContainer = notificationFullComposeVM.bodyData[0] as TextLabelContainerMlcData + val articlePicAtmImage = notificationFullComposeVM.bodyData[1] as ArticlePicAtmData + val articleVideoAtmVideo = notificationFullComposeVM.bodyData[2] as ArticleVideoMlcData + val itemListViewOrgItems = notificationFullComposeVM.bodyData[3] as ListItemGroupOrgData + val itemListViewOrgItemResult = itemListViewOrgItems.itemsList[0] + //check mapping body textContainer + val bodyItem = body[0]; + val parameter = textContainer.data!!.parameters!![0] + val parameterData = parameter.data!! + assertEquals(UiText.DynamicString(bodyItem.textLabelContainerMlc!!.text!!), textContainer.data!!.text) + assertEquals(textParam.type, parameter.type) + assertEquals(UiText.DynamicString(textParamData.alt!!), parameterData.alt) + assertEquals(UiText.DynamicString(textParamData.name!!), parameterData.name) + assertEquals(UiText.DynamicString(textParamData.resource!!), parameterData.resource) + + //check mapping body articlePicAtmImage + assertEquals(bodyItem.articlePicAtm!!.image , articlePicAtmImage.url) + + //check mapping body articleVideoAtm + assertEquals(bodyItem.articleVideoMlc!!.source , articleVideoAtmVideo.url) + + //check mapping body itemListViewOrgItems + assertEquals(UiText.DynamicString(itemListViewOrgItem.label!!), itemListViewOrgItemResult.label!!) + assertEquals(itemListViewOrgItem.action!!.type, itemListViewOrgItemResult.action!!.type) + assertEquals(itemListViewOrgItem.action!!.resource, itemListViewOrgItemResult.action!!.resource) + assertEquals(UiText.DynamicString(itemListViewOrgItem.description!!), itemListViewOrgItemResult.description) + assertEquals(itemListViewOrgItem.iconLeft!!.code!!, itemListViewOrgItemResult.iconLeft!!.code) + assertEquals(itemListViewOrgItem.iconRight!!.code!!, itemListViewOrgItemResult.iconRight!!.code) + assertEquals( + UiIcon.DynamicIconBase64(itemListViewOrgItem.logoLeft!!), itemListViewOrgItemResult.logoLeft!!) + assertEquals("LabelItemListViewOrgItem", itemListViewOrgItemResult.id) + } + } + + @Test + fun `test loadNotification check mapping of bottom data`() { + runTest { + val itemListViewOrgItem = ListItemMlc( + Action("ResourceItemListViewOrgItem", "SubTypeItemListViewOrgItem", "respurce"), + "DescriptionItemListViewOrgItem", + ListItemMlc.IconLeft("IconLeftItemListViewOrgItem"), ListItemMlc.IconRight("IconRightItemListViewOrgItem"), "LabelItemListViewOrgItem", "LogoLeftItemListViewOrgItem", "StateItemListViewOrgItem", "state") + + val bottomGroup = mutableListOf() + bottomGroup.add(BottomGroup(ListItemGroupOrg(listOf(itemListViewOrgItem), "ItemListViewOrgTitle"))) + + coEvery { apiNotifications.getPullNotification(any()) } returns NotificationFull( + null, + bottomGroup, + null + ) + notificationFullComposeVM.loadNotification(MessageIdentification(true, "resId", "notId")) + advanceUntilIdle() + + //Check bottom data mapping + val bottomListItem = notificationFullComposeVM.bottomData[0] as ListItemGroupOrgData + val bottomItem = bottomListItem.itemsList[0] + assertEquals(UiText.DynamicString(itemListViewOrgItem.label!!), bottomItem.label) + assertEquals(itemListViewOrgItem.action!!.type, bottomItem.action!!.type) + assertEquals(itemListViewOrgItem.action!!.resource, bottomItem.action!!.resource) + assertEquals(UiText.DynamicString(itemListViewOrgItem.description!!), bottomItem.description) + assertEquals(itemListViewOrgItem.iconLeft!!.code!!, bottomItem.iconLeft!!.code) + assertEquals(itemListViewOrgItem.iconRight!!.code!!, bottomItem.iconRight!!.code) + assertEquals(UiIcon.DynamicIconBase64(itemListViewOrgItem.logoLeft!!), bottomItem.logoLeft!!) + assertEquals("LabelItemListViewOrgItem", bottomItem.id) + } + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationPagingSourceComposeTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationPagingSourceComposeTest.kt new file mode 100644 index 0000000..a43750f --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationPagingSourceComposeTest.kt @@ -0,0 +1,286 @@ +package ua.gov.diia.notifications.ui.fragments.notifications.compose + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.paging.PagingSource +import androidx.paging.PagingState +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.models.notification.LoadingState +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.models.notification.pull.PullNotificationMessage +import ua.gov.diia.notifications.models.notification.pull.PullNotificationSyncAction +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationPagingSourceCompose +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsActionKey +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsMapperCompose +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsMapperComposeImpl +import ua.gov.diia.ui_base.components.molecule.message.MessageMoleculeData + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class NotificationPagingSourceComposeTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var notificationPagingSourceCompose: NotificationPagingSourceCompose + + lateinit var notificationDataSource: NotificationDataRepository + lateinit var composeMapper: NotificationsMapperCompose + + @Before + fun setUp() { + notificationDataSource = mockk(relaxed = true) + composeMapper = NotificationsMapperComposeImpl() + notificationPagingSourceCompose = NotificationPagingSourceCompose(notificationDataSource, composeMapper) { + } + } + + @Test + fun `test getRefreshKey call getTotalSize from data source`() { + runBlocking { + val pair = mockk>(relaxed = true) + every { pair.prevKey } returns 0 + val state = mockk>(relaxed = true) + coEvery { state.closestPageToPosition(any()) } returns pair + coEvery { notificationDataSource.getTotalSize() } returns 1 + notificationPagingSourceCompose.getRefreshKey(state) + + coVerify(exactly = 1) { notificationDataSource.getTotalSize() } + } + } + + @Test + fun `test getRefreshKey find anchor and apply it to current state`() { + runBlocking { + val prevKey = 0 + val pair = mockk>(relaxed = true) + every { pair.prevKey } returns prevKey + val state = mockk>(relaxed = true) + every { state.anchorPosition } returns 2 + coEvery { state.closestPageToPosition(any()) } returns pair + coEvery { notificationDataSource.getTotalSize() } returns 1 + val result = notificationPagingSourceCompose.getRefreshKey(state) + + coVerify(exactly = 1) { state.closestPageToPosition(1) } + assertEquals(prevKey, result) + } + } + + @Test + fun `test getRefreshKey return null if closestPageToPosition returns null`() { + runBlocking { + val state = mockk>(relaxed = true) + every { state.anchorPosition } returns 2 + coEvery { state.closestPageToPosition(any()) } returns null + coEvery { notificationDataSource.getTotalSize() } returns 1 + val result = notificationPagingSourceCompose.getRefreshKey(state) + + assertEquals(null, result) + } + } + + @Test + fun `test getRefreshKey return null if state anchorPosition is null`() { + runBlocking { + val state = mockk>(relaxed = true) + every { state.anchorPosition } returns null + coEvery { notificationDataSource.getTotalSize() } returns 1 + val result = notificationPagingSourceCompose.getRefreshKey(state) + + assertEquals(null, result) + } + } + + @Test + fun `test check load for first page loading`() { + val loadState = mutableListOf() + + notificationPagingSourceCompose = NotificationPagingSourceCompose(notificationDataSource, composeMapper) { + loadState.add(it) + } + runBlocking { + val params = spyk(mockk>(relaxed = true)) + coEvery { params.key } returns null + + notificationPagingSourceCompose.load(params) + + assertEquals(LoadingState.FIRST_PAGE_LOADING, loadState[0]) + assertEquals(LoadingState.NOT_LOADING, loadState[1]) + assertEquals(LoadingState.NOT_LOADING, loadState[2]) + } + } + + @Test + fun `test check load for next page loading`() { + + val loadState = mutableListOf() + + notificationPagingSourceCompose = NotificationPagingSourceCompose(notificationDataSource, composeMapper) { + loadState.add(it) + } + runBlocking { + loadState.clear() + val params = spyk(mockk>(relaxed = true)) + coEvery { params.key } returns 1 + + notificationPagingSourceCompose.load(params) + + assertEquals(LoadingState.ADDITIONAL_PAGE_LOADING, loadState[0]) + assertEquals(LoadingState.NOT_LOADING, loadState[1]) + assertEquals(LoadingState.NOT_LOADING, loadState[2]) + } + } + + @Test + fun `test load data from notificationDataSource with skip value as null`() { + runBlocking { + val pageSize = 50 + val params = spyk(mockk>(relaxed = true)) + coEvery { params.key } returns null + coEvery { params.loadSize } returns pageSize + + clearMocks(notificationDataSource) + notificationPagingSourceCompose.load(params) + + coVerify(exactly = 1) { notificationDataSource.loadDataFromNetwork(0, pageSize, any()) } + coVerify(exactly = 2) { notificationDataSource.getTotalSize() } + coVerify(exactly = 1) { notificationDataSource.getPage(0, pageSize) } + } + } + @Test + fun `test load data from notificationDataSource with skip value as 2`() { + runBlocking { + val pageSize = 50 + val params = spyk(mockk>(relaxed = true)) + coEvery { params.key } returns 2 + coEvery { params.loadSize } returns pageSize + + notificationPagingSourceCompose.load(params) + + coVerify(exactly = 1) { notificationDataSource.loadDataFromNetwork(2, pageSize, any()) } + coVerify(exactly = 2) { notificationDataSource.getTotalSize() } + coVerify(exactly = 1) { notificationDataSource.getPage(2, pageSize) } + } + } + + @Test + fun `test load gathering results`() { + runBlocking { + val pageSize = 50 + val totalSize = 150 + val skip = 50 + val params = spyk(mockk>(relaxed = true)) + coEvery { params.key } returns skip + coEvery { params.loadSize } returns pageSize + coEvery { notificationDataSource.getTotalSize() } returns totalSize + + val result = notificationPagingSourceCompose.load(params) + + assertTrue(result is PagingSource.LoadResult.Page) + val page = result as PagingSource.LoadResult.Page + assertEquals(skip - pageSize, page.prevKey) + assertEquals(skip + pageSize, page.nextKey) + assertTrue(page.data.isEmpty()) + assertEquals(skip, page.itemsBefore) + assertEquals(totalSize - skip + pageSize, page.itemsAfter) + } + } + + @Test + fun `test load mapping results`() { + runBlocking { + val pageSize = 50 + val totalSize = 150 + val skip = 50 + val params = spyk(mockk>(relaxed = true)) + coEvery { params.key } returns skip + coEvery { params.loadSize } returns pageSize + coEvery { notificationDataSource.getTotalSize() } returns totalSize + val notificationsList = mutableListOf() + val notificationOne = PullNotification("notId", "creationDate", false, PullNotificationMessage("icon", "notTitle", "notShortTitle", null), PullNotificationSyncAction.NONE) + notificationsList.add(notificationOne) + coEvery { notificationDataSource.getPage(any(), any()) } returns notificationsList + + val result = notificationPagingSourceCompose.load(params) + + assertTrue(result is PagingSource.LoadResult.Page) + val page = result as PagingSource.LoadResult.Page + val pageItemOne = page.data[0] + + assertEquals(NotificationsActionKey.SELECT_NOTIFICATION, pageItemOne.actionKey) + assertEquals(notificationOne.pullNotificationMessage!!.title, pageItemOne.title) + assertEquals(notificationOne.pullNotificationMessage!!.shortText, pageItemOne.shortText) + assertEquals(notificationOne.creationDate, pageItemOne.creationDate) + assertEquals(notificationOne.isRead, pageItemOne.isRead) + assertEquals(notificationOne.notificationId, pageItemOne.notificationId) + assertEquals(notificationOne.notificationId, pageItemOne.id) + assertEquals(notificationOne.syncAction, pageItemOne.syncAction) + } + } + + @Test + fun `test load removing deleted notification`() { + runBlocking { + val pageSize = 50 + val totalSize = 150 + val skip = 50 + val params = spyk(mockk>(relaxed = true)) + coEvery { params.key } returns skip + coEvery { params.loadSize } returns pageSize + coEvery { notificationDataSource.getTotalSize() } returns totalSize + val notificationOne = PullNotification("notId", "creationDate", false, PullNotificationMessage("icon", "notTitle", "notShortTitle", null), PullNotificationSyncAction.NONE) + val notificationTwo = PullNotification("notId", "creationDate", false, PullNotificationMessage("icon", "notTitle", "notShortTitle", null), + PullNotificationSyncAction.REMOVE) + val notificationsList = mutableListOf(notificationOne, notificationTwo) + + coEvery { notificationDataSource.getPage(any(), any()) } returns notificationsList + + val result = notificationPagingSourceCompose.load(params) + + assertTrue(result is PagingSource.LoadResult.Page) + val page = result as PagingSource.LoadResult.Page + + assertEquals(1, page.data.size) + + val pageItemOne = page.data[0] + + assertEquals(notificationOne.notificationId, pageItemOne.notificationId) + assertEquals(notificationOne.notificationId, pageItemOne.id) + } + } + + @Test + fun `test load catch error`() { + runBlocking { + val params = mockk>(relaxed = true) + val error = RuntimeException("Error") + + coEvery { notificationDataSource.getPage(any(), any()) } throws error + + val result = notificationPagingSourceCompose.load(params) + + assertTrue(result is PagingSource.LoadResult.Error) + val errorPage = result as PagingSource.LoadResult.Error + assertEquals(error, errorPage.throwable) + } + } +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationsMapperComposeTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationsMapperComposeTest.kt new file mode 100644 index 0000000..d8ea76b --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/ui/fragments/notifications/compose/NotificationsMapperComposeTest.kt @@ -0,0 +1,68 @@ +package ua.gov.diia.notifications.ui.fragments.notifications.compose + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.models.notification.pull.PullNotification +import ua.gov.diia.notifications.models.notification.pull.PullNotificationSyncAction +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsMapperCompose +import ua.gov.diia.notifications.ui.fragments.home.notifications.compose.NotificationsMapperComposeImpl +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.message.MessageMoleculeData +import ua.gov.diia.ui_base.components.molecule.message.StubMessageMlcData + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class NotificationsMapperComposeTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + lateinit var notificationsMapperComposeTestObject: NotificationsMapperComposeTestObject + + @Before + fun setUp() { + notificationsMapperComposeTestObject = NotificationsMapperComposeTestObject(NotificationsMapperComposeImpl()) + } + + @Test + fun `test remap StubMessageMlcData`() { + runBlocking { + val item = StubMessageMlcData(UiText.DynamicString("icon"), UiText.DynamicString("title")) + val result = notificationsMapperComposeTestObject.mapStubMessageMlcData(item) + + assertEquals(item.icon, result.icon) + assertEquals(item.title, result.title) + } + } + + @Test + fun `test map PullNotification to MessageMoleculeData`() { + runBlocking { + val notificationOne = PullNotification("notId", "creationDate", false, null, PullNotificationSyncAction.NONE) + val result = notificationsMapperComposeTestObject.mapPullNotificationToMessageMoleculeData(notificationOne) + + assertEquals(null, result.shortText) + assertEquals(null, result.title) + } + } + + class NotificationsMapperComposeTestObject(private val composeMapper: NotificationsMapperCompose,): NotificationsMapperCompose by composeMapper { + fun mapStubMessageMlcData(item: StubMessageMlcData): StubMessageMlcData { + return item.toComposeEmptyStateErrorMoleculeData() + } + fun mapPullNotificationToMessageMoleculeData(item: PullNotification): MessageMoleculeData { + return item.toComposeMessage() + } + } +} diff --git a/notifications/src/test/java/ua/gov/diia/notifications/util/settings_action/PushTokenUpdateActionExecutorTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/util/settings_action/PushTokenUpdateActionExecutorTest.kt new file mode 100644 index 0000000..6b97dc1 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/util/settings_action/PushTokenUpdateActionExecutorTest.kt @@ -0,0 +1,262 @@ +package ua.gov.diia.notifications.util.settings_action + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.google.common.util.concurrent.ListenableFuture +import io.mockk.coVerify +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.NotificationsConst +import ua.gov.diia.notifications.store.NotificationsPreferences +import ua.gov.diia.notifications.util.notification.push.PushTokenProvider +import ua.gov.diia.notifications.work.SendPushTokenWork +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class PushTokenUpdateActionExecutorTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + + lateinit var pushTokenProvider: PushTokenProvider + lateinit var workManager: WorkManager + lateinit var diiaStorage: DiiaStorage + lateinit var withCrashlytics: WithCrashlytics + + lateinit var pushTokenUpdateActionExecutor: PushTokenUpdateActionExecutor + + @Before + fun setUp() { + pushTokenProvider = mockk() + workManager = mockk() + diiaStorage = mockk() + withCrashlytics = mockk(relaxed = true) + + pushTokenUpdateActionExecutor = PushTokenUpdateActionExecutor(pushTokenProvider, workManager, diiaStorage, withCrashlytics) + } + + fun prepListenableFuture(state: WorkInfo.State = WorkInfo.State.SUCCEEDED) { + val workInfo = mockk(relaxed = true) + every { workInfo.state } returns state + + val feature = object : ListenableFuture> { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + return false + } + override fun isCancelled(): Boolean { + return false + } + override fun isDone(): Boolean { + return true + } + override fun get(): List { + return listOf(workInfo) + } + override fun get(timeout: Long, unit: TimeUnit?): List { + return listOf(workInfo) + } + override fun addListener(listener: Runnable, executor: Executor) {} + } + + every { workManager.getWorkInfosForUniqueWork(any()) } returns feature + } + @Test + fun `test executeAction`() { + runBlocking { + val token = "token" + mockkObject(SendPushTokenWork) + justRun { SendPushTokenWork.enqueue(workManager, token) } + prepListenableFuture() + + every { diiaStorage.get(NotificationsPreferences.PushToken, null) } returns null + every { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } returns token + + pushTokenUpdateActionExecutor.executeAction() + + verify(exactly = 1) { workManager.getWorkInfosForUniqueWork(NotificationsConst.WORK_NAME_PUSH_TOKEN_UPDATE) } + verify(exactly = 1) { diiaStorage.get(NotificationsPreferences.PushToken, null) } + verify(exactly = 1) { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } + verify { SendPushTokenWork.enqueue(workManager, token) } + } + } + @Test + fun `test executeAction not requestCurrentPushToken if token is not null`() { + runBlocking { + val token = "token" + mockkObject(SendPushTokenWork) + justRun { SendPushTokenWork.enqueue(workManager, token) } + prepListenableFuture() + + every { diiaStorage.get(NotificationsPreferences.PushToken, null) } returns token + + pushTokenUpdateActionExecutor.executeAction() + + verify(exactly = 0) { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } + } + } + + @Test + fun `test executeAction send non fatal if requestCurrentPushToken throw error`() { + runBlocking { + val token = "token" + mockkObject(SendPushTokenWork) + justRun { SendPushTokenWork.enqueue(workManager, token) } + prepListenableFuture() + every { diiaStorage.get(NotificationsPreferences.PushToken, null) } returns null + val exception = mockk() + every { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } throws exception + + pushTokenUpdateActionExecutor.executeAction() + + coVerify(exactly = 1) { withCrashlytics.sendNonFatalError(exception) } + } + } + + @Test + fun `test actionKey validation`() { + assertEquals("pushTokenUpdate", pushTokenUpdateActionExecutor.actionKey) + } + + @Test + fun `test executeAction if feature list is empty`() { + runBlocking { + val token = "token" + mockkObject(SendPushTokenWork) + justRun { SendPushTokenWork.enqueue(workManager, token) } + + val feature = object : ListenableFuture> { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + return false + } + override fun isCancelled(): Boolean { + return false + } + override fun isDone(): Boolean { + return true + } + override fun get(): List { + return listOf() + } + override fun get(timeout: Long, unit: TimeUnit?): List { + return listOf() + } + override fun addListener(listener: Runnable, executor: Executor) {} + } + + every { workManager.getWorkInfosForUniqueWork(any()) } returns feature + + every { diiaStorage.get(NotificationsPreferences.PushToken, null) } returns null + every { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } returns token + + pushTokenUpdateActionExecutor.executeAction() + + verify(exactly = 1) { workManager.getWorkInfosForUniqueWork(NotificationsConst.WORK_NAME_PUSH_TOKEN_UPDATE) } + verify(exactly = 1) { diiaStorage.get(NotificationsPreferences.PushToken, null) } + verify(exactly = 1) { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } + verify { SendPushTokenWork.enqueue(workManager, token) } + } + } + @Test + fun `test executeAction only with worker in RUNNING state`() { + runBlocking { + val token = "token" + mockkObject(SendPushTokenWork) + justRun { SendPushTokenWork.enqueue(workManager, token) } + val workInfo = mockk(relaxed = true) + every { workInfo.state } returns WorkInfo.State.RUNNING + + val feature = object : ListenableFuture> { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + return false + } + override fun isCancelled(): Boolean { + return false + } + override fun isDone(): Boolean { + return true + } + override fun get(): List { + return listOf(workInfo) + } + override fun get(timeout: Long, unit: TimeUnit?): List { + return listOf(workInfo) + } + override fun addListener(listener: Runnable, executor: Executor) {} + } + + every { workManager.getWorkInfosForUniqueWork(any()) } returns feature + + every { diiaStorage.get(NotificationsPreferences.PushToken, null) } returns null + every { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } returns token + + pushTokenUpdateActionExecutor.executeAction() + + verify(exactly = 1) { workManager.getWorkInfosForUniqueWork(NotificationsConst.WORK_NAME_PUSH_TOKEN_UPDATE) } + verify(exactly = 0) { diiaStorage.get(NotificationsPreferences.PushToken, null) } + verify(exactly = 0) { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } + } + } + + @Test + fun `test executeAction only with worker in ENQUEUED state`() { + runBlocking { + val token = "token" + mockkObject(SendPushTokenWork) + justRun { SendPushTokenWork.enqueue(workManager, token) } + val workInfo = mockk(relaxed = true) + every { workInfo.state } returns WorkInfo.State.ENQUEUED + + val feature = object : ListenableFuture> { + override fun cancel(mayInterruptIfRunning: Boolean): Boolean { + return false + } + override fun isCancelled(): Boolean { + return false + } + override fun isDone(): Boolean { + return true + } + override fun get(): List { + return listOf(workInfo) + } + override fun get(timeout: Long, unit: TimeUnit?): List { + return listOf(workInfo) + } + override fun addListener(listener: Runnable, executor: Executor) {} + } + + every { workManager.getWorkInfosForUniqueWork(any()) } returns feature + + every { diiaStorage.get(NotificationsPreferences.PushToken, null) } returns null + every { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } returns token + + pushTokenUpdateActionExecutor.executeAction() + + verify(exactly = 1) { workManager.getWorkInfosForUniqueWork(NotificationsConst.WORK_NAME_PUSH_TOKEN_UPDATE) } + verify(exactly = 0) { diiaStorage.get(NotificationsPreferences.PushToken, null) } + verify(exactly = 0) { pushTokenProvider.requestCurrentPushToken(forceRefresh = false) } + } + } + +} \ No newline at end of file diff --git a/notifications/src/test/java/ua/gov/diia/notifications/work/SendPushTokenProcessorTest.kt b/notifications/src/test/java/ua/gov/diia/notifications/work/SendPushTokenProcessorTest.kt new file mode 100644 index 0000000..4f2e721 --- /dev/null +++ b/notifications/src/test/java/ua/gov/diia/notifications/work/SendPushTokenProcessorTest.kt @@ -0,0 +1,77 @@ +package ua.gov.diia.notifications.work + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.work.ListenableWorker +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.data.data_source.network.api.notification.ApiNotificationsPublic +import ua.gov.diia.core.models.PushToken +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.notifications.MainDispatcherRule +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSource + +@ExperimentalCoroutinesApi +@RunWith(MockitoJUnitRunner::class) +class SendPushTokenProcessorTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + var rule: TestRule = InstantTaskExecutorRule() + lateinit var apiNotificationsPublic: ApiNotificationsPublic + lateinit var authorizationRepository: AuthorizationRepository + lateinit var keyValueSource: KeyValueNotificationDataSource + + lateinit var sendPushTokenProcessor: SendPushTokenProcessor + + @Before + fun setUp() { + apiNotificationsPublic = mockk(relaxed = true) + authorizationRepository = mockk(relaxed = true) + keyValueSource = mockk(relaxed = true) + + sendPushTokenProcessor = SendPushTokenProcessor(apiNotificationsPublic, authorizationRepository, keyValueSource) + } + + @Test + fun `test syncPushNotification send push token and authToken is null`() { + runBlocking { + val token = "token" + coEvery { authorizationRepository.getToken() } returns null + val result = sendPushTokenProcessor.syncPushNotification(token) + + coVerify (exactly = 1) { keyValueSource.setPushToken(token) } + coVerify (exactly = 1) { authorizationRepository.getToken() } + coVerify (exactly = 0) { apiNotificationsPublic.sendDeviceUserPushToken(PushToken(token)) } + coVerify (exactly = 0) { keyValueSource.setIsPushTokenSynced(synced = true) } + assertEquals(ListenableWorker.Result.success(), result) + } + } + + @Test + fun `test syncPushNotification send push token and authToken is not null`() { + runBlocking { + val token = "token" + val authToken = "authToken" + coEvery { authorizationRepository.getToken() } returns authToken + + val result = sendPushTokenProcessor.syncPushNotification(token) + + coVerify (exactly = 1) { keyValueSource.setPushToken(token) } + coVerify (exactly = 1) { authorizationRepository.getToken() } + coVerify (exactly = 1) { apiNotificationsPublic.sendDeviceUserPushToken(PushToken(token)) } + coVerify (exactly = 1) { keyValueSource.setIsPushTokenSynced(synced = true) } + assertEquals(ListenableWorker.Result.success(), result) + } + } +} \ No newline at end of file diff --git a/opensource/.gitignore b/opensource/.gitignore new file mode 100644 index 0000000..896eeae --- /dev/null +++ b/opensource/.gitignore @@ -0,0 +1,2 @@ +/build +/agconnect-services.json \ No newline at end of file diff --git a/opensource/README.md b/opensource/README.md new file mode 100644 index 0000000..5a673c1 --- /dev/null +++ b/opensource/README.md @@ -0,0 +1,21 @@ + +# Description + +To build you are required to have the dependency [Android Studio](https://developer.android.com/studio) installed. You can then follow these instructions: + +1. Clone or download this repository +2. Open the project in Android Studio and run it from there or build an APK directly through Gradle: + ``` ./gradlew :opensource:assembleGplayDebug``` + *NOTE: Android SDK should be added to PATH environment variable for this to work.* + +Deploy to Device/Emulator: +```./gradlew :opensource:installGplayDebug``` +*NOTE: You can also replace the "Debug" with "Release" to get an optimized release binary.* + +Before building Huawei specific app generate and place agconnect-services.json file in opensource module. + +For build Huawei specific APK file use next command: +```./gradlew :opensource:assembleHuaweiDebug``` + +To deploy to device/emulator for Huawei use this: +```./gradlew :opensource:installHuaweiDebug``` \ No newline at end of file diff --git a/opensource/build.gradle b/opensource/build.gradle new file mode 100644 index 0000000..918a98b --- /dev/null +++ b/opensource/build.gradle @@ -0,0 +1,223 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-parcelize' +apply plugin: 'kotlin-kapt' +apply plugin: 'androidx.navigation.safeargs.kotlin' +apply plugin: 'dagger.hilt.android.plugin' +apply plugin: 'com.google.gms.google-services' +apply plugin: 'com.google.firebase.crashlytics' +apply plugin: 'com.huawei.agconnect' + +apply from: '../dependencies.gradle' + +android { + + namespace 'ua.gov.diia.opensource' + + signingConfigs { + debug { + def props = new Properties() + def propsFile = file('keys/debug.properties') + if (propsFile.canRead()) { + def fileInputStream = new FileInputStream(propsFile) + props.load(fileInputStream) + fileInputStream.close() + + storeFile = file(props['storePath']) + storePassword = props['storePassword'] + keyAlias = props['keyAlias'] + keyPassword = props['keyPassword'] + } + } + } + compileSdk 34 + packagingOptions { + excludes += ['META-INF/ASL-2.0.txt', 'META-INF/LGPL-3.0.txt'] + pickFirsts += ['draftv4/schema', 'draftv3/schema'] + pickFirst 'lib/x86/libc++_shared.so' + pickFirst 'lib/x86_64/libc++_shared.so' + pickFirst 'lib/armeabi-v7a/libc++_shared.so' + pickFirst 'lib/arm64-v8a/libc++_shared.so' + } + + + def versionProps = new Properties() + def fileInputStream = new FileInputStream(file('version.properties')) + versionProps.load(fileInputStream) + fileInputStream.close() + + defaultConfig { + applicationId "ua.gov.diia.opensource" + minSdkVersion 23 + targetSdkVersion 34 + versionCode versionProps['VERSION_CODE'].toInteger() + versionName versionProps['VERSION_NAME'].toString() + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary true + multiDexEnabled true + + buildConfigField "long", "TOKEN_LEEWAY", '1' + } + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildFeatures { + dataBinding = true + compose = true + } + + buildTypes { + release { + minifyEnabled true + shrinkResources true + proguardFiles getDefaultProguardFile( + 'proguard-android-optimize.txt'), + 'proguard-rules.pro' + buildConfigField "String", "SERVER_URL", '"https://api2oss.diia.gov.ua"' + buildConfigField "String", "BANK_ID_CALLBACK_URL", '"https://api2oss.diia.gov.ua/api/v1/auth/bank-id/code/callback"' + multiDexKeepProguard file('multidex_keep_file.pro') + } + debug { + minifyEnabled false + shrinkResources false + signingConfig signingConfigs.debug + proguardFiles getDefaultProguardFile( + 'proguard-android-optimize.txt'), + 'proguard-rules.pro' + buildConfigField "String", "SERVER_URL", '"https://api2oss.diia.gov.ua"' + buildConfigField "String", "BANK_ID_CALLBACK_URL", '"https://api2oss.diia.gov.ua/api/v1/auth/bank-id/code/callback"' + multiDexKeepProguard file('multidex_keep_file.pro') + } + } + + applicationVariants.all { variant -> + if (variant.flavorName == "huawei") { + variant.buildConfigField "String", "PLATFORM_TYPE", '"Huawei"' + } else { + variant.buildConfigField "String", "PLATFORM_TYPE", '"Android"' + } + } + configurations.all { + resolutionStrategy { + force 'com.squareup.okhttp3:okhttp:3.12.2' + force 'com.squareup.okhttp3:logging-interceptor:3.12.2' + } + } + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + // work-runtime-ktx 2.1.0 and above now requires Java 8 + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + +} + +dependencies { + implementation project(':core') + implementation project(':splash') + implementation project(':home') + implementation project(':menu') + implementation project(':web') + implementation project(':verification') + implementation project(':bankid') + implementation project(':login') + implementation project(':publicservice') + implementation project(':ps_criminal_cert') + implementation project(':pin') + implementation project(':biometric') + implementation project(':notifications') + implementation project(':analytics') + implementation project(':search') + implementation project(':address_search') + implementation project(':diia_storage') + implementation project(':documents') + implementation project(':doc_driver_license') + implementation deps.activity_ktx + implementation deps.fragment_ktx + implementation deps.legacy_support + implementation deps.appcompat + + implementation deps.light_compressor + //kotlin + implementation deps.core_ktx + //constraint + implementation deps.constraint_layout + //lifecycle + implementation deps.lifecycle_extensions + implementation deps.lifecycle_livedata_ktx + implementation deps.lifecycle_viewmodel_ktx + //work + implementation deps.work_runtime_ktx + //navigation + implementation deps.navigation_fragment_ktx + implementation deps.navigation_ui_ktx + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + implementation deps.hilt_work + kapt deps.hilt_compiler + //recycler + implementation deps.recyclerview + //viewpager + implementation deps.viewpager + //glide + implementation deps.glide + kapt deps.glide_compiler + + implementation deps.glide_okhttp + //material + implementation deps.material + //coroutine + implementation deps.kotlinx_coroutines_android + implementation deps.kotlinx_coroutines_core + //retrofit + implementation deps.retrofit + //okhttp + implementation(deps.okhttp) + implementation (deps.okhttp_logging_interceptor) + implementation deps.retrofit_kotlin_coroutines_adapter + implementation deps.retrofit_gson_converter + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + //Desugaring + coreLibraryDesugaring deps.desugar_jdk_libs + + //Compose + implementation deps.activity_compose + implementation project(path: ':ui_base') + + //Firebase SDK + gplayImplementation platform(deps.gplay_firebase_bom) + gplayImplementation deps.gplay_firebase_crashlytics + //Huawei SDK + huaweiImplementation deps.huawei_agconnect_crash +} \ No newline at end of file diff --git a/opensource/google-services.json b/opensource/google-services.json new file mode 100644 index 0000000..d3fe4b2 --- /dev/null +++ b/opensource/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "700821823914", + "project_id": "diiaoss", + "storage_bucket": "diiaoss.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:700821823914:android:4d0bc2b397e0c6a4cf8bd3", + "android_client_info": { + "package_name": "ua.gov.diia.opensource" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyBvbzGeq1VmBgPP9GSLAd6HN-OQInblY5I" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/opensource/proguard-rules.pro b/opensource/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/opensource/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/opensource/src/gplay/java/ua/gov/diia/opensource/VendorActivity.kt b/opensource/src/gplay/java/ua/gov/diia/opensource/VendorActivity.kt new file mode 100644 index 0000000..853edd4 --- /dev/null +++ b/opensource/src/gplay/java/ua/gov/diia/opensource/VendorActivity.kt @@ -0,0 +1,35 @@ +package ua.gov.diia.opensource + +import androidx.lifecycle.lifecycleScope +import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.google.firebase.installations.FirebaseInstallations +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import ua.gov.diia.analytics.DiiaAnalytics +import ua.gov.diia.opensource.ui.activities.MainActivity +import javax.inject.Inject + +@AndroidEntryPoint +class VendorActivity: MainActivity() { + + @Inject + lateinit var analytics: DiiaAnalytics + + + override fun setUpAnalytics() { + lifecycleScope.launch(Dispatchers.IO) { + diiaStorage.getMobileUuid().let { + analytics.setUserId(it) + FirebaseCrashlytics.getInstance().setUserId(it) + } + FirebaseInstallations.getInstance().getToken(false).addOnCompleteListener { + try { + analytics.setPushToken(it.result.token) + } catch (e: Exception) { + crashlytics.sendNonFatalError(e) + } + } + } + } +} \ No newline at end of file diff --git a/opensource/src/huawei/java/ua/gov/diia/opensource/VendorActivity.kt b/opensource/src/huawei/java/ua/gov/diia/opensource/VendorActivity.kt new file mode 100644 index 0000000..ff75f01 --- /dev/null +++ b/opensource/src/huawei/java/ua/gov/diia/opensource/VendorActivity.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.opensource + +import com.huawei.agconnect.crash.AGConnectCrash +import ua.gov.diia.opensource.ui.activities.MainActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class VendorActivity: MainActivity() { + + override fun setUpAnalytics() { + AGConnectCrash.getInstance().enableCrashCollection(true) + } +} \ No newline at end of file diff --git a/opensource/src/main/AndroidManifest.xml b/opensource/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ef31b14 --- /dev/null +++ b/opensource/src/main/AndroidManifest.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/App.kt b/opensource/src/main/java/ua/gov/diia/opensource/App.kt new file mode 100644 index 0000000..b178435 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/App.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.opensource + +import android.app.Application +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +@HiltAndroidApp +class App : Application(), Configuration.Provider { + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + override fun getWorkManagerConfiguration() = + Configuration.Builder() + .setWorkerFactory(workerFactory) + .build() + +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/ItnDataRepositoryImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/ItnDataRepositoryImpl.kt new file mode 100644 index 0000000..716d38a --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/ItnDataRepositoryImpl.kt @@ -0,0 +1,48 @@ +package ua.gov.diia.opensource.data.data_source.itn + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import ua.gov.diia.core.models.ITN +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import ua.gov.diia.diia_storage.store.datasource.itn.ItnDataRepository + +class ItnDataRepositoryImpl( + private val scope: CoroutineScope, + private val keyValueDataSource: KeyValueItnDataSource, + private val networkDocumentsDataSource: NetworkItnDataSource +) : ItnDataRepository { + + private val _isDataLoading = MutableStateFlow(false) + override val isDataLoading: Flow + get() = _isDataLoading + + private val _data = MutableStateFlow>(DataSourceDataResult.failed()) + override val data: Flow> + get() = _data + + override fun invalidate() { + scope.launch { + if (_isDataLoading.value) { + return@launch + } + + _isDataLoading.value = true + val cachedData = keyValueDataSource.fetchData() + if (cachedData != null) { + _data.tryEmit(DataSourceDataResult.successful(cachedData)) + } else { + val networkData = networkDocumentsDataSource.fetchData() + val data = networkData.data + if (networkData.isSuccessful && data != null) { + keyValueDataSource.saveDataToStore(data) + _data.tryEmit(DataSourceDataResult.successful(data)) + } else { + _data.tryEmit(DataSourceDataResult.failed()) + } + } + _isDataLoading.value = false + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/KeyValueItnDataSource.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/KeyValueItnDataSource.kt new file mode 100644 index 0000000..7023a20 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/KeyValueItnDataSource.kt @@ -0,0 +1,31 @@ +package ua.gov.diia.opensource.data.data_source.itn + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import ua.gov.diia.core.models.ITN +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.model.PreferenceKey +import ua.gov.diia.diia_storage.store.AbstractKeyValueDataSource +import ua.gov.diia.diia_storage.store.Preferences +import javax.inject.Inject + +class KeyValueItnDataSource @Inject constructor( + diiaStorage: DiiaStorage, + withCrashlytics: WithCrashlytics +) : AbstractKeyValueDataSource(diiaStorage, withCrashlytics) { + + override val preferenceKey: PreferenceKey = Preferences.ITN + + override val jsonAdapter: JsonAdapter = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + .adapter(ITN::class.java) + + suspend fun fetchData() = if (store.containsKey(preferenceKey)) { + loadData() + } else { + null + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/NetworkItnDataSource.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/NetworkItnDataSource.kt new file mode 100644 index 0000000..4c4d13b --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/data_source/itn/NetworkItnDataSource.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.opensource.data.data_source.itn + +import ua.gov.diia.core.models.ITN +import ua.gov.diia.diia_storage.store.datasource.DataSourceDataResult +import javax.inject.Inject + +class NetworkItnDataSource @Inject constructor( +) { + + suspend fun fetchData(): DataSourceDataResult { + return try { + return DataSourceDataResult.failed(RuntimeException("Not implemented")) + + } catch (e: Exception) { + DataSourceDataResult.failed(e) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/ApiLogger.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/ApiLogger.kt new file mode 100644 index 0000000..a9f16ee --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/ApiLogger.kt @@ -0,0 +1,68 @@ +package ua.gov.diia.opensource.data.network + +import com.google.gson.GsonBuilder +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import dagger.Reusable +import okhttp3.internal.platform.Platform +import okhttp3.logging.HttpLoggingInterceptor +import org.json.JSONException +import ua.gov.diia.core.util.delegation.WithCrashlytics +import javax.inject.Inject + +@Reusable +class ApiLogger @Inject constructor( + private val crashlytics: WithCrashlytics, +) : HttpLoggingInterceptor.Logger { + + override fun log(message: String) { + if (message.startsWith("{") || message.startsWith("[")) { + try { + val msg = JsonParser().parse(message) + loopThroughJson(msg) + val prettyPrintJson = GsonBuilder() + .setPrettyPrinting() + .disableHtmlEscaping() + .create() + .toJson(msg) + Platform.get().log(Platform.INFO, prettyPrintJson, null) + } catch (e: Exception) { + crashlytics.sendNonFatalError(e) + } + } else { + Platform.get().log(Platform.INFO, message, null) + return + } + } + + @Throws(JSONException::class) + fun loopThroughJson(input: Any) { + if (input is JsonObject) { + val keys: Set = input.keySet() + val base64 = mutableListOf() + keys.forEach { key -> + if (input[key] !is JsonArray) { + if (input[key] is JsonObject) { + loopThroughJson(input[key]) + } else { + if (key == "photo" || key == "sign") { + base64.add(key) + } + } + } else { + loopThroughJson(input[key]) + } + } + base64.forEach { + input.remove(it) + input.addProperty(it, "") + } + } + if (input is JsonArray) { + input.forEach { + loopThroughJson(it) + } + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/NetworkConnectivityObserver.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/NetworkConnectivityObserver.kt new file mode 100644 index 0000000..e0c1c76 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/NetworkConnectivityObserver.kt @@ -0,0 +1,66 @@ +package ua.gov.diia.opensource.data.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import ua.gov.diia.core.network.connectivity.ConnectivityObserver + +class NetworkConnectivityObserver(context: Context) : ConnectivityObserver { + + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val request = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) + .build() + + override val isAvailable: Boolean + get() { + val caps = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + return isNetworkAvailable(caps) + } + + override fun observe(): Flow = callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + trySend(true) + } + + override fun onLost(network: Network) { + super.onLost(network) + trySend(false) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + trySend(isNetworkAvailable(networkCapabilities)) + } + + override fun onUnavailable() { + super.onUnavailable() + trySend(false) + } + } + + connectivityManager.registerNetworkCallback(request, callback) + awaitClose { connectivityManager.unregisterNetworkCallback(callback) } + } + + private fun isNetworkAvailable(capabilities: NetworkCapabilities?): Boolean { + if (capabilities == null) return false + return (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) || capabilities.hasTransport( + NetworkCapabilities.TRANSPORT_WIFI + )) + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/TimeoutConstants.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/TimeoutConstants.kt new file mode 100644 index 0000000..cbd804b --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/TimeoutConstants.kt @@ -0,0 +1,7 @@ +package ua.gov.diia.opensource.data.network + +object TimeoutConstants { + const val CONNECTION_TIMEOUT = 30L + const val WRITE_TIMEOUT = 30L + const val READ_TIMEOUT = 30L +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/api/ApiDocs.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/api/ApiDocs.kt new file mode 100644 index 0000000..d3a5735 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/api/ApiDocs.kt @@ -0,0 +1,67 @@ +package ua.gov.diia.opensource.data.network.api + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.http.QueryMap +import ua.gov.diia.core.network.annotation.Analytics +import ua.gov.diia.doc_driver_license.DriverLicenseV2 +import ua.gov.diia.documents.models.DocumentsOrder +import ua.gov.diia.documents.models.ManualDocs +import ua.gov.diia.documents.models.QRUrl +import ua.gov.diia.documents.models.TypeDefinedDocumentsOrder +import ua.gov.diia.documents.models.UpdatedDoc +import ua.gov.diia.opensource.helper.documents.DocName +import ua.gov.diia.opensource.model.documents.Docs + +interface ApiDocs { + + @Analytics("getDocs") + @GET("api/v6/documents") + suspend fun getDocs(@QueryMap filter: Map = emptyMap()): Docs + + @Analytics("getDocsManual") + @GET("api/v1/documents/manual") + suspend fun getDocsManual(): ManualDocs + + @Analytics("setDocumentsOrder") + @POST("api/v2/user/settings/documents/order") + suspend fun setDocumentsOrder( + @Body docOrder: DocumentsOrder + ) + + @Analytics("setTypedDocumentsOrder") + @POST("api/v2/user/settings/documents/{documentType}/order") + suspend fun setTypedDocumentsOrder( + @Path("documentType") documentType: String, + @Body docOrder: TypeDefinedDocumentsOrder + ) + + @Analytics("getShareUrl") + @GET("api/v1/documents/{docName}/{documentId}/share") + suspend fun getShareUrl( + @Path("docName") docName: String?, + @Path("documentId") documentId: String? + ): QRUrl + + @Analytics("getShareUrlWithLocalization") + @GET("api/v1/documents/{docName}/{documentId}/share") + suspend fun getShareUrlWithLocalization( + @Path("docName") docName: String?, + @Path("documentId") documentId: String?, + @Query("localization") localization: String, + ): QRUrl + + @Analytics("getDocumentById") + @GET("api/v2/documents/{docType}/{documentId}") + suspend fun getDocumentById( + @Path("docType") docType: String?, + @Path("documentId") documentId: String? + ): UpdatedDoc + + @Analytics("checkDriverLicense") + @GET("api/v2/documents/${DocName.DRIVER_LICENSE}/verify") + suspend fun checkDriverLicense(@Query("otp") otp: String): DriverLicenseV2.Data +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpAppInfoHeaderInterceptor.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpAppInfoHeaderInterceptor.kt new file mode 100644 index 0000000..2eebf71 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpAppInfoHeaderInterceptor.kt @@ -0,0 +1,31 @@ +package ua.gov.diia.opensource.data.network.interceptors + +import android.os.Build +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import ua.gov.diia.opensource.BuildConfig +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HttpAppInfoHeaderInterceptor @Inject constructor() : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request: Request = chain.request().newBuilder() + .header(USER_AGENT, "DIIA ${BuildConfig.PLATFORM_TYPE}-${BuildConfig.VERSION_NAME}") + .header(APP_VERSION, BuildConfig.VERSION_NAME) + .header(PLATFORM_TYPE, BuildConfig.PLATFORM_TYPE) + .header(PLATFORM_VERSION, Build.VERSION.RELEASE) + .build() + return chain.proceed(request) + } + + companion object { + private const val APP_VERSION = "App-Version" + private const val PLATFORM_TYPE = "Platform-Type" + private const val PLATFORM_VERSION = "Platform-Version" + private const val USER_AGENT = "User-Agent" + } + +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpAuthorizationInterceptor.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpAuthorizationInterceptor.kt new file mode 100644 index 0000000..82335c0 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpAuthorizationInterceptor.kt @@ -0,0 +1,104 @@ +package ua.gov.diia.opensource.data.network.interceptors + +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import retrofit2.HttpException +import ua.gov.diia.core.di.actions.GlobalActionLogout +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.network.apis.ApiAuth +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.opensource.di.GlobalActionProlongUser +import ua.gov.diia.opensource.di.network.RefreshLocker +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HttpAuthorizationInterceptor @Inject constructor( + private val authorizationRepository: AuthorizationRepository, + @GlobalActionLogout private val actionLogout: MutableLiveData, + @GlobalActionProlongUser private val actionUserVerification: MutableLiveData>, + @UnauthorizedClient private val apiAuth: ApiAuth, + private val refreshLocker: RefreshLocker, + private val withBuildConfig: WithBuildConfig, +) : Interceptor { + + fun getToken() = runBlocking { authorizationRepository.getTokenData() } + + override fun intercept(chain: Interceptor.Chain): Response { + val currentToken = getToken() + val originalRequest = chain.request() + return if (!currentToken.isExpired(withBuildConfig.getTokenLeeway())) { + chain.proceedAuth( + originalRequest.newBuilder().addHeaders(currentToken.token).build() + ) + } else { + chain.proceed(getRefreshedToken(chain)) + } + } + + private fun Interceptor.Chain.proceedAuth(request: Request): Response { + val response = proceed(request) + if (response.code() == 401) { + val oldToken = request.header(AUTH) + if (oldToken != null) { + proceed(getRefreshedToken(this)) + } + } + return response + } + + @Synchronized + private fun getRefreshedToken(chain: Interceptor.Chain): Request { + synchronized(refreshLocker) { + var request = chain.request() + val token = getToken() + if (token.isExpired(withBuildConfig.getTokenLeeway())) { + runBlocking { + try { + val newToken = apiAuth.refreshToken("$AUTH_BEARER ${token.token}") + when { + newToken.token != null -> { + authorizationRepository.setToken(newToken.token as String) + request = + chain.request().newBuilder().addHeaders(newToken.token as String).build() + } + newToken.template != null -> { + actionUserVerification.postValue(UiDataEvent(newToken.template as TemplateDialogModel)) + } + else -> { + throw IllegalStateException("HttpAuthorizationInterceptor: Unable to refresh token. There are no token and template. Received empty body") + } + } + } catch (e: HttpException) { + if (e.code() == 401) { + logout() + } + } catch (e: Exception) { + } + } + } else { + request = chain.request().newBuilder().addHeaders(token.token).build() + } + return request + } + } + + private fun logout() { + actionLogout.postValue(UiEvent()) + } + + private fun Request.Builder.addHeaders(token: String) = + this.apply { header(AUTH, "$AUTH_BEARER $token") } + + private companion object { + const val AUTH = "Authorization" + const val AUTH_BEARER = "Bearer" + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpLoggingInterceptor.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpLoggingInterceptor.kt new file mode 100644 index 0000000..6a69304 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpLoggingInterceptor.kt @@ -0,0 +1,32 @@ +package ua.gov.diia.opensource.data.network.interceptors + +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.logging.HttpLoggingInterceptor +import ua.gov.diia.opensource.BuildConfig +import ua.gov.diia.core.util.CommonConst.BUILD_TYPE_DEBUG +import ua.gov.diia.core.util.CommonConst.BUILD_TYPE_STAGE +import ua.gov.diia.opensource.data.network.ApiLogger +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HttpLoggingInterceptor @Inject constructor( + private val logger: ApiLogger, +) : Interceptor { + + private var httpLoggingInterceptor: HttpLoggingInterceptor? = null + + init { + if (BuildConfig.BUILD_TYPE == BUILD_TYPE_STAGE || BuildConfig.BUILD_TYPE == BUILD_TYPE_DEBUG) { + val logging = HttpLoggingInterceptor(logger).apply { + level = (HttpLoggingInterceptor.Level.BODY) + } + httpLoggingInterceptor = logging + } + } + + override fun intercept(chain: Interceptor.Chain): Response { + return httpLoggingInterceptor?.intercept(chain) ?: chain.proceed(chain.request()) + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpMobileUuidInterceptor.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpMobileUuidInterceptor.kt new file mode 100644 index 0000000..6ca07b2 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpMobileUuidInterceptor.kt @@ -0,0 +1,36 @@ +package ua.gov.diia.opensource.data.network.interceptors + +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HttpMobileUuidInterceptor @Inject constructor( + private val authorizationRepository: AuthorizationRepository +) : Interceptor { + + private companion object { + const val MOBILE_UID = "mobile_uid" + } + + override fun intercept(chain: Interceptor.Chain): Response { + + val request: Request = chain.request() + return if (request.header(MOBILE_UID) == null) { + val uid = runBlocking { + authorizationRepository.getMobileUuid() + } + chain.proceed( + request.newBuilder() + .header(MOBILE_UID, uid) + .build() + ) + } else { + chain.proceed(request) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpProlongAuthorizationInterceptor.kt b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpProlongAuthorizationInterceptor.kt new file mode 100644 index 0000000..ef35de0 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/data/network/interceptors/HttpProlongAuthorizationInterceptor.kt @@ -0,0 +1,29 @@ +package ua.gov.diia.opensource.data.network.interceptors + +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class HttpProlongAuthorizationInterceptor @Inject constructor( + private val authorizationRepository: AuthorizationRepository +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val currentToken = runBlocking { authorizationRepository.getTokenData() } + val request = chain.request().newBuilder().addHeaders(currentToken.token).build() + return chain.proceed(request) + } + + private fun Request.Builder.addHeaders(token: String) = + this.apply { header(AUTH, "$AUTH_BEARER $token") } + + private companion object { + const val AUTH = "Authorization" + const val AUTH_BEARER = "Bearer" + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/Annotations.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/Annotations.kt new file mode 100644 index 0000000..5a8def9 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/Annotations.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.opensource.di + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class GlobalActionProlongUser + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class MoshiAdapterPublicServiceCategories diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/AppModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/AppModule.kt new file mode 100644 index 0000000..16209e1 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/AppModule.kt @@ -0,0 +1,208 @@ +package ua.gov.diia.opensource.di + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.ElementsIntoSet +import dagger.multibindings.IntoSet +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import retrofit2.Retrofit +import ua.gov.diia.core.controller.PromoController +import ua.gov.diia.core.data.repository.DataRepository +import ua.gov.diia.core.data.repository.SystemRepository +import ua.gov.diia.core.di.actions.GlobalActionAllowAuthorizedLinks +import ua.gov.diia.core.di.actions.GlobalActionConfirmDocumentRemoval +import ua.gov.diia.core.di.actions.GlobalActionDeeplink +import ua.gov.diia.core.di.actions.GlobalActionDocLoadingIndicator +import ua.gov.diia.core.di.actions.GlobalActionFocusOnDocument +import ua.gov.diia.core.di.actions.GlobalActionLazy +import ua.gov.diia.core.di.actions.GlobalActionLogout +import ua.gov.diia.core.di.actions.GlobalActionNetworkState +import ua.gov.diia.core.di.actions.GlobalActionNotificationRead +import ua.gov.diia.core.di.actions.GlobalActionNotificationReceived +import ua.gov.diia.core.di.actions.GlobalActionSelectedMenuItem +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.core.models.ActionDataLazy +import ua.gov.diia.core.models.deeplink.DeepLinkAction +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.network.apis.ApiAuth +import ua.gov.diia.core.network.connectivity.ConnectivityObserver +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.settings_action.SettingsActionExecutor +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.PreferenceConfiguration +import ua.gov.diia.diia_storage.SecureDiiaStorage +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepositoryImpl +import ua.gov.diia.diia_storage.store.repository.system.SystemRepositoryImpl +import ua.gov.diia.login.ui.PostLoginAction +import ua.gov.diia.notifications.store.NotificationsPreferences +import ua.gov.diia.notifications.util.settings_action.PushTokenUpdateActionExecutor +import ua.gov.diia.opensource.data.network.NetworkConnectivityObserver +import ua.gov.diia.opensource.repository.ps.PublicServiceDataRepository +import ua.gov.diia.opensource.repository.settings.AppSettingsRepository +import ua.gov.diia.opensource.repository.settings.AppSettingsRepositoryImpl +import ua.gov.diia.opensource.ui.PromoControllerImpl +import ua.gov.diia.publicservice.di.DataRepositoryPublicServiceCategories +import ua.gov.diia.publicservice.models.PublicServicesCategories +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface AppModule { + + @Binds + fun bindAppSettingsRepository(impl: AppSettingsRepositoryImpl): AppSettingsRepository + + @Binds + fun bindSettingsRepository(impl: AuthorizationRepositoryImpl): AuthorizationRepository + + @Binds + fun bindSystemRepository(impl: SystemRepositoryImpl): SystemRepository + + @Binds + fun bindPromoController(impl: PromoControllerImpl): PromoController + + @Binds + @Singleton + @DataRepositoryPublicServiceCategories + fun bindPublicServiceCategoriesRepository( + impl: PublicServiceDataRepository + ): DataRepository<@JvmSuppressWildcards PublicServicesCategories?> + + @Binds + @IntoSet + fun bindPostLoginAction( + impl: PublicServiceDataRepository + ): PostLoginAction + + companion object { + + @Provides + @GlobalActionConfirmDocumentRemoval + @Singleton + fun provideActionConfirmDocumentRemoval() = MutableStateFlow?>(null) + + @Provides + @GlobalActionFocusOnDocument + @Singleton + fun provideActionFocusOnDoc() = MutableStateFlow?>(null) + + @Provides + @GlobalActionDocLoadingIndicator + @Singleton + fun provideActionDocLoadingIndicator() = MutableSharedFlow>() + + @Provides + @Singleton + @GlobalActionAllowAuthorizedLinks + fun provideActionAllowAuthorizedLinks() = MutableSharedFlow>() + + @Provides + @GlobalActionSelectedMenuItem + @Singleton + fun provideActionSelectedMenuItem() = MutableStateFlow?>(null) + + @Provides + @GlobalActionLogout + @Singleton + fun provideActionLogout() = MutableLiveData() + + @Provides + @GlobalActionProlongUser + @Singleton + fun provideActionUserVerification() = MutableLiveData>() + + @Provides + @GlobalActionNotificationRead + @Singleton + fun provideActionNotificationRead() = MutableLiveData>() + + @Provides + @Singleton + @GlobalActionNetworkState + fun provideNetworkStateFlow( + @ApplicationContext context: Context + ): ConnectivityObserver { + return NetworkConnectivityObserver(context) + } + + @Provides + @UnauthorizedClient + fun provideApiAuth( + @UnauthorizedClient retrofit: Retrofit + ): ApiAuth = retrofit.create(ApiAuth::class.java) + + @Provides + @Singleton + @GlobalActionLazy + fun provideActionLazy() = MutableSharedFlow>( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_LATEST + ) + + @Provides + @GlobalActionDeeplink + @Singleton + fun provideActionDeeplink() = MutableStateFlow?>(null) + + @Provides + @GlobalActionNotificationReceived + @Singleton + fun provideActionNotificationReceived() = MutableLiveData() + + @Provides + @MoshiAdapterPublicServiceCategories + fun provideAdapterPublicServiceCategory(): JsonAdapter = + Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + .adapter(PublicServicesCategories::class.java) + + @Provides + @Singleton + fun provideDiiaStorage( + @ApplicationContext context: Context + ): DiiaStorage { + val preferenceConfiguration = PreferenceConfiguration( + _preferenceName = Preferences.Settings.NAME_DIIA, + preferenceNamePrefix = "", + allowedScopes = setOf( + Preferences.Scopes.AUTH_SCOPE, + Preferences.Scopes.UPDATE_SCOPE, + Preferences.Scopes.PIN_SCOPE, + NotificationsPreferences.Scopes.PUSH_SCOPE, + Preferences.Scopes.USER_SCOPE, + Preferences.Scopes.DOUBLE_CHECK, + NotificationsPreferences.Scopes.NOTIFICATION, + Preferences.Scopes.FAQS, + Preferences.Scopes.FEATURES, + Preferences.Scopes.USER_PREFERENCES, + Preferences.Scopes.INVINCIBILITY_PREFERENCES, + ) + ) + return SecureDiiaStorage(context, preferenceConfiguration) + } + + @Provides + @ElementsIntoSet + fun provideSettingsAction( + pushTokenUpdateAction: PushTokenUpdateActionExecutor, + ): Set { + return setOf(pushTokenUpdateAction) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/DocumentsModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/DocumentsModule.kt new file mode 100644 index 0000000..d85e480 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/DocumentsModule.kt @@ -0,0 +1,293 @@ +package ua.gov.diia.opensource.di + +import android.content.Context +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.lifecycleScope +import com.squareup.moshi.JsonAdapter +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.plus +import retrofit2.Retrofit +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.util.date.CurrentDateProvider +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.context.dpToPx +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.doc_driver_license.DriverLicenceJsonAdapterDelegate +import ua.gov.diia.doc_driver_license.DriverLicenceLocalizationChecker +import ua.gov.diia.doc_driver_license.DriverLicenseFullInfoComposeMapper +import ua.gov.diia.documents.barcode.DocumentBarcodeFactory +import ua.gov.diia.documents.barcode.DocumentBarcodeRepository +import ua.gov.diia.documents.data.api.ApiDocuments +import ua.gov.diia.documents.data.datasource.local.BrokenDocFilter +import ua.gov.diia.documents.data.datasource.local.BrokenDocFilterImpl +import ua.gov.diia.documents.data.datasource.local.DefaultDocGroupUpdateBehavior +import ua.gov.diia.documents.data.datasource.local.DocGroupUpdateBehavior +import ua.gov.diia.documents.data.datasource.local.DocJsonAdapterDelegate +import ua.gov.diia.documents.data.datasource.local.DocumentsTransformation +import ua.gov.diia.documents.data.datasource.local.KeyValueDocumentsDataSource +import ua.gov.diia.documents.data.datasource.local.RemoveExpiredDocBehavior +import ua.gov.diia.documents.data.datasource.local.RemoveExpiredDocBehaviorImpl +import ua.gov.diia.documents.data.datasource.remote.NetworkDocumentsDataSource +import ua.gov.diia.documents.data.repository.BeforePublishAction +import ua.gov.diia.documents.data.repository.DocumentsDataRepository +import ua.gov.diia.documents.data.repository.DocumentsDataRepositoryImpl +import ua.gov.diia.documents.di.DocTypesAvailableToUsers +import ua.gov.diia.documents.di.GlobalActionUpdateDocument +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.ui.BaseLocalizationChecker +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.documents.ui.WithCheckLocalizationDocs +import ua.gov.diia.documents.ui.WithCheckLocalizationDocsImpl +import ua.gov.diia.documents.ui.WithPdfCertificate +import ua.gov.diia.documents.ui.WithRemoveDocument +import ua.gov.diia.documents.ui.actions.DocActionsNavigationHandler +import ua.gov.diia.documents.ui.actions.DocActionsProvider +import ua.gov.diia.documents.ui.actions.DocActionsProviderImpl +import ua.gov.diia.documents.ui.fullinfo.BaseFullInfoComposeMapper +import ua.gov.diia.documents.ui.fullinfo.DocFullInfoComposeMapper +import ua.gov.diia.documents.ui.fullinfo.DocFullInfoComposeMapperImpl +import ua.gov.diia.documents.ui.gallery.DocGalleryNavigationHelper +import ua.gov.diia.documents.util.BaseDocActionItemProcessor +import ua.gov.diia.documents.util.BaseDocumentActionProvider +import ua.gov.diia.documents.util.DocNameProvider +import ua.gov.diia.documents.util.DocumentActionMapper +import ua.gov.diia.documents.util.WithUpdateExpiredDocs +import ua.gov.diia.documents.util.WithUpdateExpiredDocsImpl +import ua.gov.diia.documents.util.datasource.ExpirationStrategy +import ua.gov.diia.opensource.data.network.api.ApiDocs +import ua.gov.diia.opensource.helper.documents.ApiDocumentsWrapper +import ua.gov.diia.opensource.helper.documents.DocActionsNavigationHandlerImpl +import ua.gov.diia.opensource.helper.documents.DocGalleryNavigationHelperImpl +import ua.gov.diia.opensource.helper.documents.DocName +import ua.gov.diia.opensource.helper.documents.DocNameProviderImpl +import ua.gov.diia.opensource.helper.documents.DocumentBarcodeRepositoryImpl +import ua.gov.diia.opensource.helper.documents.DocumentComposeMapperImpl +import ua.gov.diia.opensource.helper.documents.DocumentsHelperImpl +import ua.gov.diia.opensource.helper.documents.DriverLicenceActionProvider +import ua.gov.diia.opensource.helper.documents.WithPdfCertificateImpl +import ua.gov.diia.opensource.helper.documents.WithRemoveDocumentImpl +import java.util.concurrent.Executors +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface DocumentsModule { + + @Binds + fun bindDocumentsHelper(impl: DocumentsHelperImpl): DocumentsHelper + + @Binds + fun bindBrokenDocFilter(impl: BrokenDocFilterImpl): BrokenDocFilter + + @Binds + fun bindRemoveExpiredDocBehavior(impl: RemoveExpiredDocBehaviorImpl): RemoveExpiredDocBehavior + + @Binds + fun bindDocActionsNavHandler(impl: DocActionsNavigationHandlerImpl): DocActionsNavigationHandler + + @Binds + fun bindDocGalleryNavHelper(impl: DocGalleryNavigationHelperImpl): DocGalleryNavigationHelper + + @Binds + fun bindDocActionsProvider(impl: DocActionsProviderImpl): DocActionsProvider + + @Binds + fun bindDocBarcodeRepository(impl: DocumentBarcodeRepositoryImpl): DocumentBarcodeRepository + + @Binds + fun bindWithUpdateExpiredDocs(impl: WithUpdateExpiredDocsImpl): WithUpdateExpiredDocs + + @Binds + fun bindWithPdfCertificate(impl: WithPdfCertificateImpl): WithPdfCertificate + + @Binds + fun bindWithCheckLocalizationDocs(impl: WithCheckLocalizationDocsImpl): WithCheckLocalizationDocs + + @Binds + fun bindWithRemoveDocument(impl: WithRemoveDocumentImpl): WithRemoveDocument + + @Binds + fun bindPreviewComposeMapper(impl: DocumentComposeMapperImpl): DocumentComposeMapper + + @Binds + fun bindDocFullComposeMapper(impl: DocFullInfoComposeMapperImpl): DocFullInfoComposeMapper + + @Binds + fun bindDocNameProvider(impl: DocNameProviderImpl): DocNameProvider + + companion object { + + @Provides + @Singleton + fun provideActionItemProcessorList(): List<@JvmSuppressWildcards BaseDocActionItemProcessor> { + return listOf() + } + + @Provides + @AuthorizedClient + fun provideApiDocs( + @AuthorizedClient retrofit: Retrofit, + ): ApiDocs = retrofit.create(ApiDocs::class.java) + + @Provides + @GlobalActionUpdateDocument + @Singleton + fun provideActionUpdateDocument() = MutableStateFlow?>(null) + + @Provides + @Singleton + fun provideDocMenuActions( + documentActionMapper: DocumentActionMapper, + ): List<@JvmSuppressWildcards BaseDocumentActionProvider> = listOf( + DriverLicenceActionProvider(documentActionMapper), + ) + + @Provides + @Singleton + fun provideNetworkDocumentsDataSource( + apiDocs: ApiDocuments, + withCrashlytics: WithCrashlytics, + ): NetworkDocumentsDataSource { + return NetworkDocumentsDataSource(apiDocs, withCrashlytics) + } + + @Provides + @Singleton + fun provideKeyValueDocumentsDataSource( + diiaStorage: DiiaStorage, + jsonAdapter: JsonAdapter>, + docTransformations: List<@JvmSuppressWildcards DocumentsTransformation>, + @DocTypesAvailableToUsers docTypesAvailableToUsers: Set<@JvmSuppressWildcards String>, + expirationStrategy: ExpirationStrategy, + documentsHelper: DocumentsHelper, + docGroupUpdateBehaviors: List<@JvmSuppressWildcards DocGroupUpdateBehavior>, + defaultDocGroupUpdateBehavior: DefaultDocGroupUpdateBehavior, + brokenDocFilter: BrokenDocFilter, + removeExpiredDocBehavior: RemoveExpiredDocBehavior, + withCrashlytics: WithCrashlytics, + ): KeyValueDocumentsDataSource { + return KeyValueDocumentsDataSource( + jsonAdapter = jsonAdapter, + diiaStorage = diiaStorage, + docTransformations = docTransformations, + docTypesAvailableToUsers = docTypesAvailableToUsers, + expirationStrategy = expirationStrategy, + documentsHelper = documentsHelper, + docGroupUpdateBehaviors = docGroupUpdateBehaviors, + defaultDocGroupUpdateBehavior = defaultDocGroupUpdateBehavior, + brokenDocFilter = brokenDocFilter, + removeExpiredDocBehavior = removeExpiredDocBehavior, + withCrashlytics = withCrashlytics + ) + } + + @Provides + @Singleton + fun provideDocumentsDataSource( + keyValueDataSource: KeyValueDocumentsDataSource, + networkDocumentsDataSource: NetworkDocumentsDataSource, + beforePublishActions: List<@JvmSuppressWildcards BeforePublishAction>, + @DocTypesAvailableToUsers docTypesAvailableToUsers: Set<@JvmSuppressWildcards String>, + withCrashlytics: WithCrashlytics, + ): DocumentsDataRepository { + val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + val applicationScope = ProcessLifecycleOwner.get().lifecycleScope + dispatcher + return DocumentsDataRepositoryImpl( + applicationScope, + keyValueDataSource, + networkDocumentsDataSource, + beforePublishActions, + docTypesAvailableToUsers, + withCrashlytics + ) + } + + + @Provides + @Singleton + fun provideDocumentsTransformations(): List<@JvmSuppressWildcards DocumentsTransformation> { + return emptyList() + } + + @Provides + @Singleton + fun provideApiDocuments( + @AuthorizedClient apiDocs: ApiDocs, + diiaStorage: DiiaStorage, + currentDateProvider: CurrentDateProvider, + withCrashlytics: WithCrashlytics, + ): ApiDocuments = ApiDocumentsWrapper( + apiDocs, + diiaStorage, + currentDateProvider, + withCrashlytics + ) + + @Provides + @Singleton + fun provideBeforePublishActions( + ): List<@JvmSuppressWildcards BeforePublishAction> { + return emptyList() + } + + @Provides + @Singleton + fun provideDocDelegates(): List> { + return listOf( + DriverLicenceJsonAdapterDelegate(), + ) + } + + @Provides + @Singleton + @DocTypesAvailableToUsers + fun provideDocTypesForUser(): Set<@JvmSuppressWildcards String> = setOf( + DocName.DRIVER_LICENSE, + DocName.TAXPAYER_CARD + ) + + + @Provides + @Singleton + fun provideDocGroupUpdateBehaviors(): List<@JvmSuppressWildcards DocGroupUpdateBehavior> { + return listOf() + } + + private const val QR_SIZE = 218F + private const val EAN_13_H = 36F + private const val EAN_13_W = 218F + + @Provides + fun provideBarcodeFactory( + @ApplicationContext context: Context, + ): DocumentBarcodeFactory = DocumentBarcodeFactory( + qrSizePx = context.dpToPx(QR_SIZE), + ean13CodeHeight = context.dpToPx(EAN_13_H), + ean13CodeWidth = context.dpToPx(EAN_13_W) + ) + + @Provides + @Singleton + fun provideLocalizationCheckers(): List<@JvmSuppressWildcards BaseLocalizationChecker> { + return listOf(DriverLicenceLocalizationChecker()) + } + + @Provides + @Singleton + fun provideFullIntoComposeMapper(docComposeMapper: DocumentComposeMapper): List<@JvmSuppressWildcards BaseFullInfoComposeMapper> { + return listOf( + DriverLicenseFullInfoComposeMapper(docComposeMapper), + ) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/FeatureModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/FeatureModule.kt new file mode 100644 index 0000000..daa765a --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/FeatureModule.kt @@ -0,0 +1,128 @@ +package ua.gov.diia.opensource.di + +import androidx.work.WorkManager +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import ua.gov.diia.core.controller.DeeplinkProcessor +import ua.gov.diia.core.controller.NotificationController +import ua.gov.diia.core.models.SingleDeeplinkProcessor +import ua.gov.diia.diia_storage.store.datasource.itn.ItnDataRepository +import ua.gov.diia.home.helper.HomeHelper +import ua.gov.diia.notifications.NotificationControllerImpl +import ua.gov.diia.notifications.helper.NotificationHelper +import ua.gov.diia.notifications.store.datasource.notifications.KeyValueNotificationDataSource +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager +import ua.gov.diia.notifications.util.notification.push.PushTokenProvider +import ua.gov.diia.opensource.data.data_source.itn.ItnDataRepositoryImpl +import ua.gov.diia.opensource.data.data_source.itn.KeyValueItnDataSource +import ua.gov.diia.opensource.data.data_source.itn.NetworkItnDataSource +import ua.gov.diia.opensource.helper.HomeHelperImpl +import ua.gov.diia.opensource.helper.NotificationHelperImpl +import ua.gov.diia.opensource.helper.PSCriminalCertHelperImpl +import ua.gov.diia.opensource.helper.PSNavigationHelperImpl +import ua.gov.diia.opensource.helper.PinHelperImpl +import ua.gov.diia.opensource.helper.PublicServiceHelperImpl +import ua.gov.diia.opensource.helper.PublicServicesCategoriesTabMapperImpl +import ua.gov.diia.opensource.helper.SplashHelperImpl +import ua.gov.diia.opensource.util.DeeplinkProcessorImpl +import ua.gov.diia.pin.helper.PinHelper +import ua.gov.diia.ps_criminal_cert.helper.PSCriminalCertHelper +import ua.gov.diia.publicservice.helper.PSNavigationHelper +import ua.gov.diia.publicservice.helper.PublicServiceHelper +import ua.gov.diia.publicservice.ui.categories.compose.PublicServicesCategoriesTabMapper +import ua.gov.diia.publicservice.ui.compose.PublicServiceCategoryDetailsComposeMapper +import ua.gov.diia.publicservice.ui.compose.PublicServiceCategoryDetailsComposeMapperImpl +import ua.gov.diia.publicservice.ui.compose.PublicServicesSearchComposeMapper +import ua.gov.diia.publicservice.ui.compose.PublicServicesSearchComposeMapperImpl +import ua.gov.diia.splash.helper.SplashHelper +import java.util.concurrent.Executors +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface FeatureModule { + + @Binds + fun bindPinHelper(impl: PinHelperImpl): PinHelper + + @Binds + fun bindSplashHelper(impl: SplashHelperImpl): SplashHelper + + @Binds + fun bindNotificationHelper(impl: NotificationHelperImpl): NotificationHelper + + companion object { + + @Provides + fun provideHomeHelper(): HomeHelper = HomeHelperImpl() + + @Provides + fun providePublicServiceHelper(): PublicServiceHelper { + return PublicServiceHelperImpl() + } + + @Provides + fun providePublicServicesTabMapper(): PublicServicesCategoriesTabMapper { + return PublicServicesCategoriesTabMapperImpl() + } + + @Provides + fun providePublicServiceCategoryDetailsMapper(): PublicServiceCategoryDetailsComposeMapper { + return PublicServiceCategoryDetailsComposeMapperImpl() + } + + @Provides + fun providePublicServicesSearchComposeMapper(): PublicServicesSearchComposeMapper { + return PublicServicesSearchComposeMapperImpl() + } + + @Provides + fun provideDeepLinkProcessor( + ): DeeplinkProcessor { + val linkActionProcessors = listOf() + return DeeplinkProcessorImpl(linkActionProcessors) + } + + @Provides + fun provideNotificationController( + workManager: WorkManager, + notificationsDataSource: NotificationDataRepository, + notificationManager: DiiaNotificationManager, + pushTokenProvider: PushTokenProvider, + keyValueSource: KeyValueNotificationDataSource, + ): NotificationController { + return NotificationControllerImpl( + workManager = workManager, + notificationsDataSource = notificationsDataSource, + notificationManager = notificationManager, + pushTokenProvider = pushTokenProvider, + keyValueSource = keyValueSource + ) + } + + @Provides + @Singleton + fun bindIntDataSource( + network: NetworkItnDataSource, + keyValueInt: KeyValueItnDataSource, + ): ItnDataRepository { + val dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() + val applicationScope = CoroutineScope(SupervisorJob() + dispatcher) + return ItnDataRepositoryImpl(applicationScope, keyValueInt, network) + } + + @Provides + fun providePSCriminalCertHelper(): PSCriminalCertHelper = PSCriminalCertHelperImpl() + + @Provides + fun providePSNavigationHelper(): PSNavigationHelper = PSNavigationHelperImpl() + + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/GlobalUtils.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/GlobalUtils.kt new file mode 100644 index 0000000..b044b57 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/GlobalUtils.kt @@ -0,0 +1,94 @@ +package ua.gov.diia.opensource.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.util.DiiaDispatcherProvider +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.deeplink.DeepLinkActionFactory +import ua.gov.diia.core.util.delegation.WithAppConfig +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithDeeplinkHandling +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithPermission +import ua.gov.diia.core.util.delegation.WithPushHandling +import ua.gov.diia.core.util.delegation.WithPushNotification +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRatingDialogOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.opensource.ui.AndroidClientAlertDialogsFactory +import ua.gov.diia.opensource.util.AndroidDeepLinkActionFactory +import ua.gov.diia.opensource.util.DefaultDeeplinkHandleBehaviour +import ua.gov.diia.opensource.util.DefaultErrorHandlingBehaviour +import ua.gov.diia.opensource.util.DefaultErrorHandlingBehaviourOnFlow +import ua.gov.diia.opensource.util.DefaultPushHandlerBehaviour +import ua.gov.diia.opensource.util.DefaultPushNotificationBehaviour +import ua.gov.diia.opensource.util.DefaultRatingDialogBehaviour +import ua.gov.diia.opensource.util.DefaultRatingDialogBehaviourOnFlow +import ua.gov.diia.opensource.util.DefaultRetryLastActionBehaviour +import ua.gov.diia.opensource.util.DefaultSelfPermissionBehavior +import ua.gov.diia.opensource.util.DefaultWithContextMenuBehaviour +import ua.gov.diia.opensource.util.WithAppConfigImpl +import ua.gov.diia.opensource.util.WithBuildConfigImpl +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface GlobalUtils { + + @Binds + @Singleton + fun bindTemplateFactory(impl: AndroidClientAlertDialogsFactory): ClientAlertDialogsFactory + + @Binds + @Singleton + fun bindCoroutineDispatcher(impl: DiiaDispatcherProvider): DispatcherProvider + + @Binds + fun bindWithBuildConfig(impl: WithBuildConfigImpl): WithBuildConfig + + @Binds + fun bindWithContextMenu(impl: DefaultWithContextMenuBehaviour): WithContextMenu + + @Binds + fun bindWithPushHandling(impl: DefaultPushHandlerBehaviour): WithPushHandling + + @Binds + fun bindWithPushNotification(impl: DefaultPushNotificationBehaviour): WithPushNotification + + @Binds + fun bindWithRatingDialog(impl: DefaultRatingDialogBehaviour): WithRatingDialog + + @Binds + fun bindWithAppConfig(impl: WithAppConfigImpl): WithAppConfig + + @Binds + fun bindPermissionDelegate(impl: DefaultSelfPermissionBehavior): WithPermission + + @Binds + fun bindErrorHandlerDelegateOnFlow(impl: DefaultErrorHandlingBehaviourOnFlow): WithErrorHandlingOnFlow + + + @Binds + fun bindRetryDelegate(impl: DefaultRetryLastActionBehaviour): WithRetryLastAction + + @Binds + fun bindErrorHandlerDelegate(impl: DefaultErrorHandlingBehaviour): WithErrorHandling + + @Binds + fun bindDeeplinkHandler( + impl: DefaultDeeplinkHandleBehaviour + ): WithDeeplinkHandling + + @Binds + fun bindDeepLinkActionFactory(impl: AndroidDeepLinkActionFactory): DeepLinkActionFactory + + @Binds + fun bindRatingDialogDelegateOnFlow(impl: DefaultRatingDialogBehaviourOnFlow): WithRatingDialogOnFlow + +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/NotificationPublicModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/NotificationPublicModule.kt new file mode 100644 index 0000000..5bf6868 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/NotificationPublicModule.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.opensource.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import ua.gov.diia.core.data.data_source.network.api.notification.ApiNotificationsPublic +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient + +@Module +@InstallIn(SingletonComponent::class) +object NotificationPublicModule { + + @Provides + @AuthorizedClient + fun provideApiNotificationPublicAuthorized( + @AuthorizedClient retrofit: Retrofit + ): ApiNotificationsPublic = retrofit.create(ApiNotificationsPublic::class.java) + + @Provides + @UnauthorizedClient + fun provideApiNotificationPublicUnauthorized( + @UnauthorizedClient retrofit: Retrofit + ): ApiNotificationsPublic = retrofit.create(ApiNotificationsPublic::class.java) +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/ResourceIconProviderModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/ResourceIconProviderModule.kt new file mode 100644 index 0000000..492f393 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/ResourceIconProviderModule.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.opensource.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.opensource.ui.compose.DiiaResourceIconProviderImpl +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ResourceIconProviderModule { + + @Provides + @Singleton + fun provideDiiaResourceIconProvider(): DiiaResourceIconProvider { + return DiiaResourceIconProviderImpl() + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/fragment/Annotations.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/fragment/Annotations.kt new file mode 100644 index 0000000..ca1ff2d --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/fragment/Annotations.kt @@ -0,0 +1,9 @@ +package ua.gov.diia.opensource.di.fragment + +import androidx.fragment.app.Fragment +import dagger.MapKey +import kotlin.reflect.KClass + +@MapKey +@Retention(AnnotationRetention.RUNTIME) +annotation class FragmentKey(val value: KClass) diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/fragment/FragmentModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/fragment/FragmentModule.kt new file mode 100644 index 0000000..42cb72f --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/fragment/FragmentModule.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.opensource.di.fragment + +import androidx.fragment.app.Fragment +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.FragmentComponent +import dagger.multibindings.IntoMap +import ua.gov.diia.home.ui.HomeF + +@Module +@InstallIn(FragmentComponent::class) +interface FragmentModule { + + @Binds + @IntoMap + @FragmentKey(HomeF::class) + fun bindHomeF(impl: HomeF): Fragment +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/network/OkHttpClientModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/network/OkHttpClientModule.kt new file mode 100644 index 0000000..60e57de --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/network/OkHttpClientModule.kt @@ -0,0 +1,67 @@ +package ua.gov.diia.opensource.di.network + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.di.data_source.http.ProlongClient +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.opensource.data.network.interceptors.* +import ua.gov.diia.opensource.util.ext.setTimeout +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object OkHttpClientModule { + + @Provides + @UnauthorizedClient + @Singleton + fun provideUnauthorizedOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + appInfoHeaderInterceptor: HttpAppInfoHeaderInterceptor, + uuidInterceptor: HttpMobileUuidInterceptor + ): OkHttpClient = OkHttpClient.Builder() + .apply { + addInterceptor(appInfoHeaderInterceptor) + addInterceptor(uuidInterceptor) + addInterceptor(loggingInterceptor) + setTimeout() + }.build() + + @Provides + @AuthorizedClient + @Singleton + fun provideAuthorizedOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + appInfoHeaderInterceptor: HttpAppInfoHeaderInterceptor, + uuidInterceptor: HttpMobileUuidInterceptor, + authorizationInterceptor: HttpAuthorizationInterceptor + ): OkHttpClient = OkHttpClient.Builder() + .apply { + addInterceptor(appInfoHeaderInterceptor) + addInterceptor(uuidInterceptor) + addInterceptor(authorizationInterceptor) + addInterceptor(loggingInterceptor) + setTimeout() + }.build() + + @Provides + @ProlongClient + @Singleton + fun provideProlongOkHttpClient( + loggingInterceptor: HttpLoggingInterceptor, + appInfoHeaderInterceptor: HttpAppInfoHeaderInterceptor, + uuidInterceptor: HttpMobileUuidInterceptor, + prolongAuthorizationInterceptor: HttpProlongAuthorizationInterceptor + ): OkHttpClient = OkHttpClient.Builder() + .apply { + addInterceptor(appInfoHeaderInterceptor) + addInterceptor(uuidInterceptor) + addInterceptor(prolongAuthorizationInterceptor) + addInterceptor(loggingInterceptor) + setTimeout() + }.build() +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/network/RefreshLockerModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/network/RefreshLockerModule.kt new file mode 100644 index 0000000..c29f723 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/network/RefreshLockerModule.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.opensource.di.network + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RefreshLockerModule { + + @Provides + @Singleton + fun provideRefreshLocker( + ): RefreshLocker { + return RefreshLocker() + } +} + +class RefreshLocker() \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/network/RetrofitClientModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/network/RetrofitClientModule.kt new file mode 100644 index 0000000..be08334 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/network/RetrofitClientModule.kt @@ -0,0 +1,66 @@ +package ua.gov.diia.opensource.di.network + +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.di.data_source.http.ProlongClient +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.core.util.delegation.WithBuildConfig +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RetrofitClientModule { + + @Provides + @Singleton + fun provideMoshiAdapter(): Moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + + @Provides + @AuthorizedClient + @Singleton + fun provideAuthorizedRetrofitClient( + moshi: Moshi, + @AuthorizedClient okHttpClient: OkHttpClient, + withBuildConfig: WithBuildConfig, + ): Retrofit = Retrofit.Builder() + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .baseUrl(withBuildConfig.getServerUrl()) + .client(okHttpClient) + .build() + + @Provides + @UnauthorizedClient + @Singleton + fun provideUnauthorizedRetrofitClient( + moshi: Moshi, + @UnauthorizedClient okHttpClient: OkHttpClient, + withBuildConfig: WithBuildConfig, + ): Retrofit = Retrofit.Builder() + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .baseUrl(withBuildConfig.getServerUrl()) + .client(okHttpClient) + .build() + + @Provides + @ProlongClient + @Singleton + fun provideProlongRetrofitClient( + moshi: Moshi, + @ProlongClient okHttpClient: OkHttpClient, + withBuildConfig: WithBuildConfig, + ): Retrofit = Retrofit.Builder() + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .baseUrl(withBuildConfig.getServerUrl()) + .client(okHttpClient) + .build() +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/di/network/UnAuthorizedApiModule.kt b/opensource/src/main/java/ua/gov/diia/opensource/di/network/UnAuthorizedApiModule.kt new file mode 100644 index 0000000..1261345 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/di/network/UnAuthorizedApiModule.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.opensource.di.network + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import ua.gov.diia.core.data.data_source.network.api.ApiSettings +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient + +@Module +@InstallIn(SingletonComponent::class) +object UnAuthorizedApiModule { + + @Provides + @UnauthorizedClient + fun provideApiSettings( + @UnauthorizedClient retrofit: Retrofit + ): ApiSettings = retrofit.create(ApiSettings::class.java) +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/HomeHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/HomeHelperImpl.kt new file mode 100644 index 0000000..f15cb26 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/HomeHelperImpl.kt @@ -0,0 +1,37 @@ +package ua.gov.diia.opensource.helper + +import androidx.fragment.app.Fragment +import androidx.navigation.NavDirections +import ua.gov.diia.home.helper.HomeHelper +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import ua.gov.diia.documents.ui.gallery.DocGalleryFCompose +import ua.gov.diia.home.model.HomeMenuItem +import ua.gov.diia.home.ui.HomeActions +import ua.gov.diia.menu.ui.MenuFCompose +import ua.gov.diia.opensource.NavHomeChildrenDirections +import ua.gov.diia.opensource.R +import ua.gov.diia.opensource.ui.fragments.FeedF +import ua.gov.diia.publicservice.ui.categories.compose.PublicServicesCategoriesComposeF + +class HomeHelperImpl: HomeHelper { + + override fun getNavMenuItem(classObj: Class): HomeMenuItemConstructor = when (classObj) { + DocGalleryFCompose::class.java -> HomeMenuItem.DOCUMENTS + MenuFCompose::class.java -> HomeMenuItem.MENU + PublicServicesCategoriesComposeF::class.java -> HomeMenuItem.SERVICES + FeedF::class.java -> HomeMenuItem.FEED + else -> HomeMenuItem.MENU + } + + override fun getGraphId(): Int { + return R.navigation.nav_home_children + } + + override fun getNavDirection(position: Int): NavDirections = when (position) { + HomeActions.HOME_DOCUMENTS -> NavHomeChildrenDirections.globalToDocGalleryFCompose() + HomeActions.HOME_MENU -> NavHomeChildrenDirections.globalToMenuFCompose() + HomeActions.HOME_SERVICES -> NavHomeChildrenDirections.globalToPublicServicesFCompose() + HomeActions.HOME_FEED -> NavHomeChildrenDirections.globalToFeedF() + else -> NavHomeChildrenDirections.globalToMenuFCompose() + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/NotificationHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/NotificationHelperImpl.kt new file mode 100644 index 0000000..13ed723 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/NotificationHelperImpl.kt @@ -0,0 +1,89 @@ +package ua.gov.diia.opensource.helper + +import android.content.Context +import android.content.Intent +import androidx.navigation.NavDirections +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import ua.gov.diia.core.di.actions.GlobalActionFocusOnDocument +import ua.gov.diia.core.di.actions.GlobalActionSelectedMenuItem +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.models.notification.push.PushNotification +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.home.model.HomeMenuItem +import ua.gov.diia.notifications.helper.NotificationHelper +import ua.gov.diia.notifications.models.notification.push.DiiaNotificationChannel +import ua.gov.diia.opensource.model.notification.PushNotificationActionType +import ua.gov.diia.opensource.repository.settings.AppSettingsRepository +import ua.gov.diia.opensource.ui.activities.MainActivity +import ua.gov.diia.ui_base.models.homescreen.HomeMenuItemConstructor +import javax.inject.Inject + +class NotificationHelperImpl @Inject constructor( + @GlobalActionFocusOnDocument val globalActionFocusOnDocument: MutableStateFlow?>, + @GlobalActionSelectedMenuItem val globalActionSelectedMenuItem: MutableStateFlow?>, + val withCrashlytics: WithCrashlytics, + @ApplicationContext private val context: Context, + private val appSettingsRepository: AppSettingsRepository +) : NotificationHelper { + + private suspend fun focusOnDocument(docType: String) { + globalActionFocusOnDocument.emit(UiDataEvent(docType)) + globalActionSelectedMenuItem.emit(UiDataEvent(HomeMenuItem.DOCUMENTS)) + } + + override fun isMessageNotification(resourceType: String): Boolean { + return PushNotificationActionType.MESSAGE == PushNotificationActionType.fromId(resourceType) + } + + override suspend fun navigateToDocument(item: PullNotificationItemSelection): NavDirections? { + if (isViewDocType(item.resourceType)) { + val itemDocName = getDocName(item.resourceType) + + if (itemDocName == null) { + withCrashlytics.sendNonFatalError( + IllegalStateException( + "This notification isn't a DOCUMENT type, it is:${item.resourceType} type" + ) + ) + return null + } + focusOnDocument(itemDocName) + return null + } else { + return null + } + } + + override suspend fun getLastDocumentUpdate(): String? = + appSettingsRepository.getLastDocumentUpdate() + + override suspend fun getLastActiveDate(): String? = appSettingsRepository.getLastActiveDate() + + override fun getMainActivityIntent(): Intent { + return Intent(context, MainActivity::class.java) + } + + private fun getDocName(resourceType: String): String? { + return if (resourceType.startsWith(PushNotificationActionType.DOCUMENT_VIEW.id)) { + resourceType.removePrefix(PushNotificationActionType.DOCUMENT_VIEW.id) + } else { + null + } + } + + private fun isViewDocType(resourceType: String): Boolean { + return resourceType.startsWith(PushNotificationActionType.DOCUMENT_VIEW.id) + } + + override fun log(data: String) { + // Implement logging data + } + + override fun getNotificationChannel(notif: PushNotification): String { + return when (notif.action.type) { + else -> DiiaNotificationChannel.DEFAULT.id + } + } +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/PSCriminalCertHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/PSCriminalCertHelperImpl.kt new file mode 100644 index 0000000..bcb23c1 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/PSCriminalCertHelperImpl.kt @@ -0,0 +1,41 @@ +package ua.gov.diia.opensource.helper + +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.core.util.extensions.fragment.setNavigationResult +import ua.gov.diia.opensource.NavMainXmlDirections +import ua.gov.diia.opensource.R +import ua.gov.diia.ps_criminal_cert.helper.PSCriminalCertHelper + +class PSCriminalCertHelperImpl : PSCriminalCertHelper { + + private companion object { + + const val ACTION_CERT_ORDERING_COMPLETE = "certOrdered" + } + + override fun navigateToDamagedPropertyRecovery( + fragment: Fragment, + applicationId: String? + ) { + with(fragment){ + navigate( + NavMainXmlDirections.actionGlobalToDamagedPropertyRecovery( + applicationId = applicationId + ) + ) + } + } + + override fun navigateToDPRecoveryHomeF(fragment: Fragment, resId: String?) { + with(fragment){ + setNavigationResult( + arbitraryDestination = R.id.DPRecoveryHomeF, + key = ACTION_CERT_ORDERING_COMPLETE, + data = resId + ) + findNavController().popBackStack(R.id.DPRecoveryHomeF, false) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/PSNavigationHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/PSNavigationHelperImpl.kt new file mode 100644 index 0000000..6e29b6b --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/PSNavigationHelperImpl.kt @@ -0,0 +1,50 @@ +package ua.gov.diia.opensource.helper + +import androidx.fragment.app.Fragment +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.models.rating_service.RatingFormModel +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.opensource.NavMainXmlDirections +import ua.gov.diia.opensource.util.ext.sendPdf +import ua.gov.diia.opensource.util.ext.sendZip +import ua.gov.diia.publicservice.helper.PSNavigationHelper + +class PSNavigationHelperImpl : PSNavigationHelper { + override fun navigateToContextMenu( + fragment: Fragment, + menu: Array + ) { + fragment.navigate( + NavMainXmlDirections.actionGlobalDestinationContextMenu(menu) + ) + } + + override fun navigateToFaq(fragment: Fragment, categoryId: String) { + // Implement navigation to FAQ screen + } + + override fun navigateToRatingService( + fragment: Fragment, + ratingFormModel: RatingFormModel, + id: String?, + destinationId: Int, + resultKey: String, + screenCode: String?, + ratingType: String?, + formCode: String? + ) { + // Implement navigation to Rating service screen + } + + override fun navigateToSupport(fragment: Fragment) { + // Implement navigation to support screen + } + + override fun sendPdf(fragment: Fragment, file: String, name: String) { + fragment.sendPdf(file, name) + } + + override fun sendZip(fragment: Fragment, file: String, name: String) { + fragment.sendZip(file, name) + } +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/PinHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/PinHelperImpl.kt new file mode 100644 index 0000000..82872ac --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/PinHelperImpl.kt @@ -0,0 +1,52 @@ +package ua.gov.diia.opensource.helper + +import androidx.fragment.app.Fragment +import ua.gov.diia.biometric.Biometric +import ua.gov.diia.biometric.ui.showBiometricAuthPrompt +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.opensource.NavMainXmlDirections +import ua.gov.diia.opensource.repository.settings.AppSettingsRepository +import ua.gov.diia.pin.helper.PinHelper +import ua.gov.diia.pin.ui.input.AlternativeAuthCallback +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PinHelperImpl @Inject constructor( + private val biometric: Biometric, + private val settingsRepository: AppSettingsRepository, +) : PinHelper { + + override fun isAlternativeAuthAvailable(): Boolean { + return biometric.isBiometricAuthAvailable() + } + + override suspend fun isAlternativeAuthEnabled(): Boolean { + return biometric.isBiometricAuthAvailable() && settingsRepository.isBiometricAuthEnabled() + } + + override fun openAlternativeAuth(host: Fragment, callback: AlternativeAuthCallback) { + host.showBiometricAuthPrompt(biometric.getPromptDescriptionString()) { result -> + if (result) { + callback.onAlternativeAuthSuccessful() + } else { + callback.onAlternativeAuthFailed() + } + } + } + + override fun navigateToAlternativeAuthSetup( + host: Fragment, + resultDestinationId: Int, + resultKey: String, + pin: String, + ) { + host.navigate( + NavMainXmlDirections.actionGlobalToSetupBiometric( + resultDestinationId = resultDestinationId, + resultKey = resultKey, + pin = pin, + ) + ) + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/PublicServiceHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/PublicServiceHelperImpl.kt new file mode 100644 index 0000000..dac0fe0 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/PublicServiceHelperImpl.kt @@ -0,0 +1,44 @@ +package ua.gov.diia.opensource.helper + +import androidx.fragment.app.Fragment +import ua.gov.diia.core.util.extensions.fragment.currentDestinationId +import ua.gov.diia.core.util.extensions.fragment.findNavControllerById +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.opensource.NavMainXmlDirections +import ua.gov.diia.opensource.R +import ua.gov.diia.opensource.util.ext.navigateToPublicService +import ua.gov.diia.publicservice.helper.PublicServiceHelper +import ua.gov.diia.publicservice.models.PublicService +import ua.gov.diia.publicservice.models.PublicServiceCategory + +class PublicServiceHelperImpl : PublicServiceHelper { + override fun navigateToCategoryServices(fragment: Fragment, category: PublicServiceCategory) { + fragment.apply { + navigate( + NavMainXmlDirections.actionGlobalDestinationCategoryDetailsCompose( + category = category, + resultDestinationId = currentDestinationId ?: return + ), + findNavControllerById(R.id.nav_host) + ) + } + } + + override fun navigateToServiceSearch(fragment: Fragment, data: Array) { + fragment.apply { + navigate( + NavMainXmlDirections.actionGlobalDestinationPsSearchCompose( + arbitraryDestinationId = currentDestinationId ?: return, + categories = data + ), + findNavControllerById(R.id.nav_host) + ) + } + } + + override fun navigateToService(fragment: Fragment, service: PublicService) { + fragment.apply { + navigateToPublicService(service) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/PublicServicesCategoriesTabMapperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/PublicServicesCategoriesTabMapperImpl.kt new file mode 100644 index 0000000..8eeb0e6 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/PublicServicesCategoriesTabMapperImpl.kt @@ -0,0 +1,64 @@ +package ua.gov.diia.opensource.helper + +import androidx.compose.runtime.snapshots.SnapshotStateList +import ua.gov.diia.publicservice.models.PublicServiceCategory +import ua.gov.diia.publicservice.models.PublicServiceTab +import ua.gov.diia.publicservice.ui.categories.compose.PublicServicesCategoriesTabMapper +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.card.ServiceCardMlcData +import ua.gov.diia.ui_base.components.molecule.header.chiptabbar.ChipTabMoleculeDataV2 +import ua.gov.diia.ui_base.components.molecule.header.chiptabbar.ChipTabsOrgData +import ua.gov.diia.ui_base.components.molecule.input.SearchInputV2Data +import ua.gov.diia.ui_base.components.molecule.tile.ServiceCardTileOrgData +import javax.inject.Inject + +class PublicServicesCategoriesTabMapperImpl @Inject constructor() : + PublicServicesCategoriesTabMapper { + + override fun List.toComposeServiceTileOrganism(): ServiceCardTileOrgData { + return ServiceCardTileOrgData( + items = SnapshotStateList().apply { + addAll(this@toComposeServiceTileOrganism.map { it.toComposeServiceCardMolecule() }) + }) + } + + override fun generateComposeChipTabBarV2( + tabs: List?, + selectedTab: String? + ): ChipTabsOrgData? { + return if (tabs?.size == 1) { + null + } else ChipTabsOrgData( + tabs = SnapshotStateList().apply { + if (tabs != null) { + addAll(tabs.map { + ChipTabMoleculeDataV2( + id = it.code, + title = it.name, + selectionState = if (it.code == selectedTab) UIState.Selection.Selected else UIState.Selection.Unselected + ) + }) + } + }) + } + + override fun generateSearchInputMoleculeV2( + placeholder: String, + mode: Int + ): SearchInputV2Data { + return SearchInputV2Data( + placeholder = UiText.DynamicString(placeholder), + mode = mode + ) + } + + private fun PublicServiceCategory.toComposeServiceCardMolecule(): ServiceCardMlcData { + return ServiceCardMlcData( + label = this.name, + id = this.code, + icon = UiIcon.DrawableResource(this.code) + ) + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/SplashHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/SplashHelperImpl.kt new file mode 100644 index 0000000..7442b43 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/SplashHelperImpl.kt @@ -0,0 +1,38 @@ +package ua.gov.diia.opensource.helper + +import androidx.fragment.app.Fragment +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.pin.NavPinCreateDirections +import ua.gov.diia.pin.model.CreatePinFlowType +import ua.gov.diia.pin.repository.LoginPinRepository +import ua.gov.diia.splash.helper.SplashHelper +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SplashHelperImpl @Inject constructor( + private val loginPinRepository: LoginPinRepository, +) : SplashHelper { + + override suspend fun isProtectionExists(): Boolean { + return loginPinRepository.isPinPresent() + } + + override suspend fun setUserAuthorized(protectionKey: String) { + loginPinRepository.setUserAuthorized(protectionKey) + } + + override fun navigateToProtectionCreation( + host: Fragment, + resultDestinationId: Int, + resultKey: String + ) { + host.navigate( + NavPinCreateDirections.actionGlobalCreatePin( + resultDestinationId = resultDestinationId, + resultKey = resultKey, + flowType = CreatePinFlowType.AUTHORIZATION + ) + ) + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/ApiDocumentsWrapper.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/ApiDocumentsWrapper.kt new file mode 100644 index 0000000..7e2cc03 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/ApiDocumentsWrapper.kt @@ -0,0 +1,111 @@ +package ua.gov.diia.opensource.helper.documents + +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.util.DateFormats +import ua.gov.diia.core.util.date.CurrentDateProvider +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.diia_storage.CommonPreferenceKeys +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.documents.data.api.ApiDocuments +import ua.gov.diia.documents.models.DiiaDocumentGroup +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.models.DiiaDocumentsWithOrder +import ua.gov.diia.documents.models.DocumentsOrder +import ua.gov.diia.documents.models.ManualDocs +import ua.gov.diia.documents.models.TypeDefinedDocumentsOrder +import ua.gov.diia.documents.models.UpdatedDoc +import ua.gov.diia.opensource.data.network.api.ApiDocs +import ua.gov.diia.opensource.model.documents.Docs +import javax.inject.Inject + +class ApiDocumentsWrapper @Inject constructor( + @AuthorizedClient private val apiDocs: ApiDocs, + private val diiaStorage: DiiaStorage, + private val currentDateProvider: CurrentDateProvider, + private val withCrashlytics: WithCrashlytics +) : ApiDocuments, WithCrashlytics by withCrashlytics { + + override suspend fun fetchDocuments(docTypes: Map): List { + val updatedDocs = loadDocs(docTypes) + return docsToDocumentWithMetadataList(updatedDocs) + } + + override suspend fun fetchDocumentsWithTypes(docTypes: Map): DiiaDocumentsWithOrder { + val updatedDocs = loadDocs(docTypes) + val data = docsToDocumentWithMetadataList(updatedDocs) + return DiiaDocumentsWithOrder( + documents = data, + docOrder = updatedDocs.documentsTypeOrder + ) + } + + override suspend fun setDocumentsOrder(docOrder: DocumentsOrder) { + apiDocs.setDocumentsOrder(docOrder = docOrder) + } + + override suspend fun setTypedDocumentsOrder( + documentType: String, + docOrder: TypeDefinedDocumentsOrder + ) { + apiDocs.setTypedDocumentsOrder( + documentType = documentType, + docOrder = docOrder + ) + } + + override suspend fun getDocsManual(): ManualDocs { + return apiDocs.getDocsManual() + } + + override suspend fun getDocumentById(type: String, id: String): UpdatedDoc { + return apiDocs.getDocumentById(type, id) + } + + private suspend fun loadDocs(map: Map = emptyMap()): Docs { + val originalDocs = apiDocs.getDocs(map) + diiaStorage.set( + CommonPreferenceKeys.LastDocumentUpdate, + DateFormats.iso8601.format(currentDateProvider.getDate()) + ) + return originalDocs.copy( + documentsTypeOrder = originalDocs.documentsTypeOrder + ) + } + + private fun docsToDocumentWithMetadataList(docs: Docs): List { + val docsWithMetadata = mutableListOf() + docs.driverLicense?.let { + docsWithMetadata.addAll(groupToDocumentsWithMetadata(it, docs)) + } + return docsWithMetadata + } + + private fun groupToDocumentsWithMetadata( + diiaDocumentGroup: DiiaDocumentGroup<*>, + docs: Docs, + ): List { + if (diiaDocumentGroup.getData().isEmpty()) { + return listOf( + DiiaDocumentWithMetadata( + null, + docs.getTimestamp(), + diiaDocumentGroup.getDocExpirationDate(), + diiaDocumentGroup.getStatus(), + diiaDocumentGroup.getItemType(), + DiiaDocumentWithMetadata.LAST_DOC_ORDER + ) + ) + } else { + return diiaDocumentGroup.getData().mapIndexed { i, data -> + DiiaDocumentWithMetadata( + data?.apply { setNewOrder(i) }, + diiaDocumentGroup.getTimestamp(), + diiaDocumentGroup.getDocExpirationDate(), + diiaDocumentGroup.getStatus(), + diiaDocumentGroup.getItemType(), + docs.documentsTypeOrder.indexOf(diiaDocumentGroup.getItemType()) + 1 + ) + } + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocActionsNavigationHandlerImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocActionsNavigationHandlerImpl.kt new file mode 100644 index 0000000..0c1e934 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocActionsNavigationHandlerImpl.kt @@ -0,0 +1,58 @@ +package ua.gov.diia.opensource.helper.documents + +import android.os.Parcelable +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.ui.actions.DocActionsDFCompose +import ua.gov.diia.documents.ui.actions.DocActionsDFComposeArgs +import ua.gov.diia.documents.ui.actions.DocActionsNavigationHandler +import ua.gov.diia.documents.ui.actions.DocActionsVMCompose +import ua.gov.diia.documents.ui.fullinfo.FullInfoFCompose +import ua.gov.diia.documents.ui.fullinfo.FullInfoFComposeArgs +import ua.gov.diia.opensource.NavMainXmlDirections +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import javax.inject.Inject + +class DocActionsNavigationHandlerImpl @Inject constructor() : + DocActionsNavigationHandler { + override fun handleNavigation( + fragment: DocActionsDFCompose, + navigation: NavigationPath, + args: DocActionsDFComposeArgs, + ) { + with(fragment) { + when (navigation) { + is DocActionsVMCompose.Navigation.NavToFullInfo -> { + fullDocAction(args.doc) + } + + is DocActionsVMCompose.Navigation.ToDocStackOrder -> { + dismiss() + navigate(NavMainXmlDirections.actionGlobalToStackOrder()) + } + + is DocActionsVMCompose.Navigation.ToDocStackOrderWithType -> { + dismiss() + navigate(NavMainXmlDirections.actionGlobalToStackOrder((args.doc as DiiaDocument).getItemType())) + } + + else -> Unit + } + } + } + + private fun DocActionsDFCompose.fullDocAction(document: Parcelable) { + val fullDocFragment = FullInfoFCompose().apply { + arguments = FullInfoFComposeArgs(document).toBundle() + } + dismiss() + fullDocFragment.show( + requireActivity().supportFragmentManager, + FULL_DOC_INFO_TRANSACTION_TAG + ) + } + + companion object { + private const val FULL_DOC_INFO_TRANSACTION_TAG = "FULL_DOC_INF" + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocGalleryNavigationHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocGalleryNavigationHelperImpl.kt new file mode 100644 index 0000000..8497c73 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocGalleryNavigationHelperImpl.kt @@ -0,0 +1,218 @@ +package ua.gov.diia.opensource.helper.documents + +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.models.ConsumableString +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.extensions.fragment.openLink +import ua.gov.diia.core.util.extensions.fragment.previousDestinationId +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResult +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.docgroups.v2.VerificationAction +import ua.gov.diia.documents.ui.DocVM +import ua.gov.diia.documents.ui.DocsConst +import ua.gov.diia.documents.ui.gallery.DocGalleryNavigationHelper +import ua.gov.diia.documents.ui.gallery.DocGalleryVMCompose +import ua.gov.diia.opensource.R +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import javax.inject.Inject + +class DocGalleryNavigationHelperImpl @Inject constructor( + private val withCrashlytics: WithCrashlytics, +) : + DocGalleryNavigationHelper { + + override fun subscribeForNavigationEvents( + fragment: Fragment, + viewModel: DocVM + ) { + galleryUniqRegistration(fragment, viewModel) + registerFromKeyRating(fragment, viewModel) + registerFromRemoveDocument(fragment, viewModel) + registerFromDocumentRating(fragment, viewModel) + registerFromOpenLink(fragment) + registerFromRating(fragment, viewModel) + registerFromVerificationCode(fragment, viewModel) + registerFromEANCode(fragment, viewModel) + registerFromQRCode(fragment, viewModel) + } + + override fun subscribeForStackNavigationEvents( + fragment: Fragment, + viewModel: DocVM + ) { + stackUniqRegistration(fragment, viewModel) + registerFromRemoveDocument(fragment, viewModel) + registerFromDocumentRating(fragment, viewModel) + registerFromOpenLink(fragment) + registerFromRating(fragment, viewModel) + registerFromVerificationCode(fragment, viewModel) + registerFromEANCode(fragment, viewModel) + registerFromQRCode(fragment, viewModel) + } + + private fun galleryUniqRegistration( + fragment: Fragment, + viewModel: DocVM + ) { + with(fragment) { + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + } + } + + registerForTemplateDialogNavResult(DocGalleryVMCompose.RESUlT_KEY_TEMP_RETRY) { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.DIALOG_ACTION_CODE_CLOSE, + ActionsConst.ERROR_DIALOG_DEAL_WITH_IT + -> findNavController().popBackStack() + } + } + } + } + + private fun stackUniqRegistration( + fragment: Fragment, + viewModel: DocVM + ) { + with(fragment) { + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + } + } + } + } + + fun registerFromRemoveDocument(fragment: Fragment, viewModel: DocVM) { + with(fragment) { + + registerForNavigationResult(ActionsConst.RESULT_KEY_REMOVE_DOCUMENT) { key -> + key.consumeEvent { doc -> + if (previousDestinationId == R.id.homeF) { + findNavController().popBackStack() + } + viewModel.removeDoc(doc) + } + } + } + } + + fun registerFromKeyRating(fragment: Fragment, viewModel: DocVM) { + with(fragment) { + registerForNavigationResult(DocsConst.RESULT_KEY_RATING) { event -> + event.consumeEvent { rating -> + viewModel.sendRatingRequest( + rating + ) + } + } + } + } + + private fun registerFromDocumentRating( + fragment: Fragment, + viewModel: DocVM + ) { + with(fragment) { + + registerForNavigationResult(ActionsConst.RESULT_KEY_RATE_DOCUMENT) { key -> + key.consumeEvent { doc -> + if (previousDestinationId == R.id.homeF) { + findNavController().popBackStack() + } + viewModel.showRating(doc) + } + } + } + } + + private fun registerFromOpenLink(fragment: Fragment) { + with(fragment) { + registerForNavigationResult(ActionsConst.RESULT_KEY_OPEN_LINK) { key -> + key.consumeEvent { link -> + if (previousDestinationId == R.id.homeF) { + findNavController().popBackStack() + } + openLink(link, withCrashlytics) + } + } + } + } + + private fun registerFromVerificationCode(fragment: Fragment, viewModel: DocVM) { + with(fragment) { + registerForNavigationResult(ActionsConst.RESULT_KEY_VERIFICATION_CODE) { key -> + key.consumeEvent { action -> + if (previousDestinationId == R.id.homeF) { + findNavController().popBackStack() + } + val event = UIAction( + actionKey = action.actionKey, + data = action.docName, + optionalId = action.position.toString() + ) + viewModel.onUIAction(event) + } + } + } + } + + private fun registerFromEANCode(fragment: Fragment, viewModel: DocVM) { + with(fragment) { + registerForNavigationResult(ActionsConst.RESULT_KEY_EAN13_CODE) { key -> + key.consumeEvent { action -> + if (previousDestinationId == R.id.homeF) { + findNavController().popBackStack() + } + val event = UIAction( + actionKey = action.actionKey, + data = action.docName, + optionalId = action.position.toString(), + optionalType = action.id + ) + viewModel.onUIAction(event) + } + } + } + } + + private fun registerFromQRCode(fragment: Fragment, viewModel: DocVM) { + with(fragment) { + registerForNavigationResult(ActionsConst.RESULT_KEY_QR_CODE) { key -> + key.consumeEvent { action -> + if (previousDestinationId == R.id.homeF) { + findNavController().popBackStack() + } + val event = UIAction( + actionKey = action.actionKey, + data = action.docName, + optionalId = action.position.toString() + ) + viewModel.onUIAction(event) + } + } + } + } + + private fun registerFromRating(fragment: Fragment, viewModel: DocVM) { + with(fragment) { + registerForNavigationResult(ActionsConst.RATING) { event -> + event.consumeEvent { rating -> + viewModel.sendRatingRequest( + rating + ) + } + } + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocName.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocName.kt new file mode 100644 index 0000000..c9100b0 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocName.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.opensource.helper.documents + +object DocName { + const val DRIVER_LICENSE = "driver-license" + const val TAXPAYER_CARD = "taxpayer-card" +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocNameProviderImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocNameProviderImpl.kt new file mode 100644 index 0000000..16e4da3 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocNameProviderImpl.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.opensource.helper.documents + +import ua.gov.diia.doc_driver_license.DriverLicenseV2 +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.util.DocNameProvider +import javax.inject.Inject + +class DocNameProviderImpl @Inject constructor() : DocNameProvider { + override fun getDocumentName(document: DiiaDocument): String { + return when (document) { + is DriverLicenseV2.Data -> document.getItemType() + else -> "" + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentBarcodeRepositoryImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentBarcodeRepositoryImpl.kt new file mode 100644 index 0000000..39e9d1e --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentBarcodeRepositoryImpl.kt @@ -0,0 +1,83 @@ +package ua.gov.diia.opensource.helper.documents + +import retrofit2.HttpException +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.doc_driver_license.DriverLicenseV2 +import ua.gov.diia.documents.barcode.DocumentBarcodeErrorLoadResult +import ua.gov.diia.documents.barcode.DocumentBarcodeFactory +import ua.gov.diia.documents.barcode.DocumentBarcodeRepository +import ua.gov.diia.documents.barcode.DocumentBarcodeRepositoryResult +import ua.gov.diia.documents.barcode.DocumentBarcodeSuccessfulLoadResult +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.QRUrl +import ua.gov.diia.opensource.data.network.api.ApiDocs +import javax.inject.Inject + +class DocumentBarcodeRepositoryImpl @Inject constructor( + @AuthorizedClient private val apiDocs: ApiDocs, + private val barcodeFactory: DocumentBarcodeFactory, +) : DocumentBarcodeRepository { + + override suspend fun loadBarcode( + doc: DiiaDocument, + position: Int, + fullInfo: Boolean, + ): DocumentBarcodeRepositoryResult { + val docName = mapDocName(doc) + val result = try { + when (docName) { + DocName.DRIVER_LICENSE -> { + val qrInfo = apiDocs.getShareUrlWithLocalization( + docName, + doc.id, + doc.localization()?.name ?: "ua" + ) + qrUrlToBarcode(qrInfo, position) + } + + else -> { + val qrInfo = apiDocs.getShareUrl(docName, doc.id) + qrUrlToBarcode(qrInfo, position) + } + } + } catch (e: Exception) { + if ((e as? HttpException)?.code() in SERVER_ERROR_HTTP_RANGE) { + DocumentBarcodeErrorLoadResult(e, 500) + } else { + DocumentBarcodeErrorLoadResult(e) + } + } + return DocumentBarcodeRepositoryResult(result, true) + } + + private fun mapDocName(doc: DiiaDocument): String { + return when (doc) { + is DriverLicenseV2.Data -> DocName.DRIVER_LICENSE + else -> "" + } + } + + private suspend fun qrUrlToBarcode( + shareData: QRUrl, + itemPosition: Int, + ): DocumentBarcodeSuccessfulLoadResult { + barcodeFactory.buildBitmapQrCode(shareData.link) + shareData.shareCode?.let { ean -> + barcodeFactory.buildBitmapEan13Code(ean) + } + val result = DocumentBarcodeSuccessfulLoadResult( + barcodeFactory.getQrCodeResult(), + barcodeFactory.getEan13CodeResult(), + shareData.shareCode, + itemPosition, + shareData.timerText, + shareData.timerTime + ) + barcodeFactory.clearResults() + return result + } + + companion object { + private val SERVER_ERROR_HTTP_RANGE = 500..600 + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentComposeMapperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentComposeMapperImpl.kt new file mode 100644 index 0000000..7fc231f --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentComposeMapperImpl.kt @@ -0,0 +1,698 @@ +package ua.gov.diia.opensource.helper.documents + +import androidx.compose.runtime.toMutableStateList +import ua.gov.diia.core.models.common_compose.atm.chip.ChipStatusAtm +import ua.gov.diia.core.network.Http +import ua.gov.diia.doc_driver_license.DriverLicenseV2 +import ua.gov.diia.documents.barcode.DocumentBarcodeErrorLoadResult +import ua.gov.diia.documents.barcode.DocumentBarcodeResult +import ua.gov.diia.documents.barcode.DocumentBarcodeSuccessfulLoadResult +import ua.gov.diia.documents.models.DocError +import ua.gov.diia.documents.models.DocumentCard +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.models.docgroups.v2.DocButtonHeadingOrg +import ua.gov.diia.documents.models.docgroups.v2.DocCover +import ua.gov.diia.documents.models.docgroups.v2.DocHeadingOrg +import ua.gov.diia.documents.models.docgroups.v2.QrCheckStatus +import ua.gov.diia.core.models.common_compose.mlc.text.SmallEmojiPanelMlc +import ua.gov.diia.documents.models.docgroups.v2.SubtitleLabelMlc +import ua.gov.diia.core.models.common_compose.table.tableBlockPlaneOrg.TableBlockPlaneOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsPlaneOrg.TableBlockTwoColumnsPlaneOrg +import ua.gov.diia.documents.ui.DocumentComposeMapper +import ua.gov.diia.documents.ui.ToggleId +import ua.gov.diia.documents.ui.gallery.DocActions +import ua.gov.diia.opensource.R +import ua.gov.diia.opensource.ui.compose.toComposeTableBlockOrg +import ua.gov.diia.opensource.ui.compose.toComposeTableBlockPlaneOrgData +import ua.gov.diia.opensource.ui.compose.toComposeTableBlockTwoColumnsOrg +import ua.gov.diia.opensource.ui.compose.toComposeTableBlockTwoColumnsPlaneOrg +import ua.gov.diia.ui_base.components.atom.button.ButtonStrokeAdditionalAtomData +import ua.gov.diia.ui_base.components.atom.icon.SmallIconAtmData +import ua.gov.diia.ui_base.components.atom.text.TickerAtomData +import ua.gov.diia.ui_base.components.atom.text.TickerType +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.state.UIState +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.button.BtnToggleMlcData +import ua.gov.diia.ui_base.components.molecule.card.WhiteMenuCardMlcData +import ua.gov.diia.ui_base.components.molecule.doc.DocCoverMlcData +import ua.gov.diia.ui_base.components.molecule.doc.DocNumberCopyMlcData +import ua.gov.diia.ui_base.components.molecule.doc.DocNumberCopyWhiteMlcData +import ua.gov.diia.ui_base.components.molecule.doc.StackMlcData +import ua.gov.diia.ui_base.components.molecule.text.HeadingWithSubtitlesMlcData +import ua.gov.diia.ui_base.components.molecule.text.HeadingWithSubtitlesWhiteMlcData +import ua.gov.diia.ui_base.components.molecule.text.SubtitleLabelMlcData +import ua.gov.diia.ui_base.components.molecule.tile.SmallEmojiPanelMlcData +import ua.gov.diia.ui_base.components.organism.document.AddDocOrgData +import ua.gov.diia.ui_base.components.organism.document.ContentTableOrgData +import ua.gov.diia.ui_base.components.organism.document.DocButtonHeadingOrgData +import ua.gov.diia.ui_base.components.organism.document.DocCodeOrgData +import ua.gov.diia.ui_base.components.organism.document.DocErrorOrgData +import ua.gov.diia.ui_base.components.organism.document.DocHeadingOrgData +import ua.gov.diia.ui_base.components.organism.document.DocOrgData +import ua.gov.diia.ui_base.components.organism.document.DocPhotoOrgData +import ua.gov.diia.ui_base.components.organism.document.Localization +import ua.gov.diia.ui_base.components.organism.group.ToggleButtonGroupData +import ua.gov.diia.ui_base.components.organism.pager.DocCardFlipData +import ua.gov.diia.ui_base.components.organism.pager.DocCarouselOrgData +import ua.gov.diia.core.models.common_compose.atm.text.TickerAtm +import ua.gov.diia.core.models.common_compose.table.tableBlockOrg.TableBlockOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsOrg.TableBlockTwoColumnsOrg +import ua.gov.diia.ui_base.util.toUiModel +import javax.inject.Inject + +class DocumentComposeMapperImpl @Inject constructor() : DocumentComposeMapper { + + private fun getIconByCode(code: String): Int { + return when (code) { + "copy" -> R.drawable.ic_copy_settings + else -> { + R.drawable.ic_copy_settings + } + } + } + + override fun SmallEmojiPanelMlc?.toComposeEmojiPanelMlc(): SmallEmojiPanelMlcData? { + if (this == null) return null + val text = label ?: return null + val code = icon?.code ?: return null + return SmallEmojiPanelMlcData( + text = UiText.DynamicString(text), + icon = UiIcon.DrawableResource(code = code) + ) + } + + override fun SubtitleLabelMlc?.toComposeSubtitleLabelMlc(): SubtitleLabelMlcData? { + val text = this?.label ?: return null + return SubtitleLabelMlcData(label = UiText.DynamicString(text)) + } + + override fun toComposeDocError(status: QrCheckStatus): DocErrorOrgData? { + return when (status) { + QrCheckStatus.STATUS_NO_NETWORK -> { + DocErrorOrgData( + title = UiText.StringResource(R.string.check_docs_no_internet_message), + ticker = TickerAtomData( + title = "Немає інтернету • Немає інтернету • Немає інтернету • ", + type = TickerType.SMALL_NEGATIVE + ) + ) + } + + QrCheckStatus.STATUS_UNKNOWN_CODE_TYPE -> { + DocErrorOrgData( + title = UiText.StringResource(R.string.qr_code_not_registered), + ticker = TickerAtomData( + title = "Невідомий QR-код • Невідомий QR-код • Невідомий QR-код • ", + type = TickerType.SMALL_NEGATIVE + ) + ) + } + + QrCheckStatus.STATUS_DOC_NOT_LOADED_ERROR -> { + DocErrorOrgData( + title = UiText.StringResource(R.string.check_doc_error_not_found), + ticker = TickerAtomData( + title = "Документ не знайдено • Документ не знайдено • Документ не знайдено • ", + type = TickerType.SMALL_NEGATIVE + ) + ) + } + + QrCheckStatus.STATUS_QR_CODE_TIME_OUT -> { + DocErrorOrgData( + title = UiText.StringResource(R.string.check_doc_error_timeout), + ticker = TickerAtomData( + title = "Документ не знайдено • Документ не знайдено • Документ не знайдено • ", + type = TickerType.SMALL_NEGATIVE + ) + ) + } + + QrCheckStatus.STATUS_CODE_NO_REGISTRY -> { + DocErrorOrgData( + title = UiText.StringResource(R.string.check_docs_no_registry_message), + ticker = TickerAtomData( + title = "Реєстр недоступний • Реєстр недоступний • Реєстр недоступний • ", + type = TickerType.SMALL_NEGATIVE + ) + ) + } + + QrCheckStatus.STATUS_CERT_VERIFICATION_INVALID -> { + DocErrorOrgData( + title = UiText.StringResource(R.string.check_doc_error_cert), + ticker = TickerAtomData( + title = "Документ не знайдено • Документ не знайдено • Документ не знайдено • ", + type = TickerType.SMALL_NEGATIVE + ) + ) + } + + QrCheckStatus.STATUS_CERT_VERIFICATION_EXPIRED -> { + DocErrorOrgData( + title = UiText.StringResource(R.string.check_doc_expired_error_cert), + ticker = TickerAtomData( + title = "Помилка валідації • Помилка валідації • Помилка валідації • ", + type = TickerType.SMALL_NEGATIVE + ) + ) + } + + QrCheckStatus.STATUS_CODE_TIME_OUT -> { + DocErrorOrgData( + title = UiText.StringResource(R.string.check_doc_http_timeout), + ticker = TickerAtomData( + title = "Помилка валідації • Помилка валідації • Помилка валідації • ", + type = TickerType.SMALL_NEGATIVE + ) + ) + } + + else -> { + null + } + } + } + + override fun toComposeDocScanSubtitleLabel(status: QrCheckStatus): SubtitleLabelMlcData { + return when (status) { + else -> { + SubtitleLabelMlcData( + label = UiText.StringResource(R.string.qr_check_subtitle_checking_docs) + ) + } + } + } + + override fun toComposeDocOrgLoading(): DocPhotoOrgData { + return DocPhotoOrgData( + docHeading = null, + docButtonHeading = null, + tickerAtomData = null, + tableBlockOrgData = null + ) + } + + override fun toComposeAddDocOrg( + docType: String, + position: Int + ): AddDocOrgData { + return AddDocOrgData( + addDoc = WhiteMenuCardMlcData( + title = "Додати документ", + icon = UiText.StringResource(R.drawable.ic_add) + ), + changePosition = WhiteMenuCardMlcData( + actionKey = UIActionKeysCompose.CHANGE_DOC_ORDER, + title = "Змінити порядок\nдокументів", + icon = UiText.StringResource(R.drawable.ic_doc_reorder) + ), + docType = docType, + position = position + ) + } + + private fun toComposeToggleButtonOrg(localizationType: LocalizationType): ToggleButtonGroupData { + return ToggleButtonGroupData( + qr = BtnToggleMlcData( + id = ToggleId.qr.value, + label = if (localizationType == LocalizationType.ua) "QR-код" else "QR-code", + iconSelected = UiText.StringResource(R.drawable.ic_doc_qr_selected), + iconUnselected = UiText.StringResource(R.drawable.ic_doc_qr_unselected), + selectionState = UIState.Selection.Selected + ), + ean13 = BtnToggleMlcData( + id = ToggleId.ean.value, + label = if (localizationType == LocalizationType.ua) "Штрихкод" else "Barcode", + iconSelected = UiText.StringResource(R.drawable.ic_doc_ean13_selected), + iconUnselected = UiText.StringResource(R.drawable.ic_doc_ean13_unselected), + selectionState = UIState.Selection.Unselected + ) + ) + } + + private fun generateDocCoverData( + docStatus: Int, + localization: LocalizationType, + verificationCodesCount: Int + ): DocCover? { + return when (docStatus) { + Http.HTTP_1010 -> { + DocCover( + title = if (localization == LocalizationType.ua) R.string.dl_no_photo_title else R.string.dl_no_photo_title_eng, + description = if (localization == LocalizationType.ua) R.string.dl_to_e_queue else R.string.dl_to_e_queue_eng, + buttonTitle = if (localization == LocalizationType.ua) "Записатися" else "Electronic queue", + actionKey = DocActions.DOC_ACTION_IN_LINE, + verificationCodesCount = verificationCodesCount + ) + } + + Http.HTTP_1011 -> { + DocCover( + title = if (localization == LocalizationType.ua) R.string.dl_outdated_title else R.string.dl_outdated_title_eng, + description = if (localization == LocalizationType.ua) R.string.dl_outdated_description else R.string.dl_outdated_description_eng, + buttonTitle = if (localization == LocalizationType.ua) "Записатися" else "Electronic queue", + actionKey = DocActions.DOC_ACTION_IN_LINE, + verificationCodesCount = verificationCodesCount + ) + } + + Http.HTTP_1012 -> { + DocCover( + title = if (localization == LocalizationType.ua) R.string.dl_need_verify_title else R.string.dl_need_verify_title_eng, + description = if (localization == LocalizationType.ua) R.string.dl_need_verify_queue else R.string.dl_need_verify_queue_eng, + buttonTitle = if (localization == LocalizationType.ua) "Знайти адресу" else "Open Driver's Account", + actionKey = DocActions.DOC_ACTION_TO_DRIVER_ACCOUNT, + verificationCodesCount = verificationCodesCount + ) + } + + Http.HTTP_1016 -> { + DocCover( + title = if (localization == LocalizationType.ua) R.string.dl_expiration_title else R.string.dl_expiration_title, + description = if (localization == LocalizationType.ua) R.string.dl_expiration_description else R.string.dl_expiration_description, + buttonTitle = if (localization == LocalizationType.ua) "Видалити документ" else "Delete document", + actionKey = "deleteDocument", + verificationCodesCount = verificationCodesCount + ) + } + + else -> null + } + } + + private fun DocCover.toComposeDocCoverMlc(): DocCoverMlcData { + return DocCoverMlcData( + title = UiText.StringResource(this.title), + description = UiText.StringResource(this.description), + button = if (this.buttonTitle != null) ButtonStrokeAdditionalAtomData( + actionKey = this.actionKey, + title = UiText.DynamicString(this.buttonTitle ?: ""), + id = "", + interactionState = UIState.Interaction.Enabled + ) else null + ) + } + + + override fun TickerAtm?.toComposeTickerAtm( + isTickerClickable: Boolean + ): TickerAtomData? { + return this?.let { + TickerAtomData( + componentId = this.componentId.orEmpty(), + title = it.value, + type = when (this.type) { + TickerAtm.TickerType.warning -> TickerType.BIG_WARNING + TickerAtm.TickerType.positive -> TickerType.BIG_POSITIVE + + else -> { + TickerType.BIG_WARNING + } + }, + clickable = isTickerClickable + ) + } + } + + private fun DocButtonHeadingOrg?.toComposeDocButtonHeadingOrg( + isStack: Boolean, + stackSize: Int + ): DocButtonHeadingOrgData? { + return this?.let { + DocButtonHeadingOrgData( + componentId = this.headingWithSubtitlesMlc?.componentId.orEmpty(), + heading = if (this.headingWithSubtitlesMlc != null) HeadingWithSubtitlesMlcData( + value = this.headingWithSubtitlesMlc?.value, + subtitles = if (!this.headingWithSubtitlesMlc?.subtitles.isNullOrEmpty()) this.headingWithSubtitlesMlc?.subtitles else null + ) else null, + headingWhite = if (this.headingWithSubtitleWhiteMlc != null) HeadingWithSubtitlesWhiteMlcData( + value = this.headingWithSubtitleWhiteMlc?.value, + subtitles = if (!this.headingWithSubtitleWhiteMlc?.subtitles.isNullOrEmpty()) this.headingWithSubtitleWhiteMlc?.subtitles else null + ) else null, + docNumberCopy = if (this.docNumberCopyMlc != null) DocNumberCopyMlcData( + value = this.docNumberCopyMlc?.value, + icon = if (this.docNumberCopyMlc?.icon?.code != null) { + this.docNumberCopyMlc?.icon?.code?.let { + UiText.StringResource( + getIconByCode( + it + ) + ) + } + } else { + null + } + ) else null, + docNumberCopyWhite = if (this.docNumberCopyWhiteMlc != null) DocNumberCopyWhiteMlcData( + value = this.docNumberCopyWhiteMlc?.value, + icon = if (this.docNumberCopyWhiteMlc?.icon?.code != null) { + this.docNumberCopyWhiteMlc?.icon?.code?.let { + UiText.StringResource( + getIconByCode( + it + ) + ) + } + } else { + null + } + ) else null, + iconAtmData = if (this.iconAtm != null) iconAtm?.toUiModel() else null, + stackMlcData = if (this.docNumberCopyWhiteMlc != null || this.headingWithSubtitleWhiteMlc != null) + StackMlcData( + amount = stackSize, + smallIconAtmData = SmallIconAtmData(code = "stackWhite"), + isWhite = true + ) else + StackMlcData( + amount = stackSize, + smallIconAtmData = SmallIconAtmData(code = "stack") + ), + isStack = isStack, + size = stackSize + ) + } + } + + override fun DocHeadingOrg?.toComposeDocHeadingOrg(): DocHeadingOrgData? { + return this?.let { + DocHeadingOrgData( + componentId = this.componentId.orEmpty(), + heading = if (this.headingWithSubtitlesMlc != null) HeadingWithSubtitlesMlcData( + value = this.headingWithSubtitlesMlc?.value, + subtitles = if (!this.headingWithSubtitlesMlc?.subtitles.isNullOrEmpty()) this.headingWithSubtitlesMlc?.subtitles else null + ) else null, + headingWhite = if (this.headingWithSubtitleWhiteMlc != null) HeadingWithSubtitlesWhiteMlcData( + value = this.headingWithSubtitleWhiteMlc?.value, + subtitles = if (!this.headingWithSubtitleWhiteMlc?.subtitles.isNullOrEmpty()) this.headingWithSubtitleWhiteMlc?.subtitles else null + ) else null, + docNumber = if (this.docNumberCopyMlc != null) DocNumberCopyMlcData( + value = this.docNumberCopyMlc?.value, + icon = if (this.docNumberCopyMlc?.icon?.code != null) { + this.docNumberCopyMlc?.icon?.code?.let { + UiText.StringResource( + getIconByCode( + it + ) + ) + } + } else { + null + } + ) else null, + docNumberCopyWhite = if (this.docNumberCopyWhiteMlc != null) DocNumberCopyWhiteMlcData( + value = this.docNumberCopyWhiteMlc?.value, + icon = if (this.docNumberCopyWhiteMlc?.icon?.code != null) { + this.docNumberCopyWhiteMlc?.icon?.code?.let { + UiText.StringResource( + getIconByCode( + it + ) + ) + } + } else { + null + } + ) else null + ) + } + } + + override fun toComposeDocPhoto( + localisation: LocalizationType, + photo: String?, + valueImage: String?, + isStack: Boolean, + stackSize: Int, + showCover: Boolean, + cover: DocCover?, + tableBlockOrg: List?, + docHeadingOrg: DocHeadingOrg?, + docButtonHeadingOrg: DocButtonHeadingOrg?, + subtitleLabelMlc: SubtitleLabelMlc?, + tableBlockTwoColumnsPlaneOrg: List?, + tickerAtm: TickerAtm?, + isTickerClickable: Boolean, + smallEmojiPanelMlc: SmallEmojiPanelMlc? + ): DocPhotoOrgData { + val doc = if (!showCover) { + + DocPhotoOrgData( + docHeading = docHeadingOrg.toComposeDocHeadingOrg(), + tickerAtomData = tickerAtm.toComposeTickerAtm(isTickerClickable), + docButtonHeading = docButtonHeadingOrg.toComposeDocButtonHeadingOrg( + isStack, + stackSize + ), + tableBlockTwoColumns = tableBlockTwoColumnsPlaneOrg?.mapNotNull { + it.toComposeTableBlockTwoColumnsPlaneOrg(photo, valueImage) + }, + tableBlockOrgData = tableBlockOrg?.mapNotNull { + it.toComposeTableBlockPlaneOrgData(valueImage) + }, + subtitleLabelMlc = subtitleLabelMlc.toComposeSubtitleLabelMlc(), + smallEmojiPanelMlcData = smallEmojiPanelMlc.toComposeEmojiPanelMlc() + ) + } else { + DocPhotoOrgData( + docHeading = docHeadingOrg.toComposeDocHeadingOrg(), + tickerAtomData = tickerAtm.toComposeTickerAtm(), + docButtonHeading = docButtonHeadingOrg.toComposeDocButtonHeadingOrg( + isStack, + stackSize + ), + tableBlockTwoColumns = tableBlockTwoColumnsPlaneOrg?.mapNotNull { + it.toComposeTableBlockTwoColumnsPlaneOrg(photo, valueImage) + }, + tableBlockOrgData = tableBlockOrg?.mapNotNull { + it.toComposeTableBlockPlaneOrgData(valueImage) + }, + docCover = cover?.toComposeDocCoverMlc(), + subtitleLabelMlc = subtitleLabelMlc.toComposeSubtitleLabelMlc(), + smallEmojiPanelMlcData = smallEmojiPanelMlc.toComposeEmojiPanelMlc() + ) + } + return doc + } + + + override fun toComposeDocCodeOrg( + barcodeResult: DocumentBarcodeResult, + localizationType: LocalizationType, + showToggle: Boolean, + isStack: Boolean + ): DocCodeOrgData { + with(barcodeResult) { + return when (this) { + is DocumentBarcodeSuccessfulLoadResult -> DocCodeOrgData( + qrBitmap = this.shareQr.data.toAndroidBitmap(), + ean13Bitmap = this.shareEan13?.data?.toAndroidBitmap(), + eanCode = this.shareEanCode, + toggle = toComposeToggleButtonOrg(localizationType), + localization = when (localizationType) { + LocalizationType.ua -> Localization.ua + LocalizationType.eng -> Localization.eng + }, + timerText = this.timerText, + showToggle = showToggle, + isStack = isStack + ) + + is DocumentBarcodeErrorLoadResult -> DocCodeOrgData( + qrBitmap = null, + eanCode = null, + ean13Bitmap = null, + toggle = toComposeToggleButtonOrg(localizationType), + localization = when (localizationType) { + LocalizationType.ua -> Localization.ua + LocalizationType.eng -> Localization.eng + }, + exception = this.exception, + showToggle = showToggle, + isStack = isStack, + noRegistry = code + ) + + else -> { + DocCodeOrgData( + qrBitmap = null, + eanCode = null, + ean13Bitmap = null, + toggle = toComposeToggleButtonOrg(localizationType), + localization = when (localizationType) { + LocalizationType.ua -> Localization.ua + LocalizationType.eng -> Localization.eng + }, + exception = null, + showToggle = showToggle, + isStack = isStack + + ) + } + } + } + } + + override fun toDocCardFlip( + photo: String?, + id: String?, + position: Int, + docType: String, + barcodeResult: DocumentBarcodeResult?, + localizationType: LocalizationType, + valueImage: String?, + isStack: Boolean, + stackSize: Int, + cover: DocCover?, + tableBlockOrg: List?, + docHeadingOrg: DocHeadingOrg?, + docButtonHeadingOrg: DocButtonHeadingOrg?, + subtitleLabelMlc: SubtitleLabelMlc?, + tableBlockTwoColumnsPlaneOrg: List?, + tickerAtm: TickerAtm?, + isTickerClickable: Boolean, + smallEmojiPanelMlc: SmallEmojiPanelMlc? + ): DocCardFlipData { + return DocCardFlipData( + id = id, + docType = docType, + front = toComposeDocPhoto( + localizationType, + photo, + valueImage, + isStack, + stackSize, + cover != null, + cover, + tableBlockOrg, + docHeadingOrg, + docButtonHeadingOrg, + subtitleLabelMlc, + tableBlockTwoColumnsPlaneOrg, + tickerAtm, + isTickerClickable, + smallEmojiPanelMlc + ), + back = barcodeResult?.let{ + toComposeDocCodeOrg( + it, + localizationType, + isStack = isStack + ) + }, + position = position, + enableFlip = cover == null + ) + } + + override fun toDocOrg( + id: String?, + position: Int, + docType: String, + isStack: Boolean, + stackSize: Int, + cover: DocCover?, + url: String, + showCover: Boolean, + docHeadingOrg: DocHeadingOrg?, + docButtonHeadingOrg: DocButtonHeadingOrg?, + chipStatusAtm: ChipStatusAtm?, + placeHolder: Int + ): DocOrgData { + + val docData = if (!showCover) { + DocOrgData( + imageUrl = url, + position = position, + docType = docType, + docHeading = docHeadingOrg.toComposeDocHeadingOrg(), + docButtonHeading = docButtonHeadingOrg.toComposeDocButtonHeadingOrg( + isStack, + stackSize + ), + chipStatusAtmData = chipStatusAtm?.toUiModel(), + placeHolder = placeHolder + + + ) + } else DocOrgData( + imageUrl = url, + position = position, + docType = docType, + docHeading = docHeadingOrg.toComposeDocHeadingOrg(), + docButtonHeading = docButtonHeadingOrg.toComposeDocButtonHeadingOrg( + isStack, + stackSize + ), + chipStatusAtmData = chipStatusAtm?.toUiModel(), + docCover = cover?.toComposeDocCoverMlc(), + placeHolder = placeHolder + ) + return docData + } + + override fun toDocCarousel( + cards: List, + barcodeResult: DocumentBarcodeResult? + ): DocCarouselOrgData { + with(cards) { + val listDocCards = this.mapNotNull { documentCard -> + when (val doc = documentCard.doc.diiaDocument) { + is DriverLicenseV2.Data -> toDocCardFlip( + tickerAtm = doc.getTicker(), + tableBlockOrg = doc.getTableBlockPlaneOrg(), + docHeadingOrg = doc.getDocHeading(), + docButtonHeadingOrg = doc.getDocButtonHeading(), + subtitleLabelMlc = doc.getSubtitleLabel(), + tableBlockTwoColumnsPlaneOrg = doc.getTableBlockTwoColumnsPlane(), + barcodeResult = barcodeResult, + localizationType = doc.localization() + ?: LocalizationType.ua, + photo = doc.photo?.image, + id = doc.id, + docType = doc.getItemType(), + position = indexOf(documentCard), + valueImage = null, + isStack = documentCard.docCount > 1, + stackSize = documentCard.docCount, + cover = generateDocCoverData( + doc.getStatus(), + doc.localization() ?: LocalizationType.ua, + doc.verificationCodesCount() + ), + isTickerClickable = false + + ) + + is DocError -> toComposeAddDocOrg( + docType = doc.getItemType(), + indexOf(documentCard) + ) + + else -> null + } + } + + return DocCarouselOrgData(data = listDocCards.toMutableStateList()) + } + } + + override fun toComposeContentTableOrg( + tableBlockTwoColumnsOrg: List?, + tableBlockOrg: List?, + photo: String?, + valueImage: String? + ): ContentTableOrgData { + return ContentTableOrgData( + tableBlockTwoColumnsOrgData = tableBlockTwoColumnsOrg?.mapNotNull { + it.toComposeTableBlockTwoColumnsOrg(photo, valueImage) + }, + tableBlockOrgData = tableBlockOrg?.mapNotNull { + it.toComposeTableBlockOrg(valueImage) + } + ) + } +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentsHelperImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentsHelperImpl.kt new file mode 100644 index 0000000..e3b6fda --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DocumentsHelperImpl.kt @@ -0,0 +1,103 @@ +package ua.gov.diia.opensource.helper.documents + +import android.content.res.Resources +import android.os.Parcelable +import androidx.fragment.app.Fragment +import ua.gov.diia.core.models.rating_service.RatingFormModel +import ua.gov.diia.core.network.Http +import ua.gov.diia.core.util.extensions.fragment.findNavControllerById +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.doc_driver_license.DriverLicenseV2 +import ua.gov.diia.documents.helper.DocumentsHelper +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.DiiaDocumentWithMetadata +import ua.gov.diia.documents.ui.DocVM +import ua.gov.diia.documents.ui.actions.DocActionsDFCompose +import ua.gov.diia.documents.ui.actions.DocActionsDFComposeArgs +import ua.gov.diia.opensource.NavMainXmlDirections +import ua.gov.diia.opensource.R +import ua.gov.diia.ui_base.components.infrastructure.event.DocAction +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData +import javax.inject.Inject + +class DocumentsHelperImpl @Inject constructor(): DocumentsHelper { + + override fun isDocCanBeBroken(docType: String): Boolean = false + + override fun getExpiredDocStatus(docType: String): Int = Http.NEED_UPDATE_STATUS + + override suspend fun migrateDocuments( + data: List?, + shouldSaveData: (data: List) -> Unit + ): List? = data + + override fun isDocEligibleForDeletion(docType: String): Boolean = false + + override fun isDocumentValid(receivedDoc: DiiaDocumentWithMetadata): Boolean { + val doc = receivedDoc.diiaDocument + return !(doc is DriverLicenseV2.Data && doc.getStatus() == DriverLicenseV2.STATUS_IS_INVALID) + } + + override fun provideListOfDocumentsRequireUpdateOfExpirationDate(focusDocType: String): List? = null + + override fun showVerificationButtons(document: Parcelable): Boolean { + return when (document) { + is DriverLicenseV2.Data -> true + else -> false + } + } + + override fun isDocRequireGeneralMenuActions(doc: Parcelable): Boolean { + return when (doc) { + is DriverLicenseV2.Data -> true + else -> false + } + } + + override fun isDocRequireHousingMenuActions(doc: Parcelable): Boolean = false + + override fun getStackHeader(fragment: Fragment, docType: String): String { + with(fragment) { + return when (docType) { + DocName.DRIVER_LICENSE -> getString(R.string.driver_license) + else -> "" + }.replace('\n', ' ') + } + } + + override fun navigateToRatingService( + fragment: Fragment, + viewModel: DocVM, + form: RatingFormModel, + isFromStack: Boolean + ) = Unit + + override fun navigateToStackDocs(fragment: Fragment, doc: DiiaDocument) { + with(fragment){ + navigate( + NavMainXmlDirections.actionGlobalToStackFCompose(doc.getItemType(), doc.getDocColor()), + findNavControllerById(R.id.nav_host), + ) + } + } + + override fun navigateToDocOrder(fragment: Fragment) { + with(fragment){ + navigate(NavMainXmlDirections.actionGlobalToStackOrder()) + } + } + + override fun handleAction( + fragment: DocActionsDFCompose, + action: DocAction, + args: DocActionsDFComposeArgs + ) { + + } + + override fun provideActions( + document: DiiaDocument, + enableStackActions: Boolean, + resources: Resources + ): List? = null +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DriverLicenceActionProvider.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DriverLicenceActionProvider.kt new file mode 100644 index 0000000..11d3031 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/DriverLicenceActionProvider.kt @@ -0,0 +1,56 @@ +package ua.gov.diia.opensource.helper.documents + +import android.content.res.Resources +import android.os.Parcelable +import ua.gov.diia.doc_driver_license.DriverLicenseV2 +import ua.gov.diia.documents.models.LocalizationType +import ua.gov.diia.documents.ui.actions.ContextMenuType +import ua.gov.diia.documents.util.BaseDocumentActionProvider +import ua.gov.diia.documents.util.DocumentActionMapper +import ua.gov.diia.ui_base.components.molecule.list.ListItemMlcData + +class DriverLicenceActionProvider( + private val documentActionMapper: DocumentActionMapper, +) : BaseDocumentActionProvider { + + override fun isDocumentProvider(document: Parcelable): Boolean { + return document is DriverLicenseV2.Data + } + + override fun listOfActions( + docParcelable: Parcelable, + resources: Resources, + ): List { + val document = docParcelable as DriverLicenseV2.Data + + val menu = mutableListOf( + documentActionMapper.docActionForType( + document, + ContextMenuType.FULL_DOC.code, + ContextMenuType.FULL_DOC.name, + resources + ) + ) + if (document.frontCard.ua == null) { + //ignore in this case + } else { + val type = + if (document.localization() == LocalizationType.eng) + ContextMenuType.TRANSLATE_TO_UA + else + ContextMenuType.TRANSLATE_TO_ENG + menu.add(documentActionMapper.docActionForType(document, type.code, type.name, resources)) + } + + menu.add( + documentActionMapper.docActionForType( + document, + ContextMenuType.REPLACE_DRIVER_LICENSE.code, + ContextMenuType.REPLACE_DRIVER_LICENSE.name, + resources + ) + ) + return menu + } + +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/WithPdfCertificateImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/WithPdfCertificateImpl.kt new file mode 100644 index 0000000..edbf560 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/WithPdfCertificateImpl.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.opensource.helper.documents + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.models.GeneratePdfFromDoc +import ua.gov.diia.documents.ui.WithPdfCertificate +import javax.inject.Inject + +class WithPdfCertificateImpl @Inject constructor() : WithPdfCertificate { + private val _certificatePdf = + MutableLiveData>() + override val certificatePdf: LiveData> + get() = _certificatePdf.asLiveData() + + override suspend fun loadCertificatePdf( + cert: DiiaDocument + ) = Unit +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/WithRemoveDocumentImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/WithRemoveDocumentImpl.kt new file mode 100644 index 0000000..d6de8e2 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/helper/documents/WithRemoveDocumentImpl.kt @@ -0,0 +1,27 @@ +package ua.gov.diia.opensource.helper.documents + +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.documents.models.DiiaDocument +import ua.gov.diia.documents.ui.WithRemoveDocument +import javax.inject.Inject + +class WithRemoveDocumentImpl @Inject constructor() : WithRemoveDocument { + + override suspend fun removeDocument( + diiaDocument: DiiaDocument, + removeDocumentCallback: (DiiaDocument) -> Unit + ) = Unit + + override suspend fun removeMilitaryBondFromGallery( + documentType: String, + documentId: String, + showTemplateDialogCallback: (TemplateDialogModel) -> Unit + ) = Unit + + override suspend fun confirmRemoveDocument( + docName: String, + currentDoc: () -> DiiaDocument?, + showTemplateDialogCallback: (TemplateDialogModel) -> Unit, + removeDocumentCallback: (DiiaDocument) -> Unit + ) = Unit +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/model/documents/Docs.kt b/opensource/src/main/java/ua/gov/diia/opensource/model/documents/Docs.kt new file mode 100644 index 0000000..0cfc4c6 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/model/documents/Docs.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.opensource.model.documents + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.doc_driver_license.DriverLicenseV2 +import ua.gov.diia.documents.models.Expiring +import ua.gov.diia.documents.models.WithTimestamp + +@Parcelize +@JsonClass(generateAdapter = true) +data class Docs( + @Json(name = "currentDate") + internal val currentDate: String = Preferences.DEF, + @Json(name = "driverLicense") + val driverLicense: DriverLicenseV2?, + @Json(name = "expirationDate") + internal val expirationDate: String = Preferences.DEF, + @Json(name = "documentsTypeOrder") + val documentsTypeOrder: List = listOf(), +) : Parcelable, Expiring, WithTimestamp { + + override fun getDocExpirationDate() = expirationDate + + override fun getTimestamp() = currentDate +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/model/notification/PushNotificationActionType.kt b/opensource/src/main/java/ua/gov/diia/opensource/model/notification/PushNotificationActionType.kt new file mode 100644 index 0000000..c0abb45 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/model/notification/PushNotificationActionType.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.opensource.model.notification + +enum class PushNotificationActionType(val id: String) { + APP_SESSIONS("newDeviceConnecting"), + MESSAGE("message"), + DOCUMENT_VIEW("documents/"); + + companion object { + fun fromId(id: String): PushNotificationActionType? { + return values().find { e -> id.startsWith(e.id) } + } + } + +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/repository/ps/PublicServiceDataRepository.kt b/opensource/src/main/java/ua/gov/diia/opensource/repository/ps/PublicServiceDataRepository.kt new file mode 100644 index 0000000..952571a --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/repository/ps/PublicServiceDataRepository.kt @@ -0,0 +1,75 @@ +package ua.gov.diia.opensource.repository.ps + +import com.squareup.moshi.JsonAdapter +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ua.gov.diia.core.data.repository.DataRepository +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.login.ui.PostLoginAction +import ua.gov.diia.opensource.di.MoshiAdapterPublicServiceCategories +import ua.gov.diia.publicservice.models.PublicServicesCategories +import ua.gov.diia.publicservice.network.ApiPublicServices +import javax.inject.Inject + +class PublicServiceDataRepository @Inject constructor( + @MoshiAdapterPublicServiceCategories private val adapter: JsonAdapter, + @AuthorizedClient private val apiPublicServices: ApiPublicServices, + private val diiaStorage: DiiaStorage, + private val dispatcherProvider: DispatcherProvider +) : DataRepository, PostLoginAction { + + private val _data = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_LATEST + ) + override val data: Flow = _data + + override suspend fun load(): PublicServicesCategories? { + val cache = loadCachedData() + _data.emit(cache) + return try { + val services = apiPublicServices.getPublicServices() + coroutineScope { + launch { _data.emit(services) } + launch { cacheData(services) } + } + services + } catch (e: Exception) { + if (cache != null) return cache + else throw e + } + } + + override suspend fun onPostLogin() { + load() + } + + private fun loadCachedData(): PublicServicesCategories? = try { + val servicesJson = diiaStorage.getString(Preferences.PublicServicesCategories, "") + if (servicesJson.isNotEmpty()) { + adapter.fromJson(servicesJson) + } else { + null + } + } catch (e: Exception) { + null + } + + private suspend fun cacheData(categories: PublicServicesCategories) = + withContext(dispatcherProvider.work) { + val json = adapter.toJson(categories) + diiaStorage.set(Preferences.PublicServicesCategories, json) + } + + override suspend fun clear() { + _data.emit(null) + } + +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/repository/settings/AppSettingsRepository.kt b/opensource/src/main/java/ua/gov/diia/opensource/repository/settings/AppSettingsRepository.kt new file mode 100644 index 0000000..0ce4939 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/repository/settings/AppSettingsRepository.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.opensource.repository.settings + + +interface AppSettingsRepository { + + suspend fun enableBiometricAuth(enable: Boolean) + + suspend fun isBiometricAuthEnabled(): Boolean + + suspend fun getLastDocumentUpdate(): String? + + suspend fun getLastActiveDate(): String? +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/repository/settings/AppSettingsRepositoryImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/repository/settings/AppSettingsRepositoryImpl.kt new file mode 100644 index 0000000..3b66ac5 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/repository/settings/AppSettingsRepositoryImpl.kt @@ -0,0 +1,38 @@ +package ua.gov.diia.opensource.repository.settings + +import kotlinx.coroutines.withContext +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.CommonPreferenceKeys +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.store.Preferences +import javax.inject.Inject + +class AppSettingsRepositoryImpl @Inject constructor( + private val diiaStorage: DiiaStorage, + private val dispatcherProvider: DispatcherProvider +) : AppSettingsRepository { + + private companion object { + const val DEF_STRING_VALUE = "unknown" + } + + override suspend fun enableBiometricAuth(enable: Boolean) { + withContext(dispatcherProvider.work) { + diiaStorage.set(Preferences.UseTouchId, enable) + } + } + + override suspend fun isBiometricAuthEnabled(): Boolean = withContext(dispatcherProvider.work) { + diiaStorage.getBoolean(Preferences.UseTouchId, false) + } + + override suspend fun getLastDocumentUpdate(): String? = withContext(dispatcherProvider.work) { + val date = diiaStorage.getString(CommonPreferenceKeys.LastDocumentUpdate, DEF_STRING_VALUE) + if (date != DEF_STRING_VALUE) date else null + } + + override suspend fun getLastActiveDate(): String? = withContext(dispatcherProvider.work) { + val date = diiaStorage.getString(CommonPreferenceKeys.LastActivityDate, DEF_STRING_VALUE) + if (date != DEF_STRING_VALUE) date else null + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/AndroidClientAlertDialogsFactory.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/AndroidClientAlertDialogsFactory.kt new file mode 100644 index 0000000..365395e --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/AndroidClientAlertDialogsFactory.kt @@ -0,0 +1,504 @@ +package ua.gov.diia.opensource.ui + +import org.json.JSONException +import org.json.JSONObject +import ua.gov.diia.core.models.dialogs.TemplateDialogButton +import ua.gov.diia.core.models.dialogs.TemplateDialogData +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.CommonConst +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.opensource.BuildConfig +import javax.inject.Inject + +class AndroidClientAlertDialogsFactory @Inject constructor( + private val crashlytics: WithCrashlytics, +) : ClientAlertDialogsFactory { + + /** + * For debug purposes to open verify user person at any point of app + */ + override fun userVerifySuggestion(key: String) = TemplateDialogModel( + type = "largeAlert", + key = key, + isClosable = false, + data = TemplateDialogData( + icon = "☝", + title = "Підтвердьте особу користувача", + description = "Будь ласка, підтвердьте особу користувача за допомогою фотоперевірки.\n\nМи дбаємо про безпеку ваших персональних даних та хочемо впевнитися, що дані із застосунку доступні тільки вам.", + mainButton = TemplateDialogButton( + name = "Підтвердити", + action = "authMethods", + ), + alternativeButton = TemplateDialogButton( + name = "Вийти", + action = ActionsConst.DIALOG_ACTION_CODE_LOGOUT, + ) + ), + ) + + override fun nfcCardNotSupported(key: String) = TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = false, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Ця картка не\nпідтримується", + description = "Використовуйте тільки ID-карту або закордонний біометричний паспорт.", + mainButton = TemplateDialogButton( + name = "Спробувати ще", + action = ActionsConst.GENERAL_RETRY, + ), + alternativeButton = TemplateDialogButton( + name = "Вийти", + action = ActionsConst.DIALOG_ACTION_EXIT, + ) + ), + ) + + override fun nfcResidenceCardNotSupported(key: String) = TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = false, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Ця картка не\nпідтримується", + description = "Потрібне посвідчення з біометричним чипом.", + mainButton = TemplateDialogButton( + name = "Спробувати ще", + action = ActionsConst.GENERAL_RETRY, + ), + alternativeButton = TemplateDialogButton( + name = "Вийти", + action = ActionsConst.DIALOG_ACTION_EXIT, + ) + ), + ) + + override fun nfcScanFailed(e: Exception, key: String): TemplateDialogModel { + e.printLog() + return TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = false, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "На жаль, сталася помилка", + description = "Дані не зчитано. Втрачено зв’язок з картою або сталася помилка при зчитуванні", + mainButton = TemplateDialogButton( + name = "Спробувати ще", + action = ActionsConst.GENERAL_RETRY, + ) + ), + ) + } + + override fun codeScanFailed(key: String): TemplateDialogModel { + return TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = false, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "На жаль, сталася помилка", + description = "Дані не зчитано. Ви відсканували не існуючий код або сталася помилка при зчитуванні. Спробуйте знову.", + mainButton = TemplateDialogButton( + name = "Спробувати ще", + action = ActionsConst.GENERAL_RETRY, + ) + ), + ) + } + + override fun nfcScanFailedV2( + e: Exception, + key: String, + closable: Boolean + ): TemplateDialogModel { + e.printLog() + return TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = closable, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "На жаль, сталася помилка", + description = "Дані не зчитано. Втрачено зв’язок з картою або сталася помилка при зчитуванні", + mainButton = TemplateDialogButton( + name = "Спробувати ще", + action = "mrzScan", + ) + ), + ) + } + + override fun alertNoInternet(key: String) = TemplateDialogModel( + type = "smallAlert", + key = key, + isClosable = true, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Немає інтернету", + description = "Перевірте з’єднання та спробуйте ще раз", + mainButton = TemplateDialogButton( + name = "Спробувати ще", + action = ActionsConst.GENERAL_RETRY, + ), + ), + ) + + override fun alertVerificationFailed(key: String) = TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = false, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Перевірку не пройдено", + description = "Спробуйте увійти через свій інтернет-банкінг", + mainButton = TemplateDialogButton( + icon = "iVBORw0KGgoAAAANSUhEUgAAAIQAAAAoCAYAAAA/mlIyAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAXxSURBVHgB7ZzPbxNHFMffrH/g2iDMgag3nEulhFIFiao3avIP4KhqlFxak1NbJJog9VYaG+5AD0icSHJoCC20yT8QnJ4qlCqumoRb6x4bKsU5NE1s706/z47D+kfMene9Xsx+pNV41/Z4d+a7b957M2tBHo6TSCS4iPpD4S8FyaQkipHTSMpKIbIqaenFhYVc9bAgD0cZHR/nIqmRuENSRqn7QI8i/XhhPs07PvJwjI9GR6EBOUJCeYjdELkDNgofDr77Hm2u/77iWQiHSIyNcdEfILHclSHi1eC05LCfPBxBqRRxl4qBEVKKy54gHEKpmOZPyMUogjxBOIYsj85RN7vxbL38vkAgqT+oFouLKPL05hFFWyT0B+xsC1ERQlQe8X784sWa/Werq7S7u0tWiYTD9P6FC4br9gshZmoO+P25UqmUoTeNUCgqNE3fFtKptkAf0NXPP3v5w9he3LpFG5vPySqR48fbqlshDw8dniA8avCcSpew9eKfw9eSuocnCBeA7CVdvXaN3ECDIDQhhuBMkVGOcroCgcCQtJCrR71Zau7hR3F+Q2QS1JtDkSObwHVy0XCtnXRGY2fOlKMHI5w+fZraoaHnFYFJF2E8WPYFgzlIPIUQbU5/HGbvDilKnEziDwb51rlbKhanan6PQ8O6yMhEvRnUe4UsCiNw7BgXkxBDfZux1bfdPzs7MFCOGNrt5HawfNJohhjCplk0ziTZjRCTEIDpzm9RbxxCfkqcKDJJIBTiepJlMdSCWWVxnWymPxaj1Dc3OioGxjYfAg0zjWKWbE5qoXGTGCLm7DbBLGR/IJCCpWhbyBATaZo2gjqaifVmcX//LrUB5yG+n//ucJ/NS1qXK+D3v7o+RU5gp1mLYjyNUScQIkEdALfyZTKBWiicxx3woMlb6VKhkCKbYX+h05ahSoOFkKqaUFV1iQyA8bgmQtI7VrjzLpFJcOc+ZbNe3ccdcrLFx/PohFNkAE7TCwv+xwH9qGcZ9dQPNx0RAxOORGr26y1IK/r6+ujet8YNVoMgcKE79BohnZ136cdwsSzqfA/cCLNwqlPkFAhT9XkLO+lYHiIUCsXIJCVVJTeAISVWvQ5YzVMIyX8UdesZIMif1ErE0hN0TBDo1Bm92W+LNsLeDiJ4eClpWnWv2cz1mhoOT1ChQL2Cl6k0CSzDnxDDMOXzPbVUwJvcMolg36W3tFDGMQsBxyuPRswa/PgQNXrxbuM8oo3b8B8mqIdwTBBIiS8WCwVDzlc57CSKU3eRCMFHkFv5jXfYqcQwsawXKnyMKxAF9ZIovCGjBRyC7+3t5XjD7hos3DBkkq/7TBL5mGnqERoEAdN+kjwaKBaLXFREUQsHH9NOi4Kzl5zSftXWLo1DhqLM+nw+y94SYvi/9KcDc5tEUidu6LvIeLpxcTKLAkPEGk5wAo2tT12XRYEJvp125zHMwB2duvE1/WvDItx6mmUqeYw05dAdrGEoA58hi479tKZuow+puCMP0RS1IooZXtRSLwocuw2fY7tYtxTAKltbW5TL5SiGGc8qEaSzI3UpbTuw04fgh0UPLQvulFlp40IUN8GiOJgTSde9JWAZZzA7Gyeb+eHJE3ICy4Ioh5NCTDWZ2MmrinIJolikHqRUyU6mqJkoFOUBWUE2rqp8tvor3bt/n/6GtbC05lK2/rYfHWo+D69pefXEiYx6VLYO3jlmJTh0G9I0LQb/xFxuwefLVV+qPl/Gp6ovz1kIw/5Oy+/u7eVlIFDTFqrfn8M4eGR9LAr4Wyni/Ir+2to4J4aHH+5sPc0mrzIrP5e3s4MDlqbDW02MeU9/O8THY+Pc1n+4+GFfRnp5CIdgS43NaKa2K0CxOU8QTqFg6FdohVyMRjLjCcIhirtBKv0XnIN/sU3uRMLfu+kJwiGWluZICe1va1LjFdndfDirGeX/meI/H/P+Y8pBnq+vI0IYzKL5dxCVfIBDb1H38f50rJtsbmzQO+fO/YLx+hGvzUQmCylPepschh1IKbU5xad88fjh/KPq8f8B02cyYlHP6bIAAAAASUVORK5CYII=", + action = "authBanks", + ), + alternativeButton = TemplateDialogButton( + name = "Повернутись назад", + action = ActionsConst.DIALOG_ACTION_CODE_CLOSE, + ), + ), + ) + + override fun unknownErrorAlert( + closable: Boolean, + key: String, + e: Exception + ): TemplateDialogModel { + e.printLog() + val buttonLabel = if (closable) "Спробувати ще" else "Зрозуміло" + val action = + if (closable) ActionsConst.GENERAL_RETRY else ActionsConst.ERROR_DIALOG_DEAL_WITH_IT + return TemplateDialogModel( + type = "smallAlert", + key = key, + isClosable = closable, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "На жаль, сталася помилка", + mainButton = TemplateDialogButton( + name = buttonLabel, + action = action, + ), + ), + ) + } + + override fun userPhotoIdTryCountReached(closable: Boolean, key: String): TemplateDialogModel { + return TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = closable, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Перевірку не пройдено", + description = "Спробуйте обрати інший спосіб для підтвердження особи користувача", + mainButton = TemplateDialogButton( + name = "Спробувати ще", + action = ActionsConst.GENERAL_RETRY, + ), + alternativeButton = TemplateDialogButton( + name = "Вийти", + action = ActionsConst.DIALOG_ACTION_CODE_CLOSE, + ), + ), + ) + } + + override fun getUnsupportedOptionDialog(key: String): TemplateDialogModel { + return TemplateDialogModel( + key, + "middleCenterAlignAlert", + isClosable = true, + TemplateDialogData( + "\uD83D\uDE1E", + "На жаль,\nсталася помилка", + "Дана опція тимчасово недоступна.", + TemplateDialogButton("Зрозуміло", null, "skip") + ) + ) + } + + override fun getUnsupportedNFCDialog(key: String): TemplateDialogModel { + return TemplateDialogModel( + key, + "middleCenterAlignAlert", + isClosable = true, + TemplateDialogData( + "\uD83D\uDE1E", + "На жаль, ви не\nможете активувати\nДія.Підпис", + "На вашому пристрої немає NFC для зчитування даних з ID-картки або біометричного закордонного паспорта.", + TemplateDialogButton("Зрозуміло", null, "skip") + ) + ) + } + + override fun getNoVerificationMethodsDialog(key: String): TemplateDialogModel { + return TemplateDialogModel( + key, + "middleCenterAlignAlert", + isClosable = true, + TemplateDialogData( + "\uD83D\uDE1E", + "Неможливо підтвердити особу користувача", + "На жаль, на поточний момент немає доступних методів підтвердження особи користувача для створення Дія.Підпису. Спробуйте пізніше.", + TemplateDialogButton("Зрозуміло", null, "skip") + ) + ) + } + + override fun getResetSignaturePasswordDialog(key: String): TemplateDialogModel = + TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = true, + data = TemplateDialogData( + icon = "☝", + title = "Потрібно створити новий Дія.Підпис", + description = "Код до Дія.Підпису неможливо відновити. Якщо ви забули його, потрібно створити новий. Попередній Дія.Підпис ми видалимо.", + mainButton = TemplateDialogButton( + name = "Створити новий", + action = ActionsConst.DIALOG_ACTION_REMOVE_SIGNATURE, + ), + alternativeButton = TemplateDialogButton( + name = "Ні, створю пізніше", + action = ActionsConst.DIALOG_ACTION_CODE_SKIP, + ), + ), + ) + + override fun showMockGeo(key: String): TemplateDialogModel = + TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = true, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Геолокацію\nне підтверджено", + description = "Дія не може визначити вашу геолокацію через сторонні сервіси, що блокують доступ до справжньої геолокації. Будь ласка, перевірте, чи не використовуєте ви такі сервіси, та вимкніть, коли Дія підтверджуватиме геолокацію.", + mainButton = TemplateDialogButton( + name = "Зрозуміло", + action = ActionsConst.DIALOG_ACTION_CODE_SKIP, + ) + ), + ) + + override fun expiredOtp(key: String): TemplateDialogModel = TemplateDialogModel( + type = "smallAlert", + key = key, + isClosable = false, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Закінчився термін\nочікування відповіді", + mainButton = TemplateDialogButton( + name = "Зрозуміло", + action = ActionsConst.DIALOG_ACTION_EXIT, + ), + ), + ) + + override fun showAlertAfterInvalidPin(key: String): TemplateDialogModel = TemplateDialogModel( + key = key, + type = "horizontalButton", + isClosable = false, + data = TemplateDialogData( + icon = null, + title = "Ви ввели неправильний код тричі", + description = "Пройдіть повторну авторизацію у застосунку", + mainButton = TemplateDialogButton( + name = "Авторизуватися", + action = ActionsConst.DIALOG_ACTION_CODE_LOGOUT + ) + ) + ) + + override fun showAlertAfterConfirmPin(key: String): TemplateDialogModel = TemplateDialogModel( + key = key, + type = "horizontalButton", + isClosable = false, + data = TemplateDialogData( + icon = "\uD83D\uDC4C", + title = "Код змінено", + description = "Ви змінили код для входу у застосунок Дія.", + mainButton = TemplateDialogButton( + name = "Дякую", + action = ActionsConst.DIALOG_DEAL_WITH_IT + ) + ) + ) + + override fun getUnsupportedGLEDialog(key: String) = TemplateDialogModel( + key, + "middleCenterAlignAlert", + isClosable = true, + TemplateDialogData( + "\uD83D\uDE1E", + "Не вдалося запустити гру", + "На жаль, гра не підтримується на вашому пристрої.", + TemplateDialogButton("Зрозуміло", null, "skip") + ) + ) + + override fun getCancelOfficialPollCreationDialog(key: String) = TemplateDialogModel( + key, + "middleCenterAlignAlert", + isClosable = false, + TemplateDialogData( + "\uD83D\uDE1E", + "Скасувати опитування?", + "Уся введена інформація буде втрачена.", + TemplateDialogButton("Так, скасувати", null, ActionsConst.DIALOG_ACTION_EXIT_CONFIRM), + TemplateDialogButton("Ні, залишити", null, ActionsConst.DIALOG_ACTION_CANCEL) + ) + ) + + override fun getCancelledOfficialPollCreationDialog(key: String) = TemplateDialogModel( + key, + "middleCenterAlignAlert", + isClosable = false, + TemplateDialogData( + "✅", + "Створення опитування скасовано", + "Ви можете створити інше опитування, коли знадобиться.", + TemplateDialogButton("Зрозуміло", null, ActionsConst.DIALOG_ACTION_EXIT), + ) + ) + + override fun getDeletePollDialog(key: String) = TemplateDialogModel( + key, + "middleCenterAlignAlert", + isClosable = false, + TemplateDialogData( + "\uD83D\uDE1E", + "Видалити опитування?", + "Опитування стане недоступним для всіх, а набрані голоси будуть втрачені.", + TemplateDialogButton("Так, видалити", null, ActionsConst.DIALOG_ACTION_CODE_DELETE), + TemplateDialogButton("Ні, залишити", null, ActionsConst.DIALOG_ACTION_CANCEL) + ) + ) + + override fun nfcEnableDialog(key: String) = TemplateDialogModel( + key, + "middleCenterAlignAlert", + isClosable = false, + TemplateDialogData( + "☝️", + "Потрібен доступ до NFC", + "Щоб пройти верифікацію, потрібно дозволити використання технології NFC на вашому девайсі.", + TemplateDialogButton("Активувати NFC", null, ActionsConst.DIALOG_ACTION_CONFIRM), + TemplateDialogButton("Інший спосіб авторизації", null, ActionsConst.DIALOG_ACTION_CANCEL) + ) + ) + + override fun alertNoOfflineMap(key: String) = TemplateDialogModel( + type = "smallAlert", + key = key, + isClosable = false, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Немає завантажених мап", + description = "Зараз на пристрої немає інтернету та збережених мап. Повертайтеся до застосунку, щойно відновиться інтернет-зʼєднання.\n\nНаразі дізнатися адреси Пунктів незламності та укриттів можна в органах місцевого самоврядування.", + mainButton = TemplateDialogButton( + name = "Зрозуміло", + action = ActionsConst.DIALOG_ACTION_CLOSE, + ), + ), + ) + + override fun locationNotAvailable(key: String) = TemplateDialogModel( + type = "smallAlert", + key = key, + isClosable = true, + data = TemplateDialogData( + icon = "\uD83D\uDE1E", + title = "Не вдалося визначити геолокацію", + description = "Йой! Не вдалося визначити ваше місцезнаходження. Будь ласка, перевірте інтернет та спробуйте ще раз.", + mainButton = TemplateDialogButton( + name = "Зрозуміло", + action = ActionsConst.DIALOG_ACTION_CODE_CLOSE, + ), + ), + ) + + override fun failedToDownloadMap(key: String) = TemplateDialogModel( + type = "middleCenterAlignAlert", + key = key, + isClosable = true, + data = TemplateDialogData( + icon = "\uD83D\uDE14", + title = "Не вдалося завантажити мапу", + description = "Йой! Через проблеми з інтернетом або відсутність вільної памʼяті на смартфоні не вдається завантажити мапу.", + mainButton = TemplateDialogButton( + name = "Спробувати ще раз", + action = "download", + ), + alternativeButton = TemplateDialogButton( + name = "Скасувати", + action = ActionsConst.DIALOG_ACTION_CODE_CLOSE, + ), + ), + ) + + override fun failedToSendRating(key: String) = TemplateDialogModel( + type = "smallAlert", + key = key, + isClosable = true, + data = TemplateDialogData( + icon = "\uD83D\uDE14", + title = "Неможливо надіслати відгук", + description = "Йой! Залишити відгук офлайн неможливо. Чекаємо на вас, коли знову з’явиться інтернет-з’єднання.", + mainButton = TemplateDialogButton( + name = "Зрозуміло", + action = ActionsConst.DIALOG_ACTION_CODE_CLOSE, + ) + ), + ) + + override fun failedToSendReportPoint(key: String) = TemplateDialogModel( + type = "smallAlert", + key = key, + isClosable = true, + data = TemplateDialogData( + icon = "☝", + title = "Повідомлення вже надіслано", + description = "Хочете повідомити про закритий пункт незламності ще раз? Треба трохи зачекати. Надіслати нове повідомлення можна за годину.", + mainButton = TemplateDialogButton( + name = "Дякую", + action = ActionsConst.DIALOG_ACTION_CODE_CLOSE, + ) + ), + ) + + override fun failedToSendReportShelter(key: String) = TemplateDialogModel( + type = "smallAlert", + key = key, + isClosable = true, + data = TemplateDialogData( + icon = "☝", + title = "Повідомлення вже надіслано", + description = "Хочете повідомити про закрите укриття ще раз? Треба трохи зачекати. Надіслати нове повідомлення можна за годину.", + mainButton = TemplateDialogButton( + name = "Дякую", + action = ActionsConst.DIALOG_ACTION_CODE_CLOSE, + ) + ), + ) + + private fun Exception.printLog() { + if (BuildConfig.BUILD_TYPE != CommonConst.BUILD_TYPE_RELEASE) { + try { + val unknownErrorAlert = JSONObject() + unknownErrorAlert.put("unknownErrorAlert", localizedMessage) + } catch (e: JSONException) { + crashlytics.sendNonFatalError(e) + } + } + } +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/PromoControllerImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/PromoControllerImpl.kt new file mode 100644 index 0000000..141cf25 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/PromoControllerImpl.kt @@ -0,0 +1,21 @@ +package ua.gov.diia.opensource.ui + +import ua.gov.diia.core.controller.PromoController +import ua.gov.diia.core.models.dialogs.TemplateDialogModelWithProcessCode +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.store.Preferences +import javax.inject.Inject + +class PromoControllerImpl @Inject constructor( + private val keyValueStore: DiiaStorage, +) : PromoController { + override suspend fun checkPromo(callback: (template: TemplateDialogModelWithProcessCode) -> Unit) { + } + + override suspend fun subscribeToBetaByCode(value: Int?) { + } + + override suspend fun updatePromoProcessCode(value: Int) { + keyValueStore.set(Preferences.PromoProcessCode, value) + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/PublicServicesHomeConst.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/PublicServicesHomeConst.kt new file mode 100644 index 0000000..5ff02c1 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/PublicServicesHomeConst.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.opensource.ui + +object PublicServicesHomeConst { + const val PS_SERVICE_CRIME_CERTIFICATE = "criminalRecordCertificate" +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/activities/MainActivity.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/activities/MainActivity.kt new file mode 100644 index 0000000..8325eeb --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/activities/MainActivity.kt @@ -0,0 +1,167 @@ +package ua.gov.diia.opensource.ui.activities + +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.os.Build +import android.os.Bundle +import android.util.DisplayMetrics +import android.view.WindowManager +import androidx.activity.viewModels +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController +import androidx.navigation.findNavController +import androidx.work.WorkManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import ua.gov.diia.core.di.actions.GlobalActionLogout +import ua.gov.diia.core.models.deeplink.DeepLinkActionViewMessage +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.event.observeUiEvent +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.notifications.NavNotificationsDirections +import ua.gov.diia.notifications.models.notification.pull.MessageIdentification +import ua.gov.diia.opensource.NavMainXmlDirections +import ua.gov.diia.opensource.R +import ua.gov.diia.opensource.di.GlobalActionProlongUser +import ua.gov.diia.opensource.util.ext.navigate +import ua.gov.diia.opensource.util.setUpEdgeToEdge +import javax.inject.Inject + +@AndroidEntryPoint +abstract class MainActivity : AppCompatActivity() { + + @Inject + lateinit var diiaStorage: DiiaStorage + + @Inject + lateinit var crashlytics: WithCrashlytics + + @Inject + @GlobalActionLogout + lateinit var actionLogout: MutableLiveData + + @Inject + @GlobalActionProlongUser + lateinit var actionUserVerification: MutableLiveData> + + private val viewModel by viewModels() + + private val navController: NavController + get() = findNavController(R.id.nav_host) + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(R.style.Theme_Diia_NoActionBar) + setUpEdgeToEdge() + super.onCreate(savedInstanceState) + adjustFontScale(resources.configuration) + setUpAnalytics() + setContentView(R.layout.activity_main) + actionLogout.observeUiEvent(this, viewModel::doLogout) + viewModel.restartApp.observeUiEvent(this, ::restartMainNavGraph) + viewModel.apply { + timeoutDestination.observeUiDataEvent(this@MainActivity) { + restartMainNavGraph() + } + + deeplinkFlow.flowWithLifecycle(lifecycle) + .onEach { + val data = it?.peekContent() ?: return@onEach + + if (data is DeepLinkActionViewMessage) { + if (!data.needAuth) { + navigate( + NavNotificationsDirections.actionToNotificationFull( + messageId = MessageIdentification( + needAuth = data.needAuth, + notificationId = data.resourceId, + resourceId = data.resourceId, + ) + ) + ) + } + } + }.launchIn(lifecycleScope) + } + actionUserVerification.observeUiDataEvent(this) { template -> + navigate( + NavMainXmlDirections.actionGlobalToTemplateDialog( + template.copy(key = ActionsConst.KEY_GLOBAL_PROCESSING) + ) + ) + } + processIntent(intent) + viewModel.checkPinCount() + } + + + override fun attachBaseContext(newBase: Context?) { + super.attachBaseContext(newBase) + overrideConfiguration(newBase) + } + + private fun adjustFontScale(configuration: Configuration) { + if (Build.VERSION.SDK_INT < 27) { + configuration.fontScale = 1.0f + val metrics: DisplayMetrics = resources.displayMetrics + val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager + wm.defaultDisplay.getMetrics(metrics) + metrics.scaledDensity = configuration.fontScale * metrics.density + baseContext.resources.updateConfiguration(configuration, metrics) + } + } + + private fun overrideConfiguration(context: Context?) { + if (Build.VERSION.SDK_INT >= 27) { + val newOverride = Configuration(context?.resources?.configuration) + newOverride.fontScale = 1.0f + applyOverrideConfiguration(newOverride) + } + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + processIntent(intent) + } + + private fun restartMainNavGraph( + skipInitialization: Boolean = true, + serviceUserUUID: String? = null, + ) { + NavMainXmlDirections.actionGlobalToSplashClearStack( + skipInitialization = skipInitialization, + uuid4 = serviceUserUUID + ).let { navController.navigate(it) } + } + + private fun processIntent(intent: Intent?) { + val path = intent?.data?.path + if (path?.startsWith("/auth") == false) { + //after push is clicked, navigate to HomeF as there we handle all nav logic + if (viewModel.allowAuthorizedDeepLinks) { + navController.popBackStack(R.id.homeF, false) + } + viewModel.processIntentPath(path) + } + } + + abstract fun setUpAnalytics() + + override fun onDestroy() { + super.onDestroy() + try { + WorkManager.getInstance(this).cancelUniqueWork("checkVersionWork") + } catch (e: Exception) { + crashlytics.sendNonFatalError(e) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/activities/MainActivityVM.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/activities/MainActivityVM.kt new file mode 100644 index 0000000..b5af967 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/activities/MainActivityVM.kt @@ -0,0 +1,111 @@ +package ua.gov.diia.opensource.ui.activities + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import androidx.work.WorkManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import ua.gov.diia.core.di.actions.GlobalActionAllowAuthorizedLinks +import ua.gov.diia.core.di.actions.GlobalActionLogout +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.core.util.delegation.WithDeeplinkHandling +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.lifecycle.consumeEvent +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.notifications.util.notification.manager.DiiaNotificationManager +import ua.gov.diia.opensource.ui.work.LogoutWork +import ua.gov.diia.pin.repository.LoginPinRepository +import javax.inject.Inject + +@HiltViewModel +class MainActivityVM @Inject constructor( + @GlobalActionLogout private val actionLogout: MutableLiveData, + private val notificationManager: DiiaNotificationManager, + private val workManager: WorkManager, + private val dispatcherProvider: DispatcherProvider, + private val authorizationRepository: AuthorizationRepository, + private val loginPinRepository: LoginPinRepository, + @GlobalActionAllowAuthorizedLinks val allowAuthorizedLinksFlow: MutableSharedFlow>, + private val deepLinkDelegate: WithDeeplinkHandling, +): ViewModel(), WithDeeplinkHandling by deepLinkDelegate { + + private val _restartApp = MutableLiveData() + val restartApp = _restartApp.asLiveData() + + private val _timeoutDestination = MutableLiveData>() + val timeoutDestination: LiveData> + get() = _timeoutDestination + + var allowAuthorizedDeepLinks = false + private set + + init { + viewModelScope.launch { + actionLogout.asFlow().collectLatest { event -> + event.consumeEvent { doLogout() } + } + } + + viewModelScope.launch { + allowAuthorizedLinksFlow.collectLatest { event -> + if (event.getContentIfNotHandled() == true) { + allowAuthorizedDeepLinks() + } + } + } + } + + fun processIntentPath(path: String) { + viewModelScope.launch { + emitDeeplink(UiDataEvent(buildDeepLinkAction(path))) + } + } + + fun doLogout() { + viewModelScope.launch(dispatcherProvider.work) { + val logoutToken = authorizationRepository.getToken() ?: return@launch + val mobileUid = authorizationRepository.getMobileUuid() + val isServiceUser = authorizationRepository.isServiceUser() + authorizationRepository.logoutUser() + authorizationRepository.setIsServiceUser(false) + authorizationRepository.setMobileUuid(mobileUid) + notificationManager.setBadeNumber(0) + LogoutWork.enqueue(workManager, logoutToken, mobileUid, isServiceUser) + _restartApp.postValue(UiEvent()) + } + } + + fun checkPinCount() { + viewModelScope.launch { + if (loginPinRepository.getPinTryCount() >= PIN_TRY_COUNT) { + loginPinRepository.setPinTryCount(0) + doLogout() + } + } + } + + private fun allowAuthorizedDeepLinks() { + viewModelScope.launch { + allowAuthorizedDeepLinks = true + val deepLink = deeplinkFlow.value + if (deepLink?.notHandedYet == true) { + emitDeeplink(deepLink) + } + } + } + + enum class TimeoutDestination { + LOG_IN, REGISTER + } + + companion object { + const val PIN_TRY_COUNT = 3 + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/compose/DiiaResourceIconProviderImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/compose/DiiaResourceIconProviderImpl.kt new file mode 100644 index 0000000..a064a01 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/compose/DiiaResourceIconProviderImpl.kt @@ -0,0 +1,35 @@ +package ua.gov.diia.opensource.ui.compose + +import ua.gov.diia.opensource.R +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.DiiaResourceIcon +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider + +class DiiaResourceIconProviderImpl : DiiaResourceIconProvider { + + private val commonIcons = CommonDiiaResourceIcon.diiaResourceIconList() + private val publicServiceIcons = listOf( + DiiaResourceIcon( + "donation", + R.drawable.ic_ps_military_donation, + R.string.ps_icon_description_donation + ), + + DiiaResourceIcon( + "certificates", + R.drawable.ic_ps_certificates, + R.string.ps_icon_description_certificates + ), + ) + + + override fun getResourceId(code: String): Int { + return (commonIcons + publicServiceIcons).firstOrNull { code == it.code }?.drawableResourceId + ?: CommonDiiaResourceIcon.DEFAULT.drawableResourceId + } + + override fun getContentDescription(code: String): Int { + return (commonIcons + publicServiceIcons).firstOrNull { code == it.code }?.contentDescriptionResourceId + ?: CommonDiiaResourceIcon.DEFAULT.contentDescriptionResourceId + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/compose/TableBlockMapper.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/compose/TableBlockMapper.kt new file mode 100644 index 0000000..d3ded6d --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/compose/TableBlockMapper.kt @@ -0,0 +1,310 @@ +package ua.gov.diia.opensource.ui.compose + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import ua.gov.diia.core.models.common_compose.mlc.text.SmallEmojiPanelMlc +import ua.gov.diia.core.models.common_compose.table.Item +import ua.gov.diia.core.models.common_compose.table.TableItemHorizontalMlc +import ua.gov.diia.core.models.common_compose.table.TableItemPrimaryMlc +import ua.gov.diia.core.models.common_compose.table.TableItemVerticalMlc +import ua.gov.diia.core.models.common_compose.table.tableBlockOrg.TableBlockOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockPlaneOrg.TableBlockPlaneOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsOrg.TableBlockTwoColumnsOrg +import ua.gov.diia.core.models.common_compose.table.tableBlockTwoColumnsPlaneOrg.TableBlockTwoColumnsPlaneOrg +import ua.gov.diia.opensource.R +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiIcon +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.list.table.items.tableblock.DocTableItemHorizontalLongerMlcData +import ua.gov.diia.ui_base.components.molecule.list.table.items.tableblock.DocTableItemHorizontalMlcData +import ua.gov.diia.ui_base.components.molecule.list.table.items.tableblock.Size +import ua.gov.diia.ui_base.components.molecule.list.table.items.tableblock.TableBlockItem +import ua.gov.diia.ui_base.components.molecule.list.table.items.tableblock.TableHeadingMoleculeData +import ua.gov.diia.ui_base.components.molecule.list.table.items.tableblock.TableItemHorizontalMlcData +import ua.gov.diia.ui_base.components.molecule.list.table.items.tableblock.TableItemPrimaryMlcData +import ua.gov.diia.ui_base.components.molecule.list.table.items.tableblock.TableItemVerticalMlcData +import ua.gov.diia.ui_base.components.molecule.text.HeadingWithSubtitlesMlcData +import ua.gov.diia.ui_base.components.molecule.tile.SmallEmojiPanelMlcData +import ua.gov.diia.ui_base.components.organism.document.TableBlockOrgData +import ua.gov.diia.ui_base.components.organism.document.TableBlockPlaneOrgData +import ua.gov.diia.ui_base.components.organism.document.TableBlockTwoColumnsOrgData +import ua.gov.diia.ui_base.components.organism.document.TableBlockTwoColumnsPlainOrgData +import ua.gov.diia.ui_base.components.subatomic.icon.replaceWhiteWithTransparent + +private fun getIconByCode(code: String): Int { + return when (code) { + "copy" -> R.drawable.ic_copy_settings + else -> { + R.drawable.ic_copy_settings + } + } +} + +private fun TableItemHorizontalMlc?.toComposeTableItemHorizontal(image: String?): TableItemHorizontalMlcData? { + return this?.let { + TableItemHorizontalMlcData( + supportText = this.supportingValue, + title = this.label?.let { it1 -> UiText.DynamicString(it1) }, + value = this.value, + secondaryTitle = this.secondaryLabel, + secondaryValue = this.secondaryValue, + valueAsBase64String = if (this.valueImage != null) image else null, + iconRight = if (this.icon?.code != null) { + this.icon?.code?.let { + UiText.StringResource((getIconByCode(it))) + } + } else { + null + } + ) + } +} + +private fun TableItemHorizontalMlc?.toComposeDocTableItemHorizontal(image: String?): DocTableItemHorizontalMlcData? { + return this?.let { + DocTableItemHorizontalMlcData( + supportText = this.supportingValue, + title = this.label, + value = this.value, + secondaryTitle = this.secondaryLabel, + secondaryValue = this.secondaryValue, + valueAsBase64String = if (this.valueImage != null) image else null, + iconRight = if (this.icon?.code != null) { + this.icon?.code?.let { + UiText.StringResource((getIconByCode(it))) + } + } else { + null + } + ) + } +} + +private fun TableItemHorizontalMlc?.toComposeDocTableItemLongerHorizontal(image: String?): DocTableItemHorizontalLongerMlcData? { + return this?.let { + DocTableItemHorizontalLongerMlcData( + supportText = this.supportingValue, + title = this.label, + value = this.value, + secondaryTitle = this.secondaryLabel, + secondaryValue = this.secondaryValue, + valueAsBase64String = if (this.valueImage != null) image else null, + iconRight = if (this.icon?.code != null) { + this.icon?.code?.let { + UiText.StringResource((getIconByCode(it))) + } + } else { + null + } + ) + } +} + +private fun TableItemVerticalMlc?.toComposeTableItemVertical(image: String?): TableItemVerticalMlcData? { + return this?.let { + TableItemVerticalMlcData( + componentId = this.componentId.orEmpty(), + supportText = this.supportingValue, + title = this.label?.let { it1 -> UiText.DynamicString(it1) }, + value = this.value, + secondaryTitle = this.secondaryLabel, + secondaryValue = this.secondaryValue, + valueAsBase64String = if (this.valueImage != null) image else null, + icon = if (this.icon?.code != null) { + this.icon?.code?.let { + UiText.StringResource((getIconByCode(it))) + } + } else { + null + }, + signBitmap = base64ToImageBitmap(if (this.valueImage != null) image else null) + ) + } +} + +private fun base64ToImageBitmap(base64Image: String?): ImageBitmap? { + if (base64Image == null) { + return null + } + val byteArray = Base64.decode(base64Image, Base64.DEFAULT) + return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + ?.replaceWhiteWithTransparent() + ?.asImageBitmap() +} + +private fun photoBase64ToBitmap(base64Image: String?): Bitmap? { + if (base64Image == null) { + return null + } + val byteArray = Base64.decode(base64Image, Base64.DEFAULT) + return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) +} + +private fun SmallEmojiPanelMlc?.toComposeEmojiPanelMlc(): SmallEmojiPanelMlcData? { + if (this == null) return null + val text = label ?: return null + val code = icon?.code ?: return null + return SmallEmojiPanelMlcData( + text = UiText.DynamicString(text), + icon = UiIcon.DrawableResource(code = code) + ) +} + +private fun TableItemPrimaryMlc?.toComposeTableItemPrimary(): TableItemPrimaryMlcData? { + return this?.let { + TableItemPrimaryMlcData( + componentId = this.componentId.orEmpty(), + title = this.label.let { UiText.DynamicString(it) }, + value = this.value, + icon = this.icon?.let { + UiIcon.DrawableResource(code = it.code) + } + ) + } +} + +private fun Item?.toComposeTableBlockItem(valueImage: String?): TableBlockItem? { + return this?.let { + when { + this.tableItemHorizontalMlc != null -> tableItemHorizontalMlc.toComposeTableItemHorizontal( + valueImage + ) + + this.docTableItemHorizontalMlc != null -> docTableItemHorizontalMlc.toComposeDocTableItemHorizontal( + valueImage + ) + + this.docTableItemHorizontalLongerMlc != null -> docTableItemHorizontalLongerMlc.toComposeDocTableItemLongerHorizontal( + valueImage + ) + + this.tableItemPrimaryMlc != null -> tableItemPrimaryMlc.toComposeTableItemPrimary() + this.tableItemVerticalMlc != null -> tableItemVerticalMlc.toComposeTableItemVertical( + valueImage + ) + + this.smallEmojiPanelMlc != null -> smallEmojiPanelMlc.toComposeEmojiPanelMlc() + else -> null + } + } +} + + +fun TableBlockTwoColumnsPlaneOrg?.toComposeTableBlockTwoColumnsPlaneOrg( + photo: String?, + valueImage: String? +): TableBlockTwoColumnsPlainOrgData? { + return this?.let { + TableBlockTwoColumnsPlainOrgData( + componentId = this.headingWithSubtitlesMlc?.componentId.orEmpty(), + photo = photo, + photoAsBitmap = photoBase64ToBitmap(photo), + heading = if (this.headingWithSubtitlesMlc != null) HeadingWithSubtitlesMlcData( + value = this.headingWithSubtitlesMlc?.value, + subtitles = this.headingWithSubtitlesMlc?.subtitles + ) else null, + items = items?.mapNotNull { it.toComposeTableBlockItem(valueImage) } + ) + } +} + +fun TableBlockTwoColumnsOrg?.toComposeTableBlockTwoColumnsOrg( + photo: String?, + valueImage: String? +): TableBlockTwoColumnsOrgData? { + return this?.let { + TableBlockTwoColumnsOrgData( + componentId = this.headingWithSubtitlesMlc?.componentId.orEmpty(), + photo = photo, + photoAsBitmap = photoBase64ToBitmap(photo), + heading = if (this.headingWithSubtitlesMlc != null) HeadingWithSubtitlesMlcData( + value = this.headingWithSubtitlesMlc?.value, + subtitles = this.headingWithSubtitlesMlc?.subtitles + ) else null, + items = items?.mapNotNull { it.toComposeTableBlockItem(valueImage) } + ) + } +} + +fun TableBlockOrg?.toComposeTableBlockOrg(valueImage: String?): TableBlockOrgData? { + return this?.let { + TableBlockOrgData( + componentId = this.componentId.orEmpty(), + headerMain = if (this.tableMainHeadingMlc != null) TableHeadingMoleculeData( + title = this.tableMainHeadingMlc?.label?.let { UiText.DynamicString(it) }, + icon = if (this.tableMainHeadingMlc?.icon?.code != null) { + this.tableMainHeadingMlc?.icon?.code?.let { + UiText.StringResource((getIconByCode(it))) + } + } else { + null + }, + description = this.tableMainHeadingMlc?.description?.let { description -> + UiText.DynamicString( + description + ) + } + ) else null, + headerSecondary = if (this.tableSecondaryHeadingMlc != null) TableHeadingMoleculeData( + title = this.tableSecondaryHeadingMlc?.label?.let { UiText.DynamicString(it) }, + icon = if (this.tableSecondaryHeadingMlc?.icon?.code != null) { + this.tableSecondaryHeadingMlc?.icon?.code?.let { + UiText.StringResource((getIconByCode(it))) + } + } else { + null + }, + size = Size.secondary, + description = this.tableSecondaryHeadingMlc?.description?.let { description -> + UiText.DynamicString( + description + ) + } + ) else null, + items = items?.mapNotNull { it.toComposeTableBlockItem(valueImage) } + ) + + } +} + +fun TableBlockPlaneOrg?.toComposeTableBlockPlaneOrgData(valueImage: String?): TableBlockPlaneOrgData? { + return this?.let { + TableBlockPlaneOrgData( + headerMain = if (this.tableMainHeadingMlc != null) TableHeadingMoleculeData( + title = this.tableMainHeadingMlc?.label?.let { UiText.DynamicString(it) }, + icon = if (this.tableMainHeadingMlc?.icon?.code != null) { + this.tableMainHeadingMlc?.icon?.code?.let { + UiText.StringResource((getIconByCode(it))) + } + } else { + null + }, + description = this.tableMainHeadingMlc?.description?.let { description -> + UiText.DynamicString( + description + ) + } + ) else null, + headerSecondary = if (this.tableSecondaryHeadingMlc != null) TableHeadingMoleculeData( + title = this.tableSecondaryHeadingMlc?.label?.let { UiText.DynamicString(it) }, + icon = if (this.tableSecondaryHeadingMlc?.icon?.code != null) { + this.tableSecondaryHeadingMlc?.icon?.code?.let { + UiText.StringResource((getIconByCode(it))) + } + } else { + null + }, + size = Size.secondary, + description = this.tableSecondaryHeadingMlc?.description?.let { description -> + UiText.DynamicString( + description + ) + } + ) else null, + items = items?.mapNotNull { it.toComposeTableBlockItem(valueImage) } + ) + + } +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/FeedF.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/FeedF.kt new file mode 100644 index 0000000..0e4658a --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/FeedF.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.opensource.ui.fragments + +import androidx.fragment.app.Fragment +import ua.gov.diia.opensource.R + +class FeedF : Fragment(R.layout.fragment_feed) \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/context/ContextMenuDF.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/context/ContextMenuDF.kt new file mode 100644 index 0000000..7dbd768 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/context/ContextMenuDF.kt @@ -0,0 +1,49 @@ +package ua.gov.diia.opensource.ui.fragments.context + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import ua.gov.diia.core.models.ConsumableString +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.extensions.fragment.setNavigationResult +import ua.gov.diia.opensource.databinding.DialogContextMenuBinding +import ua.gov.diia.ui_base.fragments.BaseBottomDialog + +class ContextMenuDF : BaseBottomDialog() { + + private val args: ContextMenuDFArgs by navArgs() + private var binding: DialogContextMenuBinding? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = DialogContextMenuBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + + recyclerView.adapter = ContextMenuListAdapter(args.items.toList()) { + setResult(it) + } + btnClose.setOnClickListener { findNavController().popBackStack() } + } + return binding?.root + } + + private fun setResult(item: ContextMenuField) { + dismiss() + setNavigationResult( + result = ConsumableString(item.getActionType()), + key = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY + ) + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/context/ContextMenuListAdapter.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/context/ContextMenuListAdapter.kt new file mode 100644 index 0000000..fb10b7f --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/context/ContextMenuListAdapter.kt @@ -0,0 +1,51 @@ +package ua.gov.diia.opensource.ui.fragments.context + +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.util.extensions.context.getColorCompat +import ua.gov.diia.ui_base.util.view.inflater +import ua.gov.diia.opensource.databinding.ItemContextMenuFieldBinding + +class ContextMenuListAdapter( + private val items: List, + private val onItemSelected: (ContextMenuField) -> Unit, +) : RecyclerView.Adapter() { + + class ContextFieldVH( + private val binding: ItemContextMenuFieldBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind( + item: ContextMenuField, + onItemSelected: (ContextMenuField) -> Unit + ) { + with(binding) { + val context = binding.root.context + val tintColorCompat = context.getColorCompat(item.getTintColor()) + separator.setBackgroundColor(tintColorCompat) + text.text = item.getDisplayName(context) + text.setOnClickListener { onItemSelected(item) } + executePendingBindings() + } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + ContextFieldVH( + ItemContextMenuFieldBinding.inflate(parent.inflater, parent, false) + ) + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + val item = items[position] + + with((holder as ContextFieldVH)){ + bind(item,onItemSelected) + + } + } + + override fun getItemCount(): Int = items.size + + +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/settings/SettingsF.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/settings/SettingsF.kt new file mode 100644 index 0000000..09483e1 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/settings/SettingsF.kt @@ -0,0 +1,109 @@ +package ua.gov.diia.opensource.ui.fragments.settings + +import android.content.Intent +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import ua.gov.diia.core.util.extensions.fragment.currentDestinationId +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResultOnce +import ua.gov.diia.opensource.databinding.FragmentSettingsBinding +import ua.gov.diia.pin.repository.LoginPinRepository +import javax.inject.Inject + +@AndroidEntryPoint +class SettingsF : Fragment() { + + private val vm: SettingsFVM by viewModels() + private var binding: FragmentSettingsBinding? = null + + @Inject + lateinit var loginPinRepository: LoginPinRepository + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + + binding = FragmentSettingsBinding.inflate(inflater, container, false) + binding?.apply { + vmSettingsFVM = vm + invalidateAll() + } + vm.apply { + settingsAction.observe(viewLifecycleOwner) { + if (it !is SettingsFVM.SettingsAction.None) { + vm.clearAction() + } + when (it) { + is SettingsFVM.SettingsAction.PinCodeChangeAction -> { + navigateToResetPin() + } + is SettingsFVM.SettingsAction.CloseSettingsAction -> { + findNavController().navigateUp() + } + is SettingsFVM.SettingsAction.DocStack -> { + navigate(SettingsFDirections.actionGlobalToStackOrder()) + } + is SettingsFVM.SettingsAction.OpenSystemNotificationsAction -> { + openSystemNotificationSettings() + } + else -> { + } + } + } + } + + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + registerForNavigationResultOnce(RESULT_KEY_PIN) { pin -> + lifecycleScope.launch { + loginPinRepository.setUserAuthorized(pin) + } + } + } + + private fun navigateToResetPin() { + navigate( + SettingsFDirections.actionSettingsFToDestinationResetPin( + resultDestination = currentDestinationId ?: return, + resultKey = RESULT_KEY_PIN + ) + ) + } + + private fun openSystemNotificationSettings() { + val intent = Intent(APP_NOTE_SETTINGS) + intent.putExtra(APP_PACKAGE, requireActivity().packageName) + intent.putExtra(APP_UID, requireActivity().applicationInfo.uid) + intent.putExtra(APP_PROVIDER, requireActivity().packageName) + + val packageManager = requireActivity().packageManager + if (intent.resolveActivity(packageManager) != null) { + startActivity(intent) + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private companion object { + const val APP_NOTE_SETTINGS = "android.settings.APP_NOTIFICATION_SETTINGS" + const val APP_PACKAGE = "app_package" + const val APP_UID = "app_uid" + const val APP_PROVIDER = "android.provider.extra.APP_PACKAGE" + const val RESULT_KEY_PIN = "SettingsF.RESULT_KEY_PIN" + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/settings/SettingsFVM.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/settings/SettingsFVM.kt new file mode 100644 index 0000000..4de7e34 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/settings/SettingsFVM.kt @@ -0,0 +1,80 @@ +package ua.gov.diia.opensource.ui.fragments.settings + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ua.gov.diia.biometric.Biometric +import ua.gov.diia.diia_storage.DiiaStorage +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.notifications.store.NotificationsPreferences.AllowNotifications +import ua.gov.diia.notifications.util.push.notification.NotificationEnabledChecker +import javax.inject.Inject + +@HiltViewModel +class SettingsFVM @Inject constructor( + private val diiaStorage: DiiaStorage, + notificationEnabledChecker: NotificationEnabledChecker, + biometric: Biometric +) : ViewModel() { + + private var _touchIdEnabled = MutableLiveData() + val touchIdEnabled: LiveData + get() = _touchIdEnabled + + private var _touchIdAvailable = MutableLiveData() + val touchIdAvailable: LiveData + get() = _touchIdAvailable + + private var _settingsAction = MutableLiveData().apply { + value = SettingsAction.None + } + val settingsAction: LiveData + get() = _settingsAction + + init { + _touchIdEnabled.value = diiaStorage.getBoolean(Preferences.UseTouchId, false) + _touchIdAvailable.value = biometric.isBiometricAuthAvailable() + + if (!notificationEnabledChecker.notificationEnabled()) { + diiaStorage.set(AllowNotifications, false) + } + } + + fun setTouchIdAccess(isEnabled: Boolean) { + diiaStorage.set(Preferences.UseTouchId, isEnabled) + } + + fun openSystemNotifications() { + _settingsAction.value = SettingsAction.OpenSystemNotificationsAction + } + + fun changePin() { + _settingsAction.value = SettingsAction.PinCodeChangeAction + } + + fun docStack() { + _settingsAction.value = SettingsAction.DocStack + } + + fun closeSettings() { + _settingsAction.value = SettingsAction.CloseSettingsAction + } + + fun clearAction() { + _settingsAction.value = SettingsAction.None + } + + override fun onCleared() { + super.onCleared() + _settingsAction.value = SettingsAction.None + } + + sealed class SettingsAction { + object None : SettingsAction() + object PinCodeChangeAction : SettingsAction() + object DocStack : SettingsAction() + object CloseSettingsAction : SettingsAction() + object OpenSystemNotificationsAction : SettingsAction() + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/system/SystemDialog.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/system/SystemDialog.kt new file mode 100644 index 0000000..38819d1 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/system/SystemDialog.kt @@ -0,0 +1,81 @@ +package ua.gov.diia.opensource.ui.fragments.system + +import android.app.Dialog +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.Window +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import ua.gov.diia.core.models.ConsumableString +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.extensions.fragment.setNavigationResult +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.screen.SystemDialogScreen + + +class SystemDialog : DialogFragment() { + + private val viewModel: SystemDialogVM by viewModels() + private val args: SystemDialogArgs by navArgs() + private var composeView: ComposeView? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.doInit(args.dialog) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + isCancelable = args.dialog.cancelable + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + composeView?.setContent { + viewModel.apply { + navigation.collectAsEffect { navigation -> + when (navigation) { + is SystemDialogVM.Navigation.SendActionResult -> { + sendResult(navigation.result) + } + } + } + } + + SystemDialogScreen( + dataState = viewModel.uiData, + onEvent = { viewModel.onUIAction(it) } + ) + } + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog = super.onCreateDialog(savedInstanceState) + dialog.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE) + return dialog + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun sendResult(action: String) { + val resultKey = args.resultKey ?: ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY + setNavigationResult(result = ConsumableString(action), key = resultKey) + findNavController().popBackStack() + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/system/SystemDialogVM.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/system/SystemDialogVM.kt new file mode 100644 index 0000000..249cb0a --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/fragments/system/SystemDialogVM.kt @@ -0,0 +1,78 @@ +package ua.gov.diia.opensource.ui.fragments.system + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import ua.gov.diia.core.models.common.template_dialogs.SystemDialogData +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.components.atom.button.ButtonSystemAtomData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.screen.SystemDialogScreenData +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText + +class SystemDialogVM : ViewModel() { + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + val uiData = mutableStateOf(SystemDialogScreenData()) + + fun doInit(dialogData: SystemDialogData) { + val title = dialogData.title?.let { UiText.StringResource(it) } + val description = dialogData.message?.let { UiText.StringResource(it) } + val positiveButton = dialogData.positiveButtonTitle?.let { + ButtonSystemAtomData( + title = UiText.StringResource(it), + actionKey = UIActionKeysCompose.BUTTON_REGULAR + ) + } + val negativeButton = dialogData.negativeButtonTitle?.let { + ButtonSystemAtomData( + title = UiText.StringResource(it), + actionKey = UIActionKeysCompose.BUTTON_ALTERNATIVE + ) + } + + uiData.value = uiData.value.copy( + titleText = title, + descriptionText = description, + positiveButton = positiveButton, + negativeButton = negativeButton + ) + } + + private fun onPositiveButtonClicked() { + _navigation.tryEmit( + Navigation.SendActionResult(ActionsConst.SYSTEM_DIALOG_POSITIVE) + ) + } + + private fun onNegativeButtonClicked() { + _navigation.tryEmit( + Navigation.SendActionResult(ActionsConst.SYSTEM_DIALOG_NEGATIVE) + ) + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + UIActionKeysCompose.BUTTON_REGULAR -> { + onPositiveButtonClicked() + } + + UIActionKeysCompose.BUTTON_ALTERNATIVE -> { + onNegativeButtonClicked() + } + } + } + + sealed class Navigation : NavigationPath { + data class SendActionResult(val result: String) : Navigation() + + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/ui/work/LogoutWork.kt b/opensource/src/main/java/ua/gov/diia/opensource/ui/work/LogoutWork.kt new file mode 100644 index 0000000..4d5e7e3 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/ui/work/LogoutWork.kt @@ -0,0 +1,82 @@ +package ua.gov.diia.opensource.ui.work + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import retrofit2.HttpException +import ua.gov.diia.core.di.data_source.http.UnauthorizedClient +import ua.gov.diia.core.network.Http +import ua.gov.diia.core.network.apis.ApiAuth +import java.util.concurrent.TimeUnit + +@HiltWorker +class LogoutWork @AssistedInject constructor( + @UnauthorizedClient private val apiAuth: ApiAuth, + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun doWork(): Result { + val mobileUuid = inputData.getString(LOGOUT_WORK_DATA_LOGOUT_UUID) ?: return Result.failure() + val logoutToken = inputData.getString(LOGOUT_WORK_DATA_LOGOUT_TOKEN) ?: return Result.failure() + val isServiceUser = inputData.getBoolean(LOGOUT_WORK_DATA_USER_TYPE, false) + return try { + if (isServiceUser) { + apiAuth.logoutServiceUser("Bearer $logoutToken", mobileUuid) + } else { + apiAuth.logout("Bearer $logoutToken", mobileUuid) + } + Result.success() + } catch (e: HttpException) { + if (e.code() == Http.HTTP_401) { + Result.success() + } else { + Result.retry() + } + } catch (e: Exception) { + Result.retry() + } + } + + companion object { + private const val LOGOUT_WORK_DATA_LOGOUT_TOKEN = "logout_token" + private const val LOGOUT_WORK_DATA_LOGOUT_UUID = "logout_uuid" + private const val LOGOUT_WORK_DATA_USER_TYPE = "logout_user_type" + + private const val LOGOUT_WORK_DELAY = 3L + + fun enqueue( + workManager: WorkManager, + logoutToken: String, + mobileUuid: String, + isServiceUser: Boolean + ) { + val logoutWorkConstraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val data = Data.Builder() + .putString(LOGOUT_WORK_DATA_LOGOUT_TOKEN, logoutToken) + .putString(LOGOUT_WORK_DATA_LOGOUT_UUID, mobileUuid) + .putBoolean(LOGOUT_WORK_DATA_USER_TYPE, isServiceUser) + .build() + workManager.enqueue( + OneTimeWorkRequest.Builder(LogoutWork::class.java) + .setConstraints(logoutWorkConstraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + LOGOUT_WORK_DELAY, TimeUnit.SECONDS + ).setInputData(data).build() + ) + } + + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/AndroidDeepLinkActionFactory.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/AndroidDeepLinkActionFactory.kt new file mode 100644 index 0000000..875c093 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/AndroidDeepLinkActionFactory.kt @@ -0,0 +1,116 @@ +package ua.gov.diia.opensource.util + +import ua.gov.diia.core.models.deeplink.DeepLinkAction +import ua.gov.diia.core.models.deeplink.DeepLinkActionOpenNotify +import ua.gov.diia.core.models.deeplink.DeepLinkActionStartFlow +import ua.gov.diia.core.models.deeplink.DeepLinkActionViewDocument +import ua.gov.diia.core.models.deeplink.DeepLinkActionViewMessage +import ua.gov.diia.core.models.notification.push.PushNotification +import ua.gov.diia.core.util.deeplink.DeepLinkActionFactory +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.opensource.helper.documents.DocName.DRIVER_LICENSE +import ua.gov.diia.opensource.model.notification.PushNotificationActionType +import javax.inject.Inject + +class AndroidDeepLinkActionFactory @Inject constructor() : + DeepLinkActionFactory { + + override fun buildDeepLinkAction(path: String): DeepLinkAction { + if (path.startsWith(DEEP_LINK_DOCUMENT_CHECK_PREFIX)) { + val checkDocParams = path.split(SPLIT) + if (checkDocParams.size > 1) { + val docType: String? = mapCorrectDocNameType(checkDocParams[2]) + if (docType != null) { + return DeepLinkActionViewDocument( + documentType = docType, + resourceId = checkDocParams.getOrLogEvent(3), + notificationId = checkDocParams.getOrLogEvent(4), + ) + } + } + } + + if (path.startsWith(DEEP_LINK_MESSAGE)) { + val checkDocParams = path.split(SPLIT) + if (checkDocParams.size > 4 ){ + return DeepLinkActionViewMessage( + needAuth = checkDocParams[2].toBoolean(), + notificationId = checkDocParams[3], + resourceId = checkDocParams[4], + ) + } + } + + if (path.startsWith(DEEP_LINK_OPEN_PULL_NOTIFICATION)) { + val params = path.split(SPLIT) + if (params.size > 2) { + val flowType = params[SECOND_DEEP_LINK_COLUMN] + + val flowSubType = if (params.size > 3) { + params[THIRD_DEEP_LINK_COLUMN] + } else { + "" + } + + val resId = if (params.size > 4) { + params[FOURTH_DEEP_LINK_COLUMN] + } else { + Preferences.DEF + } + return DeepLinkActionOpenNotify( + flowType = flowType, + flowSubType = flowSubType, + resId = resId + ) + } + } + + val params = path.split(SPLIT) + var flowId = DEEP_LINK_HOME + var resId = Preferences.DEF + if (params.size > 1) { + flowId = params[FIRST_DEEP_LINK_COLUMN] + resId = if (params.size > 3) { + params[THIRD_DEEP_LINK_COLUMN] + } else if (params.size > 2) { + params[SECOND_DEEP_LINK_COLUMN] + } else Preferences.DEF + } + return DeepLinkActionStartFlow( + flowId, + resId + ) + } + + private fun mapCorrectDocNameType(name: String) = when (name) { + "driverLicense" -> DRIVER_LICENSE + else -> null + } + + override fun buildPathFromPushNotification(pushNotification: PushNotification): String { + return when (PushNotificationActionType.fromId(pushNotification.action.type)) { + PushNotificationActionType.MESSAGE -> { "$DIIA_HTTPS$DEEP_LINK_MESSAGE${pushNotification.needAuth}/${pushNotification.notificationId ?: Preferences.DEF}/${pushNotification.action.resourceId?:""}" } + else -> "$DIIA_HTTPS$DEEP_LINK_OPEN_PULL_NOTIFICATION/${pushNotification.action.type}/${pushNotification.action.subtype ?: ""}/${pushNotification.action.resourceId?:""}" + } + } + + private fun List.getOrLogEvent(index: Int): String { + return getOrNull(index) ?: "" + } + + companion object { + private const val DEEP_LINK_HOME = "/home/" + private const val DEEP_LINK_DOCUMENT_CHECK_PREFIX = "/documents/" + + private const val DEEP_LINK_MESSAGE = "/message/" + private const val DEEP_LINK_OPEN_PULL_NOTIFICATION = "/open-pull-notification" + + private const val SPLIT = "/" + private const val FIRST_DEEP_LINK_COLUMN = 1 + private const val SECOND_DEEP_LINK_COLUMN = 2 + private const val THIRD_DEEP_LINK_COLUMN = 3 + private const val FOURTH_DEEP_LINK_COLUMN = 4 + + private const val DIIA_HTTPS = "https://diia.app" + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DeeplinkProcessorImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DeeplinkProcessorImpl.kt new file mode 100644 index 0000000..55fdae2 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DeeplinkProcessorImpl.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.opensource.util + +import androidx.navigation.NavDirections +import ua.gov.diia.core.controller.DeeplinkProcessor +import ua.gov.diia.core.models.SingleDeeplinkProcessor +import ua.gov.diia.core.models.deeplink.DeepLinkAction + +class DeeplinkProcessorImpl( + private val linkActionProcessors: List +): DeeplinkProcessor { + + override suspend fun handleDeepLinkAction(action: DeepLinkAction): NavDirections? { + linkActionProcessors.forEach { + if (it.isHandled(action)) { + return it.handleDeepLinkAction(action) + } + } + return null + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultDeeplinkHandleBehaviour.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultDeeplinkHandleBehaviour.kt new file mode 100644 index 0000000..a9c32dd --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultDeeplinkHandleBehaviour.kt @@ -0,0 +1,32 @@ +package ua.gov.diia.opensource.util + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import ua.gov.diia.core.di.actions.GlobalActionDeeplink +import ua.gov.diia.core.models.deeplink.DeepLinkAction +import ua.gov.diia.core.models.notification.push.PushNotification +import ua.gov.diia.core.util.deeplink.DeepLinkActionFactory +import ua.gov.diia.core.util.delegation.WithDeeplinkHandling +import ua.gov.diia.core.util.event.UiDataEvent +import javax.inject.Inject + +class DefaultDeeplinkHandleBehaviour @Inject constructor( + @GlobalActionDeeplink private val globalActionDeeplink: MutableStateFlow?>, + private val deepLinkActionFactory: DeepLinkActionFactory, +) : WithDeeplinkHandling { + + override val deeplinkFlow: StateFlow?> + get() = globalActionDeeplink + + override suspend fun emitDeeplink(event: UiDataEvent) { + globalActionDeeplink.emit(event) + } + + override fun buildDeepLinkAction(path: String): DeepLinkAction { + return deepLinkActionFactory.buildDeepLinkAction(path) + } + + override fun buildPathFromPushNotification(notification: PushNotification): String { + return deepLinkActionFactory.buildPathFromPushNotification(notification) + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultErrorHandlingBehaviour.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultErrorHandlingBehaviour.kt new file mode 100644 index 0000000..6b69632 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultErrorHandlingBehaviour.kt @@ -0,0 +1,51 @@ +package ua.gov.diia.opensource.util + +import androidx.lifecycle.MutableLiveData +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.noInternetException +import ua.gov.diia.ui_base.fragments.errordialog.RequestTryCountTracker +import javax.inject.Inject + +class DefaultErrorHandlingBehaviour @Inject constructor( + private val alertFactory: ClientAlertDialogsFactory, + private val withCrashlytics: WithCrashlytics, +) : WithErrorHandling { + + private val tryCountTracker = RequestTryCountTracker() + + private val _showTemplateDialog = MutableLiveData>() + override val showTemplateDialog = _showTemplateDialog.asLiveData() + + override fun consumeException( + exception: Exception, + key: String, + needRetry: Boolean + ) { + withCrashlytics.sendNonFatalError(exception) + val templateData = if (exception.noInternetException()) { + alertFactory.alertNoInternet() + } else { + val closable = (tryCountTracker.tryCount < 1 && needRetry) + alertFactory.unknownErrorAlert(closable, e = exception) + } + tryCountTracker.increment() + _showTemplateDialog.postValue(UiDataEvent(templateData.setKey(key))) + } + + override fun resetErrorCounter() { + tryCountTracker.reset() + } + + override fun showTemplateDialog( + templateDialog: TemplateDialogModel, + key: String + ) { + _showTemplateDialog.postValue(UiDataEvent(templateDialog.setKey(key))) + } + +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultErrorHandlingBehaviourOnFlow.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultErrorHandlingBehaviourOnFlow.kt new file mode 100644 index 0000000..2630c1f --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultErrorHandlingBehaviourOnFlow.kt @@ -0,0 +1,53 @@ +package ua.gov.diia.opensource.util + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.noInternetException +import ua.gov.diia.ui_base.fragments.errordialog.RequestTryCountTracker +import javax.inject.Inject + +class DefaultErrorHandlingBehaviourOnFlow @Inject constructor( + private val alertFactory: ClientAlertDialogsFactory, + private val withCrashlytics: WithCrashlytics, +) : WithErrorHandlingOnFlow { + + private val tryCountTracker = RequestTryCountTracker() + + private val _showTemplateDialog = MutableSharedFlow>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val showTemplateDialog = _showTemplateDialog.asSharedFlow() + + override fun consumeException( + exception: Exception, + key: String, + needRetry: Boolean + ) { + withCrashlytics.sendNonFatalError(exception) + val templateData = if (exception.noInternetException()) { + alertFactory.alertNoInternet() + } else { + val closable = (tryCountTracker.tryCount < 1 && needRetry) + alertFactory.unknownErrorAlert(closable, e = exception) + } + tryCountTracker.increment() + _showTemplateDialog.tryEmit(UiDataEvent(templateData.setKey(key))) + + } + + override fun resetErrorCounter() { + tryCountTracker.reset() + } + + override fun showTemplateDialog( + templateDialog: TemplateDialogModel, + key: String + ) { + _showTemplateDialog.tryEmit(UiDataEvent(templateDialog.setKey(key))) + } + +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultPushHandlerBehaviour.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultPushHandlerBehaviour.kt new file mode 100644 index 0000000..557b595 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultPushHandlerBehaviour.kt @@ -0,0 +1,54 @@ +package ua.gov.diia.opensource.util + +import kotlinx.coroutines.flow.MutableSharedFlow +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.delegation.WithPushHandling +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import javax.inject.Inject + +class DefaultPushHandlerBehaviour @Inject constructor( + private val notificationDataSource: NotificationDataRepository, + private val crashlytics: WithCrashlytics, +) : WithPushHandling { + + private var isConsumed = false + + override val notificationConsumedEvent = + MutableSharedFlow() + + override suspend fun consumePush(notification: PullNotificationItemSelection) { + if (!isConsumed) { + isConsumed = true + markNotificationAsRead(notification) + } + } + + private suspend fun markNotificationAsRead(notification: PullNotificationItemSelection) { + try { + if (notification.notificationId != null) { + notification.notificationId?.let { notificationId -> + notificationDataSource.markNotificationAsRead(notificationId) + } + } else { + if (notification.resourceId != null) { + notification.resourceId?.let { resourceId -> + val cachedNotification = notificationDataSource + .findNotificationByResourceId(resourceId) + + if (cachedNotification?.isRead == false) { + cachedNotification.notificationId?.let { + notificationDataSource.markNotificationAsRead(it) + } + } + } + + } + } + + notificationConsumedEvent.emit(notification) + } catch (e: Exception) { + crashlytics.sendNonFatalError(e) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultPushNotificationBehaviour.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultPushNotificationBehaviour.kt new file mode 100644 index 0000000..7a411d3 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultPushNotificationBehaviour.kt @@ -0,0 +1,59 @@ +package ua.gov.diia.opensource.util + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.models.notification.pull.PullNotificationItemSelection +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.delegation.WithPushNotification +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.notifications.store.datasource.notifications.NotificationDataRepository +import javax.inject.Inject + +class DefaultPushNotificationBehaviour @Inject constructor( + private val notificationDataSource: NotificationDataRepository, + private val crashlytics: WithCrashlytics, +) : WithPushNotification { + + private val _notificationConsumedEvent = + MutableSharedFlow>() + override val notificationConsumedEvent: Flow> = + _notificationConsumedEvent + + override suspend fun consumePush(consumablePushItem: ConsumableItem) { + if (!consumablePushItem.isConsumed && consumablePushItem.item is PullNotificationItemSelection) { + consumablePushItem.isConsumed = true + markNotificationAsRead(consumablePushItem.item as PullNotificationItemSelection) + } + } + + override suspend fun markNotificationAsRead(resId: String) { + val cachedNotification = + notificationDataSource.findNotificationByResourceId(resId) + if (cachedNotification?.isRead == false) { + cachedNotification.notificationId?.let { + notificationDataSource.markNotificationAsRead(it) + } + } + } + + private suspend fun markNotificationAsRead(notification: PullNotificationItemSelection) { + try { + notification.notificationId?.let { + notificationDataSource.markNotificationAsRead(it) + } ?: notification.resourceId?.let { + val cachedNotification = notificationDataSource + .findNotificationByResourceId(it) + + if (cachedNotification?.isRead == false) { + cachedNotification.notificationId?.let { notificationId -> + notificationDataSource.markNotificationAsRead(notificationId) + } + } + } + _notificationConsumedEvent.emit(UiDataEvent(notification)) + } catch (e: Exception) { + crashlytics.sendNonFatalError(e) + } + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRatingDialogBehaviour.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRatingDialogBehaviour.kt new file mode 100644 index 0000000..b12fbfd --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRatingDialogBehaviour.kt @@ -0,0 +1,47 @@ +package ua.gov.diia.opensource.util + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import ua.gov.diia.core.models.rating_service.RatingFormModel +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import javax.inject.Inject + + +class DefaultRatingDialogBehaviour @Inject constructor() : WithRatingDialog { + + private val _showRatingDialog = MutableLiveData>() + override val showRatingDialog = _showRatingDialog.asLiveData() + + private val _showRatingDialogByUserInitiative = MutableLiveData>() + override val showRatingDialogByUserInitiative = _showRatingDialogByUserInitiative.asLiveData() + + private val _sendingRatingResult = MutableLiveData() + override val sendingRatingResult = _sendingRatingResult.asLiveData() + + override fun showRatingDialog( + ratingDialog: RatingFormModel, + key: String + ) { + _showRatingDialog.postValue(UiDataEvent(ratingDialog)) + } + + override fun T.sendRating( + ratingRequest: RatingRequest, + category: String, + serviceCode: String, + ) where T : ViewModel, T : WithErrorHandling, T : WithRetryLastAction { + // Implement your logic if required + } + + override fun T.getRating( + category: String, + serviceCode: String + ) where T : ViewModel, T : WithErrorHandling, T : WithRetryLastAction { + // Implement your logic if required + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRatingDialogBehaviourOnFlow.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRatingDialogBehaviourOnFlow.kt new file mode 100644 index 0000000..1349822 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRatingDialogBehaviourOnFlow.kt @@ -0,0 +1,43 @@ +package ua.gov.diia.opensource.util + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import ua.gov.diia.core.models.rating_service.RatingFormModel +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRatingDialogOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import javax.inject.Inject + +class DefaultRatingDialogBehaviourOnFlow @Inject constructor() : WithRatingDialogOnFlow { + + private val _showRatingDialog = MutableSharedFlow>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val showRatingDialog = _showRatingDialog.asSharedFlow() + + private val _showRatingDialogByUserInitiative = MutableSharedFlow>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val showRatingDialogByUserInitiative = _showRatingDialogByUserInitiative.asSharedFlow() + + private val _sendingRatingResult = MutableStateFlow(false) + override val sendingRatingResult = _sendingRatingResult.asStateFlow() + + override fun showRatingDialog( + ratingDialog: RatingFormModel, + key: String + ) {} + + override fun T.sendRating( + ratingRequest: RatingRequest, + category: String, + serviceCode: String + ) where T : ViewModel, T : WithErrorHandlingOnFlow, T : WithRetryLastAction {} + + override fun T.getRating( + category: String, + serviceCode: String + ) where T : ViewModel, T : WithErrorHandlingOnFlow, T : WithRetryLastAction {} +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRetryLastActionBehaviour.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRetryLastActionBehaviour.kt new file mode 100644 index 0000000..bc811e3 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultRetryLastActionBehaviour.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.opensource.util + +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import javax.inject.Inject + +class DefaultRetryLastActionBehaviour @Inject constructor(): + WithRetryLastAction { + private var lastAction: (() -> Unit)? = null + + override fun retryLastAction() { + lastAction?.invoke() + lastAction = null + } + + override fun setLastAction(action: () -> Unit) { + lastAction = action + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultSelfPermissionBehavior.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultSelfPermissionBehavior.kt new file mode 100644 index 0000000..4384225 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultSelfPermissionBehavior.kt @@ -0,0 +1,180 @@ +package ua.gov.diia.opensource.util + +import android.os.Bundle +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.navigation.fragment.findNavController +import ua.gov.diia.core.models.ConsumableString +import ua.gov.diia.core.models.common.template_dialogs.SystemDialogData +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.delegation.Permission +import ua.gov.diia.core.util.delegation.WithPermission +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResult +import ua.gov.diia.opensource.R +import ua.gov.diia.opensource.util.ext.isPermissionGraned +import ua.gov.diia.opensource.util.ext.openAppSettings +import javax.inject.Inject + +class DefaultSelfPermissionBehavior @Inject constructor() : WithPermission { + + private companion object { + + const val RESULT_KEY_SYS_DIALOG_PERMISSION = + "DefaultSelfPermissionBehavior.RESULT_KEY_SYS_DIALOG_PERMISSION" + + const val RESULT_KEY_SYS_DIALOG_RATIONALE = + "DefaultSelfPermissionBehavior.RESULT_KEY_SYS_DIALOG_RATIONALE" + + const val RESULT_KEY_PERMISSION = + "DefaultSelfPermissionBehavior.RESULT_KEY_PERMISSION" + } + + private val _doOnCameraPermissionGrantedEvent = MutableLiveData() + override val doOnCameraPermissionGrantedEvent: LiveData = + _doOnCameraPermissionGrantedEvent + + private val _doOnGeoPermissionGrantedEvent = MutableLiveData() + override val doOnGeoPermissionGrantedEvent: LiveData = + _doOnGeoPermissionGrantedEvent + + private val _doOnPostNotificationPermissionGrantedEvent = + MutableLiveData() + override val doOnPostNotificationPermissionGrantedEvent: LiveData = + _doOnPostNotificationPermissionGrantedEvent + + private val _doOnStoragePermissionGrantedEvent = MutableLiveData() + override val doOnStoragePermissionGrantedEvent: LiveData = + _doOnStoragePermissionGrantedEvent + + private val _doOnPermissionDeniedEvent = MutableLiveData() + override val doOnPermissionDeniedEvent: LiveData = + _doOnPermissionDeniedEvent + + private var requestedPermission: Permission? = null + private var permissionLauncher: ActivityResultLauncher>? = null + + override fun T.approvePermission(permission: Permission) { + val granted = permission.value.all { isPermissionGraned(it) } + if (granted) { + when (permission) { + Permission.CAMERA -> _doOnCameraPermissionGrantedEvent.value = + UiEvent() + Permission.LOCATION -> _doOnGeoPermissionGrantedEvent.value = + UiEvent() + Permission.POST_NOTIFICATIONS -> _doOnPostNotificationPermissionGrantedEvent.value = + UiEvent() + + Permission.STORAGE_READ -> _doOnStoragePermissionGrantedEvent.value = + UiEvent() + Permission.STORAGE_WRITE -> _doOnStoragePermissionGrantedEvent.value = + UiEvent() + } + } else { + requestedPermission = permission + val shouldShowRationale = permission.value.all { shouldShowRequestPermissionRationale(it) } + val partiallyGranted = permission.value.any { isPermissionGraned(it) } + if (shouldShowRationale) { + requestAppSettings(permission.rationaleDialog) + } else if (partiallyGranted){ + requestPermission() + } else { + requestAppPermission(permission.permissionDialog) + } + } + } + + private fun T.obtainPermissionLauncher(permission: Permission): ActivityResultLauncher>? = + if (permissionLauncher == null) { + requireActivity().activityResultRegistry.register( + RESULT_KEY_PERMISSION, + ActivityResultContracts.RequestMultiplePermissions() + ) { resultMapping -> + val granted = resultMapping.any { it.value } + if (granted) { + when (permission) { + Permission.CAMERA -> _doOnCameraPermissionGrantedEvent.value = + UiEvent() + Permission.LOCATION -> _doOnGeoPermissionGrantedEvent.value = + UiEvent() + Permission.POST_NOTIFICATIONS -> _doOnPostNotificationPermissionGrantedEvent.value = + UiEvent() + + Permission.STORAGE_READ -> _doOnStoragePermissionGrantedEvent.value = + UiEvent() + + Permission.STORAGE_WRITE -> _doOnStoragePermissionGrantedEvent.value = + UiEvent() + } + } else { + _doOnPermissionDeniedEvent.value = UiEvent() + } + }.also { permissionLauncher = it } + } else { + permissionLauncher + } + + private fun T.requestPermission() { + val permission = requestedPermission ?: return + val launcher = obtainPermissionLauncher(permission) ?: return + launcher.launch(permission.value) + } + + private fun T.requestAppPermission(dialogData: SystemDialogData) { + registerForNavigationResult( + RESULT_KEY_SYS_DIALOG_PERMISSION + ) { dataEvent -> + dataEvent.consumeEvent { action -> + when (action) { + ActionsConst.SYSTEM_DIALOG_NEGATIVE -> _doOnPermissionDeniedEvent.value = + UiEvent() + + ActionsConst.SYSTEM_DIALOG_POSITIVE -> requestPermission() + } + } + } + + navigateToSystemDialog( + dialogData = dialogData, + resultKey = RESULT_KEY_SYS_DIALOG_PERMISSION + ) + } + + private fun T.requestAppSettings(dialogData: SystemDialogData) { + registerForNavigationResult( + RESULT_KEY_SYS_DIALOG_RATIONALE + ) { dataEvent -> + dataEvent.consumeEvent { action -> + when (action) { + ActionsConst.SYSTEM_DIALOG_POSITIVE -> openAppSettings() + ActionsConst.SYSTEM_DIALOG_NEGATIVE -> _doOnPermissionDeniedEvent.value = + UiEvent() + } + } + } + + navigateToSystemDialog( + dialogData = dialogData, + resultKey = RESULT_KEY_SYS_DIALOG_RATIONALE + ) + } + + private fun T.navigateToSystemDialog( + dialogData: SystemDialogData, + resultKey: String + ) { + val args = Bundle().apply { + putString("resultKey", resultKey) + putParcelable("dialog", dialogData) + } + findNavController().navigate(R.id.destination_systemDialog, args) + } + + override fun onDestroy(owner: LifecycleOwner) { + permissionLauncher?.unregister() + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultWithContextMenuBehaviour.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultWithContextMenuBehaviour.kt new file mode 100644 index 0000000..bab2e4c --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/DefaultWithContextMenuBehaviour.kt @@ -0,0 +1,40 @@ +package ua.gov.diia.opensource.util + +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavDirections +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import javax.inject.Inject + +class DefaultWithContextMenuBehaviour @Inject constructor(): + WithContextMenu { + + private var contextMenu: Array? = null + + private val _openContextMenu = MutableLiveData>>() + override val openContextMenu = _openContextMenu.asLiveData() + + private val _showContextMenu = MutableLiveData(false) + override val showContextMenu = _showContextMenu.asLiveData() + + // Fill nav directory to FAQ after implementation + override val faqNavDirection: NavDirections? = null + + override fun openContextMenu() { + contextMenu?.let { + _openContextMenu.postValue(UiDataEvent(it)) + } + } + + override fun setContextMenu(contextMenu: Array?) { + this.contextMenu = contextMenu + + val isVisible = !contextMenu.isNullOrEmpty() + _showContextMenu.postValue(isVisible) + } + + override fun getMenu(): Array? = contextMenu + +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/EdgeToEdge.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/EdgeToEdge.kt new file mode 100644 index 0000000..4f1c4a3 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/EdgeToEdge.kt @@ -0,0 +1,41 @@ +package ua.gov.diia.opensource.util + +import android.content.res.Resources +import android.os.Build +import android.view.View +import android.view.Window +import androidx.annotation.RequiresApi +import androidx.core.app.ComponentActivity +import androidx.core.view.WindowCompat +import ua.gov.diia.core.util.extensions.context.getColorCompat + +fun ComponentActivity.setUpEdgeToEdge() { + val impl = if (Build.VERSION.SDK_INT >= 26) { + EdgeToEdgeApi26() + } else { + EdgeToEdgeApi23() + } + impl.setUp(window, findViewById(android.R.id.content), theme) +} + +private interface EdgeToEdgeImpl { + fun setUp(window: Window, view: View, theme: Resources.Theme) +} + +@RequiresApi(26) +private class EdgeToEdgeApi26 : EdgeToEdgeImpl { + + override fun setUp(window: Window, view: View, theme: Resources.Theme) { + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = view.context.getColorCompat(android.R.color.transparent, theme) + window.navigationBarColor = view.context.getColorCompat(android.R.color.transparent, theme) + } +} + +private class EdgeToEdgeApi23 : EdgeToEdgeImpl { + + override fun setUp(window: Window, view: View, theme: Resources.Theme) { + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = view.context.getColorCompat(android.R.color.transparent, theme) + } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/WithAppConfigImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/WithAppConfigImpl.kt new file mode 100644 index 0000000..59c5942 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/WithAppConfigImpl.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.opensource.util + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import ua.gov.diia.core.util.delegation.WithAppConfig +import ua.gov.diia.opensource.R +import javax.inject.Inject + +class WithAppConfigImpl @Inject constructor( + @ApplicationContext private val context: Context, +): WithAppConfig { + + override fun getAppPolicyUrl(): String = context.getString(R.string.url_app_policy) + + override fun getAboutUrl(): String = context.getString(R.string.url_about_diia) +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/WithBuildConfigImpl.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/WithBuildConfigImpl.kt new file mode 100644 index 0000000..4c276ef --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/WithBuildConfigImpl.kt @@ -0,0 +1,33 @@ +package ua.gov.diia.opensource.util + +import android.os.Build +import ua.gov.diia.core.util.delegation.WithBuildConfig +import ua.gov.diia.opensource.BuildConfig +import javax.inject.Inject + +class WithBuildConfigImpl @Inject constructor() : WithBuildConfig { + + override fun getServerUrl(): String = BuildConfig.SERVER_URL + + override fun getBankIdHost(): String = "" + + override fun getBankIdClientId(): String = "" + + override fun getBankIdCallbackUrl(): String = BuildConfig.BANK_ID_CALLBACK_URL + + override fun getTokenLeeway(): Long = BuildConfig.TOKEN_LEEWAY + + override fun getSign(): String = "" + + override fun getDGCVerificationBaseUrl(): String = "" + + override fun getVersionCode(): Int = BuildConfig.VERSION_CODE + + override fun getVersionName(): String = BuildConfig.VERSION_NAME + + override fun getSdkVersion(): Int = Build.VERSION.SDK_INT + + override fun getBuildType(): String = BuildConfig.BUILD_TYPE + + override fun getApplicationId(): String = BuildConfig.APPLICATION_ID +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/ext/ActivityNavigation.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/ext/ActivityNavigation.kt new file mode 100644 index 0000000..f6be2fa --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/ext/ActivityNavigation.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.opensource.util.ext + +import android.app.Activity +import androidx.navigation.NavController +import androidx.navigation.NavDirections +import androidx.navigation.findNavController +import ua.gov.diia.opensource.R + +fun Activity.navigate( + destination: NavDirections, + navController: NavController = findNavController(R.id.nav_host) +) = with(navController) { + currentDestination + ?.getAction(destination.actionId) + ?.let { navigate(destination) } +} \ No newline at end of file diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/ext/FragmentNavigationExt.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/ext/FragmentNavigationExt.kt new file mode 100644 index 0000000..0c3000b --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/ext/FragmentNavigationExt.kt @@ -0,0 +1,35 @@ +package ua.gov.diia.opensource.util.ext + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.opensource.NavMainXmlDirections +import ua.gov.diia.opensource.ui.PublicServicesHomeConst +import ua.gov.diia.publicservice.models.PublicService + +fun Fragment.navigateToPublicService(service: PublicService) { + when (service.code) { + PublicServicesHomeConst.PS_SERVICE_CRIME_CERTIFICATE -> navigate( + NavMainXmlDirections.actionHomeFToCriminalCert( + contextMenu = service.menu + ) + ) + } +} + +fun Fragment.isPermissionGraned(permission: String): Boolean = + ContextCompat.checkSelfPermission( + requireActivity(), + permission + ) == PackageManager.PERMISSION_GRANTED + +fun Fragment.openAppSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = Uri.fromParts("package", requireContext().packageName, null) + } + startActivity(intent) +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/ext/FragmentSendExt.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/ext/FragmentSendExt.kt new file mode 100644 index 0000000..c3d2d34 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/ext/FragmentSendExt.kt @@ -0,0 +1,52 @@ +package ua.gov.diia.opensource.util.ext + +import android.content.Intent +import androidx.core.content.FileProvider +import androidx.fragment.app.Fragment +import ua.gov.diia.diia_storage.AndroidBase64Wrapper +import ua.gov.diia.opensource.BuildConfig +import ua.gov.diia.opensource.util.file.AndroidInternalFileManager + +fun Fragment.sendPdf(base64pdf: String, fileName: String) { + val pdfInBytes = AndroidBase64Wrapper().decode(base64pdf.toByteArray()) + sendPdf(pdfInBytes, fileName) +} + +fun Fragment.sendPdf(pdfInBytes: ByteArray, fileName: String) { + val fileManager = AndroidInternalFileManager(requireContext(), "docs") + fileManager.saveFile(fileName, pdfInBytes) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/pdf" + putExtra( + Intent.EXTRA_STREAM, FileProvider.getUriForFile( + requireContext(), + BuildConfig.APPLICATION_ID, + fileManager.getFile(fileName) + ) + ) + putExtra(Intent.EXTRA_SUBJECT, fileName) + } + startActivity(intent) +} + +fun Fragment.sendZip(base64zip: String, fileName: String) { + val zipInBytes = AndroidBase64Wrapper().decode(base64zip.toByteArray()) + val fileManager = AndroidInternalFileManager(requireContext(), "docs") + + fileManager.saveFile(fileName, zipInBytes) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = "application/zip" + putExtra( + Intent.EXTRA_STREAM, FileProvider.getUriForFile( + requireContext(), + BuildConfig.APPLICATION_ID, + fileManager.getFile(fileName) + ) + ) + putExtra(Intent.EXTRA_SUBJECT, fileName) + } + startActivity(intent) + +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/ext/OkhttpExtensions.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/ext/OkhttpExtensions.kt new file mode 100644 index 0000000..69731ec --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/ext/OkhttpExtensions.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.opensource.util.ext + +import okhttp3.OkHttpClient +import ua.gov.diia.opensource.data.network.TimeoutConstants +import java.util.concurrent.TimeUnit + +fun OkHttpClient.Builder.setTimeout() { + connectTimeout(TimeoutConstants.CONNECTION_TIMEOUT, TimeUnit.SECONDS) + writeTimeout(TimeoutConstants.WRITE_TIMEOUT, TimeUnit.SECONDS) + readTimeout(TimeoutConstants.READ_TIMEOUT, TimeUnit.SECONDS) +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/file/AndroidInternalFileManager.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/file/AndroidInternalFileManager.kt new file mode 100644 index 0000000..95d82ef --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/file/AndroidInternalFileManager.kt @@ -0,0 +1,63 @@ +package ua.gov.diia.opensource.util.file + +import android.content.Context +import java.io.BufferedInputStream +import java.io.File +import java.io.FileInputStream +import java.io.InputStream + +open class AndroidInternalFileManager( + private val context: Context, + private val subDir: String, +) : FileManager { + + override fun saveFile(filename: String, data: ByteArray): File { + val file = File(getDir(), filename) + file.writeBytes(data) + return file + } + + override fun getFile(filename: String): File { + return File(getDir(), filename) + } + + override fun readFileData(filename: String): ByteArray { + val file = File(getDir(), filename) + val size = file.length().toInt() + val bytes = ByteArray(size) + val buf = BufferedInputStream(FileInputStream(file)) + buf.read(bytes, 0, bytes.size) + buf.close() + return bytes + } + + override fun deleteFile(filename: String) { + File(getDir(), filename).delete() + } + + override fun clearPath() { + getDir().deleteRecursively() + } + + override fun getDir(): File { + val appDir: File = context.filesDir + val subDir = File(appDir, subDir) + if (!subDir.exists()) subDir.mkdir() + return subDir + } + + override fun readAssetsBytes(asset: String): ByteArray { + var inputStream: InputStream? = null + try { + inputStream = context.assets.open(asset) + val size = inputStream.available() + val buffer = ByteArray(size) + if (size != inputStream.read(buffer)) { + throw IllegalStateException() + } + return buffer + } finally { + inputStream?.close() + } + } +} diff --git a/opensource/src/main/java/ua/gov/diia/opensource/util/file/FileManager.kt b/opensource/src/main/java/ua/gov/diia/opensource/util/file/FileManager.kt new file mode 100644 index 0000000..97f48f3 --- /dev/null +++ b/opensource/src/main/java/ua/gov/diia/opensource/util/file/FileManager.kt @@ -0,0 +1,42 @@ +package ua.gov.diia.opensource.util.file + +import java.io.File + +interface FileManager { + + /** + * Save file to working dir + */ + fun saveFile(filename: String, data: ByteArray): File + + /** + * Get file from current working dir + */ + fun getFile(filename: String): File + + /** + * Read file from working dir to byte array + */ + fun readFileData(filename: String): ByteArray + + /** + * Remove file from working dir + */ + fun deleteFile(filename: String) + + /** + * Clear all files from current working dir + */ + fun clearPath() + + /** + * Return current working dir + */ + fun getDir(): File + + /** + * Read file as byte array from assets + */ + fun readAssetsBytes(asset: String): ByteArray + +} \ No newline at end of file diff --git a/opensource/src/main/res/drawable/ic_diia_foreground.xml b/opensource/src/main/res/drawable/ic_diia_foreground.xml new file mode 100644 index 0000000..b4dc5b7 --- /dev/null +++ b/opensource/src/main/res/drawable/ic_diia_foreground.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/opensource/src/main/res/drawable/ic_notifications.xml b/opensource/src/main/res/drawable/ic_notifications.xml new file mode 100644 index 0000000..12843b1 --- /dev/null +++ b/opensource/src/main/res/drawable/ic_notifications.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/opensource/src/main/res/drawable/ic_order.xml b/opensource/src/main/res/drawable/ic_order.xml new file mode 100644 index 0000000..0ac191a --- /dev/null +++ b/opensource/src/main/res/drawable/ic_order.xml @@ -0,0 +1,16 @@ + + + + diff --git a/opensource/src/main/res/drawable/ic_passcode_settings.xml b/opensource/src/main/res/drawable/ic_passcode_settings.xml new file mode 100644 index 0000000..7a96809 --- /dev/null +++ b/opensource/src/main/res/drawable/ic_passcode_settings.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/opensource/src/main/res/drawable/ic_touch_id.xml b/opensource/src/main/res/drawable/ic_touch_id.xml new file mode 100644 index 0000000..7c11dac --- /dev/null +++ b/opensource/src/main/res/drawable/ic_touch_id.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/opensource/src/main/res/layout/activity_main.xml b/opensource/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..b45fe6f --- /dev/null +++ b/opensource/src/main/res/layout/activity_main.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/opensource/src/main/res/layout/dialog_context_menu.xml b/opensource/src/main/res/layout/dialog_context_menu.xml new file mode 100644 index 0000000..38a8fb7 --- /dev/null +++ b/opensource/src/main/res/layout/dialog_context_menu.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/opensource/src/main/res/layout/fragment_feed.xml b/opensource/src/main/res/layout/fragment_feed.xml new file mode 100644 index 0000000..d225646 --- /dev/null +++ b/opensource/src/main/res/layout/fragment_feed.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/opensource/src/main/res/layout/fragment_settings.xml b/opensource/src/main/res/layout/fragment_settings.xml new file mode 100644 index 0000000..32fbddd --- /dev/null +++ b/opensource/src/main/res/layout/fragment_settings.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/opensource/src/main/res/layout/item_context_menu_field.xml b/opensource/src/main/res/layout/item_context_menu_field.xml new file mode 100644 index 0000000..6bf2c00 --- /dev/null +++ b/opensource/src/main/res/layout/item_context_menu_field.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/opensource/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/opensource/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..c78c023 --- /dev/null +++ b/opensource/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/opensource/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/opensource/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..c93b212 --- /dev/null +++ b/opensource/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/opensource/src/main/res/mipmap-hdpi/ic_launcher.png b/opensource/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..f4723644159ba5ed949a7ca8ace7347a5148aafa GIT binary patch literal 1251 zcmV<91RVQ`P)H!YK1WzxPC9G!~4c->o`Af4$W)2~dQn0`^1J_%$0DEMMGBuhsl(-$UCnZ5|V zeHA2eevSzzyi=LbrwI`pS#y$Jc+C*OQCkFAe9C>~^Q!?N(Vrav(C?yES{5J-y+0fP zP`lu(Y(S`12LSY!$Y0rj{&WC9f7=7d(ixVQ+##l;-ZV^vpI!^6V^X=rFjeSiuJ3!$#A?pY-# zC)@0wkdOci3k$Hewg#o8rRoDTGBRR~09$HoY_!>5r_+Itj}Q3y`GH=qS1+K=%}p2{ z9+v3h;^IX>s$az{fLdBwz}eYZqV@IlCxCF(Dl034si`T6IyyQiip1x3adClyf&ws` z%`iDR`J_)zPmgi{H8(dusl$2YF(6#hM@L7g{O0Bc?(gsExT~v6vdrT#_Pf5mruwU^ zD>ywpCE+oaEiW&t4-iI0Mn(qY=jTISULKsBoKTyRk`hVZ$jC@5h`zo)$jr=yq@*NL zTU(oQ09m4;sj2BzfW(TAkB7_4OE^A0CLrRvqM{<|ixCqX94x7;t*uoKAl&5PU>h15 zWCOxI5T@Ap_&9ibdrRtaa&oMIf`WpS2?!_LrKKerY-MGoY(QgUV>asp0|Vjc=!ohE z2L~w~)6>&QIH4;7P(VNcY;A4PV6(HcWdZW_^`$umBPcO3@jv0`=H|k|!GU!`$M&e( z+goJ-!Z`%z4^&J{j4VJ16zi}JMhgOoj*f<~urSFc5_jQeXJ^!Qc6OE)h1JznWdJHK zFNeFkJD8c70e^r0R|CQ&ytTEJ>bkqTDH5xys$hG28~XeE!QI{63aGQQlcH>HZVtl3 z!)cpPUtj-i1Hw&UYHBKdqd3rkfdQHu($dl-+TGoy`rh7NNJ&Ydw8H*wZf7uN{8u&Wr~=jF(oYXz-xtRf)}Qi&Em$!29@J^FhW*V zR_NyrS65f-XJ8q&X>V`0mMad>{{BAf?CjXo-rgRip%@URSVctz93CFhN`<@Vh=>SD z3f9!rQ2+V)c?by!d6L>#H!(5s?E{L7i+i?hR8$l}5(Bab9S;o+rMX5`7prdtNVQk< z9MB6Nh$(?fGs<8vJlht31Tz|qv<-NvE&o@8|Lwy8aX=gp2gCt!KpYSU!~t0(T(R?und$f#{Y&(b=s?qZa?&r8AMSOQCy^WNi7=pjFQY(@97#Ns(cp3*A$(SfF_DT0Vc$d1d~G9A*rrTyhoN~etw>)HT-yoqEQk= zBtxwRiKL0=8}Z&sZE~vv=2|n8qFRBNAt#Vx2}24*RbyM^_PP8p2;a9+^h%8$Phu<) z#qZgNZBlBhOUao`Q7bfbz=fn(g4l*SY@L z1l8D6w8ok?L0gxOxAW%g=SfbOcsRs1emGv5#4B9vW#lNe^*v{kq!)ZnpBIuNlLRun z;Vg6GIYg-7kiC%{nWVodrb;rZBB<629J0NR`v+kxN|kAS>OtHS=4v9yR!%GJ2ryaEFQ$%P9Sh{B_+8sqAgQ0GybDkjd?kf zAU8&{5~WEQ;NW0;3HpP=U*N^; zi~=c|)r5F81RXwnm|Ytj9Au7}3wiP4Mdq}{veD7eEXoZG46rglP*+!%8T`DR1aX;R z!9(o{r2tKWXFyjBrh+| z3?N1YLI27LqfQw6d7*}&J$v?;30SaTfn5ZNC@Cpnuc@hF*SovBNqTxZ z(Q36LuHU$ELk&S)6tk5Gnk5X5ITYqgOG{b9o<4m_va_=tB1osxk;|7avvSw3Unh}~ zk)z7g)zz63NH90~x`PA_34>^<& zKY;!F_p^Gy3=0YhSijB51f?kvWJL9H$izU9lP6EIhCO=ph$JK=I7HC8b?Zn^PY;V| zD_5>mER&g;Y4$Unot@;;rAy?@nKNcQd-hC?iTy{BEE2?{ShN}^J$dqEQeR)s8g}>Y zUB#}1HG-hme&omzR;Ht)V}yB$eg-sV#flZAsi}!+nx%wPg_BMYqZlVBL`|57T;<-q zd#qvA)zyk+tPvC*9?r;xG7Sw4W@1uOQb=xY?kH2r%F1FyLivXeAF}&aRaL3gphwsz z9YGKvnBq4_6Gadh%mSY1=FOWOA_z(=gyr>E1@n(23uNM*mYhuBqW590e%Rg z9hf7K0l0bdrkX_YPn;+nu|^xR=pc&I_7Vh7H6kK{oH})iHL9(xjpXFyuyZnw9zAL{ zpVg~Zvks}~go}%d*>%u>wQJXsvuDq;P|hRc^5x6TW!J1(W6p&tD=SB6mxE54%lr*; z;U{-iA-(?GUV@U7lWpptTI#f>rKOqcI&tEJIXdxd8nhC8o7mle+z9*+PZS~YIU$O1 znahz2z0#WIW%inuGYMM1e!W>^z|0;$eoS`l+Qod6=*Ul>KFvskXa}0+q(qV91LQ(4 z512tni>GL{$5D#})z;Rs?t(QmHa02}1d(skrcKPW4jnqgaw$k0kUYnj$Sy`DPK|E@KmdFvyMME7h z7dXYzm^7T6BrH}ixq~1#&((j194IcX(&B~JFk72oBC0H?x>@I^8Fl;eGj` z8YjM_K#RTH1bTz50!ag2WL7}V4jho5su_njIOwoqBh{pjg;`i{*ihGnnq$QN*0^o- zg^Ja~+^_4n$wCx!bbE&--($6arF(!emA(fh9QXn3_U!nJg z7oN<;wuEau=w&BbA)$mwkxCo;5&b-eGX+X{iTrXCOAyP-^+4U{=)DSX$%;`(06p?W zOn#VvMPp;=X`r9CR1{|8zKcEVeku=6pbi!EdD2EtcDfbd6V0(>lnY$Sw(-w41M-wCQr z0z$D^2|@m(7$Gh*F+P?M!Il(jLeub%?+Ic4q&NdJFT@Zbd`yZpt-uGuUq%H!Wh=ny z3b+&jML-cy1YV(lPN##)%1SU83@=k)U|;}FPfuZNY>XX&($Z2G9UV=fzrP=}T5ZDE zj*br4+}wo0!9jKeJRT3k<8e4YKZnD^L*)AU8g_SgAwNGqWxUa71hrbto`Bcug|f0T zWPN>I6i}&DxEc!!3&`y3ELg2pJeT7*XlQ7F>FH_lIAvsH1dmB7;C8#kzSY%LQGliq z&H0s;6$ppJm_mhxg*dLdx*E>T&T!Re;B#|xaCLPB^Yim5>Gx~`ZEbBR(A3nFRzT>} z>-7){g4i2`+Xbr}Vko15Y8?haa8TgATa?ryA? zp`jsM70S`kk+cGI7Vq!x!Dh2PC_rc2=kvkE#RXU_7IB=}Y=)Vc8I-uay+!D2NhUxG zjTYs>!9l{!DOo_H(ZKfhHY_eKVv$cwOh8pt6&|avuZLhT2!6jGdV71}^72v|0UA_$ zdpiUI0lez;^z>j#PjaFv9<^73-*7p_##CP4F> z<~m*OboDVlJ`Q_(d*V%txlg0CfL$&Z&h|r}k!osc(&e)9eLxXV1QdZ+B=Dkt+;uX% z0-rMp2%83P2)`NJ;Go-0?=lGpTTFU_pTRArAB4PY_Q*Is*M9P~5apLIDlx))(ind6 z>BD8aQ%Ox71UKP}%y{@%@^T)zSF9r#2_~5_^07Q=H~N*;n3o2s<5B#@9&Xj4L)mt5P_ zK6nqwgMvjwA&62fQfg5#kQfZ)A&F6`w_G&pp3WK0ENB1xyZ>%n-TmR?-<_SAbG|v3 zxouF;)*q8Vuz;1`Mh~xR3i9J)7O=SZEm9y+;6p+pmFYbe>dUW+e+Z!MtLc7$Z;$=ncs6KfqrSNfitD+Sl5>;ALa;XhtDawXSb04XzcHOW3T&0* zzx0#xdL+Kq=w_Jb^E3eqegY5dLZf+t`~3(DSnxBH4u3Np&F);MhOxH?00BF*M4c;F zQjEgc47(8k%7aFiDS>zNte_Yj3jn`2iiaw#6?kgu^R@4rOeQERD}#oH1}H2n^hGS( zVWV&26T}|2s&r|A;NW10h=|ZdXlUpg^=&pA3=R&$=H@0`yLQbN7C;^M^2tr5r79LQ zE<4K4&xg*=PFeZ{TfB$}81T?qb_q)t7P5q*>z2FMoPN;KGFqkeZq*k(ZYTOG`^m1Tr%- zp}oBwZr{Ew(b(7sVPRp8eqv%`psK3MsSe`u<;xHi6=g)=FKsG~q%EJv1Wunm?I=r3 zOoWAn1t$XNHWL#QFg-mDYin!J*VhLjAt8=9YHe+mR(tsHAq)=>!_d$Wym;{frlzJK zE-uc9z_dUtCy*pC;|YNyM~+AWr%s)6O8`ePDJcnRYHDC*Wd+WhIRj?1S^k!ll?5|1 zGm>ChS{lU1$HVU3yCqo<3E&<(l@mx8_|FppII>8fzP{cq0fmbfFT(u%JRCZ7$WfM) zlOxNzyStt0qVgURz=MJ;PGFCx1WukjDG5|mR2UM#;JtV69!yS7N}pHd=t}+l{qXA5 zE4XsyigZWB?Ch*J1omNSp$_SDJhYtuC9jX z&!0MYT7@>uyV^M@? zbVmS3@X3=WG9@lAFY5wb0N*JoDRSbvbm@|GH!MTTq1#{zO-@dR(a}+gwpF9(%ES#kt49=$_k1A&YZ4dq@$z5iNH23r$VhX5)TW%V`YS zwzf8>8CZ*R=gvtII5BPG)^boFTDuicaZvBhjr_c=%IXUg|mQl7h$jr_2J zrC2W9+k;>`yX52Tu8ozKI|E*FG_WQ70#!WSKE z-H&R}&k*1{%AfM?8Un!g@}jL3BJKmT80FyU19>yHT~wKm|8xuvch4Cwa)X?*XDQ#v zE*rjYFXAjM<}_$o$6rP0gPmhEK2pHu2}xG;6KcvT6jGJ61v@izyPZDR4Y*B?L_|=M z+bNqh3A2=zh;_!7`xYgH)@ckUnZCnp6$x+mll+esJX53M9juiXCgNf}(m1cAn-;ig$wZ*xrYlD_nv=VIDsHpeDqIu`6?gzx%LUW@)zK_SFo}pY|HoU#MxL~sBhJP;@ ze~_dEj&p;iq^~Hrz&=y65Ww2_53jtL)!|YCyf`2RxGjMI;QF5?Lp@H^oF9`8$>rnl-JcHR5t&I6H`51~^URe-%Qh5@0|=`9WQ$foA4tt~AI z3W4aCm6Mxc)2DqxL#uEXz=@ax*7Z5}#`V?F!pMz@D&iV9OZ&yF^W#6H0OVXpXXjc? zPfxNNfC~F7;da-?T1X_4kujFF-Ua&l`lv)Wiu#o~yH-OYrJhk!E0|Zb@o;ewneB+J zUu0iOAE|h$1@I#&MbhCl!;tR8?_Q8d1Uf8CtrNqPP1s${bU?1j9bgz# zD=-4&4WoMBw^1lwSyH29JtL$1*4EZ%LqiuG?}&2c@%J9E>2@+z{Gtl$JK^t&bQ*S- zhaN{>xni9o1Azny3kyenneUDqD>3uc3h0VDiSu~s0vE#dJlZass)XTlGvy3qi3vN) z56Qdx+e@cW1^xqyA(y#vK5RDoK$*U|$<2o=KQ<%z7K6bwd)9l&5a~?jRP0GfNeEF8 z3kE00icOv>Hdj}x1p$-0pF@_GmMUOs-Yo`eYh$H!V@F5FVnHzX<87|Sf%5S1Fb`kU z#>5$a%)-mW>Xq@`_q-V;Df;Kl%}ea;?5@|A!>(~D@%JR`k&_0U3(p_p1(s8%T$|Rkby`Ki=(nwEF4%~U47?Zar6ai-4^qT1%-uwHMgov zj_2e^&b{sE={+4pY z_3Pq0r8V~@EPZ_PlX{O_O-eaxfJ1^_2GhYphYL;p!-~asLPy=DDDRmzK^2^>!5+Vb zMoar3#$Efg`Ch+f&c0yte4g)~jS;1dm)mrFY(%~2Z#6GPHL(A~``jprDq zuNMkckJ2eKO*)LT*V2H&LXPeu;-sgw9r)+0GVDMX?3R`((RvRrYF8)fetfHcCCpOOYa_3chaP30IPZ|VS z#mC1tw6%FdfBiMqD$wBL^P7cjB3iy)FH5Y!`fxk%|7FI1ZKB@&es@+#iSQFP%d>)j zAkGp8!{Eot!H@{p-1~AHI_lwLbSn7M!6$JwLk4sxbaL^QAqkdNZ{_7e*oO>r#7Zql z;pR_-f4dZ=D@H*kWjr%qzc8~SNt|A zWUAmQIF0tJ5|JwDPEBwVa3vXNkdU{YjQ*B!O%Rz9ap3nXhI}Hw!q`VU RC)I-^U9@wtC0hrj{0o^UKkfhk literal 0 HcmV?d00001 diff --git a/opensource/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/opensource/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..ab56d0050dbb28d50248f7399a1a44c575b15155 GIT binary patch literal 3485 zcmV;O4Px?%P)i>J^-77jlmAST1uc$PEjrq%5A7o$T=@a<9+1Z)@oB6Mq z*_9;oV}5-2SO8Xn%?o%riz&9iKPW&u!4^b4HlZG?DOS%T9EIiZBJfy1GT>W)c7XPT zcJy5`mGMLfzlS+Pgb}I~Mid|iFbuE|upV##Z~^cyz&C)Wfaip#^j#U1IY8Geq&jk_ zP9^EV&M?D%)H>cAPz3l4a2D_cz;puEb%yFLqI;1P0=yPb zMW|MwN~5e=o#k}T;nW7L_BB|+X8?WyDAf=+7jTUZqrXN$kn;CZYD-2#B*LPY+L?gg z0xA>&s?<^7z7kTYxL*aeEmNT?4Qs4L!8Zkb40xoXTIxdRp2$$Dsz=n;rW$=Q#He=& ze~$)S7dq%kH`gclfZ%^)o5u+Ka2R1WSOwjg0w@tW#MKN7b8z*<|1P1vNJ69?wuY8} z29m!nj(TP=h;1&TKB@IJ?DYMIicw!4 zBsheCYOkO}s~eE;nM%L*0p3!`px#D)6Tk^!q|`J(;Va!%{QsAL#QK^Ec46cnZ-6St zSCw>v24J0%U=#lRdjtw!Ny0us4Tao05`Ewe5u$c{QAe8v@5ovgqko}bm?Ba5%DfVz zgkDHz0iS056`lc$aELM(94gPqE~FRAB3SQx$|Es~{?qg}$`%1zP2qbr%72w=cV$@g z9Q{QK-De8j-5&7&|3t4@zzqsUeODFJ&4jip!c_J9$IXU{(xt}XDaLBei`0YfCuL24|oL+M5bXiDSTm8}fLyYA2+iW&gP*A}7^y$NT_Uy@;HER}5alwy# zwFNwSvr>l7Fj4Qofdko%8#mbN*RR?0=g-;p?b}(47A?XhI{u5G)-ArMpH#rVjiUdf zqodjS_3K%6b+r>upFU*+1`G%nJMbTJXuJkyeDl!=BdH%GVBNZPE(APz@`Uy8-#?tx z&<7;jC~w{m&Vt|THTo?U3mZOsI9s}OsoxkfWC*ibtv=mj*sx*psOJP6IdX)xY}qoL zyrFf9jcc0f2WP=4JqcL5cCE|k^c5>tu4Hz*-RFCyrlzuP-MX<39XiOfBFx-TJo6Ci zNeyxDr=3*0d)ZI~gn2xGn|=!=_r0@j1Ps-4#Y;)Rt5>hs)vH%oX=$k&Zr!@YDl01+ zDglTedvZs){{Ok2>%B?>%F4=Ea&j_@i;HtZ$BrFYd3m{40@Bmd+0daw*@zJ%RDt#& zHa6B{yP~3^SWZrk?7pIn(y9y5t5+`;6BDDyy79+acEBPqvsTaOS4qI_+qY$5tCh{o z&1HA*-t|ZT);D+VT$h=m#Im^0`P!Zxt=KqB>^aaUq;8LZ8lH&3(PJ$v@(NdTAS ze*w8#1XyVatwK)%P(Wgy=E{{Tx)P9)k-;uqy5zhDBWSI%W5$eOFJHcNmK{8Ja9tCC zvA}oKJRwd%KHz_P5`gaBmMvSHojG^zoTiHy2mylz4PuWUKbFg57SvM$4u7ktsBqRl zfByWsCICq;c)0e0NR)H=g&LK zZ{51pV-5(*&zLcTJ$v@dc@55p3l}c1)2C0fQ>RWjA87K# zjHJH=V73z%V0if{Q>J)r589taix$c1st)4-(FhlKA^NX>^&$XOjp(DJ0Yn3*2LU+RQE0sEvSrI;mlyLqt5&UYs+i-)kGn0< zW1XU7@bKY7Ik^`?FGx&9BnkuGg#bIHBK{aG0VsGJjX2ux+_~fI$dV;XKE?~;x`t|Fqd-v`xaS5g>L>o|CTWbDvd zkdKkxJRu~g1(_Z_dia#)^%bZhI(P2ua*sK4=4h73S%kmi>`^B2#Gnv~8#!{Ms*TW* z08xgMNXAiGD`NOV2insZ)&vw57Rq^lu^i4G4D`^$QL`Q6$B&oQ6<1H2Hf_?38$v(; z##h})#tyAbS@=Uzu#55{0l08-;J^X4X3ZMEfa=2&0jO4b_wLP(9Xlrfjs|)5?Aaa# zHFM@n*;peMFJ3IGBdxL%CQNXaoj!fK+iVJefb)PBBxB7jiqX=WCq?~1F9I;?#A)tt z9b?Ci^+x;P7xgE^9F65zHucL%LDM*Ffo9j$Z|I%*k^8p2)4)22;hvu4eb4Hzzj``dW* z`|*0jWN7=KFnQav|mMcAQjuKz!g zyg$`lRSjWc?mfNN2O{iz80P=GlPshCA%O1@YEDNjO#%WVU<1i8UJBs35j-gC0=Q?A z;J5Ja1A37RV*)v-ZK2dDu9E%QB*1S1J|)>DNI^U`C5jdha_P9G`KTJ-JfEu+cqRI) z`?Y343-V>nCuRATcAiTzYb@#T%pf~4iE+U;GcNEt`gfA75+$9Unq?s$78jE{3Y&LL zRpg#>xCQtg$tqsb@p1Q7vgWw6cbsrTQx(<90A(_TWYkm&!P65R6w$S@TCK}Xd*Ir4 zkV!G^`AL>S^gL+`rBiWwWlOa$XNc4^g_v&GMlwkybse%dZScKAXyNk|$C+v(O!(aB z9;996-0KPX8#Zi$J-~CX|BNsaMB#r)n;1f-_q9}!ofhv~Q3H-k4g1{zYVQy@Ny*Aq zB#SzI<&{M+%B=z47Pd4tRKqOx=^$-0Z!H+zI)B3wfAR+|wPnz@>p!db#tq5O=LWZs z`kX>CsJGW!aT1bfG~|Ih_A9CO#Wz(3pAFT5zON)#GmrY3RNul^s-w55VeM@;+e9H^ zHtK8;+k6v*k3m*@>f_r|n7!DIlkgT9r5wB6{(agFpf;MqJ*Oki+P^_xy3@LDD_UF+ z%eU(Bv#)W0bU+sR$1rl{2&rt*lL#l5wMNB*D}}cIjC}ko>QkI#fVXj5X$2^SvV!kA z9FES=ncqpS?+mU90~=AT62Fu~TR+7%(+0In>Pw7dp!d>qm6Sk13XY&Wn9BX2b9eyc zu8O{`t3Zwuue3{dp>6BIehnZ)n?-F;kPPx7Pc4-cBX~*pG_HIgqAzszH^ROnwO~gN zRaK)>pR&x?(3Vf2P5nvTv=h9X7$c}0UnA!@A!fi%=D{?74>~^^{{Mya<^-WPRiQ++ zm*2GN!@T_$cyDM!CmQkX1m8YRGWg4!wQPe!Afgpb^Y$Qw0vLh4&|m}bfw5oFu7YZv z^gQm*`3mdCd*HqBp5#_$QCqm+9Y)gi!4MHk{&bo!D{@gVfR3Px-^UROz#SQX0vwXw zJbCb%^k%m)Zk2gJxJTbzqcT{w1g{1B6zjq|N$9!4XiuXy#72VPwQK}GlaNG4FOwEF zvPoF^Ah<$62UHj6lLLN$LIwPg@IHPA=!RveY$56NT&k-L)!mZr6;JoJH8fQd2oYA& zxzVHsn$qG%I;n>Y5?&UK-fTjSfNc6Li^^uuwdqt>Q>r_f?q!X1!TUl;6s^L=QIw54 z4c;MrovJ_z{gy~&J^tq4+$y~a|z(4<7r=-3)dl@_s7skIc5YDv^uG|GrrszvQ< zi=m}6MJlKz)=?C(eAbRGe4*cW&iBnb=e>K+J@37H{^z`Ve=iYhZ3f~M=jGtw0HMv1 zSJ*c7aPn}m*Uy(`n>aX5d_W^jY{QwWxp;43FVS~>QhzS=BTsrHT@tbyf_P|l$uZr_ zMNh4leLQUeCvi)Ke^mR58Y0;h$%fBxZxG>A7vSPR8`~oIEE=l_>i%Y-nH@0g6)xXP zFQ+8_NncYw;Et|qu=Jcb2$+r>C;{=tn-*Z++5sW$+%%&1EWA;Hv+kgGRjR|Z}kx~Evzx`&q|-IPZx&>`REdZ=tZ`2Df$z0 zV)D)B1fk^xNu)O@*&WX4j|Og%gU~%dXB?N6s*iPhJWjS=L3!A~R;BH)>nYlW>DWh! zLus!af}}Tf>}h7{jxHq)^+v&OrnM$d#|57D@g(bDDkpt;hr)h~MB%|H{=WBuV0Paj*B}X>uo`7q!rl!=eqslZh#XG{K-U5K{x-yG zxgG;;v1#+v1QT*qu!3GbWJGX=DB74uSRDYGw=7&0E;R;{LmCWV#v=$)#?kzQP8Vq7xQe`04s3y*H}3ubEWK~_p}#TU z+Qv-BG2;lMn0;mYN>|X~zWfj8RIjz2sXFeh8o9f&SK>t-vo|pzI5r0rkG6!AjWz`q zJ~4=>Ng$JnY+bA)ZPW!GTPG(aqo}{3Q|&Pab}wGM_&)5l9rm!F&k(VwSt2k!8?>T`pI*{9Z zKmdx07<_iwfotLJi?T9M#}x;!VSg3pCal;BtMbS8QbzdK?dRc}bBUZS(!iin1Rjs4 zRaNzKRA~F6sHqqWi=5YC&#d#__C3AuW6esV^VetAiGp&M5^{6Tp!f~VQl#P$)>26B zxbE}J{EbD1912n!`75R{U$>;BL_1=VEkp);9!LXU`_?hwHsU8yparDiTcg1%GabZ| zk-l8r!TylGeB7{G{|Tpe%xVKmCt#+UN$WH2F1#cAOJ`R?utWpTQ_eJLdPE<&w8?l>P53kQ-? zQXD+tOmOJI{*RK8`Ui;5^gN9ukMeTF=Y<85L#6AU3x?=}6Ny7;^C9`7)~8yxzPT-# zqpFV6E^aZ`$n2*}oVVZlBTeps{o+6|#{K)>8??wTYc$`Hms|ppdO!kBf;lq4UG3gQ zqVgdl@ysQCNvMQwP|-Y#8zc_<%{b;(cqq<+W~k>=u)XMY3sQQD>fpUCV@dhtY$3Ci5_BA?k9Fl|a5zE6MS+#Wr-e$g;+_I@!1ivi;5ywm>Lk@PP!a|fT%!8$kH zHW|Z9Yx&q_1Uyd`)99{x7P_-+H*YnPl$3NyK-!3(fIG=+WnAUl7S&wr+ipjF@vy%&Ukk&zlAr`T1(}TwM;7tZ%mO?-obYKj}NOF(xj@%m6FgJFNKr?BD1 zS2@Wc$w8`w5<^fK;-8h{g3*98Q&?lQUccKXwY+4PzeG!m%d1cY4`m+V4!}(>FNtnz zoT8%Q+u1I1=JUOgeSxm7t_Q8>(>wfRgaD{m#E_(oQjxSV10WBgE+v>zRWbT3y~y42 z>1s#juZ0?>p6#hVU)Cn0M;PIWyM0hBACMN3g&!HTXUTjBYnDxlW-=8%6|xfBOd%O z82~0n6flMV2LnGTz;5zDHZa(1KpcAihlazPI*wG+HeFy)8^U;0A@yN^J@k3pA?0wt z+}GG)%A`IG$WqXM7i3x&T)+DL}Kk+{ zKQq8}_n#u8dftdqY~p*D!S9o5F$>Br{(j2!5^{G2VXbHPq*9)l8%<1a${eJ{ynGzx z&SD|VOF#1~Dx9SB*;527_U7(*;&Tf|=)-2?ybL!z^gukf$Gz9C0mkTpsDA0mTVF1x zUqhJhQEE~tQD$SB=qa5Cw<`2Y&Qxb!Deg5uST~FMyE@XhR>xU zS7DM7Zh2>yiW@ATMRS{Oy5!oWKd#0*OSK~F1V!F~&5qUDu6gKdEd6dj^uh2^E!9#; zw{Y=xnwe6XVtV$skB-r!;qsiLq_55I0#B>wK61)%7|s0ZrrHha1ZbsHoqA^phv5G4 dlPW#^@j%ImW@nCh5X-?HXp}YbrKwxozX2SulM4U< literal 0 HcmV?d00001 diff --git a/opensource/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/opensource/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..ccaae6f08851ec59099eaa35ab0370bd52a2be20 GIT binary patch literal 5412 zcmV+<72E2GP)T1ba%6hFrm9A^at!ORa?&1oy zvL5SNA}E50x}buhcu^==h#;4Uh#(k_B)#vO`JZ{|>B;nDX8M}fRevRfKL7Xi|9}7S zUMq^+4!|k}CRJ3GI_@03;k(5>O5Ujjs{FEe@lY;^%5+%6C1}0C^ zwae*VB$#ZrarY!{a4jR4%wzH~Sq!~F<`xA~LsCmp7eV-b{J1kZ7E6?Bx|ex$Z`qoA z_UiylZYEkA48Tw(3z+QC8NeC=C>$V(vOaYJxcD`-`|O~59in*wq*Y4rrd2Ko?KCD^ zbOw4!OFmTi`Zdd#po_Tq&kT zVH1JVp3HhyB*y1OcjZe7oJW=X3PH)}Lout2WmMLfz%W%9Dpuy!N?(ey}-TkT~`(%c&A zbE2201lua4%Swz(`+!L+w~=eVkQn@m9`v*v__zz#={6=E-C~LO19WGyUxLRp5JA_K zP6rV;q1~7qlHf5NL_aFQUT#OaGmd)nx$KkRvD6Yd&YkfvvIUc^5=Kn7r^7OtLwszO_$ka+h(mFRczWS%US+7O@d1f zo_&&y{P(Nv7Mb~kMB!m2c^kl-(x8T!wVmxkszu(jPP#6~xUvlbcr4{ZytVQ#5f0W>HJcy>5ZH4%PNyWtI zdDFKX6z4VSRPi1HD950 znY2ExvqGx`?~+jKGelY**IJ@W6KRFG^?6m6D_mRVcr#H}NaQ{;Y3T0c;*dmGA&$yL z()zgG3dKDl<9VMpGVhZVc_B=f?iA0yA-)Xl-O~CbjTL%`#ysYpA&=k>Kb8OrN@ixJ z+PZaXwQbwBYKs;vRIk_THUNFWBwZN)n7Pj&NuGmp{`u#tFTC)Ax^d%1^`nnIQr~#v z4Ry+tDQZ?$mfOhmNJW1!_o!P{0is8x^-C>mx88c?M%?kaU z#y%xh_ti|PpWl_#TjZ{Dn~S+hpH>#n=h+}vC@mNtH2a&}WP z(>(f`Xa#w;D}W##k4MeO$Ozwq5eF1Iw-1TWI$`|1Pbk9Bas6|CZ5oOeH;fw|MmE zQMIC?B5JTqoH$Wkym+zt_19mkCr+G*+8w|B_S}^q#6YfcD&!J@`Y6Z zLP2!JiWN}}_tLEqD8@ex_&!-L=G$>Gj{pFWmV&Zcq0f>+NOu~dA0HqRvUT9SY z+A08b?b7|wj~qEt-L-31Tmk}<0K~`s8Nx)WQ4R#wy*$|f zG=BVeUGg1HkbxdOdbFW?DlaeBt>ildKxnqlG#bkLY3C1<&4t~0=beG*m3l=P}8bCXg z;8MZ;8pF~wX-{b_}Cw3(mNUe^=+=HTBTii<&~xb2>j=zmtG1v z*ZleO4QGOpLDwFS1Y)O7oty%olT7*(HvEy2h-uUVa>^`FIZh^p#7%{?h0D+Hf z-MTg8T$f#Tnc;Jc9XmFJ10e-~zdHm#C}#{bk_+Mh>S-l_Dl041Lx&DEP}s3!#|(3h zcmfE6jBzqGNzrY{$9Z9CAA9UE^_pw0QOAuNrz@|& z{<_rwswNkf7O9}h0fdjl9kVJ58#881h{Es&<4rajK-y>!t_b0Gw3YBSZQAJaI)~K4 z5Kx^H4S>)KeaI>Rp~qOOFyvv37A@liAfywJuMy92`0(Mt`J3`wm?(hRtPTVaK4DR1 z1VFqKv7J@TWRRzxdMZR=FTVJqp`=?p0d&I+H-wyH+qP{HGslfdj~+eLB})T3C9dT9}7+!uL*Jv7()0 z!h{JS%~mKQ80A0l0uX?JT#a}<3Zi290n>SU_Ux&yT)FbMsB!JuwSg%=jh!0_L~WV^ z^5KUcs)dDxb_(Ogc8)xw075HAE|VwC1`y0Y+B~(&LaReGeaG2)REcH3;4sV(MJNgkb=oALwc`5{Y~k1YFeg>C;0Lg(*6ST%wt= zfRYEY#d@lvz{J5E>Eis!J}pME9Q1ruy^GKZhK%fB*iFnwFR! z!s;EEaS<-6(`(oV&x%yzvj{+9A$0^{BHajp5a(wxDPi($qTQGs0d(=j7l&}0aDvDT zGcy2!a4rbeXwsxfA>|o>5u6V$(}n=zW4@PiKv|STj5s^hM>`h~`%Wm4wj+QL!Z+ro z#D3;kvu5c=PTKAQy<#fD{<5+%yH?}Hsm(>?z6`CKTW836>cd;k6Shmc*7 zD6e0?K9CHIlpTk4!J?enf-F)MD4^Q7or910V!}c*qf?u`gf_(e*IB`V;1KgMp|JK!Ur$Pega~=FJOODWee~z^0w$fms?z1sFL8z^pwU8Sw9R%#r{~ zS^zDgV5+$izdArWocb;^aW|?TFW?s;V-a zhe<-~gVi`X(%7ddP5g!`59jm9lufm&M_P*M0GdT(-ZI+oUC)g*BU6Uiss|*9To0Zf znDnDD&sJjm>?L<&`Ba^M+KM(;DW*`gQc=z&JzBkoDQbes-m?%uNLMX)Gcu zq@2N}O8s(p(h@nxbyX8_?vSX+t1&CYd!|vP@8b1(XG#D$3qWklo=}1l2HOig&tAoZ zjawnUju4i>$C`?3WmM*jsEb*jcWKN@mHK5{OtC(Cgt~}G7*_GzD?wy?@L;*R^J&al zn?al-5$YW7%6vZG4T2k!?#zC?r{KalGxrLjvj%XNiwWf_zkLL#agyTQ3amQ!Y_ z?U8M+Bi=KgTvsO=vkbFcS7Wa1EV?m#)#XL$%53MlR?ygVqOr2fb%k?f+2m6(6$Bq^ zc}IfCHsE8My051D_xhK72;)%TKi?J}S|%IglO(Jo z@T_M#?gL$u&I@P^QtU>WjYeiXyf35&>c$=r(b<1>y5giXy`vD|S;lnRlPphX(zzI} zI|e-AKvckqHG9x1uh;vSpa2P?rt_T|rpv!Vr$m@L36{sP;L!t7Ga_@PWMV6QKHu+^ zhTkQVF3p(B;F=~lYC>Mc3UOV#F72rkeO`5}$a$FMZh(!4~)73oUk`?qKwj-Tp zx)D5j$wp*DTTtx`gs%t8Fmq{R+3)XZ{CsVa14ksv8<>v9lgsKsx@%3k%pl!*6_;;f zM`Lp$eC_FN0+4#LH|)<$S$i}7?&{>`54aso~j37)-#bde*iPITNTi=vA{Qesy!QM~HK6J=_agoutj#9&4AYSd6D&>-de#zp_7D>ApU_$v{L_>&TxQA6sSb&5{{!);~sIZaLKrL%I9!bRZh=XM928e5o23$r(du+Jt97Int?up&o_3R$c zp?l?Sisy5<#o@q7m%xcXkkU!HFzaU#vFtz@ZP6-WfO;|4Fq{qC9Zc}mnRn1=#eN=5 zpv%I!agBFyO(s*=J&Yhr(^F$@y3xJ2rDtg&Esl9WN~N@YbBaugh;Vllz{D-nZ-8QQ zF46*QU~gwKo5{b}ek+yWD*RiO;75i*&aqZ9KD2j)!4koj3PMjNk z;B>8?bZu^FIA}W3y%y2E^Y5{zO5m6aBp*d2+$!Y=U_x!EloFE_!dqXd1FBwRAoXOPv<6aN3{bWExM6cH>EFi8R`Ex5cIpoyZlkM=xCxBVa1XDkzFa~R|R O0000w literal 0 HcmV?d00001 diff --git a/opensource/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/opensource/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..57ce330d48d331a92c5a0c8dbdf3dee6e139c35c GIT binary patch literal 3542 zcmb7H2{_bWzyHk`43afk2xBKB%vi$^SwljFM%l_PjBPAqEsU~`CHs;slte?8wAhyt z8rjN_A*QBeAMgC%`@i?O@AKUIKKGvIobUHJ%jcZ$_pG0jWMyH*$$pX@002%CV*_i3 z?*1!5Ss2oK#+#K$w>^og!iHNU#-uw4X|Sf5*$CWZ8jNk)0N24T zl&2eTCL-GmIGt4un7Qy*KFUWQdaOZ`7xe?h?AT4H z#b@+h^mNI-OIG@sS>Eav@5Y~n5Jw1Rl*<_0laDfgKI2EaLGmEo@@Nr?&oF2>h#F4d z%s+K`oNW0@dt`q*bqSdQ>cdSTZaETd{QJAqDP(&9EM&2ZJ$r?~Uj}()G7Oh3zQpRc@NPRD ziVfqPT{q}-msM(vFP9+ibX@WIQf z-;6njNw8zFOA*)DKBl5329Ql~3WRAHc-l_LVHDvy6bR$J@xblHvVBFHUUoRjMS+zY zph@7!ll;TaYh~E1DtXPvc8`|JM>wAAV~&CgH|<*l)@#Z=(^Jwu{$+{*7g*TJ_d;Ic zLmj;h1EY$Va~p^PvLMf(ARRW4=qPw-%&{&t8Mt$-3sRy`p_s{+{$cIm#ZtLu>lsAKC!oC$UEYA~HkuKE449Fil zc1%NK(g1d|D4M>L)jvNohI4XrsPmv_1K4y`SwbOJ#j za6WP?FF*ez1ce)-Ox8P0!wunAn_NvS9$d?aSp8VD%y0<7A#mobpi_j>8p9?{FaQ<> zL;;2(n*g#ZpV~tjNgddb3&yfNui{vF08sLITw~|%Kmlg*nmf5K{e;rZp7^Y~=9`c- z+3|safw4Y*4|ud9N$9-JeI}5mF;1~_y9a+<3APyA7QF5`7r8YX%;rC&)SK%zNXQdJ zhl(M7Ee{Wd3skqYO*^;|ND_Ibk-L_eoLE(FdDWk<`$0zP=rslUZ@W3}89DyE}$y_`a}~zH~trY9@3swRHFJTVY7i z4yCG?ro5EGnC5JNPuc=O?KVhaLSs-_>RNIY2OnI{aH*D`dmF#ha92 zSIwof-7VjSU9G7as9fp;@7a+e6uHZv`D)Y z9e3_qLEunllH=N z`mNT_ICaj1;g$Cgk-NX~pVQSwxl5psmAMY=K>L>;UtWugcjqI$3T8z}$6)4Kr&PWD zPnQRUx%%S)NhuU{uA@*e;M+*y+|eVKxKV8vV6Fk}^HF>CS6GA})m!T9ya~fkIR;*= z4(2W~hN#oG7YhYxHAuW60#J$7)U!x=NeDep1KGd1O zQA~q$AIBzRIT~AGseq&ejz66UukhAA?rtaH1a^$tSsI~?PSjnwHGz(d>}Va!_Pmmm z;}k_Ii2OrBCv82s`P!Pc5XS{WDM9^bF;g0f72bNs9~DNh{a|#i;WU}272?}o5u$Ay z;aX}gFxyGrUCXN2je^LCf?QdaVpvc6qx&BEw$;35PSYWpW7^P@>Fbx8wEeDWbS~c4 zdMRh^8Dn}CFgY8R^co{pDN$0+oU81X{7gHq--Alm#b7Y*Kfk^mp|L|`ctEb)lxbJf zHlV5~-19EGsAbh>-}YhB+da$S8qaO9yR_OQjed>}^r4 zKx9g@$GB8g0p*cydl1FhLj6m;6^Cn7j*Km)I?GXF@no(zDu&IUzVl5>@oO7qO+Q2_ zKmi9`SuOXQ@0@k$fCs)LD|>#F`Srm}G0gJ$SxH_>aA6cCg zdd$TC0%#go%je1=`tR)5-{(iI{-_)xO%T4*)AlRUbY2G8X0)3&4W5^}Tvp>AC((ad z0WXG%;Sj~rU+g}8sOI;_v_i`o*x6}-Ck=hVpVr>(V)|qTC%*ZdCO?6*nKr)iUi0=? zNmj9%XhJY0iVJGh4esN=QA@ETO-VgGzy!$Rg0wQID?O+DUVb?cqC6X1OK!+WT(ID2 zvY%=7o6lmJ?>&tj+6kjIoblrFFt2q`n<)sbSsJv3OMXaAr*|X@w#MMD71H`nEMl zt*YnRMB%`E3qvZS<~#83?=Q?PUJQBs;2iDVuRZep;te6!aBEX(*KGCc!kI%bqMSK% zx*58=^!QPbra2C~L6ojjZERbW^Aj$4oXWVu^;M^~jUW$^UIG&((6OiBKIyJ1``ZD- z_8q&cb#qwGJe$UmT&35KopQ7UO~3r7dpq z9<__>I$9wisDB;W^d1i8PXm-!G7tP|F%h^zFOD8_$d6*klv8wvnlv@~>fJ2Op*)q| zkCT&$bm2aWG1x`r#doQ{CXe+rOH$3+r!zDq{yI|1~B1CHXiha;LfoBiQ4IB0r z!`XwL3Eev3enL{(c_&-DrmIhUxal=baZqoMs$jnesx(R&j&UDm?oZlOH4-2>o~Y{A zH4R)*AFeT;JIE{sH|bAZ`{s3O0Y>6wTtEUXz_=s-E1 zCGr;YJ!zA%>$ehj*K8)wMmDEDE%4TOt1MueaT&oHJ6k9}YNT5|4!UKL}Y-Xj7_@RV&+19w9@wXt3kS1@7H0ae6_`a7Dwh}Uy zuMQ^^<)7lrmq+GnEhP~4Yl>L1-43ySB^Dw+*PEa8SAk4p7m(C4;zHMwZ9OqVhWy5o zAi8Z}ykcWlsWVsmyQ1{>kH+ZAWdj)HzSEEXV$=9g6cl&Eg8%y0TQKGJS Z7Uy@=31L;mvF!jOm>60ZH0a-m{Rdy^WRCy< literal 0 HcmV?d00001 diff --git a/opensource/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/opensource/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..00b303dee22e559ba9ff5c67f9ea17e233509feb GIT binary patch literal 7629 zcmV;;9WvsHP)U5%nXwl7$%U1nPC87n1MwRRwW#Bc#Ej(`VjD`EBN$b_84#lMU3m}A}i*A zy2`^RiY!mU!+@fK3W6B~5kU}8$zkfN={w|%)#RngrCOJqOP)Hl=LtZDK z2!zGKN5V$*a7Yfawh(A5P$keqps&C&0s{q(Ck&*2>r3CMqTjTjbA&4n76l)18Fe#l zzSCTwQecR{2!U||j|t2YSR}AgV4c8Lfn5T71r7)_3h0DJ`nSFGovrknm2{3-bk1>f zjUjZ+<~H}08OPa(k0d9(B#-gP7U(K4RNyZHF9<9X*hmD{UBUsn<}$hm+{+&XYUv)0 zhZHf3fe+h}KHcQsL8^Zjm`tPb78_#9k$k@e4ulE2-#6M^e+%8?WV+{Ci)Z4Qfsfxt zM&n4IE6|_Jjd=o_EXW2z?lTax(S^~!(I7N=4`$!pM9(meo+&qs=jS5@oY_o+qXb1RSL zMEn&dqkSrq-cAh0wpl@K*xWE9u(RwzJ3mbk!k+{_Wa7t62x!Z54Qk5m$;KSunf4IPELuc`b zA3xJb{(UvIJ(I0i~gbH4l>vziml0m0sfF#<{Y!b!Ric#!xKEh@SOgK0zgXaZ3{f5?m_MNQ)hhz6SOk%gAG;!a z$l=YT370Mr=)xihFS*rQU=`c@Nf#wT;;b(x&FsURg9U!12HVR7N*c(%MBkpkVbVj% zFKf(C7ue3^PkIrckB2HjjWO0fm`F|&{P2;LgMtD`EKMM@-y9lH$hph>hu-(sy z&>z6+zhcd&n{cHmn8M^|I26GX1}^#L2+$3m&2~Q{N`GJ^aVg*@(+4+<*L<$!(Vr3J zEbzV7^b!wW_ycK54EI?kKO;sWcuDaCJZ%cesLwfu7%z7w!)bm%{&|$wX8~#YlnQ*o zqdz0YvjA$x+Id+7@(m*QalD@qr~JS_H1n4oddBoQQaiY;k)uA0SVx6+XiaNKJQWp^ zG1_!0W@6Ka5l>TSjxyk;dCBxSlO11%OAhe}ub4t|Rez4aA|Cx2F3keiG%a5{bXSpt zWqff17x3uMaCsK^FXCQSqDTKct!Xt6M}8P?Mua}qnpPJktXPgme@3Ebfw9EFK%z$f zY=L*#_Gcu>7g(zJ03Hcw`nBNnCU-JDjGZqhI6EpON5MU>-TbuH^-!?cZNJ z_+o0#`Z5xg9mcjQC%73A${_rmBR>p}M1-DFcg(uWNjrr&z&t@=Ip+@nvNI8h%lVuaqYV@GZu zb7yz~mE@$yt2{!E{&V2xZ`ZeFLC9SS8{tD?hL=DHEqNeb2CV)>UV7Z#1|J_a6woC zc<{joxxLKIph594g90RiD;4jXqd#%Lg%@5JmH;MCp3LoaXf#OjaXfJ~5Nq=XS+<#M z{=^r92M-QQ0M}e|4Y!xM88k1}(I9F5V0W&yUKK!YZm!;~TQ}EGTU)E=<>kfvIa;@F ztuI@)%$EFHwrtV6ckj-mHy6NqfgZ6mgQWR0%*5vM4Epu!r>|SL&NY1g`RDpE#~c%v zFz87qousc_yVjEYmtA%lhe56V0N_I6Y}B?G96-tZQHi#Lxv2A z+w&9@6zHd%a*BS#4L9g_+;N9~%PqI)=bUqnURqkpWDhNZDa6^#Xe&fzfe{wI%=NPU zpKJnPL}dnHgND3VltyF{s=RFfvj7+-en4F;qCo+|3Ci@+$O2#ic)U0cT>T?)H!B+Z zU!LGvSdj__~MH%^u2rc zvH<*P2E9bw&2*X%kOIJ}`?vfofTc^9>Xnt1dU0`aL_ic~)22-^6F_EWrk<0N6BXhW z{zUjS+Iwr(teKYtfZgMYqY;2~AliA|0S+aA4?g%nZ_%QKqjUcFNj-8<^Gf`|$W3-y5m2kO^ed#(P&6Hn;Vr%#XS-hkV0zujw&a2Lgob>slU zhB}&xjC|_@ctHSZX=(bKZ@w9o-?4i2YQ0OBE>X2p*REZIL`9-at~H1i=FXk#B_9CW z^q)i=&T`@a!+vU}`Q&v+UKapNFf3+?hG3WhkRWr?J}^;6jT)tY`Q?|Eeoc}HK;`J4 ziNiUOI>J%_z!hF6zaIpEEH0e!Xb5)WjW;@Kn?N9-Uw!q}`mewK8rR5}M+Bf7DnqlK z3P397UO&|TdszS=;6o2R6g9zm_U!4XZLYfNDt*U}9dQp0dq@D1tB(?gn>i5xe1PTx z&-?XMpGg z(5_v(paZ;c;X-}(?AeyULU^lZc=E|7BffuxB{B1aa(3~IFTeb9aQBJvfS_AC5Sxbj1}{_(=d?Qz2)wNCc2ZiouVnwS!wA`c?q%zyH4H z1#setCk97)vun+tKi^T7_3pdxwrrDczx_5iLqvK`p9uh4CiWsOhwTVU0c6wCvWP{+vJ58UI;EDO>zNj7U-vh^urc_ z41r=7xUGH=0CrQe>KHhnNd0?N0EiOJn>Ww&I(zo)(fjxB@94ZJPq1o}`|i6hn4L~? z0bmQCx9ON{bX=Y9S4*B#A@J`=!h zffI?#Igtn;hko>Xt?3OMo)>@}iJp7zxnLO5s{(*MZe{*pr#PqQ9X@=x?f#+-4GqaI z0PGxn3UN6%TmhsT1mKI;AYFIeb(S47Wy+LbIf54jfbGsd`|LBzYaof{^xTN@*cITx zG;H6#eKHGRuR#Fm;R+y|Qpf|yj`F+!?z-zP%Z|D2w%Z)pyKV>oi*B}V-D(-Va^k3J z%4T6tJ@u4jbO|%eqD6~>&(Wq$n_#FI(K0(=n-(7l0OtG2#O0hw1du~N>gN|Hc-pjS zmYs}^q@3m!;w=Ew=35a8r3cZTzi;2Z`p-ZAY+DL}_Yf>!zC2jwU{|UEtX#R$Z%**> z#N}Kk0_Y>K#Sa2_>#es;I|U`cc+-kk1%N1z+4(Um#NzyzL6Ap?wiMf^)u7w7rrn~O zJ2io@x5^J*7eIb~zU9dD(@#GIw@vh-0AO}thB7-p7V*dGbx4TzAna+CX-;ARY$7v0 zViQOT0Bb5&dtCsCqks3^ccz_!QebR2>qP-zjj$D&VA92M0Xz!vu%ooq^CYPN)=@Gy zVgWRxA9WB|;dKGnIg&6joH~;66aZrTPe1*%C7I4Y|NNM@6Cz>t_4WGHsZ;f@zy3Pr zOCvoffUg9)5|^_h5rA9_QzEd~%K`wo$BrFq*_lr2=-m(i!jD$=Km3-es;ZCz>eQ)| zBXMJ4%|#boWa}IwaegZooHq_St8H6`f9!Sva><&;Y<*NE0SZ2s+P3Sgl#`@Pe2U;&LEd0mv=U^GE>G zyet58<~QGbW7&~MAAPhZ1ptDXofo4l@)lDOSAVICcQ9fP9XeEBv0{bgb0Pv3&f97rBA8cp z8~!43mIS2`lB>59hYN|*5eWd(Mhk)SG>>kK)wy%$pgCaH8E}dR3>XmgDzkV900nMV zF?noJD4lzZ#P{BNZ^+OkWa)?4ohOP2~0FFHJ$Y3!Rq7mc9jkDbE1|uTs zR&KiKCeuhpxOn7|M=W08Zes^g2DF3vdPjNo=Aq1p|ivfCE%fd?K4dwsAE1Ry#3fk1oW zaM(#ORRD5dpi-@Ai#ZlRj0Av4kJD>_$aqV9xaA%2lk7~NRjXF%ty;B8ru6`lqtl7I zg^?t|jLEP<+F0ls#j$8)0XP=`{1VvgPR_r0@#5g9kGh_4b0nxKvI-N!%sThnbCYR7 zjLOeR#NEP3l3-@J03;Gl_rl7sWDo!nOz=%YnK{N$A4En5INY!9I5sJ31o|5U#Cn zB`{>1&47JIoo=-P=iBPMPG*!i+5c0Cv&F>Wbj`u5Qe`Bl1$ondqHQJ;W(L^_05N;F z?!#(}@JI!~h>Q7yS>RIQY_XFFkTDWeKqaNU672`D02rYJAe?{L|NV)xjv_!tM>v-% z>hQYM87u%6fX4+em+b$R#NABI;hW;6Bb-g)qzWY;kdVSS762ne0i2BgSI`WQ?Zg2# zI>K4xO_ym+JMI3Ms9Asozz8w=Z=sx38TqbRP90#QACO0i>L~EPi3*3Z02p@RPa%Ls z;oenC<}lQBK&y|akPXun;FgMulfNpA5cMNP(t1s!vbK~ zm_I*>&r^w`Es3)k(F#D#2NY0oQ73_!i4XwZwvM;Gy#4muhXJ+tZrul#VVd0o${Pj` z9?Yani~tsp`P15I1!y?o&{C?gzRV;65qF@HvZSQM6eu%x>pqadwYm?d8CFJA$uHn$ z;$~U2;ZM78s5FD>1wQxkZV`->8vU_-=aGu}lN~Gb$7lxS(?wtgJ;(xJcw`26hB#S4 z9F3XzV`c`mCQlphUfRYYU?h0-$Kv;4#L3cFn?GjJpdwNl-oX7b-ON7j5QfXxM*?_* zxL85lY!++t$1EvUMoF>ZNL@BV{wc z?~Bh`;$q8KCBMR{4a%ogVwD1yBqAirNcquUr{c^?5L)U?531HOKH*EfNq4r@$QnKUD{%AN)J@p&C_uRU=wZqBcjGc4aAgB!qW zXy)@Qz;J2wf0su8_Qb*F@pOLeGQ&k=e<2KYzUGs+$}@az`|lyYvx+Q`qIhI}ovagp zFEE`2z=*Q|{zY8tXj1p@ngFCTtj+?RDLcH8MZky?`8SKtp~N+H^v`sY{Kk-|e3ux) zQP@iTubKz;5oILhf|sepvCi7TcYkHMJ^GvZ0)>VSU@=Liyf)V4A2fVMkH*u^j zan5zy-)t6;5uujKEHF}Q+Mtw8Ax11rA8_eX;#MVbt$8AB|4?~>GEykQqEE5_7*U!& zLGGldr@u&?sv?fL8ut%3B9u!tLWs>)3p5CP%DDkXlrjG=#b;mQR0rZ%L@&N}YZj0- zLaj-uwPXtI;+cTq(Dd0YK1UOms)<{&=EoDGzgeYd5tXK*QnZ_xzSNat92H`OW%{7l zO>pNi%J|k0r;6fT@fk~pxC7ldUas*bn?ei+rOtK4p^jd1e8X{wv4u*hVhRlrSj!?{ zgh~GI#plVyohsr|ftMU#JBL^`g(@g?1ycz18#~z_V1%&ycL}asMW#;|?ch>JY5JsV z9zM)Wp|+&tBgBK`WmU6_UkrJp`t4sRYAV>HzW! z%pi_{E1i_6kLOJvyJ9(+AgiKe1{|At92sJ$$u2?ux3#9X5E&C*2`P4qr747@MGMs* z2m}I0iO(hM6tmMysbUTNB#|>@6RKrw+C^eHE|@{4-%UIC5{nj0etXAQ zhDbY5T}Yj1$U0#|o&a0YFwOF8;H^;~Sd@{GaWeW$YbtK5rM{G*P8scC^4o|&Cd=dw z6G7)mG-|yVSsoINyh9mvcs5Avs4qjDJ4D}1C3{~wyVcZ(rPQY>5$cRpvJ4G|x)AIE zStTPx5P=wBE)Wg=sUf}2vw^##{yt^YUn%RrzlU~k_vR8M!^`B4plky5ASjB$ z9+2{NOx?*O>Lt)uj5tpz4l=JYb8+>SbaVyrr=h%^?EO0Gqe|+tR@8^t$ynath6qp* zn5RUD;7HX-zkp_g3q?mPHJg?haWi*-kFm}heW2L;HHx!aOnujk$?seQYJ{kWY)NSn z9ihwyeXy1k6@~^UnS~G=;_p{R{SBh6Cu0UyM*Slclc$vWN{#d|`J)VzO242$F^M{< zQ6icRP8WZlAv$S0&j_&_@rn4`1zthDmNM$sQ6F?tOr8S8-seb99Q=YjiY1p(grSP; z&U%^+8bl}khv=*Yw5u@B2+@uBdj%GXHoX{aOQU{0wRsiwK^gT)9^3owm_#yEs?G*- z7rI_j2uQ|=&U;T_9}6L3;_nk!EZXo&h4{UwjcW{}z6^1)$>Uj3m%agJ zh2A8DHxaea4lmO^ps|_Ls77{>a3tdQHkdnIwS%uGDm9Gy9O6viYyi8nB}E@P(A3J+);OwQV`Ibpen1i4}n{Tq+|) zB{Vg6q)cRY6CwOTjBpQ&4*y)hI0NiYLNPQNO&lWqet|DUTReg`F%Z8OwOuu}WgBW+ z8R0R8H<|nio(+s6^Q5T(mtsuc{EY(AN+hS($n;;qu_pTd=k7u1N zp8YzsfkOOxC7D&N*!itZ%pD%}JsK&>qA*;(;vckCW(2uAU4!B$U{*L)5aKpLjCmGf zP#s0D38K+(Z@LBP=ZR;Wh;Xq&dW`f9ij6Nv{I&}5^QldQy0-7)}>N`HLXTEP=0-;yXJ*xG_^0M|L~H@1T31C7xxxc&0PRsa1(DYj?URHh!Bh zh|lEru@Fkgkwg5tl0tgjXaqb=48lY>4k>?uzY8+WB~twoAyH!iTVdgz=-%ccQ!6l9 z-1|WJ90t-$GpB~ypgpxo2@^m0g^)*a>z0(jk>-({6}l@Xl2H)7AqX*=j>M=c@Enpv za3m2~G8E>;1-J%X3)ftTd!P}16z&aWAu8d?u9qfGH)XV!W=Lhb_OwYRm>#SXQ)?3);@xGzbCWlQh^CrvmpLHoo0;1DBY&fd5yq2)YAxTA|h|5 zKX0J_TSMPnPQQIaoa-f=6Fv&A0dq!N^B9Hf4a$A>qMCeZ-gGsPzMWyTm*Gt5+%oY~ zKnNKYvqCZ1zvarz(3u>=;7dM;RJ}+PMl=}En@HLhBEhOQLEh5@&cv3G*bIzt7X904 z_zu2H#b$jJyS>4L>@spwuN}G z4PC1Ko*KAGqAV+vfcxlsTG3oh;Rt#o>kTXOsnHBj&z#?)i zTM{u#h@_=NSSb{V3WEA?@_VK9+Y&lgOFDNET`QliovV!SW`t+b^AQs<7zJS_ifkfd zjxuB9DIzIQW)L&T|Ib%`*IXfa4qd~H>}G^#((~b(D2&38;s_`r$x_5(2K77YZ!;~3 v&SdAqbAqrEN4kYr%t)PXLvkj343GZ@%tSW6Y41t+00000NkvXXu0mjftYAbE literal 0 HcmV?d00001 diff --git a/opensource/src/main/res/navigation/nav_home_children.xml b/opensource/src/main/res/navigation/nav_home_children.xml new file mode 100644 index 0000000..92e715c --- /dev/null +++ b/opensource/src/main/res/navigation/nav_home_children.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/opensource/src/main/res/navigation/nav_main.xml b/opensource/src/main/res/navigation/nav_main.xml new file mode 100644 index 0000000..3893f2f --- /dev/null +++ b/opensource/src/main/res/navigation/nav_main.xml @@ -0,0 +1,286 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + + + + + + \ No newline at end of file diff --git a/opensource/src/main/res/values/strings.xml b/opensource/src/main/res/values/strings.xml new file mode 100644 index 0000000..b63280b --- /dev/null +++ b/opensource/src/main/res/values/strings.xml @@ -0,0 +1,55 @@ + + DiiaOS + Інші сповіщення + + Завантажити сертифікат (PDF) + Перекласти українською + Перекласти англійською + Оцінити документ + + Немає інтернету. Перевірте з’єднання та спробуйте ще раз + Немає доступу до реєстру + QR-код не зареєстровано в Дії + Невідомий QR-код + "Помилка при валідації сертифіката" + Сертифікат недійсний. Його було відкликано. + Час сесії валідації QR-коду вичерпано або він недійсний + Не вдалось завантажити документ для перевірки + Обробка + Перевірка документів + Спробувати завантажити документ ще раз + Documents verification + Перевірка документів + Документ знайдено + Документ не знайдено 😔 + Помилка валідації + Документа\nз таким\nQR-кодом\nне існує + + Відсутнє фото в реєстрі 😔 + There is no photo in the registry 😔 + Запишіться в електронну чергу, щоб додати або оновити фото. + Register in the electronic queue to add or update your photo. + До електронної черги + Electronic queue + Документ старого зразка 😔 + You have an old document 😔 + Запишіться в електронну чергу, щоб його оновити та додати до застосунку. + Register in the electronic queue to update document. It will appear in the application afterward. + Знайти адресу + Open Driver\‘s Account + Ваш документ потребує\nверифікації 😔 + Verification required for your document 😔 + Будь ласка, зверніться до найближчого сервісного центру МВС. + Access your Driver‘s Account and complete the online form. + Термін дії посвідчення закінчився 😔 + Ви можете отримати нове водійське посвідчення і видалити цей документ. + + Служба підтримки + Налаштування + Змінити код для входу + Змінити порядок документів + Біометрична авторизація + Налаштувати сповіщення + Розповісти друзям + Дія хоче відкрити «%s» + \ No newline at end of file diff --git a/opensource/src/main/res/xml/file_paths.xml b/opensource/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..82a4976 --- /dev/null +++ b/opensource/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/opensource/version.properties b/opensource/version.properties new file mode 100644 index 0000000..2bdde28 --- /dev/null +++ b/opensource/version.properties @@ -0,0 +1,2 @@ +VERSION_CODE=1 +VERSION_NAME=4.3.0.1433 \ No newline at end of file diff --git a/pin/.gitignore b/pin/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/pin/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/pin/README.md b/pin/README.md new file mode 100644 index 0000000..b3cbb00 --- /dev/null +++ b/pin/README.md @@ -0,0 +1,47 @@ +# Description + +This is module responsible for pin code input, creation and changing. + +# How to install + +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':pin') +``` + +2. Module requires next modules to work + +```groovy +implementation project(':core') +implementation project(':ui_base') +implementation project(':diia_storage') +``` + +3. Add next nav graphs to main navigation graph + +```xml + + + +``` + +4. nav_ids file describe all ids that this module requires. Entry point should implement all those ids. + +`./src/main/res/values/nav_ids.xml` + +5. The following actions should be added into the root navigation graph + +```xml + + + +``` + +6. Entry point should implement next interfaces and provide them through Hilt DI: + +`./src/main/java/ua/gov/diia/pin/helper/PinHelper.kt` diff --git a/pin/build.gradle b/pin/build.gradle new file mode 100644 index 0000000..778463b --- /dev/null +++ b/pin/build.gradle @@ -0,0 +1,133 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' + id 'androidx.navigation.safeargs.kotlin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.pin' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation project(':core') + implementation project(':ui_base') + implementation project(':diia_storage') + + implementation deps.fragment_ktx + implementation deps.appcompat + implementation deps.material + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + kapt deps.hilt_compiler + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + //navigation + implementation deps.navigation_fragment_ktx + implementation deps.navigation_ui_ktx + //Compose + implementation deps.activity_compose + implementation deps.compose_ui + implementation deps.compose_material + implementation deps.compose_ui_tooling + implementation deps.compose_ui_tooling_preview + implementation deps.compose_constraintlayout + + //test + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.hamcrest_library + testImplementation deps.json + testImplementation deps.turbine + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} + +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/pin/consumer-rules.pro b/pin/consumer-rules.pro new file mode 100644 index 0000000..e9dd86b --- /dev/null +++ b/pin/consumer-rules.pro @@ -0,0 +1,4 @@ +-keep enum * { *; } + +-keep public class ua.gov.diia.pin.model.CreatePinFlowType + diff --git a/pin/excludes.jacoco b/pin/excludes.jacoco new file mode 100644 index 0000000..c95f01e --- /dev/null +++ b/pin/excludes.jacoco @@ -0,0 +1,4 @@ +ua/gov/diia/pin/ui/**/*F.* +ua/gov/diia/pin/**/*$*.* +ua/gov/diia/pin/repository/* +ua/gov/diia/pin/ui/**/compose/*.* \ No newline at end of file diff --git a/pin/proguard-rules.pro b/pin/proguard-rules.pro new file mode 100644 index 0000000..fccc5bb --- /dev/null +++ b/pin/proguard-rules.pro @@ -0,0 +1,5 @@ + +-keep enum * { *; } + +-keep public class ua.gov.diia.pin.model.CreatePinFlowType + diff --git a/pin/src/main/AndroidManifest.xml b/pin/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e2b31d5 --- /dev/null +++ b/pin/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/di/PinModule.kt b/pin/src/main/java/ua/gov/diia/pin/di/PinModule.kt new file mode 100644 index 0000000..deda6bc --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/di/PinModule.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.pin.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import ua.gov.diia.pin.repository.LoginPinRepository +import ua.gov.diia.pin.repository.LoginPinRepositoryImpl + +@Module +@InstallIn(SingletonComponent::class) +interface PinModule { + + @Binds + fun bindLoginPinRepository(impl: LoginPinRepositoryImpl): LoginPinRepository +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/helper/PinHelper.kt b/pin/src/main/java/ua/gov/diia/pin/helper/PinHelper.kt new file mode 100644 index 0000000..1c49870 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/helper/PinHelper.kt @@ -0,0 +1,32 @@ +package ua.gov.diia.pin.helper + +import androidx.fragment.app.Fragment +import ua.gov.diia.pin.ui.input.AlternativeAuthCallback + +interface PinHelper { + + /** + * Checks whether alternative authorization (probably biometric) is available on device + */ + fun isAlternativeAuthAvailable(): Boolean + + /** + * Check whether alternative authorization method is enabled by user + */ + suspend fun isAlternativeAuthEnabled(): Boolean + + /** + * Run alternative authorization flow and return authorization result into [callback] + */ + fun openAlternativeAuth(host: Fragment, callback: AlternativeAuthCallback) + + /** + * Navigate to screen with alternative authorization method setup + */ + fun navigateToAlternativeAuthSetup( + host: Fragment, + resultDestinationId: Int, + resultKey: String, + pin: String, + ) +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/model/CreatePinFlowType.kt b/pin/src/main/java/ua/gov/diia/pin/model/CreatePinFlowType.kt new file mode 100644 index 0000000..ef6416f --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/model/CreatePinFlowType.kt @@ -0,0 +1,5 @@ +package ua.gov.diia.pin.model + +enum class CreatePinFlowType { + AUTHORIZATION, PROLONG, RESET_PIN, GENERATE_SIGNATURE; +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/repository/LoginPinRepository.kt b/pin/src/main/java/ua/gov/diia/pin/repository/LoginPinRepository.kt new file mode 100644 index 0000000..6ff7812 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/repository/LoginPinRepository.kt @@ -0,0 +1,14 @@ +package ua.gov.diia.pin.repository + +interface LoginPinRepository { + + suspend fun setUserAuthorized(pin: String) + + suspend fun isPinValid(pin: String): Boolean + + suspend fun isPinPresent(): Boolean + + suspend fun getPinTryCount(): Int + + suspend fun setPinTryCount(count: Int) +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/repository/LoginPinRepositoryImpl.kt b/pin/src/main/java/ua/gov/diia/pin/repository/LoginPinRepositoryImpl.kt new file mode 100644 index 0000000..ae0635a --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/repository/LoginPinRepositoryImpl.kt @@ -0,0 +1,35 @@ +package ua.gov.diia.pin.repository + +import kotlinx.coroutines.withContext +import ua.gov.diia.core.util.DispatcherProvider +import ua.gov.diia.diia_storage.store.Preferences +import ua.gov.diia.diia_storage.DiiaStorage +import javax.inject.Inject + +class LoginPinRepositoryImpl @Inject constructor( + private val dispatcherProvider: DispatcherProvider, + private val storage: DiiaStorage +) : LoginPinRepository { + + override suspend fun setUserAuthorized(pin: String) { + withContext(dispatcherProvider.work) { + storage.userAuthorized(pin) + } + } + + override suspend fun isPinValid(pin: String): Boolean = withContext(dispatcherProvider.work) { + storage.isPinValid(pin) + } + + override suspend fun isPinPresent(): Boolean = withContext(dispatcherProvider.work) { + storage.pinPresent() + } + + override suspend fun getPinTryCount(): Int = withContext(dispatcherProvider.work) { + storage.getInt(Preferences.PinTryCountGlobal, 0) + } + + override suspend fun setPinTryCount(count: Int) = withContext(dispatcherProvider.work) { + storage.set(Preferences.PinTryCountGlobal, count) + } +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/create/compose/CreatePinScreen.kt b/pin/src/main/java/ua/gov/diia/pin/ui/create/compose/CreatePinScreen.kt new file mode 100644 index 0000000..4881d4b --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/create/compose/CreatePinScreen.kt @@ -0,0 +1,166 @@ +package ua.gov.diia.pin.ui.create.compose + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.constraintlayout.compose.ConstraintLayout +import ua.gov.diia.ui_base.R +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.atom.text.textwithparameter.TextWithParametersData +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlc +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrg +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganism +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData + + +@Composable +fun CreatePinScreen( + modifier: Modifier = Modifier, + data: SnapshotStateList, + diiaResourceIconProvider: DiiaResourceIconProvider, + onUIAction: (UIAction) -> Unit +) { + ConstraintLayout( + modifier = modifier + .fillMaxSize() + .paint( + painterResource(id = R.drawable.bg_blue_yellow_gradient), + contentScale = ContentScale.FillBounds + ) + .safeDrawingPadding() + ) { + val title = createRef() + val numButton = createRef() + val descriptionText = createRef() + data.forEach { item -> + if (item is TopGroupOrgData) { + TopGroupOrg( + modifier = Modifier.constrainAs(title) { + start.linkTo(parent.start) + end.linkTo(parent.end) + top.linkTo(parent.top) + }, + data = item, + onUIAction = onUIAction, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + + if (item is TextLabelMlcData) { + TextLabelMlc( + modifier = modifier + .constrainAs(descriptionText) { + top.linkTo(title.bottom) + }, + data = item, + onUIAction = onUIAction + ) + } + if (item is NumButtonTileOrganismData) { + NumButtonTileOrganism( + modifier = Modifier + .constrainAs(numButton) { + linkTo( + top = descriptionText.bottom, + bottom = parent.bottom, + bias = 0.3f + ) + }, + data = item, + onUIAction = onUIAction + ) + } + } + } +} + +@Composable +@Preview +fun CreatePinScreenPreview() { + val _uiData = remember { mutableStateListOf() } + val uiData: SnapshotStateList = _uiData + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.DynamicString("Повторіть код з 4 цифр"), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + accessibilityDescription = UiText.StringResource(R.string.accessibility_back_button), + action = DataActionWrapper( + type = "back", + subtype = null, + resource = null + ) + ) + ) + ) + ) + _uiData.add( + TextLabelMlcData( + text = UiText.DynamicString("Цей код ви будете вводити для входу у застосунок Дія.") + ) + ) + _uiData.add( + NumButtonTileOrganismData() + ) + CreatePinScreen( + data = uiData, + onUIAction = { }, + diiaResourceIconProvider = DiiaResourceIconProvider.forPreview(), + ) +} + +@Composable +@Preview( + name = "phone", + device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480" +) +fun CreatePinScreenPreview_small_screen() { + val _uiData = remember { mutableStateListOf() } + val uiData: SnapshotStateList = _uiData + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.DynamicString("Повторіть код з 4 цифр"), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + accessibilityDescription = UiText.StringResource(R.string.accessibility_back_button), + action = DataActionWrapper( + type = "back", + subtype = null, + resource = null + ) + ) + ) + ) + ) + _uiData.add( + TextWithParametersData( + text = UiText.DynamicString("Цей код ви будете вводити для входу у застосунок Дія.") + ) + ) + _uiData.add( + NumButtonTileOrganismData(pinLength = 5) + ) + CreatePinScreen( + data = uiData, + onUIAction = { }, + diiaResourceIconProvider = DiiaResourceIconProvider.forPreview(), + ) +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinF.kt b/pin/src/main/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinF.kt new file mode 100644 index 0000000..e83b673 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinF.kt @@ -0,0 +1,115 @@ +package ua.gov.diia.pin.ui.create.confirm + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.core.util.extensions.fragment.setNavigationResult +import ua.gov.diia.pin.helper.PinHelper +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.pin.ui.create.compose.CreatePinScreen +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import javax.inject.Inject + +@AndroidEntryPoint +class ConfirmPinF : Fragment() { + + private val viewModel: ConfirmPinVM by viewModels() + private val args: ConfirmPinFArgs by navArgs() + private var composeView: ComposeView? = null + + @Inject + lateinit var pinHelper: PinHelper + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.doInit(args.flowType, args.pin) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + composeView?.setContent { + val uiDataElements = viewModel.uiData + + viewModel.apply { + navigation.collectAsEffect { navigation -> + when (navigation) { + is ConfirmPinVM.Navigation.ToAlternativeAuthSetup -> { + navigateToBiometric(navigation.pin) + } + + is ConfirmPinVM.Navigation.PinCreation -> { + completeFlow(navigation.pin) + } + + is BaseNavigation.Back -> { + findNavController().popBackStack() + } + } + } + showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + } + CreatePinScreen( + data = uiDataElements, + onUIAction = { viewModel.onUIAction(it) }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.DIALOG_DEAL_WITH_IT -> { + viewModel.matchedPin.value?.let { completeFlow(it) } + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun completeFlow(pin: String) { + setNavigationResult( + arbitraryDestination = args.resultDestinationId, + key = args.resultKey, + data = pin + ) + findNavController().popBackStack(args.resultDestinationId, false) + } + + private fun navigateToBiometric(pin: String) { + pinHelper.navigateToAlternativeAuthSetup( + host = this, + resultDestinationId = args.resultDestinationId, + resultKey = args.resultKey, + pin = pin, + ) + } +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinVM.kt b/pin/src/main/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinVM.kt new file mode 100644 index 0000000..a433b2c --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinVM.kt @@ -0,0 +1,146 @@ +package ua.gov.diia.pin.ui.create.confirm + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.pin.R +import ua.gov.diia.pin.helper.PinHelper +import ua.gov.diia.pin.model.CreatePinFlowType +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData +import ua.gov.diia.ui_base.navigation.BaseNavigation +import javax.inject.Inject + +@HiltViewModel +class ConfirmPinVM @Inject constructor( + private val pinHelper: PinHelper, + private val clientAlertDialogsFactory: ClientAlertDialogsFactory, + private val errorHandling: WithErrorHandlingOnFlow, + private val retryLastAction: WithRetryLastAction +) : ViewModel(), WithErrorHandlingOnFlow by errorHandling, + WithRetryLastAction by retryLastAction { + + private var newPin: String = "" + + private var tryCounter: Int = 0 + + val matchedPin = MutableLiveData() + + val flowType = MutableLiveData() + + private val _uiData = mutableStateListOf() + val uiData: SnapshotStateList = _uiData + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + fun doInit(flowType: CreatePinFlowType, pin: String) { + newPin = pin + this.flowType.value = flowType + var title = R.string.confirm_screen_title_text + var descText = R.string.confirm_screen_description_text + var codeSize = 4 + when (flowType) { + CreatePinFlowType.GENERATE_SIGNATURE -> { + title = R.string.confirm_screen_title_text_sign + descText = R.string.confirm_screen_description_text_sign + codeSize = 5 + } + + CreatePinFlowType.RESET_PIN -> { + title = R.string.pin_reset_confirm_title_text + descText = R.string.pin_reset_confirm_description_text + } + + else -> { + } + } + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.StringResource(title) + ) + ) + ) + _uiData.add(TextLabelMlcData(text = UiText.StringResource(descText))) + _uiData.add(NumButtonTileOrganismData(pinLength = codeSize)) + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM -> { + val pin = uiAction.data ?: return + matchPinCodes(pin) + } + + UIActionKeysCompose.PIN_CLEARED_NUM_BUTTON_ORGANISM -> { + _uiData.findAndChangeFirstByInstance { + it.copy(clearWithShake = false) + } + } + } + } + + private fun matchPinCodes(pin: String) { + tryCounter += 1 + if (tryCounter < MAX_TRY_COUNT) { + val matched = newPin == pin + if (matched) { + continueWithMatchedPin(pin) + } else { + _uiData.findAndChangeFirstByInstance { + it.copy(clearWithShake = true) + } + } + } else { + _navigation.tryEmit(BaseNavigation.Back) + } + } + + private fun continueWithMatchedPin(pin: String) { + when (flowType.value) { + CreatePinFlowType.AUTHORIZATION -> { + if (pinHelper.isAlternativeAuthAvailable()) { + _navigation.tryEmit(Navigation.ToAlternativeAuthSetup(pin)) + } else { + _navigation.tryEmit(Navigation.PinCreation(pin)) + } + } + + CreatePinFlowType.GENERATE_SIGNATURE -> _navigation.tryEmit(Navigation.PinCreation(pin)) + + else -> { + showTemplateDialog(clientAlertDialogsFactory.showAlertAfterConfirmPin()) + matchedPin.value = pin + } + } + } + + sealed class Navigation : NavigationPath { + data class ToAlternativeAuthSetup(val pin: String) : Navigation() + data class PinCreation(val pin: String) : Navigation() + } + + private companion object { + const val MAX_TRY_COUNT = 3 + } +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/create/create/CreatePinF.kt b/pin/src/main/java/ua/gov/diia/pin/ui/create/create/CreatePinF.kt new file mode 100644 index 0000000..56f20b8 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/create/create/CreatePinF.kt @@ -0,0 +1,78 @@ +package ua.gov.diia.pin.ui.create.create + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.pin.ui.create.compose.CreatePinScreen +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import javax.inject.Inject + +@AndroidEntryPoint +class CreatePinF : Fragment() { + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private val viewModel: CreatePinVM by viewModels() + private val args: CreatePinFArgs by navArgs() + private var composeView: ComposeView? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.doInit(args.flowType) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + composeView?.setContent { + val uiDataElements = viewModel.uiData + + viewModel.apply { + navigation.collectAsEffect { navigation -> + when (navigation) { + is CreatePinVM.Navigation.ToPinConformation -> { + navigateToConformation(navigation.pin) + } + } + } + } + + CreatePinScreen( + data = uiDataElements, + onUIAction = { viewModel.onUIAction(it) }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun navigateToConformation(pin: String) { + navigate( + CreatePinFDirections.actionDestinationCreatePinToDestinationConfirmPin( + args.resultDestinationId, args.resultKey, args.flowType, pin + ) + ) + } +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/create/create/CreatePinVM.kt b/pin/src/main/java/ua/gov/diia/pin/ui/create/create/CreatePinVM.kt new file mode 100644 index 0000000..999a4e0 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/create/create/CreatePinVM.kt @@ -0,0 +1,80 @@ +package ua.gov.diia.pin.ui.create.create + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import ua.gov.diia.pin.R +import ua.gov.diia.pin.model.CreatePinFlowType +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData +import javax.inject.Inject + +@HiltViewModel +class CreatePinVM @Inject constructor() : ViewModel() { + + private val _uiData = mutableStateListOf() + val uiData: SnapshotStateList = _uiData + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + fun doInit(flowType: CreatePinFlowType) { + + var title = R.string.create_screen_title_text + var descText = R.string.create_screen_description_text + var codeSize = 4 + when (flowType) { + CreatePinFlowType.GENERATE_SIGNATURE -> { + title = R.string.create_screen_title_text_sign + descText = R.string.create_screen_description_text_sign + codeSize = 5 + } + CreatePinFlowType.RESET_PIN -> { + title = R.string.pin_reset_create_title_text + descText = R.string.pin_reset_create_description_text + } + + else -> { + } + } + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.StringResource(title) + ) + ) + ) + _uiData.add(TextLabelMlcData(text = UiText.StringResource(descText))) + _uiData.add(NumButtonTileOrganismData(pinLength = codeSize)) + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM -> { + _uiData.findAndChangeFirstByInstance { + it.copy(clearPin = true) + } + _navigation.tryEmit(Navigation.ToPinConformation(uiAction.data ?: return)) + } + } + } + + sealed class Navigation : NavigationPath { + data class ToPinConformation(val pin: String) : Navigation() + } +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/input/AlternativeAuthCallback.kt b/pin/src/main/java/ua/gov/diia/pin/ui/input/AlternativeAuthCallback.kt new file mode 100644 index 0000000..995edba --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/input/AlternativeAuthCallback.kt @@ -0,0 +1,8 @@ +package ua.gov.diia.pin.ui.input + +interface AlternativeAuthCallback { + + fun onAlternativeAuthSuccessful() + + fun onAlternativeAuthFailed() +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/input/PinInputF.kt b/pin/src/main/java/ua/gov/diia/pin/ui/input/PinInputF.kt new file mode 100644 index 0000000..90d46e7 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/input/PinInputF.kt @@ -0,0 +1,118 @@ +package ua.gov.diia.pin.ui.input + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.pin.helper.PinHelper +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.pin.ui.input.compose.PinInputScreen +import javax.inject.Inject + +@AndroidEntryPoint +class PinInputF : Fragment() { + + private val viewModel: PinInputVM by viewModels() + private val args: PinInputFArgs by navArgs() + private var composeView: ComposeView? = null + + @Inject + lateinit var pinHelper: PinHelper + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + viewModel.doInit(args.verification) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + composeView?.setContent { + val uiDataElements = viewModel.uiData + + viewModel.apply { + val validationFinished = + validationFinished.collectAsState(initial = UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_UI_BLOCKING to true) + navigation.collectAsEffect { navigation -> + when (navigation) { + is PinInputVM.Navigation.PinApproved -> { + pinApproved() + } + + is PinInputVM.Navigation.ToQr -> { + navigateToQr() + } + + is PinInputVM.Navigation.ToHome -> { + navigateToHome() + } + + is PinInputVM.Navigation.AlternativeAuth -> { + pinHelper.openAlternativeAuth(this@PinInputF, viewModel) + } + } + } + showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + + PinInputScreen( + data = uiDataElements, + contentLoaded = validationFinished.value, + onUIAction = { viewModel.onUIAction(it) } + ) + } + } + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.DIALOG_ACTION_CODE_LOGOUT -> viewModel.resetPin() + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } + + private fun pinApproved() { + findNavController().popBackStack() + } + + private fun navigateToHome() { + navigate( + PinInputFDirections.actionDestinationPinInputToHomeF() + ) + } + + private fun navigateToQr() { + navigate( + PinInputFDirections.actionDestinationPinInputToQrScanF() + ) + } + +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/input/PinInputVM.kt b/pin/src/main/java/ua/gov/diia/pin/ui/input/PinInputVM.kt new file mode 100644 index 0000000..7ab4167 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/input/PinInputVM.kt @@ -0,0 +1,240 @@ +package ua.gov.diia.pin.ui.input + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.transform +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.core.di.actions.GlobalActionLogout +import ua.gov.diia.core.models.UserType +import ua.gov.diia.core.models.dialogs.TemplateDialogButton +import ua.gov.diia.core.models.dialogs.TemplateDialogData +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst.DIALOG_ACTION_CANCEL +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst.DIALOG_ACTION_CODE_LOGOUT +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.pin.R +import ua.gov.diia.pin.helper.PinHelper +import ua.gov.diia.pin.repository.LoginPinRepository +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.NavigationPanelMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData +import javax.inject.Inject + +@HiltViewModel +class PinInputVM @Inject constructor( + @GlobalActionLogout private val actionLogout: MutableLiveData, + private val loginPinRepository: LoginPinRepository, + private val pinHelper: PinHelper, + private val authorizationRepository: AuthorizationRepository, + private val clientAlertDialogsFactory: ClientAlertDialogsFactory, + private val errorHandling: WithErrorHandlingOnFlow, + private val retryLastAction: WithRetryLastAction +) : ViewModel(), + WithRetryLastAction by retryLastAction, + WithErrorHandlingOnFlow by errorHandling, + AlternativeAuthCallback { + + private val _validationFinished = MutableStateFlow(true) + val validationFinished: Flow> = + _validationFinished.transform { value -> + UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_UI_BLOCKING to value + } + + private var isVerification = false + private var alternativeAuthCount = 0 + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _uiData = mutableStateListOf() + val uiData: SnapshotStateList = _uiData + + + init { + _uiData.add( + NavigationPanelMlcData( + title = UiText.StringResource(R.string.pin_input_title_text), + isContextMenuExist = false + ) + ) + _uiData.add(NumButtonTileOrganismData()) + _uiData.add(TextLabelMlcData(text = UiText.StringResource(R.string.pin_input_description_text))) + checkForAlternativeAuth() + resetAlternativeAuthCount() + } + + fun doInit(verification: Boolean) { + isVerification = verification + } + + @VisibleForTesting + fun checkForAlternativeAuth() { + executeActionOnFlow(contentLoadedIndicator = _validationFinished) { + if (pinHelper.isAlternativeAuthEnabled()) { + enabledAlternativeAuthBtn(true) + alternativeAuth() + } + } + } + + private fun approvePin(pin: String) { + executeActionOnFlow(contentLoadedIndicator = _validationFinished) { + if (loginPinRepository.isPinValid(pin)) { + completeVerification(pin) + } else { + validateTryCount { + clearPinWithShake() + } + } + } + } + + private fun showResetPinRationale() { + val dialogData = TemplateDialogModel( + key = FRAGMENT_USER_ACTION_RESULT_KEY, + type = ALERT_TYPE_HORIZONTAL_BUTTONS, + isClosable = false, + data = TemplateDialogData( + icon = null, + title = "Забули код для входу?", + description = "Пройдіть повторну авторизацію у застосунку", + mainButton = TemplateDialogButton( + name = "Скасувати", + action = DIALOG_ACTION_CANCEL + ), + alternativeButton = TemplateDialogButton( + name = "Авторизуватися", + action = DIALOG_ACTION_CODE_LOGOUT + ) + ) + ) + showTemplateDialog(dialogData) + } + + fun resetPin() { + actionLogout.value = UiEvent() + } + + private fun alternativeAuth() { + _navigation.tryEmit(Navigation.AlternativeAuth) + } + + override fun onAlternativeAuthSuccessful() { + executeActionOnFlow(contentLoadedIndicator = _validationFinished) { + completeVerification() + } + } + + override fun onAlternativeAuthFailed() { + executeActionOnFlow(contentLoadedIndicator = _validationFinished) { + validateAlternativeAuthTryCount() + } + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + UIActionKeysCompose.TEXT_LABEL_MLC -> { + showResetPinRationale() + } + + UIActionKeysCompose.PIN_CLEARED_NUM_BUTTON_ORGANISM -> { + _uiData.findAndChangeFirstByInstance { + it.copy(clearPin = false, clearWithShake = false) + } + } + + UIActionKeysCompose.TOUCH_ID_BUTTON -> { + alternativeAuth() + } + + UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM -> { + approvePin(uiAction.data ?: return) + } + } + } + + private fun enabledAlternativeAuthBtn(enable: Boolean) { + _uiData.findAndChangeFirstByInstance { + it.copy(hasBiometric = enable) + } + } + + private fun clearPinWithShake() { + _uiData.findAndChangeFirstByInstance { + it.copy(clearWithShake = true) + } + } + + private suspend fun completeVerification(pin: String? = null) { + loginPinRepository.setPinTryCount(0) + resetAlternativeAuthCount() + if (isVerification) { + _navigation.tryEmit(Navigation.PinApproved) + } else { + pin?.let { + loginPinRepository.setUserAuthorized(it) + } + when (authorizationRepository.getUserType()) { + UserType.PRIMARY_USER -> _navigation.tryEmit(Navigation.ToHome) + UserType.SERVICE_USER -> _navigation.tryEmit(Navigation.ToQr) + } + } + } + + private suspend inline fun validateTryCount(doIdHasAttempt: () -> Unit) { + val incrementedPinTryCount = loginPinRepository.getPinTryCount().inc() + loginPinRepository.setPinTryCount(incrementedPinTryCount) + if (incrementedPinTryCount >= PIN_TRY_COUNT) { + showTemplateDialog(clientAlertDialogsFactory.showAlertAfterInvalidPin()) + } else { + doIdHasAttempt.invoke() + } + } + + private fun validateAlternativeAuthTryCount() { + alternativeAuthCount += 1 + if (alternativeAuthCount > ALT_AUTH_TRY_COUNT) { + enabledAlternativeAuthBtn(false) + } + } + + private fun resetAlternativeAuthCount() { + alternativeAuthCount = 0 + } + + private companion object { + const val PIN_TRY_COUNT = 3 + const val ALT_AUTH_TRY_COUNT = 5 + const val ALERT_TYPE_HORIZONTAL_BUTTONS = "horizontalButtons" + } + + sealed class Navigation : NavigationPath { + object PinApproved : Navigation() + object ToQr : Navigation() + object ToHome : Navigation() + object AlternativeAuth : Navigation() + } +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/input/compose/PinInputScreen.kt b/pin/src/main/java/ua/gov/diia/pin/ui/input/compose/PinInputScreen.kt new file mode 100644 index 0000000..ef971ee --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/input/compose/PinInputScreen.kt @@ -0,0 +1,118 @@ +package ua.gov.diia.pin.ui.input.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.constraintlayout.compose.ConstraintLayout +import ua.gov.diia.ui_base.R +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.NavigationPanelMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganism +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData +import ua.gov.diia.ui_base.components.subatomic.loader.TridentLoaderWithUIBlocking +import ua.gov.diia.ui_base.components.theme.DiiaTextStyle + +@Composable +fun PinInputScreen( + modifier: Modifier = Modifier, + data: SnapshotStateList, + contentLoaded: Pair, + onUIAction: (UIAction) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + ConstraintLayout( + modifier = modifier + .paint( + painterResource(id = R.drawable.bg_blue_yellow_gradient), + contentScale = ContentScale.FillBounds + ) + .fillMaxSize() + .safeDrawingPadding() + ) { + val title = createRef() + val numButton = createRef() + val bottomText = createRef() + data.forEach { item -> + if (item is NavigationPanelMlcData) { + Text( + modifier = Modifier.constrainAs(title) { + start.linkTo(parent.start) + end.linkTo(parent.end) + bottom.linkTo(numButton.top, margin = 40.dp) + }, + text = item.title?.asString() ?: "", + style = DiiaTextStyle.heroText + ) + } + + if (item is NumButtonTileOrganismData) { + NumButtonTileOrganism( + modifier = Modifier + .constrainAs(numButton) { + top.linkTo(parent.top, margin = 16.dp) + bottom.linkTo(parent.bottom) + }, + data = item, + onUIAction = onUIAction + ) + } + + if (item is TextLabelMlcData) { + ClickableText( + modifier = modifier + .wrapContentSize() + .constrainAs(bottomText) { + start.linkTo(parent.start) + end.linkTo(parent.end) + bottom.linkTo(parent.bottom, margin = 30.dp) + }, + text = AnnotatedString(item.text.asString()), + style = DiiaTextStyle.t3TextBody, + ) { + onUIAction(UIAction(actionKey = item.actionKey)) + } + } + } + } + TridentLoaderWithUIBlocking(contentLoaded = contentLoaded) + } +} + +@Composable +@Preview +fun PinInputScreenPreview() { + val _uiData = remember { mutableStateListOf() } + val uiData: SnapshotStateList = _uiData + _uiData.add( + NavigationPanelMlcData( + title = UiText.DynamicString("Код для входу"), + isContextMenuExist = false + ) + ) + _uiData.add( + NumButtonTileOrganismData() + ) + _uiData.add( + TextLabelMlcData( + text = UiText.DynamicString("Не пам\\'ятаю код для входу") + ) + ) + PinInputScreen(data = uiData, onUIAction = { }, contentLoaded = "" to true) +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/reset/ResetPinF.kt b/pin/src/main/java/ua/gov/diia/pin/ui/reset/ResetPinF.kt new file mode 100644 index 0000000..a8a2499 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/reset/ResetPinF.kt @@ -0,0 +1,100 @@ +package ua.gov.diia.pin.ui.reset + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.pin.model.CreatePinFlowType +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ui_base.components.infrastructure.collectAsEffect +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.pin.ui.reset.compose.ResetPinScreen +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import javax.inject.Inject + +@AndroidEntryPoint +class ResetPinF : Fragment() { + + @Inject + lateinit var diiaResourceIconProvider: DiiaResourceIconProvider + + private val viewModel: ResetPinVM by viewModels() + private val args: ResetPinFArgs by navArgs() + private var composeView: ComposeView? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + composeView = ComposeView(requireContext()) + return composeView + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + composeView?.setContent { + val uiDataElements = viewModel.uiData + viewModel.apply { + val validationFinished = + validationFinished.collectAsState(initial = UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_UI_BLOCKING to true) + navigation.collectAsEffect { navigation -> + when (navigation) { + is ResetPinVM.Navigation.CreateNewPin -> { + navigateToCreation() + } + + is BaseNavigation.Back -> { + findNavController().popBackStack() + } + } + } + + showTemplateDialog.collectAsEffect { + openTemplateDialog(it.peekContent()) + } + ResetPinScreen( + data = uiDataElements, + contentLoaded = validationFinished.value, + onUIAction = { viewModel.onUIAction(it) }, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + } + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.DIALOG_DEAL_WITH_IT -> findNavController().popBackStack() + ActionsConst.DIALOG_ACTION_CODE_LOGOUT -> viewModel.resetPin() + } + } + } + + private fun navigateToCreation() { + navigate( + ResetPinFDirections.actionPinResetToCreatePin( + resultDestinationId = args.resultDestination, + resultKey = args.resultKey, + flowType = CreatePinFlowType.RESET_PIN + ) + ) + } + + override fun onDestroyView() { + super.onDestroyView() + composeView = null + } +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/reset/ResetPinVM.kt b/pin/src/main/java/ua/gov/diia/pin/ui/reset/ResetPinVM.kt new file mode 100644 index 0000000..8675ff2 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/reset/ResetPinVM.kt @@ -0,0 +1,145 @@ +package ua.gov.diia.pin.ui.reset + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.transform +import ua.gov.diia.core.di.actions.GlobalActionLogout +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.vm.executeActionOnFlow +import ua.gov.diia.pin.R +import ua.gov.diia.pin.repository.LoginPinRepository +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.findAndChangeFirstByInstance +import ua.gov.diia.ui_base.components.infrastructure.navigation.NavigationPath +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData +import ua.gov.diia.ui_base.navigation.BaseNavigation +import javax.inject.Inject + +@HiltViewModel +class ResetPinVM @Inject constructor( + @GlobalActionLogout private val actionLogout: MutableLiveData, + private val loginPinRepository: LoginPinRepository, + private val clientAlertDialogsFactory: ClientAlertDialogsFactory, + private val errorHandling: WithErrorHandlingOnFlow, + private val retryLastAction: WithRetryLastAction +) : ViewModel(), + WithErrorHandlingOnFlow by errorHandling, + WithRetryLastAction by retryLastAction { + + private val _validationFinished = MutableStateFlow(true) + val validationFinished: Flow> = + _validationFinished.transform { value -> + UIActionKeysCompose.PAGE_LOADING_TRIDENT_WITH_UI_BLOCKING to value + } + + private val _navigation = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val navigation = _navigation.asSharedFlow() + + private val _uiData = mutableStateListOf() + val uiData: SnapshotStateList = _uiData + + init { + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.StringResource(R.string.pin_reset_title_text), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + accessibilityDescription = UiText.StringResource(R.string.accessibility_back_button), + action = DataActionWrapper( + type = ActionsConst.ACTION_NAVIGATE_BACK, + subtype = null, + resource = null + ) + ) + ) + ) + ) + _uiData.add(TextLabelMlcData(text = UiText.StringResource(R.string.pin_reset_description_text))) + _uiData.add(NumButtonTileOrganismData()) + } + + private fun approvePin(pin: String) { + executeActionOnFlow(contentLoadedIndicator = _validationFinished) { + if (loginPinRepository.isPinValid(pin)) { + loginPinRepository.setPinTryCount(0) + _navigation.tryEmit(Navigation.CreateNewPin) + } else { + val incrementedPinTryCount = loginPinRepository.getPinTryCount().inc() + if (incrementedPinTryCount >= PIN_TRY_COUNT) { + showTemplateDialog(clientAlertDialogsFactory.showAlertAfterInvalidPin()) + loginPinRepository.setPinTryCount(incrementedPinTryCount) + } else { + clearPinWithShake() + loginPinRepository.setPinTryCount(incrementedPinTryCount) + } + } + } + } + + fun resetPin() { + actionLogout.value = UiEvent() + } + + fun onUIAction(uiAction: UIAction) { + when (uiAction.actionKey) { + UIActionKeysCompose.PIN_CLEARED_NUM_BUTTON_ORGANISM -> { + _uiData.findAndChangeFirstByInstance { + it.copy(clearWithShake = false) + } + } + + UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM -> { + approvePin(uiAction.data ?: return) + } + + UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK -> { + _navigation.tryEmit(BaseNavigation.Back) + } + UIActionKeysCompose.TITLE_GROUP_MLC -> { + uiAction.action?.type.let { + if (it == ActionsConst.ACTION_NAVIGATE_BACK) { + _navigation.tryEmit(BaseNavigation.Back) + } + } + } + } + } + + private fun clearPinWithShake() { + _uiData.findAndChangeFirstByInstance { + it.copy(clearWithShake = true) + } + } + + sealed class Navigation : NavigationPath { + object CreateNewPin : Navigation() + } + + private companion object { + const val PIN_TRY_COUNT = 3 + } +} \ No newline at end of file diff --git a/pin/src/main/java/ua/gov/diia/pin/ui/reset/compose/ResetPinScreen.kt b/pin/src/main/java/ua/gov/diia/pin/ui/reset/compose/ResetPinScreen.kt new file mode 100644 index 0000000..3ee8351 --- /dev/null +++ b/pin/src/main/java/ua/gov/diia/pin/ui/reset/compose/ResetPinScreen.kt @@ -0,0 +1,174 @@ +package ua.gov.diia.pin.ui.reset.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.constraintlayout.compose.ConstraintLayout +import ua.gov.diia.ui_base.R +import ua.gov.diia.ui_base.components.CommonDiiaResourceIcon +import ua.gov.diia.ui_base.components.DiiaResourceIconProvider +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.UIElementData +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.utils.resource.UiText +import ua.gov.diia.ui_base.components.molecule.header.TitleGroupMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlc +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrg +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganism +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData +import ua.gov.diia.ui_base.components.subatomic.loader.TridentLoaderWithUIBlocking + + +@Composable +fun ResetPinScreen( + modifier: Modifier = Modifier, + data: SnapshotStateList, + contentLoaded: Pair, + diiaResourceIconProvider: DiiaResourceIconProvider, + onUIAction: (UIAction) -> Unit +) { + Box(modifier = Modifier.fillMaxSize()) { + ConstraintLayout( + modifier = modifier + .fillMaxSize() + .paint( + painterResource(id = R.drawable.bg_blue_yellow_gradient), + contentScale = ContentScale.FillBounds + ) + .safeDrawingPadding() + ) { + val title = createRef() + val numButton = createRef() + val descriptionText = createRef() + data.forEach { item -> + if (item is TopGroupOrgData) { + TopGroupOrg( + modifier = modifier + .constrainAs(title) { + start.linkTo(parent.start) + end.linkTo(parent.end) + top.linkTo(parent.top) + }, + data = item, + onUIAction = onUIAction, + diiaResourceIconProvider = diiaResourceIconProvider, + ) + } + + if (item is TextLabelMlcData) { + TextLabelMlc( + modifier = modifier + .constrainAs(descriptionText) { + top.linkTo(title.bottom) + }, + data = item, + onUIAction = onUIAction + ) + } + if (item is NumButtonTileOrganismData) { + NumButtonTileOrganism( + modifier = Modifier + .constrainAs(numButton) { + linkTo( + top = descriptionText.bottom, + bottom = parent.bottom, + bias = 0.3f + ) + }, + data = item, + onUIAction = onUIAction + ) + } + } + } + TridentLoaderWithUIBlocking(contentLoaded = contentLoaded) + } +} + +@Composable +@Preview +fun ResetPinScreenPreview() { + val _uiData = remember { mutableStateListOf() } + val uiData: SnapshotStateList = _uiData + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.DynamicString("Повторіть код з 4 цифр"), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + accessibilityDescription = UiText.StringResource(R.string.accessibility_back_button), + action = DataActionWrapper( + type = "back", + subtype = null, + resource = null + ) + ) + ) + ) + ) + _uiData.add( + TextLabelMlcData( + text = UiText.DynamicString("Щоб впевнитися, що це ви змінюєте код для входу.") + ) + ) + _uiData.add( + NumButtonTileOrganismData() + ) + ResetPinScreen( + data = uiData, + contentLoaded = "" to true, + onUIAction = { }, + diiaResourceIconProvider = DiiaResourceIconProvider.forPreview(), + ) +} + +@Composable +@Preview( + name = "phone", + device = "spec:shape=Normal,width=360,height=640,unit=dp,dpi=480" +) +fun ResetPinScreenPreview_small_screen() { + val _uiData = remember { mutableStateListOf() } + val uiData: SnapshotStateList = _uiData + _uiData.add( + TopGroupOrgData( + titleGroupMlcData = TitleGroupMlcData( + heroText = UiText.DynamicString("Повторіть код з 4 цифр"), + leftNavIcon = TitleGroupMlcData.LeftNavIcon( + code = CommonDiiaResourceIcon.BACK.code, + accessibilityDescription = UiText.StringResource(R.string.accessibility_back_button), + action = DataActionWrapper( + type = "back", + subtype = null, + resource = null + ) + ) + ) + ) + ) + _uiData.add( + TextLabelMlcData( + text = UiText.DynamicString("Щоб впевнитися, що це ви змінюєте код для входу.") + ) + ) + _uiData.add( + NumButtonTileOrganismData() + ) + ResetPinScreen( + data = uiData, + contentLoaded = "" to true, + onUIAction = { }, + diiaResourceIconProvider = DiiaResourceIconProvider.forPreview(), + ) +} \ No newline at end of file diff --git a/pin/src/main/res/navigation/nav_pin_create.xml b/pin/src/main/res/navigation/nav_pin_create.xml new file mode 100644 index 0000000..1c0d5af --- /dev/null +++ b/pin/src/main/res/navigation/nav_pin_create.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pin/src/main/res/navigation/nav_pin_input.xml b/pin/src/main/res/navigation/nav_pin_input.xml new file mode 100644 index 0000000..100ce9a --- /dev/null +++ b/pin/src/main/res/navigation/nav_pin_input.xml @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/pin/src/main/res/navigation/nav_pin_reset.xml b/pin/src/main/res/navigation/nav_pin_reset.xml new file mode 100644 index 0000000..4dc137f --- /dev/null +++ b/pin/src/main/res/navigation/nav_pin_reset.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pin/src/main/res/values/nav_ids.xml b/pin/src/main/res/values/nav_ids.xml new file mode 100644 index 0000000..a4e5715 --- /dev/null +++ b/pin/src/main/res/values/nav_ids.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/pin/src/main/res/values/strings.xml b/pin/src/main/res/values/strings.xml new file mode 100644 index 0000000..7f43601 --- /dev/null +++ b/pin/src/main/res/values/strings.xml @@ -0,0 +1,35 @@ + + + + Код для входу + Не пам\'ятаю код для входу + + + Придумайте\nкод з 4 цифр + Цей код ви будете вводити для входу у застосунок Дія. + + Повторіть\nкод з 4 цифр + Переконайтеся, що не помилилися і пам\'ятаєте код для входу. + + + Придумайте\nкод з 5 цифр + Цей код ви будете вводити для підписання документів за допомогою Дія.Підпис + + Повторіть\nкод з 5 цифр + Цей код ви будете вводити для підписання документів за допомогою Дія.Підпис + + + Повторіть\nкод з 4 цифр + Щоб впевнитися, що це ви змінюєте код для входу. + + Новий\nкод з 4 цифр + Цей код ви будете вводити для входу у застосунок Дія. + + Повторіть\nкод з 4 цифр + Переконайтеся, що не помилилися і пам\'ятаєте код для входу. + + + Код для Дія.Підпис + Не пам\'ятаю код для Дія.Підпис + + \ No newline at end of file diff --git a/pin/src/test/java/ua/gov/diia/pin/rules/MainDispatcherRule.kt b/pin/src/test/java/ua/gov/diia/pin/rules/MainDispatcherRule.kt new file mode 100644 index 0000000..844a0b9 --- /dev/null +++ b/pin/src/test/java/ua/gov/diia/pin/rules/MainDispatcherRule.kt @@ -0,0 +1,23 @@ +package ua.gov.diia.pin.rules + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule( + val testDispatcher: TestDispatcher = StandardTestDispatcher(), +) : TestWatcher() { + override fun starting(description: Description) { + Dispatchers.setMain(testDispatcher) + } + + override fun finished(description: Description) { + Dispatchers.resetMain() + } +} diff --git a/pin/src/test/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinVMTest.kt b/pin/src/test/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinVMTest.kt new file mode 100644 index 0000000..455f4bd --- /dev/null +++ b/pin/src/test/java/ua/gov/diia/pin/ui/create/confirm/ConfirmPinVMTest.kt @@ -0,0 +1,188 @@ +package ua.gov.diia.pin.ui.create.confirm + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import ua.gov.diia.core.models.dialogs.TemplateDialogButton +import ua.gov.diia.core.models.dialogs.TemplateDialogData +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.pin.helper.PinHelper +import ua.gov.diia.pin.model.CreatePinFlowType +import ua.gov.diia.pin.rules.MainDispatcherRule +import ua.gov.diia.pin.utils.StubErrorHandlerOnFlow +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData + +@RunWith(Parameterized::class) +class ConfirmPinVMTest( + private val flowType: CreatePinFlowType +) { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Mock + private lateinit var alertDialogsFactory: ClientAlertDialogsFactory + + @Mock + private lateinit var retryLastAction: WithRetryLastAction + + @Mock + private lateinit var pinHelper: PinHelper + + private lateinit var viewModel: ConfirmPinVM + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + viewModel = ConfirmPinVM( + pinHelper = pinHelper, + clientAlertDialogsFactory = alertDialogsFactory, + errorHandling = StubErrorHandlerOnFlow(), + retryLastAction = retryLastAction + ) + viewModel.doInit(flowType, "1234") + whenever(alertDialogsFactory.showAlertAfterConfirmPin()).doReturn(template()) + } + + @Test + fun `initial state`() = runTest { + val snapshot = viewModel.uiData.toList() + Assert.assertEquals(3, snapshot.size) + Assert.assertTrue(snapshot[0] is TopGroupOrgData) + Assert.assertTrue(snapshot[1] is TextLabelMlcData) + Assert.assertTrue(snapshot[2] is NumButtonTileOrganismData) + } + + @Test + fun `valid pin confirmation`() = runTest { + whenever(pinHelper.isAlternativeAuthAvailable()).thenReturn(false) + if (flowType == CreatePinFlowType.AUTHORIZATION || flowType == CreatePinFlowType.GENERATE_SIGNATURE) { + viewModel.navigation.test { + viewModel.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + data = "1234" + ) + ) + val nav = awaitItem() as ConfirmPinVM.Navigation.PinCreation + Assert.assertEquals("1234", nav.pin) + } + } else { + viewModel.showTemplateDialog.test { + viewModel.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + data = "1234" + ) + ) + Assert.assertEquals(template(), awaitItem().getContentIfNotHandled()) + } + } + val item = viewModel.uiData.firstNotNullOf { it as? NumButtonTileOrganismData } + Assert.assertFalse(item.clearWithShake) + } + + @Test + fun `valid pin confirmation with alt`() = runTest { + whenever(pinHelper.isAlternativeAuthAvailable()).thenReturn(true) + when (flowType) { + CreatePinFlowType.AUTHORIZATION -> viewModel.navigation.test { + viewModel.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + data = "1234" + ) + ) + val nav = awaitItem() as ConfirmPinVM.Navigation.ToAlternativeAuthSetup + Assert.assertEquals("1234", nav.pin) + } + CreatePinFlowType.GENERATE_SIGNATURE -> viewModel.navigation.test { + viewModel.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + data = "1234" + ) + ) + val nav = awaitItem() as ConfirmPinVM.Navigation.PinCreation + Assert.assertEquals("1234", nav.pin) + } + else -> viewModel.showTemplateDialog.test { + viewModel.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + data = "1234" + ) + ) + Assert.assertEquals(template(), awaitItem().getContentIfNotHandled()) + } + } + val item = viewModel.uiData.firstNotNullOf { it as? NumButtonTileOrganismData } + Assert.assertFalse(item.clearWithShake) + } + + @Test + fun `invalid pin confirmation`() = runTest { + viewModel.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + data = "5655" + ) + ) + val item = viewModel.uiData.firstNotNullOf { it as? NumButtonTileOrganismData } + Assert.assertTrue(item.clearWithShake) + } + + @Test + fun `pin cleared`() = runTest { + viewModel.onUIAction(UIAction(UIActionKeysCompose.PIN_CLEARED_NUM_BUTTON_ORGANISM)) + val data = viewModel.uiData.firstNotNullOf { it as? NumButtonTileOrganismData } + Assert.assertFalse(data.clearWithShake) + } + + companion object { + + @JvmStatic + @Parameters + fun parameters() = CreatePinFlowType.values() + + private fun template() = + TemplateDialogModel( + key = ActionsConst.FRAGMENT_USER_ACTION_RESULT_KEY, + type = "", + isClosable = false, + data = TemplateDialogData( + icon = null, + title = "", + description = null, + mainButton = TemplateDialogButton( + name = null, + icon = null, + action = "" + ), + alternativeButton = null + ) + ) + + } +} diff --git a/pin/src/test/java/ua/gov/diia/pin/ui/create/create/CreatePinVMTest.kt b/pin/src/test/java/ua/gov/diia/pin/ui/create/create/CreatePinVMTest.kt new file mode 100644 index 0000000..61edd8d --- /dev/null +++ b/pin/src/test/java/ua/gov/diia/pin/ui/create/create/CreatePinVMTest.kt @@ -0,0 +1,82 @@ +package ua.gov.diia.pin.ui.create.create + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import app.cash.turbine.test +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters +import org.mockito.MockitoAnnotations +import ua.gov.diia.pin.model.CreatePinFlowType +import ua.gov.diia.pin.rules.MainDispatcherRule +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData + +@RunWith(Parameterized::class) +class CreatePinVMTest( + private val flowType: CreatePinFlowType +) { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private lateinit var viewModel: CreatePinVM + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + viewModel = CreatePinVM() + viewModel.doInit(flowType) + } + + @Test + fun `initial state`() = runTest { + val snapshot = viewModel.uiData.toList() + Assert.assertEquals(3, snapshot.size) + Assert.assertTrue(snapshot[0] is TopGroupOrgData) + Assert.assertTrue(snapshot[1] is TextLabelMlcData) + Assert.assertTrue(snapshot[2] is NumButtonTileOrganismData) + } + + @Test + fun `create pin`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + data = "1234" + ) + ) + Assert.assertEquals(CreatePinVM.Navigation.ToPinConformation("1234"), awaitItem()) + } + } + + @Test + fun `create no pin`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction( + UIAction( + actionKey = UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + ) + ) + expectNoEvents() + } + } + + companion object { + + @JvmStatic + @Parameters + fun parameters() = CreatePinFlowType.values() + } +} \ No newline at end of file diff --git a/pin/src/test/java/ua/gov/diia/pin/ui/input/PinInputVMTest.kt b/pin/src/test/java/ua/gov/diia/pin/ui/input/PinInputVMTest.kt new file mode 100644 index 0000000..3d801f9 --- /dev/null +++ b/pin/src/test/java/ua/gov/diia/pin/ui/input/PinInputVMTest.kt @@ -0,0 +1,234 @@ +package ua.gov.diia.pin.ui.input + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.stub +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameters +import org.mockito.Mock +import org.mockito.MockitoAnnotations +import ua.gov.diia.diia_storage.store.repository.authorization.AuthorizationRepository +import ua.gov.diia.core.models.UserType +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.pin.helper.PinHelper +import ua.gov.diia.pin.repository.LoginPinRepository +import ua.gov.diia.pin.rules.MainDispatcherRule +import ua.gov.diia.pin.utils.StubErrorHandlerOnFlow +import ua.gov.diia.pin.utils.awaitFor +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM +import ua.gov.diia.ui_base.components.molecule.header.NavigationPanelMlcData +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData + +@RunWith(Parameterized::class) +class PinInputVMTest(private val isVerification: Boolean) { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val actionLogout = MutableLiveData() + + @Mock + lateinit var loginPinRepository: LoginPinRepository + + @Mock + lateinit var pinHelper: PinHelper + + @Mock + lateinit var authorizationRepository: AuthorizationRepository + + @Mock + lateinit var clientAlertDialogsFactory: ClientAlertDialogsFactory + + @Mock + lateinit var retryLastAction: WithRetryLastAction + + private lateinit var viewModel: PinInputVM + + @Before + fun setUp() { + MockitoAnnotations.openMocks(this) + loginPinRepository.stub { + var pinTryCount = 0 + onBlocking { getPinTryCount() }.thenAnswer { pinTryCount } + onBlocking { setPinTryCount(any()) }.thenAnswer { + pinTryCount = it.getArgument(0) + Unit + } + } + authorizationRepository.stub { + onBlocking { getUserType() }.thenReturn(UserType.PRIMARY_USER) + } + + viewModel = PinInputVM( + actionLogout = actionLogout, + loginPinRepository = loginPinRepository, + pinHelper = pinHelper, + authorizationRepository = authorizationRepository, + clientAlertDialogsFactory = clientAlertDialogsFactory, + errorHandling = StubErrorHandlerOnFlow(), + retryLastAction = retryLastAction + ) + viewModel.doInit(verification = isVerification) + } + + @Test + fun `initial state`() = runTest { + val snapshot = viewModel.uiData.toList() + Assert.assertEquals(3, snapshot.size) + Assert.assertTrue(snapshot[0] is NavigationPanelMlcData) + Assert.assertTrue(snapshot[1] is NumButtonTileOrganismData) + Assert.assertTrue(snapshot[2] is TextLabelMlcData) + } + + @Test + fun `alternative auth enabled`() = runTest { + whenever(pinHelper.isAlternativeAuthEnabled()).thenReturn(true) + viewModel.navigation.test { + viewModel.checkForAlternativeAuth() + Assert.assertTrue(awaitItem() is PinInputVM.Navigation.AlternativeAuth) + } + } + + @Test + fun `alternative auth success`() = runTest { + viewModel.navigation.test { + viewModel.onAlternativeAuthSuccessful() + val expected = if (isVerification) { + PinInputVM.Navigation.PinApproved + } else { + PinInputVM.Navigation.ToHome + } + Assert.assertEquals(expected, awaitItem()) + } + } + + @Test + fun `alternative auth failed`() = runTest { + whenever(pinHelper.isAlternativeAuthEnabled()).thenReturn(true) + viewModel.checkForAlternativeAuth() + repeat(6) { attempt -> + viewModel.onAlternativeAuthFailed() + advanceUntilIdle() + Assert.assertEquals( + attempt < 5, + viewModel.uiData.firstNotNullOf { it as? NumButtonTileOrganismData }.hasBiometric + ) + } + } + + @Test + fun `launch alternative auth`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.TOUCH_ID_BUTTON)) + Assert.assertEquals(PinInputVM.Navigation.AlternativeAuth, awaitItem()) + } + } + + @Test + fun `valid pin input`() = runTest { + whenever(loginPinRepository.isPinValid("1234")).thenReturn(true) + viewModel.navigation.test { + viewModel.onUIAction(UIAction(PIN_CREATED_NUM_BUTTON_ORGANISM, "1234")) + val expected = if (isVerification) { + PinInputVM.Navigation.PinApproved + } else { + PinInputVM.Navigation.ToHome + } + Assert.assertEquals(expected, awaitItem()) + } + } + + @Test + fun `valid pin input service user`() = runTest { + whenever(authorizationRepository.getUserType()).thenReturn(UserType.SERVICE_USER) + whenever(loginPinRepository.isPinValid("1234")).thenReturn(true) + viewModel.navigation.test { + viewModel.onUIAction(UIAction(PIN_CREATED_NUM_BUTTON_ORGANISM, "1234")) + val expected = if (isVerification) { + PinInputVM.Navigation.PinApproved + } else { + PinInputVM.Navigation.ToQr + } + Assert.assertEquals(expected, awaitItem()) + } + } + + @Test + fun `exceed invalid try count`() = runTest { + whenever(loginPinRepository.isPinValid(any())).thenReturn(false) + whenever(clientAlertDialogsFactory.showAlertAfterInvalidPin()).thenReturn(mock()) + viewModel.showTemplateDialog.test { + repeat(3) { + viewModel.onUIAction(UIAction(PIN_CREATED_NUM_BUTTON_ORGANISM, "1234")) + awaitFor { + viewModel.uiData.firstNotNullOf { + it as? NumButtonTileOrganismData + }.clearWithShake + } + } + awaitItem() + verify(clientAlertDialogsFactory).showAlertAfterInvalidPin() + } + } + + @Test + fun `show reset pin`() = runTest { + viewModel.showTemplateDialog.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.TEXT_LABEL_MLC)) + Assert.assertEquals( + ActionsConst.DIALOG_ACTION_CODE_LOGOUT, + awaitItem().getContentIfNotHandled()?.data?.alternativeButton?.action + ) + } + } + + @Test + fun `reset pin`() = runTest { + viewModel.resetPin() + Assert.assertTrue(actionLogout.value?.notHandedYet == true) + } + + @Test + fun `pin cleared`() = runTest { + viewModel.onUIAction(UIAction(UIActionKeysCompose.PIN_CLEARED_NUM_BUTTON_ORGANISM)) + val data = viewModel.uiData.firstNotNullOf { it as? NumButtonTileOrganismData } + Assert.assertFalse(data.clearPin) + Assert.assertFalse(data.clearWithShake) + } + + @Test + fun `empty actions`() = runTest { + viewModel.onUIAction(UIAction(PIN_CREATED_NUM_BUTTON_ORGANISM)) + advanceUntilIdle() + verify(loginPinRepository, never()).isPinValid(any()) + } + + companion object { + + @JvmStatic + @Parameters + fun parameters() = listOf(true, false) + } +} diff --git a/pin/src/test/java/ua/gov/diia/pin/ui/reset/ResetPinVMTest.kt b/pin/src/test/java/ua/gov/diia/pin/ui/reset/ResetPinVMTest.kt new file mode 100644 index 0000000..9f23345 --- /dev/null +++ b/pin/src/test/java/ua/gov/diia/pin/ui/reset/ResetPinVMTest.kt @@ -0,0 +1,129 @@ +package ua.gov.diia.pin.ui.reset + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import app.cash.turbine.test +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnitRunner +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ui_base.navigation.BaseNavigation +import ua.gov.diia.core.util.alert.ClientAlertDialogsFactory +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.pin.repository.LoginPinRepository +import ua.gov.diia.pin.rules.MainDispatcherRule +import ua.gov.diia.pin.utils.StubErrorHandlerOnFlow +import ua.gov.diia.ui_base.components.infrastructure.DataActionWrapper +import ua.gov.diia.ui_base.components.infrastructure.event.UIAction +import ua.gov.diia.ui_base.components.infrastructure.event.UIActionKeysCompose +import ua.gov.diia.ui_base.components.molecule.text.TextLabelMlcData +import ua.gov.diia.ui_base.components.organism.header.TopGroupOrgData +import ua.gov.diia.ui_base.components.organism.tile.NumButtonTileOrganismData + +@RunWith(MockitoJUnitRunner::class) +class ResetPinVMTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Mock + lateinit var loginPinRepository: LoginPinRepository + + @Mock + lateinit var clientAlertDialogsFactory: ClientAlertDialogsFactory + + @Mock + lateinit var retryLastAction: WithRetryLastAction + + private val actionLogout = MutableLiveData() + + private lateinit var viewModel: ResetPinVM + + @Before + fun setUp() { + viewModel = ResetPinVM( + actionLogout = actionLogout, + loginPinRepository = loginPinRepository, + clientAlertDialogsFactory = clientAlertDialogsFactory, + errorHandling = StubErrorHandlerOnFlow(), + retryLastAction = retryLastAction, + ) + } + + @Test + fun `initial state`() = runTest { + val snapshot = viewModel.uiData.toList() + Assert.assertEquals(3, snapshot.size) + Assert.assertTrue(snapshot[0] is TopGroupOrgData) + Assert.assertTrue(snapshot[1] is TextLabelMlcData) + Assert.assertTrue(snapshot[2] is NumButtonTileOrganismData) + } + + @Test + fun `navigate back`() = runTest { + viewModel.navigation.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.TOOLBAR_NAVIGATION_BACK)) + Assert.assertEquals(BaseNavigation.Back, awaitItem()) + } + viewModel.navigation.test { + viewModel.onUIAction(UIAction( + actionKey = UIActionKeysCompose.TITLE_GROUP_MLC, + action = DataActionWrapper(type = ActionsConst.ACTION_NAVIGATE_BACK) + )) + Assert.assertEquals(BaseNavigation.Back, awaitItem()) + } + } + + @Test + fun `reset valid pin`() = runTest { + whenever(loginPinRepository.isPinValid(any())).thenReturn(true) + viewModel.navigation.test { + viewModel.onUIAction( + UIAction( + UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, + data = "1234" + ) + ) + Assert.assertEquals(ResetPinVM.Navigation.CreateNewPin, awaitItem()) + } + } + + @Test + fun logout() = runTest { + viewModel.resetPin() + Assert.assertTrue(actionLogout.value?.notHandedYet == true) + } + + @Test + fun `reset invalid pin`() = runTest { + whenever(loginPinRepository.isPinValid(any())).thenReturn(false) + whenever(loginPinRepository.getPinTryCount()).thenReturn(3) + whenever(clientAlertDialogsFactory.showAlertAfterInvalidPin()).thenReturn(mock()) + viewModel.showTemplateDialog.test { + viewModel.onUIAction(UIAction(UIActionKeysCompose.PIN_CREATED_NUM_BUTTON_ORGANISM, "1234")) + awaitItem() + verify(clientAlertDialogsFactory).showAlertAfterInvalidPin() + } + } + + @Test + fun `pin cleared`() = runTest { + viewModel.onUIAction(UIAction(UIActionKeysCompose.PIN_CLEARED_NUM_BUTTON_ORGANISM)) + val data = viewModel.uiData.firstNotNullOf { it as? NumButtonTileOrganismData } + Assert.assertFalse(data.clearPin) + Assert.assertFalse(data.clearWithShake) + } +} \ No newline at end of file diff --git a/pin/src/test/java/ua/gov/diia/pin/utils/StubErrorHandlerOnFlow.kt b/pin/src/test/java/ua/gov/diia/pin/utils/StubErrorHandlerOnFlow.kt new file mode 100644 index 0000000..4d14f39 --- /dev/null +++ b/pin/src/test/java/ua/gov/diia/pin/utils/StubErrorHandlerOnFlow.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.pin.utils + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import ua.gov.diia.core.models.dialogs.TemplateDialogModel +import ua.gov.diia.core.util.delegation.WithErrorHandlingOnFlow +import ua.gov.diia.core.util.event.UiDataEvent + +class StubErrorHandlerOnFlow : WithErrorHandlingOnFlow { + + override val showTemplateDialog = MutableSharedFlow>( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + var lastError: Exception? = null + private set + + override fun showTemplateDialog(templateDialog: TemplateDialogModel, key: String) { + showTemplateDialog.tryEmit(UiDataEvent(templateDialog.setKey(key))) + } + + override fun consumeException(exception: Exception, key: String, needRetry: Boolean) { + lastError = exception + } + + override fun resetErrorCounter() = Unit +} \ No newline at end of file diff --git a/pin/src/test/java/ua/gov/diia/pin/utils/TestUtils.kt b/pin/src/test/java/ua/gov/diia/pin/utils/TestUtils.kt new file mode 100644 index 0000000..0898240 --- /dev/null +++ b/pin/src/test/java/ua/gov/diia/pin/utils/TestUtils.kt @@ -0,0 +1,26 @@ +package ua.gov.diia.pin.utils + +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.yield + +suspend fun assertAwait(block: suspend () -> Unit) { + while (true) { + try { + block() + break + } catch (_: AssertionError) { + yield() + } + } +} + +suspend fun awaitFor(call: () -> Boolean) { + while (currentCoroutineContext().isActive) { + if (call()) { + return + } else { + yield() + } + } +} \ No newline at end of file diff --git a/ps_criminal_cert/.gitignore b/ps_criminal_cert/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ps_criminal_cert/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ps_criminal_cert/README.md b/ps_criminal_cert/README.md new file mode 100644 index 0000000..84841da --- /dev/null +++ b/ps_criminal_cert/README.md @@ -0,0 +1,53 @@ +# Description + +This is module responsible for criminal certificate public service. + +# How to install +1. Copy module folder to your project and add module to gradle dependency like this: + +```groovy +implementation project(':ps_criminal_cert') +``` + +2. Module requires next modules to work +```groovy +implementation project(path: ':ui_base') +implementation project(path: ':core') +implementation project(path: ':publicservice') +implementation project(path: ':address_search') +implementation project(path: ':search') +``` + +3. nav_id file describe all ids that this module requires. Entry point should implement all those ids. + +`./src/main/res/values/nav_ids.xml` + +4. Enter point should implement next interfaces and provide them through Hilt DI: + +`./src/main/java/ua/gov/diia/ps_criminal_cert/helper/PSCriminalCertHelper.kt` + +5. Add next nav graphs to main navigation graph +```xml + +``` +6. The following action should be added into the root navigation graph +```xml + + + + +``` \ No newline at end of file diff --git a/ps_criminal_cert/build.gradle b/ps_criminal_cert/build.gradle new file mode 100644 index 0000000..6142207 --- /dev/null +++ b/ps_criminal_cert/build.gradle @@ -0,0 +1,156 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'androidx.navigation.safeargs.kotlin' + id 'kotlin-kapt' + id 'kotlin-android' + id 'kotlin-parcelize' + id 'dagger.hilt.android.plugin' +} + +apply from: '../dependencies.gradle' + +android { + namespace 'ua.gov.diia.ps_criminal_cert' + compileSdk 34 + + defaultConfig { + minSdk 23 + targetSdk 34 + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + + flavorDimensions = ["vendor"] + productFlavors { + gplay { + dimension "vendor" + } + + huawei { + dimension "vendor" + } + } + + buildTypes { + release { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + prod { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + stage { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + debug { + testCoverageEnabled true + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + vendors { + minifyEnabled true + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), + 'proguard-rules.pro' + } + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + composeOptions { + kotlinCompilerExtensionVersion '1.3.2' + } + + kotlinOptions { + jvmTarget = "11" + } + + kapt { + correctErrorTypes true + } + + lint { + disable 'MissingTranslation' + } + + buildFeatures { + dataBinding = true + compose = true + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) + implementation deps.activity_ktx + implementation deps.fragment_ktx + implementation deps.appcompat + implementation project(path: ':ui_base') + implementation project(path: ':core') + implementation project(path: ':publicservice') + implementation project(path: ':address_search') + implementation project(path: ':search') + //lifecycle + implementation deps.lifecycle_extensions + implementation deps.lifecycle_livedata_ktx + implementation deps.lifecycle_viewmodel_ktx + //navigation + implementation deps.navigation_fragment_ktx + implementation deps.navigation_ui_ktx + //retrofit + implementation deps.retrofit + // Moshi + implementation deps.moshi + implementation deps.moshi_adapters + implementation deps.moshi_kotlin + implementation deps.retrofit_moshi_converter + kapt deps.moshi_codegen + // chrome tabs + implementation deps.browser + //Desugaring + coreLibraryDesugaring deps.desugar_jdk_libs + //hilt + implementation deps.hilt_android + kapt deps.hilt_android_compiler + implementation deps.hilt_navigation_fragment + kapt deps.hilt_compiler + //viewpager + implementation deps.viewpager + //glide + implementation deps.glide + kapt deps.glide_compiler + //lottie + implementation deps.lottie + + implementation deps.better_link_movement_method + + //Compose + implementation deps.activity_compose + //Paging + implementation deps.paging_runtime_ktx + + //test + testImplementation deps.junit + testImplementation deps.mockito_inline + testImplementation deps.mockito_kotlin + testImplementation deps.mockito_core + testImplementation deps.kotlinx_coroutines_test + testImplementation deps.androidx_core_testing + testImplementation deps.hamcrest_library + testImplementation deps.mockwebserver + testImplementation deps.json + androidTestImplementation deps.android_junit + androidTestImplementation deps.android_espresso_core +} +apply from: '../jacoco.gradle' \ No newline at end of file diff --git a/ps_criminal_cert/consumer-rules.pro b/ps_criminal_cert/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ps_criminal_cert/excludes.jacoco b/ps_criminal_cert/excludes.jacoco new file mode 100644 index 0000000..27c4ab1 --- /dev/null +++ b/ps_criminal_cert/excludes.jacoco @@ -0,0 +1,4 @@ +ua/gov/diia/ps_criminal_cert/ui/**/*F.* +ua/gov/diia/ps_criminal_cert/**/*$*.* +ua/gov/diia/ps_criminal_cert/generated/* +ua/gov/diia/ps_criminal_cert/**/*Adapter.* \ No newline at end of file diff --git a/ps_criminal_cert/proguard-rules.pro b/ps_criminal_cert/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ps_criminal_cert/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/ps_criminal_cert/src/androidTest/java/ua/gov/diia/ps_criminal_cert/ExampleInstrumentedTest.kt b/ps_criminal_cert/src/androidTest/java/ua/gov/diia/ps_criminal_cert/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f7454c8 --- /dev/null +++ b/ps_criminal_cert/src/androidTest/java/ua/gov/diia/ps_criminal_cert/ExampleInstrumentedTest.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.ps_criminal_cert + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = + InstrumentationRegistry.getInstrumentation().targetContext + assertEquals( + "ua.gov.diia.ps_criminal_cert.test", + appContext.packageName + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/AndroidManifest.xml b/ps_criminal_cert/src/main/AndroidManifest.xml new file mode 100644 index 0000000..91ac317 --- /dev/null +++ b/ps_criminal_cert/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/di/CriminalCertApiModule.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/di/CriminalCertApiModule.kt new file mode 100644 index 0000000..e3a29ea --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/di/CriminalCertApiModule.kt @@ -0,0 +1,20 @@ +package ua.gov.diia.ps_criminal_cert.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert + +@Module +@InstallIn(SingletonComponent::class) +object CriminalCertApiModule { + + @Provides + @AuthorizedClient + fun provideApiCriminalCert( + @AuthorizedClient retrofit: Retrofit + ): ApiCriminalCert = retrofit.create(ApiCriminalCert::class.java) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/helper/PSCriminalCertHelper.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/helper/PSCriminalCertHelper.kt new file mode 100644 index 0000000..305a671 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/helper/PSCriminalCertHelper.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.ps_criminal_cert.helper + +import androidx.fragment.app.Fragment + +interface PSCriminalCertHelper { + + /** + * @return navigation action to Damaged Property Recovery public service + * */ + fun navigateToDamagedPropertyRecovery(fragment: Fragment, applicationId: String?) + + /** + * @return navigation action to go back to the Damaged Property Recovery public service with result + * */ + fun navigateToDPRecoveryHomeF(fragment: Fragment, resId: String?) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/Birth.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/Birth.kt new file mode 100644 index 0000000..0e81fb0 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/Birth.kt @@ -0,0 +1,10 @@ +package ua.gov.diia.ps_criminal_cert.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class Birth( + val country: String, + val city: String +) : Parcelable \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/CriminalCertHomeState.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/CriminalCertHomeState.kt new file mode 100644 index 0000000..14c98d2 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/CriminalCertHomeState.kt @@ -0,0 +1,9 @@ +package ua.gov.diia.ps_criminal_cert.models + +data class CriminalCertHomeState( + val hasDoneList: Boolean? = null, + val hasProcessingList: Boolean? = null +) { + + val hasContent = hasDoneList == true || hasProcessingList == true +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/CriminalCertUserData.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/CriminalCertUserData.kt new file mode 100644 index 0000000..cdec485 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/CriminalCertUserData.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.ps_criminal_cert.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertType +import ua.gov.diia.ps_criminal_cert.models.request.PublicService + +@Parcelize +data class CriminalCertUserData( + val reasonId: String? = null, + val certificateType: CriminalCertType? = null, + val prevNames: PreviousNames? = null, + val birth: Birth? = null, + val nationalities: List? = null, + val registrationAddressId: String? = null, + val phoneNumber: String? = null, + val publicService: PublicService? = null +) : Parcelable \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/PreviousNames.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/PreviousNames.kt new file mode 100644 index 0000000..2f809ef --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/PreviousNames.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.ps_criminal_cert.models + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class PreviousNames( + val previousFirstNameList: List? = null, + val previousMiddleNameList: List? = null, + val previousLastNameList: List? = null +) : Parcelable \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertApplicationInfoNextStep.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertApplicationInfoNextStep.kt new file mode 100644 index 0000000..6910a2d --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertApplicationInfoNextStep.kt @@ -0,0 +1,6 @@ +package ua.gov.diia.ps_criminal_cert.models.enums + +@Suppress("EnumEntryName") +enum class CriminalCertApplicationInfoNextStep { + reasons, requester +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertLoadActionType.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertLoadActionType.kt new file mode 100644 index 0000000..6a5af92 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertLoadActionType.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.ps_criminal_cert.models.enums + +import com.squareup.moshi.Json + +enum class CriminalCertLoadActionType { + @Json(name = "downloadArchive") + DOWNLOAD_ARCHIVE, + + @Json(name = "viewPdf") + VIEW_PDF +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertScreen.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertScreen.kt new file mode 100644 index 0000000..eded3c8 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertScreen.kt @@ -0,0 +1,17 @@ +package ua.gov.diia.ps_criminal_cert.models.enums + +import com.squareup.moshi.Json + +enum class CriminalCertScreen { + @Json(name = "birthPlace") + BIRTH_PLACE, + + @Json(name = "nationalities") + NATIONALITIES, + + @Json(name = "registrationPlace") + REGISTRATION_PLACE, + + @Json(name = "contacts") + CONTACTS +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertStatus.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertStatus.kt new file mode 100644 index 0000000..b6d21bc --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertStatus.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.ps_criminal_cert.models.enums + +import com.squareup.moshi.Json + +enum class CriminalCertStatus(val str: String) { + @Json(name = "applicationProcessing") + PROCESSING("applicationProcessing"), + + @Json(name = "done") + DONE("done") +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertType.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertType.kt new file mode 100644 index 0000000..652d4b6 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/enums/CriminalCertType.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.ps_criminal_cert.models.enums + +import com.squareup.moshi.Json + +enum class CriminalCertType { + @Json(name = "full") + FULL, + + @Json(name = "short") + SHORT +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/request/CriminalCertConfirmationRequest.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/request/CriminalCertConfirmationRequest.kt new file mode 100644 index 0000000..948aa35 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/request/CriminalCertConfirmationRequest.kt @@ -0,0 +1,39 @@ +package ua.gov.diia.ps_criminal_cert.models.request + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertType + +@JsonClass(generateAdapter = true) +data class CriminalCertConfirmationRequest( + @Json(name = "reasonId") + val reasonId: String?, + @Json(name = "certificateType") + val certificateType: CriminalCertType?, + @Json(name = "previousFirstName") + val previousFirstName: String?, + @Json(name = "previousMiddleName") + val previousMiddleName: String?, + @Json(name = "previousLastName") + val previousLastName: String?, + @Json(name = "birthPlace") + val birthPlace: BirthPlace?, + @Json(name = "nationalities") + val nationalities: List?, + @Json(name = "registrationAddressId") + val registrationAddressId: String?, + @Json(name = "phoneNumber") + val phoneNumber: String?, + @Json(name = "email") + val email: String? = null, + @Json(name = "publicService") + val publicService: PublicService? +) { + @JsonClass(generateAdapter = true) + data class BirthPlace( + @Json(name = "country") + val country: String?, + @Json(name = "city") + val city: String? + ) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/request/PublicService.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/request/PublicService.kt new file mode 100644 index 0000000..3aa7a74 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/request/PublicService.kt @@ -0,0 +1,16 @@ +package ua.gov.diia.ps_criminal_cert.models.request + + +import android.os.Parcelable +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import kotlinx.parcelize.Parcelize + +@Parcelize +@JsonClass(generateAdapter = true) +data class PublicService( + @Json(name = "code") + val code: String?, + @Json(name = "resourceId") + val resourceId: String? +): Parcelable \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertBirthPlace.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertBirthPlace.kt new file mode 100644 index 0000000..553cb94 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertBirthPlace.kt @@ -0,0 +1,59 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertBirthPlace( + @Json(name = "birthPlaceDataScreen") + val data: BirthPlaceDataScreen?, + @Json(name = "template") + val template: TemplateDialogModel? = null, +) { + + @JsonClass(generateAdapter = true) + data class BirthPlaceDataScreen( + @Json(name = "title") + val title: String?, + @Json(name = "country") + val country: Country?, + @Json(name = "city") + val city: City?, + @Json(name = "nextScreen") + val nextScreen: CriminalCertScreen? + ) + + @JsonClass(generateAdapter = true) + data class Country( + @Json(name = "label") + val label: String, + @Json(name = "value") + val value: String?, + @Json(name = "hint") + val hint: String, + @Json(name = "checkbox") + val checkbox: String?, + @Json(name = "otherCountry") + val otherCountry: OtherCountry? + ) + + @JsonClass(generateAdapter = true) + data class City( + @Json(name = "label") + val label: String, + @Json(name = "hint") + val hint: String, + @Json(name = "description") + val description: String? + ) + + @JsonClass(generateAdapter = true) + data class OtherCountry( + @Json(name = "label") + val label: String, + @Json(name = "hint") + val hint: String + ) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertConfirmation.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertConfirmation.kt new file mode 100644 index 0000000..7f75324 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertConfirmation.kt @@ -0,0 +1,91 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.message.AttentionMessageParameterized +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertConfirmation( + @Json(name = "application") + val application: Application?, + @Json(name = "processCode") + val processCode: Int?, + @Json(name = "template") + val template: TemplateDialogModel? +) { + + @JsonClass(generateAdapter = true) + data class Application( + @Json(name = "title") + val title: String?, + @Json(name = "attentionMessage") + val attentionMessage: AttentionMessageParameterized?, + @Json(name = "applicant") + val applicant: Applicant?, + @Json(name = "contacts") + val contacts: Contacts?, + @Json(name = "certificateType") + val certificateType: CertificateType?, + @Json(name = "reason") + val reason: Reason?, + @Json(name = "checkboxName") + val checkboxName: String?, + ) + + @JsonClass(generateAdapter = true) + data class Applicant( + @Json(name = "title") + val title: String?, + @Json(name = "fullName") + val fullName: LabelValuePair?, + @Json(name = "previousFirstName") + val previousFirstName: LabelValuePair?, + @Json(name = "previousLastName") + val previousLastName: LabelValuePair?, + @Json(name = "previousMiddleName") + val previousMiddleName: LabelValuePair?, + @Json(name = "gender") + val gender: LabelValuePair?, + @Json(name = "nationality") + val nationality: LabelValuePair?, + @Json(name = "birthDate") + val birthDate: LabelValuePair?, + @Json(name = "birthPlace") + val birthPlace: LabelValuePair?, + @Json(name = "registrationAddress") + val registrationAddress: LabelValuePair?, + ) + + @JsonClass(generateAdapter = true) + data class Contacts( + @Json(name = "title") + val title: String?, + @Json(name = "phoneNumber") + val phoneNumber: LabelValuePair? + ) + + @JsonClass(generateAdapter = true) + data class CertificateType( + @Json(name = "title") + val title: String?, + @Json(name = "type") + val type: String? + ) + + @JsonClass(generateAdapter = true) + data class Reason( + @Json(name = "title") + val title: String?, + @Json(name = "reason") + val reason: String? + ) + + @JsonClass(generateAdapter = true) + data class LabelValuePair( + @Json(name = "label") + val label: String, + @Json(name = "value") + val value: String + ) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertConfirmed.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertConfirmed.kt new file mode 100644 index 0000000..51b5aeb --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertConfirmed.kt @@ -0,0 +1,18 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.NavigationPanel +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertConfirmed( + @Json(name = "applicationId") + val applicationId: String?, + @Json(name = "processCode") + val processCode: Int?, + @Json(name = "template") + val template: TemplateDialogModel?, + @Json(name = "navigationPanel") + val navigationPanel: NavigationPanel?, +) \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertContacts.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertContacts.kt new file mode 100644 index 0000000..3b501fb --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertContacts.kt @@ -0,0 +1,19 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertContacts( + @Json(name = "title") + val title: String?, + @Json(name = "text") + val text: String?, + @Json(name = "phoneNumber") + val phoneNumber: String?, + @Json(name = "email") + val email: String?, + @Json(name = "template") + val template: TemplateDialogModel? = null +) \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertDetails.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertDetails.kt new file mode 100644 index 0000000..0be99dd --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertDetails.kt @@ -0,0 +1,51 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.NavigationPanel +import ua.gov.diia.core.models.common.menu.ContextMenuItem +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertLoadActionType +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertStatus +import ua.gov.diia.core.models.rating_service.RatingFormModel + +@JsonClass(generateAdapter = true) +data class CriminalCertDetails( + @Json(name = "contextMenu") + val contextMenu: List?, + @Json(name = "title") + val title: String, + @Json(name = "status") + val status: CriminalCertStatus, + @Json(name = "statusMessage") + val statusMessage: StatusMessage, + @Json(name = "loadActions") + val loadActions: List?, + @Json(name = "ratingForm") + val ratingForm: RatingFormModel?, + @Json(name = "navigationPanel") + val navigationPanel: NavigationPanel? +) { + + @JsonClass(generateAdapter = true) + data class StatusMessage( + @Json(name = "title") + val title: String?, + @Json(name = "text") + val text: String?, + @Json(name = "icon") + val icon: String? + ) + + @JsonClass(generateAdapter = true) + data class LoadAction( + @Json(name = "type") + val type: CriminalCertLoadActionType, + @Json(name = "icon") + val icon: String?, + @Json(name = "name") + val name: String, + + val isLoading: Boolean = false, + val isEnabled: Boolean = true + ) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertFileData.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertFileData.kt new file mode 100644 index 0000000..1a9bfa9 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertFileData.kt @@ -0,0 +1,13 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertFileData( + @Json(name = "file") + val file: String?, + @Json(name = "template") + val template: TemplateDialogModel?, +) \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertInfo.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertInfo.kt new file mode 100644 index 0000000..a2a64a2 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertInfo.kt @@ -0,0 +1,22 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.common.message.AttentionMessage +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertInfo( + @Json(name = "showContextMenu") + val showContextMenu: Boolean? = null, + @Json(name = "title") + val title: String? = null, + @Json(name = "text") + val text: String? = null, + @Json(name = "attentionMessage") + val attentionMessage: AttentionMessage? = null, + @Json(name = "template") + val template: TemplateDialogModel? = null, + @Json(name = "nextScreen") + val nextScreen: String? = null, +) diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertListData.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertListData.kt new file mode 100644 index 0000000..505fa8e --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertListData.kt @@ -0,0 +1,72 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import androidx.annotation.ColorRes +import androidx.annotation.StringRes +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.core.models.common.NavigationPanel +import ua.gov.diia.core.models.common.message.StubMessage +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertStatus + +@JsonClass(generateAdapter = true) +data class CriminalCertListData( + @Json(name = "stubMessage") + val stubMessage: StubMessage?, + @Json(name = "total") + val total: Int?, + @Json(name = "certificatesStatus") + val certsStatus: Status?, + @Json(name = "certificates") + val certificates: List?, + @Json(name = "navigationPanel") + val navigationPanel: NavigationPanel?, +) { + + @JsonClass(generateAdapter = true) + data class CertItem( + @Json(name = "applicationId") + val id: String, + @Json(name = "status") + val status: CriminalCertStatus?, + @Json(name = "reason") + val reason: String, + @Json(name = "creationDate") + val creationDate: String, + @Json(name = "type") + val type: String, + ) { + val labelBackgroundRes: Int + @ColorRes + get() = when (status) { + CriminalCertStatus.PROCESSING -> R.color.state_collecting + CriminalCertStatus.DONE -> R.color.state_approved + null -> R.color.state_collecting + } + + val labelTextRes: Int + @StringRes + get() = when (status) { + CriminalCertStatus.PROCESSING -> R.string.criminal_cert_state_processing + CriminalCertStatus.DONE -> R.string.criminal_cert_state_done + null -> R.string.criminal_cert_state_processing + } + + + val labelTextColor: Int + @ColorRes + get() = when (status) { + CriminalCertStatus.PROCESSING -> R.color.black + CriminalCertStatus.DONE -> R.color.white + null -> R.color.black + } + } + + @JsonClass(generateAdapter = true) + data class Status( + @Json(name = "code") + val status: CriminalCertStatus, + @Json(name = "name") + val name: String, + ) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertNationalities.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertNationalities.kt new file mode 100644 index 0000000..1c1748e --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertNationalities.kt @@ -0,0 +1,57 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.ui_base.views.NameModel +import ua.gov.diia.core.models.common.message.AttentionMessage +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertNationalities( + @Json(name = "nationalitiesScreen") + val data: NationalitiesScreen?, + @Json(name = "template") + val template: TemplateDialogModel? = null, + + val countryList: List = emptyList() +) { + + val showAttentionMessage = data?.attentionMessage != null + val isNextAvailable = countryList.any { it.name.isNotBlank() } + val canAdd = countryList.size < (data?.maxNationalitiesCount ?: 0) + && countryList.any { it.name.uppercase() != "УКРАЇНА" } + && countryList.any { it.name.isNotBlank() } + + @JsonClass(generateAdapter = true) + data class NationalitiesScreen( + @Json(name = "title") + val title: String?, + @Json(name = "attentionMessage") + val attentionMessage: AttentionMessage?, + @Json(name = "country") + val country: Country?, + @Json(name = "maxNationalitiesCount") + val maxNationalitiesCount: Int?, + @Json(name = "nextScreen") + val nextScreen: CriminalCertScreen? + ) + + @JsonClass(generateAdapter = true) + data class Country( + @Json(name = "label") + val label: String, + @Json(name = "hint") + val hint: String, + @Json(name = "addAction") + val addAction: Action? + ) + + @JsonClass(generateAdapter = true) + data class Action( + @Json(name = "icon") + val icon: String, + @Json(name = "name") + val name: String + ) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertReasons.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertReasons.kt new file mode 100644 index 0000000..4aeaa86 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertReasons.kt @@ -0,0 +1,28 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertReasons( + @Json(name = "title") + val title: String?, + @Json(name = "subtitle") + val subtitle: String?, + @Json(name = "reasons") + val reasons: List?, + @Json(name = "template") + val template: TemplateDialogModel? = null, +) { + + @JsonClass(generateAdapter = true) + data class Reason( + @Json(name = "code") + val code: String, + @Json(name = "name") + val name: String, + + val isSelected: Boolean = false + ) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertRequester.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertRequester.kt new file mode 100644 index 0000000..ec68c0d --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertRequester.kt @@ -0,0 +1,51 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.ui_base.views.NameModel +import ua.gov.diia.core.models.common.message.AttentionMessage +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertRequester( + @Json(name = "requesterDataScreen") + val requesterDataScreen: RequesterDataScreen?, + @Json(name = "processCode") + val processCode: Int?, + @Json(name = "template") + val template: TemplateDialogModel?, + + val prevFirstNameData: NameData = NameData(), + val prevMiddleNameData: NameData = NameData(), + val prevLastNameData: NameData = NameData() +) { + + val showAttentionMessage = requesterDataScreen?.attentionMessage != null + + @JsonClass(generateAdapter = true) + data class RequesterDataScreen( + @Json(name = "title") + val title: String, + @Json(name = "attentionMessage") + val attentionMessage: AttentionMessage?, + @Json(name = "fullName") + val fullName: Name, + @Json(name = "nextScreen") + val nextScreen: CriminalCertScreen, + ) + + @JsonClass(generateAdapter = true) + data class Name( + @Json(name = "label") + val label: String, + @Json(name = "value") + val value: String + ) + + data class NameData( + val list: List = emptyList() + ) { + val canAdd: Boolean = list.size < 10 && list.all { it.name.isNotBlank() } + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertTypes.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertTypes.kt new file mode 100644 index 0000000..51e1d4b --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/models/response/CriminalCertTypes.kt @@ -0,0 +1,31 @@ +package ua.gov.diia.ps_criminal_cert.models.response + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertType +import ua.gov.diia.core.models.dialogs.TemplateDialogModel + +@JsonClass(generateAdapter = true) +data class CriminalCertTypes( + @Json(name = "title") + val title: String?, + @Json(name = "subtitle") + val subtitle: String?, + @Json(name = "types") + val types: List?, + @Json(name = "template") + val template: TemplateDialogModel? = null +) { + + @JsonClass(generateAdapter = true) + data class Type( + @Json(name = "code") + val code: CriminalCertType, + @Json(name = "name") + val name: String, + @Json(name = "description") + val description: String, + + val isSelected: Boolean = false + ) +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/network/ApiCriminalCert.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/network/ApiCriminalCert.kt new file mode 100644 index 0000000..d181d23 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/network/ApiCriminalCert.kt @@ -0,0 +1,92 @@ +package ua.gov.diia.ps_criminal_cert.network + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query +import ua.gov.diia.ps_criminal_cert.models.request.CriminalCertConfirmationRequest +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertBirthPlace +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertConfirmation +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertConfirmed +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertContacts +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertDetails +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertFileData +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertInfo +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertListData +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertNationalities +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertReasons +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertRequester +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertTypes +import ua.gov.diia.core.network.annotation.Analytics + +interface ApiCriminalCert { + + @Analytics("getCriminalCertList") + @GET("api/v1/public-service/criminal-cert/applications/{applicationStatus}") + suspend fun getCriminalCertList( + @Path("applicationStatus") certStatus: String, + @Query("skip") skip: Int, + @Query("limit") limit: Int + ): CriminalCertListData + + @Analytics("getCriminalCertsDetails") + @GET("api/v1/public-service/criminal-cert/{certId}") + suspend fun getCriminalCertsDetails( + @Path("certId") id: String + ): CriminalCertDetails + + @Analytics("getCriminalCertPdf") + @GET("api/v1/public-service/criminal-cert/{certId}/pdf") + suspend fun getCriminalCertPdf( + @Path("certId") certId: String + ): CriminalCertFileData + + @Analytics("getCriminalCertZip") + @GET("api/v1/public-service/criminal-cert/{certId}/download") + suspend fun getCriminalCertZip( + @Path("certId") certId: String + ): CriminalCertFileData + + @Analytics("getCriminalCertInfo") + @GET("api/v1/public-service/criminal-cert/application/info") + suspend fun getCriminalCertInfo( + @Query("publicService") publicService: String? + ): CriminalCertInfo + + @Analytics("getCriminalCertReasons") + @GET("api/v1/public-service/criminal-cert/reasons") + suspend fun getCriminalCertReasons(): CriminalCertReasons + + @Analytics("getCriminalCertTypes") + @GET("api/v1/public-service/criminal-cert/types") + suspend fun getCriminalCertTypes(): CriminalCertTypes + + @Analytics("getCriminalCertRequester") + @GET("api/v1/public-service/criminal-cert/requester") + suspend fun getCriminalCertRequester(): CriminalCertRequester + + @Analytics("getCriminalCertBirthPlace") + @GET("api/v1/public-service/criminal-cert/birth-place") + suspend fun getCriminalCertBirthPlace(): CriminalCertBirthPlace + + @Analytics("getCriminalCertNationalities") + @GET("api/v1/public-service/criminal-cert/nationalities") + suspend fun getCriminalCertNationalities(): CriminalCertNationalities + + @Analytics("getCriminalCertContacts") + @GET("api/v1/public-service/criminal-cert/contacts") + suspend fun getCriminalCertContacts(): CriminalCertContacts + + @Analytics("getCriminalCertConfirmationData") + @POST("api/v1/public-service/criminal-cert/confirmation") + suspend fun getCriminalCertConfirmationData( + @Body body: CriminalCertConfirmationRequest + ): CriminalCertConfirmation + + @Analytics("confirmCriminalCert") + @POST("api/v1/public-service/criminal-cert/application") + suspend fun orderCriminalCert( + @Body body: CriminalCertConfirmationRequest + ): CriminalCertConfirmed +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/CriminalCertConst.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/CriminalCertConst.kt new file mode 100644 index 0000000..e4582a2 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/CriminalCertConst.kt @@ -0,0 +1,11 @@ +package ua.gov.diia.ps_criminal_cert.ui + +object CriminalCertConst { + const val FEATURE_CODE = "criminalRecordCertificate" + const val ADDRESS_SCHEMA = "registration-place" + const val ADDRESS_FEATURE_CODE = "criminal-cert" + const val RATING_SERVICE_CATEGORY = "public-service" + const val RATING_SERVICE_CODE = "criminal-cert" + const val TEMPLATE_ACTION_OPEN_STATUS = "damagedPropertyRecoveryStatus" + +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/CriminalCertRatingScreenCodes.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/CriminalCertRatingScreenCodes.kt new file mode 100644 index 0000000..a04b40a --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/CriminalCertRatingScreenCodes.kt @@ -0,0 +1,15 @@ +package ua.gov.diia.ps_criminal_cert.ui + +object CriminalCertRatingScreenCodes { + const val SC_STATUS = "" + const val SC_HOME = "" + const val SC_REGISTRATION_PLACE_SELECTION = "" + const val SC_BIRTH_PLACE = "" + const val SC_APPLICATION = "" + const val SC_CONTACTS = "" + const val SC_NATIONALITY = "" + const val SC_REASON_SELECTION = "" + const val SC_REQUESTER_DATA = "" + const val SC_CERT_TYPE_SELECTION = "" + const val SC_START = "" +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsF.kt new file mode 100644 index 0000000..de6094d --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsF.kt @@ -0,0 +1,157 @@ +package ua.gov.diia.ps_criminal_cert.ui.details + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertDetailsBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.delegation.download_files.base64.DownloadableBase64File +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.doOnSystemBackPressed +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResult +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes + +@AndroidEntryPoint +class CriminalCertDetailsF : Fragment() { + + private val viewModel: CriminalCertDetailsVM by viewModels() + private val args: CriminalCertDetailsFArgs by navArgs() + private var binding: FragmentCriminalCertDetailsBinding? = null + + private var adapter: CriminalCertLoadActionsAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + load(args.certId) + setScreenName(getString(R.string.criminal_cert_title)) + } + adapter = CriminalCertLoadActionsAdapter { + viewModel.loadAction(it, args.certId) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + binding = FragmentCriminalCertDetailsBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + + recyclerView.adapter = adapter + backBtn.setOnClickListener { navigateToList() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertDetailsF, + menu + ) + } + showRatingDialog.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + this@CriminalCertDetailsF, + ratingModel, + args.certId, + R.id.criminalCertDetailsF, + RESULT_KEY_RATING, + CriminalCertRatingScreenCodes.SC_STATUS, + formCode = ratingModel.formCode + ) + } + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertDetailsF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertDetailsF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_STATUS, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + pdfEvent.observeUiDataEvent(viewLifecycleOwner, ::openPdf) + zipEvent.observeUiDataEvent(viewLifecycleOwner, ::shareZip) + } + + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertDetailsF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertDetailsF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + + viewModel.state.observe(viewLifecycleOwner) { + adapter?.submitList(it.loadActions) + } + + doOnSystemBackPressed(::navigateToList) + } + + private fun navigateToList() { + if (args.isNew) { + navigate( + CriminalCertDetailsFDirections.actionCriminalCertDetailsFToCriminalCertHomeF( + contextMenu = viewModel.getMenu(), + isNew = args.isNew + ) + ) + } else { + findNavController().popBackStack() + } + } + + private fun openPdf(pdfData: DownloadableBase64File) { + viewModel.sendPdf(this@CriminalCertDetailsF, pdfData.file, pdfData.name) + } + + private fun shareZip(zipData: DownloadableBase64File) { + viewModel.sendZip(this@CriminalCertDetailsF, zipData.file, zipData.name) + } + + override fun onDestroyView() { + super.onDestroyView() + binding?.recyclerView?.adapter = null + binding = null + } + + override fun onDestroy() { + super.onDestroy() + adapter = null + } + + companion object { + private const val RESULT_KEY_RATING = "RATING" + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsVM.kt new file mode 100644 index 0000000..63e9f06 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertDetailsVM.kt @@ -0,0 +1,178 @@ +package ua.gov.diia.ps_criminal_cert.ui.details + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.DateFormats +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithPushNotification +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.delegation.download_files.base64.DownloadableBase64File +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertLoadActionType +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertLoadActionType.DOWNLOAD_ARCHIVE +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertLoadActionType.VIEW_PDF +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertDetails +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertDetails.LoadAction +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.publicservice.helper.PSNavigationHelper +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class CriminalCertDetailsVM @Inject constructor( + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val pushDelegate: WithPushNotification, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithPushNotification by pushDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper{ + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _state = MutableLiveData() + val state = _state.asLiveData() + + private val _pdfEvent = MutableLiveData>() + val pdfEvent = _pdfEvent.asLiveData() + + private val _zipEvent = MutableLiveData>() + val zipEvent = _zipEvent.asLiveData() + + private val _isZipLoading = MutableLiveData() + private val zipLoadingObserver = Observer { + setLoading(DOWNLOAD_ARCHIVE, it) + } + private val _isPdfLoading = MutableLiveData() + private val pdfLoadingObserver = Observer { + setLoading(VIEW_PDF, it) + } + + private val _screenHeader = MutableLiveData() + val screenHeader = _screenHeader.asLiveData() + + init { + _isPdfLoading.observeForever(pdfLoadingObserver) + _isZipLoading.observeForever(zipLoadingObserver) + } + + fun load(certId: String) { + executeAction(progressIndicator = _isLoading) { + _state.value = api.getCriminalCertsDetails(certId).also { + setContextMenu(it.contextMenu?.toTypedArray()) + if (it.ratingForm != null) { + showRatingDialog(it.ratingForm) + } + it.navigationPanel?.header?.let { h -> + setScreenName(h) + } + } + } + } + + fun loadAction(loadAction: LoadAction, certId: String) { + when (loadAction.type) { + DOWNLOAD_ARCHIVE -> downloadZip(certId) + VIEW_PDF -> downloadPdf(certId) + } + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + private fun downloadZip(certId: String) { + state.value?.also { + executeAction(progressIndicator = _isZipLoading) { + val res = api.getCriminalCertZip(certId) + res.file?.also { + _zipEvent.value = UiDataEvent( + DownloadableBase64File( + file = it, + name = "vytiah_pro_nesudymist_${ + DateFormats.criminalCertFileFormat.format( + Date() + ) + }.zip", + mimeType = "application/zip" + ) + ) + } + res.template?.apply(::showTemplateDialog) + } + } + } + + private fun downloadPdf(certId: String) { + state.value?.also { + executeAction(progressIndicator = _isPdfLoading) { + val res = api.getCriminalCertPdf(certId) + res.file?.also { + _pdfEvent.value = UiDataEvent( + DownloadableBase64File( + file = it, + name = "vytiah_pro_nesudymist_${ + DateFormats.criminalCertFileFormat.format( + Date() + ) + }.pdf", + mimeType = "application/pdf" + ) + ) + } + res.template?.apply(::showTemplateDialog) + } + } + } + + private fun setLoading(type: CriminalCertLoadActionType, isLoading: Boolean) { + val state = _state.value ?: return + _state.value = state.copy( + loadActions = state.loadActions?.map { + if (type == it.type) { + it.copy(isEnabled = !isLoading, isLoading = isLoading) + } else { + it.copy(isEnabled = !isLoading) + } + }.orEmpty() + ) + } + + override fun onCleared() { + super.onCleared() + _isPdfLoading.removeObserver(pdfLoadingObserver) + _isZipLoading.removeObserver(zipLoadingObserver) + } + + fun setScreenName(header: String) { + _screenHeader.postValue(header) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertLoadActionsAdapter.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertLoadActionsAdapter.kt new file mode 100644 index 0000000..2b76825 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/details/CriminalCertLoadActionsAdapter.kt @@ -0,0 +1,108 @@ +package ua.gov.diia.ps_criminal_cert.ui.details + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.ItemCriminalCertLoadActionBinding +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertLoadActionType.DOWNLOAD_ARCHIVE +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertLoadActionType.VIEW_PDF +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertDetails +import ua.gov.diia.ui_base.util.view.inflater + +class CriminalCertLoadActionsAdapter( + private val onItemClick: (CriminalCertDetails.LoadAction) -> Unit +) : ListAdapter( + DiffCallback +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = + ViewHolder( + binding = ItemCriminalCertLoadActionBinding.inflate(parent.inflater, parent, false), + onItemClick = { + onItemClick(getItem(it)) + } + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + getItem(position).run(holder::bind) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + payloads.forEach { list -> + (list as List).forEach { payload -> + if (payload == DiffCallback.PAYLOAD_IS_LOADING) { + holder.setLoading(getItem(position).isLoading) + } else if (payload == DiffCallback.PAYLOAD_IS_ENABLED) { + holder.setEnabled(getItem(position).isEnabled) + } + } + } + } + } + + class ViewHolder( + private val binding: ItemCriminalCertLoadActionBinding, + private val onItemClick: (Int) -> Unit + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + onItemClick(bindingAdapterPosition) + } + } + + fun bind(action: CriminalCertDetails.LoadAction) { + with(binding) { + val iconRes = when(action.type) { + DOWNLOAD_ARCHIVE -> R.drawable.ic_download + VIEW_PDF -> R.drawable.ic_view + } + btn.setIcon(iconRes) + btn.setTitle(action.name) + setLoading(action.isLoading) + setEnabled(action.isEnabled) + } + } + + fun setLoading(isLoading: Boolean) { + binding.btn.setIsLoading(isLoading) + } + + fun setEnabled(isEnabled: Boolean) { + binding.btn.setIsEnabled(isEnabled) + binding.root.isEnabled = isEnabled + } + } + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: CriminalCertDetails.LoadAction, + newItem: CriminalCertDetails.LoadAction + ): Boolean = + oldItem.type == newItem.type + + override fun areContentsTheSame( + oldItem: CriminalCertDetails.LoadAction, + newItem: CriminalCertDetails.LoadAction + ): Boolean = oldItem == newItem + + override fun getChangePayload(oldItem: CriminalCertDetails.LoadAction, newItem: CriminalCertDetails.LoadAction): Any { + val payloads = mutableListOf() + if (oldItem.isLoading != newItem.isLoading) { + payloads.add(PAYLOAD_IS_LOADING) + } + if (oldItem.isEnabled != newItem.isEnabled) { + payloads.add(PAYLOAD_IS_ENABLED) + } + return payloads + } + + const val PAYLOAD_IS_LOADING = "PAYLOAD_IS_LOADING" + const val PAYLOAD_IS_ENABLED = "PAYLOAD_IS_ENABLED" + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeF.kt new file mode 100644 index 0000000..0a6a526 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeF.kt @@ -0,0 +1,169 @@ +package ua.gov.diia.ps_criminal_cert.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import com.google.android.material.tabs.TabLayoutMediator +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertHomeBinding +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.doOnSystemBackPressed +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResult +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes + +@AndroidEntryPoint +class CriminalCertHomeF : Fragment() { + + private val viewModel: CriminalCertHomeVM by viewModels() + private val args: CriminalCertHomeFArgs by navArgs() + private var binding: FragmentCriminalCertHomeBinding? = null + + private var tabsAdapter: CriminalCertHomeTabsAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + setCertId(args.certId, args.directionFlag, args.resourceId) + setScreenName(getString(R.string.criminal_cert_title)) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + binding = FragmentCriminalCertHomeBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + viewModel.setListRefreshing() + setupTabs() + + backBtn.setOnClickListener { + navigateToBack() + } + doOnSystemBackPressed { navigateToBack() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertHomeF, + menu + ) + } + + navigateToWelcome.observeUiDataEvent(viewLifecycleOwner, ::navigateToWelcome) + navigateToDetails.observeUiDataEvent(viewLifecycleOwner, ::navigateToDetails) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertHomeF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertHomeF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_HOME, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertHomeF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertHomeF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + } + + private fun navigateToWelcome(skip: Boolean) { + if (skip) { + navigate( + CriminalCertHomeFDirections.actionCriminalCertHomeFToCriminalCertWelcomeFSkip( + contextMenu = args.contextMenu, + certId = args.certId, + publicService = args.publicService, + resourceId = args.resourceId, + directionFlag = args.directionFlag + ) + ) + } else { + navigate( + CriminalCertHomeFDirections.actionCriminalCertHomeFToCriminalCertWelcomeF( + contextMenu = args.contextMenu, + publicService = args.publicService, + resourceId = args.resourceId, + directionFlag = args.directionFlag + ) + ) + } + } + + override fun onDestroyView() { + super.onDestroyView() + tabsAdapter = null + binding = null + } + + private fun navigateToDetails(id: String) { + navigate( + CriminalCertHomeFDirections.actionCriminalCertHomeFToCriminalCertDetailsF( + certId = id + ) + ) + } + + private fun navigateToBack() { + if (viewModel.directionFlag.value == false) { + findNavController().popBackStack() + } else { + viewModel.navigateToDamagedPropertyRecovery(this@CriminalCertHomeF, args.certId) + } + } + + private fun FragmentCriminalCertHomeBinding.setupTabs() { + tabsAdapter = CriminalCertHomeTabsAdapter( + contextMenu = args.contextMenu, + fragmentManager = childFragmentManager, + lifecycle = viewLifecycleOwner.lifecycle + ) + viewPager.offscreenPageLimit = 2 + viewPager.adapter = tabsAdapter + if (args.isNew) { + viewPager.setCurrentItem(1, false) + } + TabLayoutMediator(tabs, viewPager) { tab, pos -> + when (pos) { + 0 -> tab.text = getString(R.string.criminal_cert_tab_title_done) + else -> tab.text = getString(R.string.criminal_cert_tab_title_processing) + } + }.attach() + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeTabsAdapter.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeTabsAdapter.kt new file mode 100644 index 0000000..2638ef2 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeTabsAdapter.kt @@ -0,0 +1,37 @@ +package ua.gov.diia.ps_criminal_cert.ui.home + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Lifecycle +import androidx.viewpager2.adapter.FragmentStateAdapter +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertStatus + +class CriminalCertHomeTabsAdapter( + private val contextMenu: Array?, + fragmentManager: FragmentManager, + lifecycle: Lifecycle +) : FragmentStateAdapter(fragmentManager, lifecycle) { + + override fun createFragment(position: Int): Fragment { + return when (position) { + 0 -> CriminalCertListF().apply { + arguments = Bundle().apply { + putSerializable("certStatus", CriminalCertStatus.DONE) + putSerializable("contextMenu", contextMenu) + } + } + else -> CriminalCertListF().apply { + arguments = Bundle().apply { + putSerializable("certStatus", CriminalCertStatus.PROCESSING) + putSerializable("contextMenu", contextMenu) + } + } + } + } + + override fun getItemCount(): Int { + return 2 + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeVM.kt new file mode 100644 index 0000000..6af011c --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertHomeVM.kt @@ -0,0 +1,228 @@ +package ua.gov.diia.ps_criminal_cert.ui.home + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.models.common.message.StubMessage +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.lifecycle.combineWith +import ua.gov.diia.ps_criminal_cert.helper.PSCriminalCertHelper +import ua.gov.diia.ps_criminal_cert.models.CriminalCertHomeState +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertStatus +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertListData.CertItem +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.publicservice.helper.PSNavigationHelper +import ua.gov.diia.ui_base.util.paging.offsetPagingData +import javax.inject.Inject + + +@HiltViewModel +class CriminalCertHomeVM @Inject constructor( + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, + private val criminalCertHelper: PSCriminalCertHelper +) : ViewModel(), + WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper, + PSCriminalCertHelper by criminalCertHelper { + + private var certId: String? = null + val directionFlag = MutableStateFlow(null) + val resId = MutableLiveData() + + private val _state = MutableLiveData(CriminalCertHomeState()) + val state = _state.asLiveData() + + private val showProcessingListDelimiter = + _state.map { it.hasProcessingList } + + private val showDoneListDelimiter = _state.map { it.hasDoneList } + + private val _isDoneListLoading = MutableLiveData() + private val _isProcessingLoading = MutableLiveData() + val isLoading = + _isProcessingLoading.combineWith(_isDoneListLoading) { isProcessingLoading, isDoneListLoading -> + isProcessingLoading != false || isDoneListLoading != false + }.combineWith(_state) { isLoading, state -> + isLoading != false && state?.hasContent != true + } + + private val processingListEmptyState = MutableLiveData() + private val doneListEmptyState = MutableLiveData() + + private val _navigateToWelcome = MutableLiveData>() + val navigateToWelcome = _navigateToWelcome.asLiveData() + + private val _navigateToDetails = MutableLiveData>() + val navigateToDetails = _navigateToDetails.asLiveData() + + private val initialDoneList = MutableStateFlow(null) + private val initialProcessingList = MutableStateFlow(null) + + private val doneListContent = offsetPagingData { offset, pageSize -> + fetchDoneList(offset, pageSize) + } + + private val processingListContent = offsetPagingData { offset, pageSize -> + fetchProcessingList(offset, pageSize) + } + + private val _screenHeader = MutableLiveData() + val screenHeader = _screenHeader.asLiveData() + + init { + viewModelScope.launch { + initialDoneList.combine(initialProcessingList) { hasDoneList, hasProcessingList -> + if (hasDoneList == false && hasProcessingList == false) { + _navigateToWelcome.value = UiDataEvent(true) + } else if (hasDoneList == true || hasProcessingList == true) { + handleNotification() + } + }.collect() + } + } + + fun setDoneListLoading(isLoading: Boolean) { + _isDoneListLoading.value = isLoading + } + + fun setProcessingListLoading(isLoading: Boolean) { + _isProcessingLoading.value = isLoading + } + + fun setListRefreshing() { + initialDoneList.value = null + initialProcessingList.value = null + } + + fun setCertId( + certId: String?, + directionFlag: Boolean, + resourceId: String? + ) { + this.certId = certId + this.directionFlag.value = directionFlag + this.resId.value = resourceId + } + + private fun handleNotification() { + val certId = certId + if (certId != null && navigateToDetails.value == null) { + _navigateToDetails.value = UiDataEvent(certId) + } + } + + fun onNext() { + _navigateToWelcome.value = UiDataEvent(false) + } + + fun listContent(status: CriminalCertStatus): LiveData> { + return when (status) { + CriminalCertStatus.PROCESSING -> processingListContent + CriminalCertStatus.DONE -> doneListContent + } + } + + fun stubMessage(status: CriminalCertStatus): LiveData { + return when (status) { + CriminalCertStatus.PROCESSING -> processingListEmptyState + CriminalCertStatus.DONE -> doneListEmptyState + } + } + + fun delimiterState(status: CriminalCertStatus): LiveData { + return when (status) { + CriminalCertStatus.PROCESSING -> showProcessingListDelimiter + CriminalCertStatus.DONE -> showDoneListDelimiter + } + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun setScreenName(header: String) { + _screenHeader.postValue(header) + } + + @VisibleForTesting + suspend fun fetchDoneList(offset: Int, pageSize: Int) = api.getCriminalCertList( + certStatus = CriminalCertStatus.DONE.str, + skip = offset, + limit = pageSize + ).let { listData -> + if (initialDoneList.value == null) { // initial page loading + val hasDoneList = !listData.certificates.isNullOrEmpty() + _state.value = state.value?.copy(hasDoneList = hasDoneList) + initialDoneList.value = hasDoneList + + doneListEmptyState.value = listData.stubMessage + } + listData.navigationPanel?.contextMenu?.let { + setContextMenu(it.toTypedArray()) + } + listData.navigationPanel?.header?.let { + setScreenName(it) + } + listData.certificates.orEmpty() + } + + @VisibleForTesting + suspend fun fetchProcessingList(offset: Int, pageSize: Int) = api.getCriminalCertList( + certStatus = CriminalCertStatus.PROCESSING.str, + skip = offset, + limit = pageSize + ).let { listData -> + if (initialProcessingList.value == null) { // initial page loading + val hasProcessingList = !listData.certificates.isNullOrEmpty() + _state.value = + state.value?.copy(hasProcessingList = hasProcessingList) + initialProcessingList.value = hasProcessingList + + processingListEmptyState.value = listData.stubMessage + } + listData.navigationPanel?.contextMenu?.let { + setContextMenu(it.toTypedArray()) + } + listData.navigationPanel?.header?.let { + setScreenName(it) + } + listData.certificates.orEmpty() + } +} diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertListAdapter.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertListAdapter.kt new file mode 100644 index 0000000..c3f3ef4 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertListAdapter.kt @@ -0,0 +1,48 @@ +package ua.gov.diia.ps_criminal_cert.ui.home + +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import ua.gov.diia.ps_criminal_cert.databinding.ItemCriminalCertBinding +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertListData.CertItem +import ua.gov.diia.ui_base.util.view.inflater + +class CriminalCertListAdapter( + private val onItemSelected: (CertItem) -> Unit +) : PagingDataAdapter(DiffCallback) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + binding = ItemCriminalCertBinding.inflate(parent.inflater, parent, false), + onItemSelected = onItemSelected + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + getItem(position)?.run(holder::bind) + } + + class ViewHolder( + val binding: ItemCriminalCertBinding, + private val onItemSelected: ((CertItem) -> Unit)? = null + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(caseItem: CertItem) { + binding.item = caseItem + binding.root.setOnClickListener { onItemSelected?.invoke(caseItem) } + + binding.executePendingBindings() + } + } + + object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CertItem, newItem: CertItem): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: CertItem, newItem: CertItem): Boolean { + return oldItem == newItem + } + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertListF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertListF.kt new file mode 100644 index 0000000..aa71196 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/home/CriminalCertListF.kt @@ -0,0 +1,110 @@ +package ua.gov.diia.ps_criminal_cert.ui.home + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.fragment.navArgs +import androidx.paging.LoadState.Error +import androidx.paging.LoadState.Loading +import androidx.paging.LoadState.NotLoading +import androidx.recyclerview.widget.ConcatAdapter +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import ua.gov.diia.ui_base.adapters.common.PagingLoadStateAdapter +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertListBinding +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertStatus.DONE +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertStatus.PROCESSING +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertListData.CertItem +import ua.gov.diia.core.util.extensions.fragment.navigate + +@AndroidEntryPoint +class CriminalCertListF : Fragment() { + + private val viewModel: CriminalCertHomeVM by viewModels({ requireParentFragment() }) + private val args: CriminalCertListFArgs by navArgs() + private var binding: FragmentCriminalCertListBinding? = null + + private var adapter: CriminalCertListAdapter? = null + private var concatAdapter: ConcatAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + adapter = CriminalCertListAdapter(::navigateToDetails) + concatAdapter = adapter?.withLoadStateHeaderAndFooter( + header = PagingLoadStateAdapter { adapter?.retry() }, + footer = PagingLoadStateAdapter { adapter?.retry() } + ) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentCriminalCertListBinding.inflate(inflater, container, false).apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + status = args.certStatus + + recyclerView.adapter = concatAdapter + adapter?.refresh() + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewLifecycleOwner.lifecycleScope.launch { + adapter?.loadStateFlow?.collectLatest { states -> + when (val refresh = states.refresh) { + is Error -> { + when (args.certStatus) { + PROCESSING -> viewModel.setProcessingListLoading(false) + DONE -> viewModel.setDoneListLoading(false) + } + viewModel.consumeException(exception = refresh.error as Exception, needRetry = false) + } + is Loading -> { + when (args.certStatus) { + PROCESSING -> viewModel.setProcessingListLoading(adapter?.itemCount == 0) + DONE -> viewModel.setDoneListLoading(adapter?.itemCount != 0) + } + } + is NotLoading -> { + when (args.certStatus) { + PROCESSING -> viewModel.setProcessingListLoading(false) + DONE -> viewModel.setDoneListLoading(false) + } + } + } + } + } + + viewModel.listContent(args.certStatus).observe(viewLifecycleOwner) { + adapter?.submitData(viewLifecycleOwner.lifecycle, it) + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding?.recyclerView?.adapter = null + binding = null + } + + override fun onDestroy() { + super.onDestroy() + adapter = null + concatAdapter = null + } + + private fun navigateToDetails(cert: CertItem) { + requireParentFragment().navigate( + CriminalCertHomeFDirections.actionCriminalCertHomeFToCriminalCertDetailsF( + contextMenu = args.contextMenu, + certId = cert.id + ) + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressF.kt new file mode 100644 index 0000000..8f907a4 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressF.kt @@ -0,0 +1,126 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.address + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertStepAddressBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.address_search.models.AddressIdentifier +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.search.models.SearchableBullet +import ua.gov.diia.search.models.SearchableItem +import ua.gov.diia.address_search.ui.AddressSearchControllerF +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResult +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes + +@AndroidEntryPoint +class CriminalCertStepAddressF : AddressSearchControllerF() { + + override val viewModel: CriminalCertStepAddressVM by viewModels() + private val args: CriminalCertStepAddressFArgs by navArgs() + private var binding: FragmentCriminalCertStepAddressBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentCriminalCertStepAddressBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + backBtn.setOnClickListener { findNavController().popBackStack() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertStepAddressF, + menu + ) + } + + addressResult.observeUiDataEvent(viewLifecycleOwner, ::navigateNext) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertStepAddressF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertStepAddressF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_REGISTRATION_PLACE_SELECTION, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertStepAddressF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertStepAddressF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + } + + override fun navigateToListSearch(data: Array, resultKey: String) { + navigate( + CriminalCertStepAddressFDirections.actionCriminalCertStepAddressFToSearchF( + key = resultKey, + searchableList = data + ) + ) + } + + override fun navigateToBulletSearch(data: Array, resultKey: String) { + navigate( + CriminalCertStepAddressFDirections.actionCriminalCertStepAddressFToSearchBulletF( + resultKey = resultKey, + data = data, + screenHeader = viewModel.screenHeader.value.orEmpty(), + contentTitle = viewModel.addressDescription.value.orEmpty() + ) + ) + } + + private fun navigateNext(addressIdentifier: AddressIdentifier) { + navigate( + CriminalCertStepAddressFDirections.actionCriminalCertStepAddressFToCriminalCertStepContactsF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + registrationAddressId = addressIdentifier.resourceId + ) + ) + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressVM.kt new file mode 100644 index 0000000..eb52d40 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/address/CriminalCertStepAddressVM.kt @@ -0,0 +1,82 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.address + +import androidx.lifecycle.MutableLiveData +import dagger.hilt.android.lifecycle.HiltViewModel +import ua.gov.diia.address_search.models.AddressFieldRequest +import ua.gov.diia.address_search.models.AddressFieldRequestValue +import ua.gov.diia.address_search.network.ApiAddressSearch +import ua.gov.diia.address_search.ui.AddressParameterMapper +import ua.gov.diia.address_search.ui.AddressSearchVM +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.publicservice.helper.PSNavigationHelper +import javax.inject.Inject + +@HiltViewModel +class CriminalCertStepAddressVM @Inject constructor( + @AuthorizedClient private val apiAddressSearch: ApiAddressSearch, + private val contextMenuDelegate: WithContextMenu, + errorHandlingDelegate: WithErrorHandling, + retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, + addressParameterMapper: AddressParameterMapper, +) : AddressSearchVM(apiAddressSearch, addressParameterMapper, errorHandlingDelegate, retryActionDelegate), + WithContextMenu by contextMenuDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper{ + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + init { + loadContent() + } + + private fun loadContent() { + executeAction(progressIndicator = _isLoading) { + // Address search api requires call with empty body first + apiAddressSearch.getFieldContext( + featureCode = CriminalCertConst.ADDRESS_FEATURE_CODE, + addressTemplateCode = CriminalCertConst.ADDRESS_SCHEMA + ) + val data = apiAddressSearch.getFieldContext( + featureCode = CriminalCertConst.ADDRESS_FEATURE_CODE, + addressTemplateCode = CriminalCertConst.ADDRESS_SCHEMA, + request = AddressFieldRequest( + values = listOf( + AddressFieldRequestValue( + id = "804", + type = "country", + value = "УКРАЇНА" + ) + ) + ) + ) + setAddressSearchArs(data, CriminalCertConst.ADDRESS_FEATURE_CODE, CriminalCertConst.ADDRESS_SCHEMA) + } + } + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthF.kt new file mode 100644 index 0000000..abab145 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthF.kt @@ -0,0 +1,148 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.birth + +import android.os.Bundle +import android.text.InputType +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertStepBirthBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.address_search.models.NationalityItem +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.ps_criminal_cert.models.Birth +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResult +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResultOnce +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes + +@AndroidEntryPoint +class CriminalCertStepBirthF : Fragment() { + + private val viewModel: CriminalCertStepBirthVM by viewModels() + private val args: CriminalCertStepBirthFArgs by navArgs() + private var binding: FragmentCriminalCertStepBirthBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentCriminalCertStepBirthBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + viewModel.loadContent() + otherCountryInput.setImeOptions(EditorInfo.IME_ACTION_NEXT) + otherCountryInput.setTextInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_WORDS) + cityInput.setTextInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_WORDS) + cityInput.setImeOptions(EditorInfo.IME_ACTION_DONE) + backBtn.setOnClickListener { findNavController().popBackStack() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertStepBirthF, + menu + ) + } + + viewModel.navigateToCountrySelection.observeUiDataEvent(viewLifecycleOwner, ::navigateToCountrySelection) + onNextEvent.observeUiDataEvent(viewLifecycleOwner, ::navigateNext) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertStepBirthF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertStepBirthF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_BIRTH_PLACE, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertStepBirthF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertStepBirthF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + + registerForNavigationResultOnce(NATIONALITIES, viewModel::setCountry) + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun navigateToCountrySelection(items: List) { + navigate( + CriminalCertStepBirthFDirections.actionCriminalCertStepBirthFToSearchF( + key = NATIONALITIES, + searchableList = items.toTypedArray() + ) + ) + } + + private fun navigateNext(screenData: Pair) { + val destination = when (screenData.first) { + CriminalCertScreen.BIRTH_PLACE -> { + //already here + null + } + CriminalCertScreen.NATIONALITIES -> CriminalCertStepBirthFDirections.actionCriminalCertStepBirthFToCriminalCertStepNationalityF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + birth = screenData.second + ) + ) + CriminalCertScreen.REGISTRATION_PLACE -> CriminalCertStepBirthFDirections.actionCriminalCertStepBirthFToCriminalCertStepAddressF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + birth = screenData.second + ) + ) + CriminalCertScreen.CONTACTS -> CriminalCertStepBirthFDirections.actionCriminalCertStepBirthFToCriminalCertStepContactsF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + birth = screenData.second + ) + ) + } + destination?.run(::navigate) + } + + private companion object { + private const val NATIONALITIES = "nationalities" + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthVM.kt new file mode 100644 index 0000000..7fea860 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/birth/CriminalCertStepBirthVM.kt @@ -0,0 +1,141 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.birth + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import ua.gov.diia.address_search.network.ApiAddressSearch +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.address_search.models.AddressNationality +import ua.gov.diia.address_search.models.NationalityItem +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertBirthPlace +import ua.gov.diia.ps_criminal_cert.models.Birth +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.CombinedLiveData +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.publicservice.helper.PSNavigationHelper + +@HiltViewModel +class CriminalCertStepBirthVM @Inject constructor( + @AuthorizedClient private val apiAddressSearch: ApiAddressSearch, + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper { + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _onNextEvent = MutableLiveData>>() + val onNextEvent = _onNextEvent.asLiveData() + + private val _nationalities = MutableLiveData() + val nationalities = _nationalities.asLiveData() + + private val _birthPlace = MutableLiveData() + val birthPlace = _birthPlace.asLiveData() + + private val _navigateToCountrySelection = + MutableLiveData>>() + val navigateToCountrySelection = _navigateToCountrySelection.asLiveData() + + val countryInput = MutableLiveData() + val otherCountryInput = MutableLiveData() + val cityInput = MutableLiveData() + val isOtherCountryChecked = MutableLiveData(false) + + val isNextAvailable = CombinedLiveData( + countryInput, + otherCountryInput, + cityInput, + isOtherCountryChecked + ) { data -> + val country = data[0]?.toString() + val otherCountry = data[1]?.toString() + val city = data[2]?.toString() + val isOtherCountryChecked = data[3] as? Boolean + if (isOtherCountryChecked == true) { + !otherCountry.isNullOrBlank() && !city.isNullOrBlank() + } else { + !country.isNullOrBlank() && !city.isNullOrBlank() + } + } + + fun loadContent() { + executeAction(progressIndicator = _isLoading) { + _nationalities.value = apiAddressSearch.getNationalities() + val res = api.getCriminalCertBirthPlace() + _birthPlace.value = res + if (res.data?.country?.value != null) { + isOtherCountryChecked.value = false + countryInput.value = res.data.country.value + } + res.template?.apply(::showTemplateDialog) + } + } + + fun selectCountry() { + nationalities.value?.also { + _navigateToCountrySelection.value = UiDataEvent(it.nationalities) + } + } + + fun setCountry(item: NationalityItem) { + countryInput.value = item.name + } + + fun checkOtherCountry() { + isOtherCountryChecked.value = isOtherCountryChecked.value == false + if (isOtherCountryChecked.value == true) { + countryInput.value = null + } + } + + fun onNext() { + birthPlace.value?.also { + it.data?.nextScreen ?: return@also + _onNextEvent.value = UiDataEvent( + content = it.data.nextScreen to Birth( + country = if (isOtherCountryChecked.value == true) { + otherCountryInput.value.orEmpty() + } else { + countryInput.value.orEmpty() + }, + city = cityInput.value.orEmpty() + ) + ) + } + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmF.kt new file mode 100644 index 0000000..d946c33 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmF.kt @@ -0,0 +1,126 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.confirm + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertStepConfirmBinding +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.core.util.delegation.WithCrashlytics +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.navigate +import ua.gov.diia.core.util.extensions.fragment.openLink +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.registerForNavigationResult +import ua.gov.diia.core.util.extensions.fragment.registerForTemplateDialogNavResult +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes +import javax.inject.Inject + +@AndroidEntryPoint +class CriminalCertStepConfirmF : Fragment() { + + @Inject + lateinit var withCrashlytics: WithCrashlytics + private val viewModel: CriminalCertStepConfirmVM by viewModels() + private val args: CriminalCertStepConfirmFArgs by navArgs() + private var binding: FragmentCriminalCertStepConfirmBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentCriminalCertStepConfirmBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + + orderBtn.setOnButtonClickListener { viewModel.confirm(args.dataUser) } + backBtn.setOnClickListener { findNavController().popBackStack() } + viewModel.load(args.dataUser) + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertStepConfirmF, + menu + ) + } + openLinkAM.observeUiDataEvent(viewLifecycleOwner) { openLink(it, withCrashlytics) } + + navigateToDetails.observeUiDataEvent(viewLifecycleOwner, ::navigateToDetails) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertStepConfirmF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertStepConfirmF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_APPLICATION, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertStepConfirmF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertStepConfirmF) + CriminalCertConst.TEMPLATE_ACTION_OPEN_STATUS -> navigateToStatus() + + + ActionsConst.DIALOG_ACTION_CRIMINAL_RECORD_CERTIFICATE -> { + viewModel.navigateToDetails() + } + + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun navigateToStatus() { + viewModel.navigateToDPRecoveryHomeF(this@CriminalCertStepConfirmF, args.dataUser.publicService?.resourceId) + } + + private fun navigateToDetails(applicationId: String) { + navigate( + CriminalCertStepConfirmFDirections.actionCriminalCertStepConfirmFToCriminalCertDetailsF( + certId = applicationId, + isNew = true + ) + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmVM.kt new file mode 100644 index 0000000..0fbb712 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/confirm/CriminalCertStepConfirmVM.kt @@ -0,0 +1,134 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.confirm + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ua.gov.diia.ps_criminal_cert.models.CriminalCertUserData +import ua.gov.diia.ps_criminal_cert.models.request.CriminalCertConfirmationRequest +import ua.gov.diia.ps_criminal_cert.models.request.CriminalCertConfirmationRequest.BirthPlace +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertConfirmation +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.helper.PSCriminalCertHelper +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.publicservice.helper.PSNavigationHelper +import javax.inject.Inject + +@HiltViewModel +class CriminalCertStepConfirmVM @Inject constructor( + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, + private val criminalCertHelper: PSCriminalCertHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper, + PSCriminalCertHelper by criminalCertHelper { + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _isOrdering = MutableLiveData() + val isOrdering = _isOrdering.asLiveData() + + private val _navigateToDetails = MutableLiveData>() + val navigateToDetails = _navigateToDetails.asLiveData() + + val isConfirmed = MutableLiveData() + + private val _state = MutableLiveData() + val state = _state.asLiveData() + + val openLinkListenerAM: (String) -> Unit = { link -> _openLinkAM.value = UiDataEvent(link) } + + private val _openLinkAM = MutableLiveData>() + val openLinkAM = _openLinkAM.asLiveData() + + private var applicationId: String? = null + + fun load(data: CriminalCertUserData) { + executeAction(progressIndicator = _isLoading) { + _state.value = api.getCriminalCertConfirmationData(getRequestData(data)) + state.value?.template?.run(::showTemplateDialog) + } + } + + fun confirm(data: CriminalCertUserData) { + executeAction(progressIndicator = _isOrdering) { + val res = api.orderCriminalCert(getRequestData(data)) + res.template?.run(::showTemplateDialog) + applicationId = res.applicationId + res.navigationPanel?.contextMenu?.let { + setContextMenu(it.toTypedArray()) + } + } + } + + fun navigateToDetails() { + val applicationId = applicationId ?: return + _navigateToDetails.value = UiDataEvent(applicationId) + } + + private fun getRequestData(data: CriminalCertUserData): CriminalCertConfirmationRequest { + return CriminalCertConfirmationRequest( + reasonId = data.reasonId, + certificateType = data.certificateType, + previousFirstName = if (data.prevNames?.previousFirstNameList.isNullOrEmpty()) { + null + } else { + data.prevNames?.previousFirstNameList?.joinToString(", ") + }, + previousMiddleName = if (data.prevNames?.previousMiddleNameList.isNullOrEmpty()) { + null + } else { + data.prevNames?.previousMiddleNameList?.joinToString(", ") + }, + previousLastName = if (data.prevNames?.previousLastNameList.isNullOrEmpty()) { + null + } else { + data.prevNames?.previousLastNameList?.joinToString(", ") + }, + birthPlace = if (data.birth == null) { + null + } else { + BirthPlace( + country = data.birth.country, + city = data.birth.city + ) + }, + nationalities = data.nationalities, + registrationAddressId = data.registrationAddressId, + phoneNumber = data.phoneNumber, + publicService = data.publicService + ) + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsF.kt new file mode 100644 index 0000000..bb5bf78 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsF.kt @@ -0,0 +1,100 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.contacts + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertStepContactsBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.* +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes + +@AndroidEntryPoint +class CriminalCertStepContactsF : Fragment() { + + private val viewModel: CriminalCertStepContactsVM by viewModels() + private val args: CriminalCertStepContactsFArgs by navArgs() + private var binding: FragmentCriminalCertStepContactsBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentCriminalCertStepContactsBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + viewModel.loadContent() + backBtn.setOnClickListener { findNavController().popBackStack() } + phoneInput.setImeOptions(EditorInfo.IME_ACTION_DONE) + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertStepContactsF, + menu + ) + } + + onNextEvent.observeUiDataEvent(viewLifecycleOwner, ::navigateNext) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertStepContactsF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertStepContactsF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_CONTACTS, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertStepContactsF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertStepContactsF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + } + + private fun navigateNext(phone: String) { + navigate( + CriminalCertStepContactsFDirections.actionCriminalCertStepContactsFToCriminalCertStepConfirmF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + phoneNumber = phone + ) + ) + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsVM.kt new file mode 100644 index 0000000..d58d571 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/contacts/CriminalCertStepContactsVM.kt @@ -0,0 +1,89 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.contacts + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.map +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertContacts +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.core.util.phone.PHONE_NUMBER_VALIDATION_PATTERN +import ua.gov.diia.core.util.phone.RAW_PHONE_NUMBER_PREFIX +import ua.gov.diia.core.util.phone.removePhoneCodeIfNeed +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.publicservice.helper.PSNavigationHelper + +@HiltViewModel +class CriminalCertStepContactsVM @Inject constructor( + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper { + + private val _onNextEvent = MutableLiveData>() + val onNextEvent = _onNextEvent.asLiveData() + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _state = MutableLiveData() + val state = _state.asLiveData() + + val phoneInput = MutableLiveData() + + val isNextAvailable = phoneInput.map { + validateRawPhoneNumber(it) + } + + fun loadContent() { + executeAction(progressIndicator = _isLoading) { + _state.value = api.getCriminalCertContacts().also { + it.template?.apply(::showTemplateDialog) + } + if (phoneInput.value == null) { + phoneInput.value = state.value?.phoneNumber?.removePhoneCodeIfNeed() + } + } + } + + fun onNext() { + _onNextEvent.value = UiDataEvent("+$RAW_PHONE_NUMBER_PREFIX${phoneInput.value}") + } + + private fun validateRawPhoneNumber(rawNumber: String?): Boolean { + val phoneNumber = "${RAW_PHONE_NUMBER_PREFIX}$rawNumber" + return phoneNumber.matches(PHONE_NUMBER_VALIDATION_PATTERN.toRegex()) + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityF.kt new file mode 100644 index 0000000..fc643e2 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityF.kt @@ -0,0 +1,167 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.nationality + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertStepNationalityBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.address_search.models.NationalityItem +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.core.util.extensions.fragment.* +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes + +@AndroidEntryPoint +class CriminalCertStepNationalityF : Fragment() { + + private val viewModel: CriminalCertStepNationalityVM by viewModels() + private val args: CriminalCertStepNationalityFArgs by navArgs() + private var binding: FragmentCriminalCertStepNationalityBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = FragmentCriminalCertStepNationalityBinding.inflate( + inflater, + container, + false + ) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + viewModel.loadContent() + backBtn.setOnClickListener { findNavController().popBackStack() } + countryView.onAddItem(viewModel::addCountry) + countryView.onRemove(viewModel::removeCountry) + countryView.onSelect(viewModel::selectCountry) + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent( + viewLifecycleOwner, + ::openTemplateDialog + ) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertStepNationalityF, + menu + ) + } + + viewModel.navigateToCountrySelection.observeUiDataEvent( + viewLifecycleOwner, + ::navigateToCountrySelection + ) + onNextEvent.observeUiDataEvent(viewLifecycleOwner, ::navigateNext) + + showRatingDialogByUserInitiative.observeUiDataEvent( + viewLifecycleOwner + ) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertStepNationalityF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertStepNationalityF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_NATIONALITY, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq( + this@CriminalCertStepNationalityF, + CriminalCertConst.FEATURE_CODE + ) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertStepNationalityF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + + registerForNavigationResultOnce(SEARCH, viewModel::setCountry) + + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> + viewModel.sendRatingRequest( + rating + ) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun navigateToCountrySelection(items: List) { + navigate( + CriminalCertStepNationalityFDirections.actionCriminalCertStepNationalityFToSearchF( + key = SEARCH, + searchableList = items.toTypedArray() + ) + ) + } + + private fun navigateNext(screenData: Pair>) { + val destination = when (screenData.first) { + CriminalCertScreen.BIRTH_PLACE -> { + CriminalCertStepNationalityFDirections.actionCriminalCertStepNationalityFToCriminalCertStepBirthF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + nationalities = screenData.second + ) + ) + } + CriminalCertScreen.NATIONALITIES -> { + //already here + null + } + CriminalCertScreen.REGISTRATION_PLACE -> CriminalCertStepNationalityFDirections.actionCriminalCertStepNationalityFToCriminalCertStepAddressF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + nationalities = screenData.second + ) + ) + CriminalCertScreen.CONTACTS -> CriminalCertStepNationalityFDirections.actionCriminalCertStepNationalityFToCriminalCertStepContactsF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + nationalities = screenData.second + ) + ) + } + destination?.run(::navigate) + } + + private companion object { + private const val SEARCH = "SEARCH" + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityVM.kt new file mode 100644 index 0000000..b5452d6 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/nationality/CriminalCertStepNationalityVM.kt @@ -0,0 +1,174 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.nationality + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ua.gov.diia.address_search.network.ApiAddressSearch +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.ui_base.views.NameModel +import ua.gov.diia.address_search.models.AddressNationality +import ua.gov.diia.address_search.models.NationalityItem +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertNationalities +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.ui_base.views.common.card_item.DiiaCardInputField.FieldMode.BUTTON +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.publicservice.helper.PSNavigationHelper +import javax.inject.Inject + +@HiltViewModel +class CriminalCertStepNationalityVM @Inject constructor( + @AuthorizedClient private val apiAddressSearch: ApiAddressSearch, + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper { + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _state = MutableLiveData() + val state = _state.asLiveData() + + private val _nationalities = MutableLiveData() + val nationalities = _nationalities.asLiveData() + + private val _onNextEvent = MutableLiveData>>>() + val onNextEvent = _onNextEvent.asLiveData() + + private val _navigateToCountrySelection = MutableLiveData>>() + val navigateToCountrySelection = _navigateToCountrySelection.asLiveData() + + private var selectionItem: NameModel? = null + + fun loadContent() { + executeAction(progressIndicator = _isLoading) { + _nationalities.value = apiAddressSearch.getNationalities() + api.getCriminalCertNationalities().also { res -> + _state.value = CriminalCertNationalities( + data = res.data, + countryList = state.value?.countryList?.map { item -> + item.copy( + hint = res.data?.country?.hint.orEmpty(), + title = res.data?.country?.label.orEmpty() + ) + } ?: listOf( + NameModel( + id = "0", + name = "", + withRemove = false, + hint = res.data?.country?.hint.orEmpty(), + title = res.data?.country?.label.orEmpty(), + fieldMode = BUTTON + ) + ) + ) + res.template?.apply(::showTemplateDialog) + } + } + } + + fun addCountry() { + _state.value?.also { state -> + if (state.canAdd) { + _state.value = state.copy( + countryList = state.countryList.plus( + NameModel( + id = "${state.countryList.size}", + name = "", + hint = state.data?.country?.hint.orEmpty(), + title = state.data?.country?.label.orEmpty(), + fieldMode = BUTTON + ) + ) + ) + } + } + } + + fun removeCountry(model: NameModel) { + _state.value?.also { state -> + _state.value = state.copy( + countryList = state.countryList.toMutableList().apply { + remove(model) + } + ) + } + } + + fun setCountry(item: NationalityItem) { + selectionItem?.also { + updateCountry(it.copy(name = item.name)) + } + } + + private fun updateCountry(model: NameModel) { + _state.value?.also { state -> + _state.value = state.copy( + countryList = state.countryList.map { + if (model.id == it.id) { + it.copy(name = model.name) + } else { + it + } + } + ) + } + } + + fun selectCountry(model: NameModel) { + nationalities.value?.also { addressNationality -> + selectionItem = model + val selectedList = state.value?.countryList.orEmpty() + val nationalityList = addressNationality.nationalities.toMutableList() + nationalityList.removeIf { item -> + if(selectedList.size > 1 && item.name.uppercase() == "УКРАЇНА") { + true + } else { + selectedList.find { it.name == item.name } != null + } + } + _navigateToCountrySelection.value = UiDataEvent(nationalityList) + } + } + + fun onNext() { + state.value?.also { state -> + state.data?.nextScreen ?: return@also + _onNextEvent.value = UiDataEvent( + content = state.data.nextScreen to state.countryList.map { it.name } + ) + } + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertReasonsAdapter.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertReasonsAdapter.kt new file mode 100644 index 0000000..b393f3e --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertReasonsAdapter.kt @@ -0,0 +1,92 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.reason + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ua.gov.diia.ps_criminal_cert.databinding.ItemCriminalCertReasonBinding +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertReasons +import ua.gov.diia.ui_base.util.view.inflater + +class CriminalCertReasonsAdapter( + private val onItemClicked: (CriminalCertReasons.Reason) -> Unit +) : ListAdapter( + DiffCallback +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + binding = ItemCriminalCertReasonBinding.inflate(parent.inflater, parent, false), + onItemClicked = { + onItemClicked(getItem(it)) + } + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + getItem(position)?.run(holder::bind) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + payloads.forEach { list -> + (list as List).forEach { payload -> + if (payload == PAYLOAD_IS_SELECTED) { + getItem(position)?.also { + holder.setSelected(it.isSelected) + } + } + } + } + } + } + + class ViewHolder( + private val binding: ItemCriminalCertReasonBinding, + private val onItemClicked: (Int) -> Unit + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + onItemClicked(bindingAdapterPosition) + } + } + + fun bind(reason: CriminalCertReasons.Reason) { + setSelected(reason.isSelected) + binding.nameTv.text = reason.name + } + + fun setSelected(isSelected: Boolean) { + binding.radioBtn.isChecked = isSelected + } + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: CriminalCertReasons.Reason, + newItem: CriminalCertReasons.Reason, + ): Boolean { + return oldItem.code == newItem.code + } + + override fun areContentsTheSame( + oldItem: CriminalCertReasons.Reason, + newItem: CriminalCertReasons.Reason, + ): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: CriminalCertReasons.Reason, newItem: CriminalCertReasons.Reason): Any { + val payloads = mutableListOf() + if (oldItem.isSelected != newItem.isSelected) { + payloads.add(PAYLOAD_IS_SELECTED) + } + return payloads + } + + const val PAYLOAD_IS_SELECTED = "PAYLOAD_IS_SELECTED" + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsF.kt new file mode 100644 index 0000000..e6e1613 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsF.kt @@ -0,0 +1,127 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.reason + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertStepReasonsBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.ps_criminal_cert.models.CriminalCertUserData +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.decorators.ListDelimiterDecorator +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.* +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog + +@AndroidEntryPoint +class CriminalCertStepReasonsF : Fragment() { + + private val viewModel: CriminalCertStepReasonsVM by viewModels() + private val args: CriminalCertStepReasonsFArgs by navArgs() + private var binding: FragmentCriminalCertStepReasonsBinding? = null + + private var adapter: CriminalCertReasonsAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + } + adapter = CriminalCertReasonsAdapter(viewModel::selectReason) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentCriminalCertStepReasonsBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + viewModel.loadContent() + recyclerView.adapter = adapter + recyclerView.addItemDecoration( + ListDelimiterDecorator( + context = requireContext(), + dividerRes = R.drawable.divider, + ignorePadding = true, + includeTopAge = false, + includeBottomAge = false + ) + ) + + backBtn.setOnClickListener { findNavController().popBackStack() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertStepReasonsF, + menu + ) + } + + onNextEvent.observeUiDataEvent(viewLifecycleOwner, ::navigateNext) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertStepReasonsF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertStepReasonsF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_REASON_SELECTION, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertStepReasonsF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertStepReasonsF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + + viewModel.state.observe(viewLifecycleOwner) { + adapter?.submitList(it.reasons) + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding?.recyclerView?.adapter = null + binding = null + } + + override fun onDestroy() { + super.onDestroy() + adapter = null + } + + private fun navigateNext(reasonId: String) { + navigate( + CriminalCertStepReasonsFDirections.actionCriminalCertStepReasonsFToCriminalCertStepTypeF( + contextMenu = args.contextMenu, + dataUser = CriminalCertUserData(reasonId = reasonId) + ) + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsVM.kt new file mode 100644 index 0000000..9495d73 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/reason/CriminalCertStepReasonsVM.kt @@ -0,0 +1,106 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.reason + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertReasons +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.publicservice.helper.PSNavigationHelper + +@HiltViewModel +class CriminalCertStepReasonsVM @Inject constructor( + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper { + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _isNextAvailable = MutableLiveData(false) + val isNextAvailable = _isNextAvailable.asLiveData() + + private val _onNextEvent = MutableLiveData>() + val onNextEvent = _onNextEvent.asLiveData() + + private val _state = MutableLiveData() + val state = _state.asLiveData() + + fun loadContent() { + executeAction(progressIndicator = _isLoading) { + api.getCriminalCertReasons().also { res -> + _state.value = CriminalCertReasons( + title = res.title, + subtitle = res.subtitle, + reasons = res.reasons?.map { reason -> + CriminalCertReasons.Reason( + code = reason.code, + name = reason.name, + isSelected = state.value?.reasons?.find { it.code == reason.code }?.isSelected == true + ) + } + ) + res.template?.apply(::showTemplateDialog) + } + } + } + + fun selectReason(reason: CriminalCertReasons.Reason) { + _state.value = _state.value?.copy( + reasons = _state.value?.reasons?.map { + if (it.code == reason.code && !it.isSelected) { + it.copy(isSelected = true) + } else if (it.isSelected) { + it.copy(isSelected = false) + } else { + it + } + }.orEmpty() + ) + _isNextAvailable.value = findSelected() != null + } + + fun onNext() { + findSelected()?.also { selectedReason -> + _onNextEvent.value = UiDataEvent(selectedReason.code) + } + } + + private fun findSelected(): CriminalCertReasons.Reason? { + return _state.value?.reasons?.find { it.isSelected } + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterF.kt new file mode 100644 index 0000000..7e5de5b --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterF.kt @@ -0,0 +1,139 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.requester + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertStepRequesterBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen.BIRTH_PLACE +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen.CONTACTS +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen.NATIONALITIES +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen.REGISTRATION_PLACE +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.ps_criminal_cert.models.PreviousNames +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.* +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog + +@AndroidEntryPoint +class CriminalCertStepRequesterF : Fragment() { + + private val viewModel: CriminalCertStepRequesterVM by viewModels() + private val args: CriminalCertStepRequesterFArgs by navArgs() + private var binding: FragmentCriminalCertStepRequesterBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentCriminalCertStepRequesterBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + viewModel.loadContent() + + firstNameView.onAddItem(viewModel::addFirstName) + middleNameView.onAddItem(viewModel::addMiddleName) + lastNameView.onAddItem(viewModel::addLastName) + firstNameView.onRemove(viewModel::removeFirstName) + middleNameView.onRemove(viewModel::removeMiddleName) + lastNameView.onRemove(viewModel::removeLastName) + firstNameView.onChanged(viewModel::updateFirstName) + middleNameView.onChanged(viewModel::updateMiddleName) + lastNameView.onChanged(viewModel::updateLastName) + backBtn.setOnClickListener { findNavController().popBackStack() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertStepRequesterF, + menu + ) + } + + onNextEvent.observeUiDataEvent(viewLifecycleOwner, ::navigateNext) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertStepRequesterF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertStepRequesterF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_REQUESTER_DATA, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertStepRequesterF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertStepRequesterF) + CriminalCertConst.FEATURE_CODE -> findNavController().popBackStack() + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + } + + private fun navigateNext(screenData: Pair) { + val destination = when (screenData.first) { + BIRTH_PLACE -> CriminalCertStepRequesterFDirections.actionCriminalCertStepRequesterFToCriminalCertStepBirthF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + prevNames = screenData.second + ) + ) + NATIONALITIES -> CriminalCertStepRequesterFDirections.actionCriminalCertStepRequesterFToCriminalCertStepNationalityF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + prevNames = screenData.second + ) + ) + REGISTRATION_PLACE -> CriminalCertStepRequesterFDirections.actionCriminalCertStepRequesterFToCriminalCertStepAddressF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + prevNames = screenData.second + ) + ) + CONTACTS -> CriminalCertStepRequesterFDirections.actionCriminalCertStepRequesterFToCriminalCertStepContactsF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + prevNames = screenData.second + ) + ) + } + navigate(destination) + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterVM.kt new file mode 100644 index 0000000..d4120a6 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/requester/CriminalCertStepRequesterVM.kt @@ -0,0 +1,227 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.requester + +import android.app.Application +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.ui_base.views.NameModel +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.models.PreviousNames +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertScreen +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertRequester +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.publicservice.helper.PSNavigationHelper +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class CriminalCertStepRequesterVM @Inject constructor( + private val application: Application, + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper { + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _state = MutableLiveData() + val state = _state.asLiveData() + + private val _onNextEvent = MutableLiveData>>() + val onNextEvent = _onNextEvent.asLiveData() + + fun loadContent() { + executeAction(progressIndicator = _isLoading) { + api.getCriminalCertRequester().also { res -> + _state.value = res.copy( + prevFirstNameData = state.value?.prevFirstNameData ?: res.prevFirstNameData, + prevLastNameData = state.value?.prevLastNameData ?: res.prevLastNameData, + prevMiddleNameData = state.value?.prevMiddleNameData ?: res.prevMiddleNameData + ) + res.template?.run(::showTemplateDialog) + } + } + } + + fun addFirstName() { + _state.value?.also { state -> + _state.value = state.copy( + prevFirstNameData = state.prevFirstNameData.copy( + list = state.prevFirstNameData.list.plus( + NameModel( + id = UUID.randomUUID().toString(), + name = "", + title = application.getString(R.string.criminal_cert_requester_prev_first_name), + hint = application.getString(R.string.criminal_cert_requester_first_name) + ) + ) + ) + ) + } + } + + fun addMiddleName() { + _state.value?.also { state -> + _state.value = state.copy( + prevMiddleNameData = state.prevMiddleNameData.copy( + list = state.prevMiddleNameData.list.plus( + NameModel( + id = UUID.randomUUID().toString(), + name = "", + title = application.getString(R.string.criminal_cert_requester_prev_middle_name), + hint = application.getString(R.string.criminal_cert_requester_middle_name) + ) + ) + ) + ) + } + } + + fun addLastName() { + _state.value?.also { state -> + _state.value = state.copy( + prevLastNameData = state.prevLastNameData.copy( + list = state.prevLastNameData.list.plus( + NameModel( + id = UUID.randomUUID().toString(), + name = "", + title = application.getString(R.string.criminal_cert_requester_prev_last_name), + hint = application.getString(R.string.criminal_cert_requester_last_name) + ) + ) + ) + ) + } + } + + fun removeFirstName(name: NameModel) { + _state.value?.also { state -> + _state.value = state.copy( + prevFirstNameData = state.prevFirstNameData.copy( + list = state.prevFirstNameData.list.toMutableList().apply { + remove(name) + } + ) + ) + } + } + + fun removeMiddleName(name: NameModel) { + _state.value?.also { state -> + _state.value = state.copy( + prevMiddleNameData = state.prevMiddleNameData.copy( + list = state.prevMiddleNameData.list.toMutableList().apply { + remove(name) + } + ) + ) + } + } + + fun removeLastName(name: NameModel) { + _state.value?.also { state -> + _state.value = state.copy( + prevLastNameData = state.prevLastNameData.copy( + list = state.prevLastNameData.list.toMutableList().apply { + remove(name) + } + ) + ) + } + } + + fun updateFirstName(name: NameModel) { + _state.value?.also { state -> + _state.value = state.copy( + prevFirstNameData = state.prevFirstNameData.copy( + list = state.prevFirstNameData.list.map { + if (name.id == it.id) { + it.copy(name = name.name) + } else { + it + } + } + ) + ) + } + } + + fun updateMiddleName(name: NameModel) { + _state.value?.also { state -> + _state.value = state.copy( + prevMiddleNameData = state.prevMiddleNameData.copy( + list = state.prevMiddleNameData.list.map { + if (name.id == it.id) { + it.copy(name = name.name) + } else { + it + } + } + ) + ) + } + } + + fun updateLastName(name: NameModel) { + _state.value?.also { state -> + _state.value = state.copy( + prevLastNameData = state.prevLastNameData.copy( + list = state.prevLastNameData.list.map { + if (name.id == it.id) { + it.copy(name = name.name) + } else { + it + } + } + ) + ) + } + } + + fun onNext() { + state.value?.also { state -> + state.requesterDataScreen ?: return@also + _onNextEvent.value = UiDataEvent( + content = state.requesterDataScreen.nextScreen to PreviousNames( + previousFirstNameList = state.prevFirstNameData.list.map { it.name }, + previousMiddleNameList = state.prevMiddleNameData.list.map { it.name }, + previousLastNameList = state.prevLastNameData.list.map { it.name } + ) + ) + } + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeF.kt new file mode 100644 index 0000000..3ee7e7d --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeF.kt @@ -0,0 +1,131 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.type + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertStepTypeBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertType +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.decorators.ListDelimiterDecorator +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.extensions.fragment.* +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes + +@AndroidEntryPoint +class CriminalCertStepTypeF : Fragment() { + + private val viewModel: CriminalCertStepTypeVM by viewModels() + private val args: CriminalCertStepTypeFArgs by navArgs() + private var binding: FragmentCriminalCertStepTypeBinding? = null + + private var adapter: CriminalCertTypesAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + } + adapter = CriminalCertTypesAdapter(viewModel::selectType) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + binding = FragmentCriminalCertStepTypeBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + viewModel.loadContent() + + recyclerView.adapter = adapter + recyclerView.addItemDecoration( + ListDelimiterDecorator( + context = requireContext(), + dividerRes = R.drawable.divider, + ignorePadding = true, + includeTopAge = false, + includeBottomAge = false + ) + ) + + backBtn.setOnClickListener { findNavController().popBackStack() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertStepTypeF, + menu + ) + } + + onNextEvent.observeUiDataEvent(viewLifecycleOwner, ::navigateNext) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertStepTypeF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertStepTypeF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_CERT_TYPE_SELECTION, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertStepTypeF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertStepTypeF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + + viewModel.state.observe(viewLifecycleOwner) { + adapter?.submitList(it.types) + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding?.recyclerView?.adapter = null + binding = null + } + + override fun onDestroy() { + super.onDestroy() + adapter = null + } + + private fun navigateNext(type: CriminalCertType) { + navigate( + CriminalCertStepTypeFDirections.actionCriminalCertStepTypeFToCriminalCertStepRequesterF( + contextMenu = args.contextMenu, + dataUser = args.dataUser.copy( + certificateType = type + ) + ) + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeVM.kt new file mode 100644 index 0000000..b5a73d5 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertStepTypeVM.kt @@ -0,0 +1,105 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.type + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertType +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertTypes +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.publicservice.helper.PSNavigationHelper + +@HiltViewModel +class CriminalCertStepTypeVM @Inject constructor( + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper { + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _isNextAvailable = MutableLiveData(false) + val isNextAvailable = _isNextAvailable.asLiveData() + + private val _onNextEvent = MutableLiveData>() + val onNextEvent = _onNextEvent.asLiveData() + + private val _state = MutableLiveData() + val state = _state.asLiveData() + + fun loadContent() { + executeAction(progressIndicator = _isLoading) { + api.getCriminalCertTypes().also { res -> + _state.value = CriminalCertTypes( + title = res.title, + subtitle = res.subtitle, + types = res.types?.map { type -> + type.copy( + isSelected = state.value?.types?.find { it.code == type.code }?.isSelected == true + ) + } + ) + res.template?.apply(::showTemplateDialog) + } + } + } + + fun selectType(reason: CriminalCertTypes.Type) { + _state.value = _state.value?.copy( + types = _state.value?.types?.map { + if (it.code == reason.code && !it.isSelected) { + it.copy(isSelected = true) + } else if (it.isSelected) { + it.copy(isSelected = false) + } else { + it + } + }.orEmpty() + ) + _isNextAvailable.value = findSelected() != null + } + + fun onNext() { + findSelected()?.also { selectedType -> + _onNextEvent.value = UiDataEvent(selectedType.code) + } + } + + private fun findSelected(): CriminalCertTypes.Type? { + return _state.value?.types?.find { it.isSelected } + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertTypesAdapter.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertTypesAdapter.kt new file mode 100644 index 0000000..a6d7d8b --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/steps/type/CriminalCertTypesAdapter.kt @@ -0,0 +1,91 @@ +package ua.gov.diia.ps_criminal_cert.ui.steps.type + +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertTypes +import ua.gov.diia.ui_base.util.view.inflater +import ua.gov.diia.ps_criminal_cert.databinding.ItemCriminalCertTypeBinding + +class CriminalCertTypesAdapter( + private val onItemClicked: (CriminalCertTypes.Type) -> Unit +) : ListAdapter( + DiffCallback +) { + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + return ViewHolder( + binding = ItemCriminalCertTypeBinding.inflate(parent.inflater, parent, false), + onItemClicked = { + onItemClicked(getItem(it)) + } + ) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + getItem(position).run(holder::bind) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList) { + if (payloads.isEmpty()) { + super.onBindViewHolder(holder, position, payloads) + } else { + payloads.forEach { list -> + (list as List).forEach { payload -> + if (payload == PAYLOAD_IS_SELECTED) { + holder.setSelected(getItem(position).isSelected) + } + } + } + } + } + + class ViewHolder( + private val binding: ItemCriminalCertTypeBinding, + private val onItemClicked: (Int) -> Unit + ) : RecyclerView.ViewHolder(binding.root) { + + init { + binding.root.setOnClickListener { + onItemClicked(bindingAdapterPosition) + } + } + + fun bind(type: CriminalCertTypes.Type) { + setSelected(type.isSelected) + binding.nameTv.text = type.name + binding.descriptionTv.text = type.description + } + + fun setSelected(isSelected: Boolean) { + binding.radioBtn.isChecked = isSelected + } + } + + companion object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: CriminalCertTypes.Type, + newItem: CriminalCertTypes.Type, + ): Boolean { + return oldItem.code == newItem.code + } + + override fun areContentsTheSame( + oldItem: CriminalCertTypes.Type, + newItem: CriminalCertTypes.Type, + ): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: CriminalCertTypes.Type, newItem: CriminalCertTypes.Type): Any { + val payloads = mutableListOf() + if (oldItem.isSelected != newItem.isSelected) { + payloads.add(PAYLOAD_IS_SELECTED) + } + return payloads + } + + const val PAYLOAD_IS_SELECTED = "PAYLOAD_IS_SELECTED" + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeF.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeF.kt new file mode 100644 index 0000000..78d7b2a --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeF.kt @@ -0,0 +1,149 @@ +package ua.gov.diia.ps_criminal_cert.ui.welcome + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.fragment.navArgs +import dagger.hilt.android.AndroidEntryPoint +import ua.gov.diia.ps_criminal_cert.R +import ua.gov.diia.ps_criminal_cert.databinding.FragmentCriminalCertWelcomeBinding +import ua.gov.diia.core.models.ConsumableItem +import ua.gov.diia.ps_criminal_cert.models.CriminalCertUserData +import ua.gov.diia.ps_criminal_cert.models.request.PublicService +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.core.ui.dynamicdialog.ActionsConst +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.event.observeUiDataEvent +import ua.gov.diia.core.util.event.observeUiEvent +import ua.gov.diia.core.util.extensions.fragment.* +import ua.gov.diia.ui_base.util.navigation.openTemplateDialog +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertRatingScreenCodes + +@AndroidEntryPoint +class CriminalCertWelcomeF : Fragment() { + + private val viewModel: CriminalCertWelcomeVM by viewModels() + private val args: CriminalCertWelcomeFArgs by navArgs() + private var binding: FragmentCriminalCertWelcomeBinding? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + with(viewModel) { + setContextMenu(args.contextMenu) + handleNotification(args.certId) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + binding = FragmentCriminalCertWelcomeBinding.inflate(inflater, container, false) + .apply { + lifecycleOwner = viewLifecycleOwner + vm = viewModel + viewModel.loadContent( + publicService = args.publicService, + directionFlag = args.directionFlag, + resourceId = args.resourceId + ) + backBtn.setOnClickListener { + navigateToBack() + } + doOnSystemBackPressed { navigateToBack() } + } + return binding?.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + with(viewModel) { + showTemplateDialog.observeUiDataEvent(viewLifecycleOwner, ::openTemplateDialog) + openContextMenu.observeUiDataEvent(viewLifecycleOwner) { menu -> + viewModel.navigateToContextMenu( + this@CriminalCertWelcomeF, + menu + ) + } + + navigateToReasons.observeUiEvent(viewLifecycleOwner, ::navigateToReasons) + navigateToDetails.observeUiDataEvent(viewLifecycleOwner, ::navigateToDetails) + navigateToRequester.observeUiEvent(viewLifecycleOwner, ::navigateToRequester) + + showRatingDialogByUserInitiative.observeUiDataEvent(viewLifecycleOwner) { ratingModel -> + viewModel.navigateToRatingService( + fragment = this@CriminalCertWelcomeF, + ratingFormModel = ratingModel, + id = null, + destinationId = R.id.criminalCertWelcomeF, + resultKey = ActionsConst.RESULT_KEY_RATING_SERVICE, + screenCode = CriminalCertRatingScreenCodes.SC_START, + ratingType = ActionsConst.TYPE_USER_INITIATIVE, + formCode = ratingModel.formCode + ) + } + } + registerForTemplateDialogNavResult { action -> + findNavController().popBackStack() + when (action) { + ActionsConst.GENERAL_RETRY -> viewModel.retryLastAction() + ActionsConst.FAQ_CATEGORY -> viewModel.navigateToFaq(this@CriminalCertWelcomeF, CriminalCertConst.FEATURE_CODE) + ActionsConst.SUPPORT_SERVICE -> viewModel.navigateToSupport(this@CriminalCertWelcomeF) + ActionsConst.RATING -> viewModel.getRatingForm() + } + } + registerForNavigationResult(ActionsConst.RESULT_KEY_RATING_SERVICE) { event -> + event.consumeEvent { rating -> viewModel.sendRatingRequest(rating) } + } + } + + override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + private fun navigateToReasons() { + navigate( + CriminalCertWelcomeFDirections.actionCriminalCertWelcomeFToCriminalCertStepReasonsF( + contextMenu = args.contextMenu + ) + ) + } + + private fun navigateToRequester() { + navigate( + CriminalCertWelcomeFDirections.actionCriminalCertWelcomeFToCriminalCertStepRequesterF( + contextMenu = args.contextMenu, + dataUser = CriminalCertUserData( + reasonId = null, + certificateType = null, + publicService = PublicService( + code = args.publicService, + resourceId = args.resourceId + ) + ) + ) + ) + } + + private fun navigateToDetails(id: String) { + navigate( + CriminalCertWelcomeFDirections.actionCriminalCertWelcomeFToCriminalCertDetailsF( + certId = id + ) + ) + } + + private fun navigateToBack() { + if (viewModel.directionFlag.value == false) { + findNavController().popBackStack() + } else { + viewModel.navigateToDamagedPropertyRecovery(this@CriminalCertWelcomeF, args.certId) + } + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeVM.kt b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeVM.kt new file mode 100644 index 0000000..e7fa5e1 --- /dev/null +++ b/ps_criminal_cert/src/main/java/ua/gov/diia/ps_criminal_cert/ui/welcome/CriminalCertWelcomeVM.kt @@ -0,0 +1,106 @@ +package ua.gov.diia.ps_criminal_cert.ui.welcome + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject +import ua.gov.diia.core.di.data_source.http.AuthorizedClient +import ua.gov.diia.core.models.ContextMenuField +import ua.gov.diia.ps_criminal_cert.models.enums.CriminalCertApplicationInfoNextStep +import ua.gov.diia.ps_criminal_cert.models.response.CriminalCertInfo +import ua.gov.diia.core.models.rating_service.RatingRequest +import ua.gov.diia.ps_criminal_cert.ui.CriminalCertConst +import ua.gov.diia.core.util.delegation.WithContextMenu +import ua.gov.diia.core.util.delegation.WithErrorHandling +import ua.gov.diia.core.util.delegation.WithRatingDialog +import ua.gov.diia.core.util.delegation.WithRetryLastAction +import ua.gov.diia.core.util.event.UiDataEvent +import ua.gov.diia.core.util.event.UiEvent +import ua.gov.diia.core.util.extensions.lifecycle.asLiveData +import ua.gov.diia.core.util.extensions.vm.executeAction +import ua.gov.diia.ps_criminal_cert.helper.PSCriminalCertHelper +import ua.gov.diia.ps_criminal_cert.network.ApiCriminalCert +import ua.gov.diia.publicservice.helper.PSNavigationHelper + +@HiltViewModel +class CriminalCertWelcomeVM @Inject constructor( + @AuthorizedClient private val api: ApiCriminalCert, + private val errorHandlingDelegate: WithErrorHandling, + private val contextMenuDelegate: WithContextMenu, + private val retryActionDelegate: WithRetryLastAction, + private val withRatingDialog: WithRatingDialog, + private val navigationHelper: PSNavigationHelper, + private val criminalCertHelper: PSCriminalCertHelper, +) : ViewModel(), WithErrorHandling by errorHandlingDelegate, + WithContextMenu by contextMenuDelegate, + WithRetryLastAction by retryActionDelegate, + WithRatingDialog by withRatingDialog, + PSNavigationHelper by navigationHelper, + PSCriminalCertHelper by criminalCertHelper { + + private val _state = MutableLiveData() + val state = _state.asLiveData() + + private val _isLoading = MutableLiveData() + val isLoading = _isLoading.asLiveData() + + private val _navigateToReasons = MutableLiveData() + val navigateToReasons = _navigateToReasons.asLiveData() + + private val _navigateToRequester = MutableLiveData() + val navigateToRequester = _navigateToRequester.asLiveData() + + private val _navigateToDetails = MutableLiveData>() + val navigateToDetails = _navigateToDetails.asLiveData() + + val directionFlag = MutableStateFlow(null) + val resId = MutableLiveData() + + fun loadContent(publicService: String?, directionFlag: Boolean, resourceId: String?) { + this.directionFlag.value = directionFlag + this.resId.value = resourceId + executeAction(progressIndicator = _isLoading) { + _state.value = api.getCriminalCertInfo(publicService).also { res -> + if (res.showContextMenu != true) { + setContextMenu(null) + } + res.template?.apply(::showTemplateDialog) + } + } + } + + fun onNext() { + when (state.value?.nextScreen) { + CriminalCertApplicationInfoNextStep.reasons.name -> { + _navigateToReasons.value = UiEvent() + } + CriminalCertApplicationInfoNextStep.requester.name -> { + _navigateToRequester.value = UiEvent() + } else ->{ + //nothing + } + } + } + + fun handleNotification(certId: String?) { + if (certId != null && navigateToDetails.value == null) { + _navigateToDetails.value = UiDataEvent(certId) + } + } + + fun getRatingForm() { + getRating( + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } + + fun sendRatingRequest(ratingRequest: RatingRequest) { + sendRating( + ratingRequest = ratingRequest, + category = CriminalCertConst.RATING_SERVICE_CATEGORY, + serviceCode = CriminalCertConst.RATING_SERVICE_CODE + ) + } +} \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_details.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_details.xml new file mode 100644 index 0000000..78e66d5 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_details.xml @@ -0,0 +1,166 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_home.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_home.xml new file mode 100644 index 0000000..0b2f743 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_home.xml @@ -0,0 +1,149 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_list.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_list.xml new file mode 100644 index 0000000..902f4d0 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_list.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_address.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_address.xml new file mode 100644 index 0000000..5a00e2b --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_address.xml @@ -0,0 +1,231 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_birth.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_birth.xml new file mode 100644 index 0000000..e68fb16 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_birth.xml @@ -0,0 +1,255 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_confirm.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_confirm.xml new file mode 100644 index 0000000..45ee7d6 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_confirm.xml @@ -0,0 +1,598 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_contacts.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_contacts.xml new file mode 100644 index 0000000..0a8d4a4 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_contacts.xml @@ -0,0 +1,180 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_nationality.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_nationality.xml new file mode 100644 index 0000000..9a44c9a --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_nationality.xml @@ -0,0 +1,177 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_reasons.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_reasons.xml new file mode 100644 index 0000000..4e849f7 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_reasons.xml @@ -0,0 +1,189 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_requester.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_requester.xml new file mode 100644 index 0000000..6f96524 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_requester.xml @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_type.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_type.xml new file mode 100644 index 0000000..0bf3040 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_step_type.xml @@ -0,0 +1,191 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_welcome.xml b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_welcome.xml new file mode 100644 index 0000000..735dd68 --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/fragment_criminal_cert_welcome.xml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ps_criminal_cert/src/main/res/layout/item_criminal_cert.xml b/ps_criminal_cert/src/main/res/layout/item_criminal_cert.xml new file mode 100644 index 0000000..798676a --- /dev/null +++ b/ps_criminal_cert/src/main/res/layout/item_criminal_cert.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + +