diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9de8cda32b..fe7cb18a46 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -59,7 +59,7 @@ env: jobs: GradleBuild: - runs-on: macos-12 + runs-on: macos-13 outputs: status: ${{ steps.status.outputs.status }} steps: @@ -90,7 +90,7 @@ jobs: distribution: 'temurin' - name: Assemble - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.8.0 with: arguments: assemble @@ -128,7 +128,7 @@ jobs: name: androidArtifacts - name: Firebase App Distribution Google - uses: wzieba/Firebase-Distribution-Github-Action@v1 + uses: wzieba/Firebase-Distribution-Github-Action@v1.5.1 with: appId: ${{secrets.ANDROID_GOOGLE_FIREBASE_APP_ID}} token: ${{secrets.FIREBASE_CLI_TOKEN}} @@ -136,7 +136,7 @@ jobs: file: google/release/app-google-release.apk - name: Firebase App Distribution Huawei - uses: wzieba/Firebase-Distribution-Github-Action@v1 + uses: wzieba/Firebase-Distribution-Github-Action@v1.5.1 with: appId: ${{secrets.ANDROID_HUAWEI_FIREBASE_APP_ID}} token: ${{secrets.FIREBASE_CLI_TOKEN}} @@ -144,7 +144,7 @@ jobs: file: huawei/release/app-huawei-release.apk - name: Delete Android Artifacts - uses: geekyeggo/delete-artifact@v2 + uses: geekyeggo/delete-artifact@v2.0.0 with: name: androidArtifacts @@ -153,7 +153,7 @@ jobs: run: echo "::set-output name=status::success" XCodeBuild: - runs-on: macos-12 + runs-on: macos-13 outputs: status: ${{ steps.status.outputs.status }} steps: @@ -190,7 +190,7 @@ jobs: distribution: 'temurin' - name: Generate Pods - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.8.0 with: arguments: :ios:provider:podGenIOS :client:core:res:podGenIOS --parallel @@ -219,7 +219,7 @@ jobs: run: echo "::set-output name=status::success" DistributeIOS: - runs-on: macos-12 + runs-on: macos-13 needs: [ XCodeBuild ] if: github.event_name == 'push' outputs: @@ -240,7 +240,7 @@ jobs: fastlane distribute - name: Delete iOS IPA - uses: geekyeggo/delete-artifact@v2 + uses: geekyeggo/delete-artifact@v2.0.0 with: name: iOSArtifacts @@ -249,7 +249,7 @@ jobs: run: echo "::set-output name=status::success" Quality: - runs-on: macos-12 + runs-on: macos-13 outputs: status: ${{ steps.status.outputs.status }} steps: @@ -266,7 +266,7 @@ jobs: distribution: 'temurin' - name: Run Quality Jobs - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.8.0 with: arguments: check koverMergedXmlReport --parallel @@ -300,19 +300,19 @@ jobs: path: build - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v3.1.4 with: token: ${{ secrets.CODECOV_TOKEN }} files: build/report.xml - name: Upload coverage to Codacy - uses: codacy/codacy-coverage-reporter-action@v1 + uses: codacy/codacy-coverage-reporter-action@v1.3.0 with: project-token: ${{ secrets.CODACY_PROJECT_TOKEN }} coverage-reports: build/report.xml - name: SonarCloud Scan - uses: sonarsource/sonarcloud-github-action@master + uses: sonarsource/sonarcloud-github-action@v2.0.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} @@ -323,7 +323,7 @@ jobs: -Dsonar.coverage.jacoco.xmlReportPaths=build/report.xml - name: Delete Coverage Report - uses: geekyeggo/delete-artifact@v2 + uses: geekyeggo/delete-artifact@v2.0.0 with: name: coverageReport @@ -349,7 +349,7 @@ jobs: distribution: 'temurin' - name: Detekt - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.8.0 with: arguments: detektAll @@ -378,7 +378,7 @@ jobs: false == (needs.UploadQualityReports.outputs.status == 'success') || (false == (needs.DistributeAndroid.outputs.status == 'success') && github.event_name == 'push') || (false == (needs.DistributeIOS.outputs.status == 'success') && github.event_name == 'push') - uses: voxmedia/github-action-slack-notify-build@v2 + uses: voxmedia/github-action-slack-notify-build@v1.6.0 with: channel: ccc-github status: FAILED diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index 7bb3cc287f..e3b715655e 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -38,6 +38,17 @@ jobs: status_value: "🏗 PR Review" move_related_issues: true + - name: 'Add Dependency PR by renovate to "🏗 PR Review"' + if: github.event_name == 'pull_request' && github.event.pull_request.user.login == 'renovate[bot]' && (github.event.action == 'opened' || github.event.action == 'ready_for_review' || github.event.action == 'reopened') + uses: leonsteinhaeuser/project-beta-automations@v2.1.0 + with: + gh_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} + organization: Oztechan + project_id: 2 + resource_node_id: ${{ github.event.pull_request.node_id }} + operation_mode: custom_field + custom_field_values: '[{\"name\": \"Iteration\",\"type\": \"iteration\",\"value\": \"@current\"}]' + - name: 'Move Related Issue to "🚧 In Progress"' if: github.event_name == 'pull_request' && github.event.action == 'converted_to_draft' uses: leonsteinhaeuser/project-beta-automations@v2.1.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c0a1677dfc..682e6818f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,7 +30,7 @@ jobs: - name: Notify slack fail if: false == (needs.PublishRelease.outputs.status == 'success') - uses: voxmedia/github-action-slack-notify-build@v2 + uses: voxmedia/github-action-slack-notify-build@v1.6.0 env: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64fadba984..b3d5e830fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,7 +85,7 @@ jobs: distribution: 'temurin' - name: Generate Artifacts - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.8.0 with: arguments: :android:app:bundleRelease :backend:app:jar --parallel @@ -138,7 +138,7 @@ jobs: status: completed - name: Delete App Bundle - uses: geekyeggo/delete-artifact@v2 + uses: geekyeggo/delete-artifact@v2.0.0 with: name: googleBundle @@ -159,17 +159,17 @@ jobs: name: huaweiBundle - name: Deploy to Huawei App Gallery - uses: muhamedzeema/appgallery-deply-action@main + uses: muhamedzeema/appgallery-deply-action@v1 with: client-id: ${{secrets.HUAWEI_CLIENT_ID}} client-key: ${{secrets.HUAWEI_CLIENT_KEY}} - app-id: "com.oztechan.ccc.huawei" + app-id: "104920917" file-extension: "aab" file-path: "app-huawei-release.aab" file-name: "app-huawei-release" - name: Delete App Bundle - uses: geekyeggo/delete-artifact@v2 + uses: geekyeggo/delete-artifact@v2.0.0 with: name: huaweiBundle @@ -191,7 +191,7 @@ jobs: path: artifact - name: Deploy to Server - uses: easingthemes/ssh-deploy@main + uses: easingthemes/ssh-deploy@v4.1.8 env: SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }} REMOTE_HOST: ${{ secrets.REMOTE_HOST }} @@ -200,7 +200,7 @@ jobs: SOURCE: "artifact/" - name: Delete Backend Jar - uses: geekyeggo/delete-artifact@v2 + uses: geekyeggo/delete-artifact@v2.0.0 with: name: backendJar @@ -209,7 +209,7 @@ jobs: run: echo "::set-output name=status::success" UploadToAppStore: - runs-on: macos-12 + runs-on: macos-13 outputs: status: ${{ steps.status.outputs.status }} steps: @@ -235,7 +235,7 @@ jobs: gpg -d --passphrase "${{ secrets.SECRET_PASSWORD }}" --batch Release.xcconfig.asc > ios/CCC/Resources/Release/Config.xcconfig - name: Generate Pods - uses: gradle/gradle-build-action@v2.6.0 + uses: gradle/gradle-build-action@v2.8.0 with: arguments: :ios:provider:podGenIOS :client:core:res:podGenIOS --parallel @@ -262,7 +262,7 @@ jobs: needs.UploadToHuaweiAppGallery.outputs.status == 'success' && needs.DeployToServer.outputs.status == 'success' && needs.UploadToAppStore.outputs.status == 'success' - uses: voxmedia/github-action-slack-notify-build@v2 + uses: voxmedia/github-action-slack-notify-build@v1.6.0 with: channel: ccc-github status: SUCCESS @@ -274,7 +274,7 @@ jobs: false == (needs.UploadToHuaweiAppGallery.outputs.status == 'success') || false == (needs.DeployToServer.outputs.status == 'success') || false == (needs.UploadToAppStore.outputs.status == 'success') - uses: voxmedia/github-action-slack-notify-build@v2 + uses: voxmedia/github-action-slack-notify-build@v1.6.0 with: channel: ccc-github status: FAILED diff --git a/CCC.gradle.kts b/CCC.gradle.kts index 21310a6662..7a62e54e33 100755 --- a/CCC.gradle.kts +++ b/CCC.gradle.kts @@ -5,7 +5,6 @@ import io.gitlab.arturbosch.detekt.Detekt import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { alias(kover) alias(detekt) @@ -60,7 +59,14 @@ allprojects { setSource(files(project.projectDir)) exclude("**/build/**") exclude { - it.file.relativeTo(projectDir).startsWith(project.buildDir.relativeTo(projectDir)) + it.file.relativeTo(projectDir).startsWith( + project.layout.buildDirectory.asFile.get().relativeTo(projectDir) + ) + } + }.onEach { detekt -> + // skip detekt tasks unless a it is specifically called + detekt.onlyIf { + gradle.startParameter.taskNames.any { it.contains("detekt") } } } @@ -72,7 +78,6 @@ allprojects { detektPlugins(rootProject.libs.common.detektFormatting) } } - tasks.withType { kotlinOptions { allWarningsAsErrors = true diff --git a/android/app/android-app.gradle.kts b/android/app/android-app.gradle.kts index 56bda39e7a..1c0f5e51f0 100644 --- a/android/app/android-app.gradle.kts +++ b/android/app/android-app.gradle.kts @@ -8,7 +8,6 @@ import config.key.Key import config.key.secret plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidApp.get().pluginId) id(crashlytics.get().pluginId) @@ -18,7 +17,6 @@ plugins { } } -@Suppress("UnstableApiUsage") android { ProjectSettings.apply { namespace = Modules.Android.app.packageName diff --git a/android/app/src/main/kotlin/com/oztechan/ccc/android/app/di/Koin.kt b/android/app/src/main/kotlin/com/oztechan/ccc/android/app/di/Koin.kt index 94ac69a961..75446dd103 100644 --- a/android/app/src/main/kotlin/com/oztechan/ccc/android/app/di/Koin.kt +++ b/android/app/src/main/kotlin/com/oztechan/ccc/android/app/di/Koin.kt @@ -91,7 +91,7 @@ fun initKoin(context: Context) = startKoin { // endregion ) }.also { - Logger.i { "Koin initialised" } + Logger.v { "Koin initialised" } } private fun getAndroidPlatformModule() = module { singleOf(::provideDevice) } diff --git a/android/core/ad/android-core-ad.gradle.kts b/android/core/ad/android-core-ad.gradle.kts index 58d3094ed8..fb5d7d719b 100644 --- a/android/core/ad/android-core-ad.gradle.kts +++ b/android/core/ad/android-core-ad.gradle.kts @@ -8,7 +8,6 @@ import config.key.secret import config.key.string plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(android.get().pluginId) @@ -28,7 +27,6 @@ android { } DeviceFlavour.apply { - @Suppress("UnstableApiUsage") flavorDimensions.addAll(listOf(flavorDimension)) productFlavors { diff --git a/android/core/ad/src/google/kotlin/com/oztechan/ccc/android/core/ad/AdManagerImpl.kt b/android/core/ad/src/google/kotlin/com/oztechan/ccc/android/core/ad/AdManagerImpl.kt index 1249b84b2d..7b52822cc2 100644 --- a/android/core/ad/src/google/kotlin/com/oztechan/ccc/android/core/ad/AdManagerImpl.kt +++ b/android/core/ad/src/google/kotlin/com/oztechan/ccc/android/core/ad/AdManagerImpl.kt @@ -14,7 +14,6 @@ import com.google.android.gms.ads.interstitial.InterstitialAdLoadCallback import com.google.android.gms.ads.rewarded.RewardedAd import com.google.android.gms.ads.rewarded.RewardedAdLoadCallback -@Suppress("VisibleForTests") internal class AdManagerImpl : AdManager { private val adRequest: AdRequest by lazy { @@ -22,7 +21,7 @@ internal class AdManagerImpl : AdManager { } init { - Logger.i { "AdManagerImpl init" } + Logger.v { "AdManagerImpl init" } MobileAds.setAppVolume(0.0f) MobileAds.setAppMuted(true) } @@ -33,7 +32,7 @@ internal class AdManagerImpl : AdManager { adId: String, onAdLoaded: (Int?) -> Unit ): BannerAdView { - Logger.i { "AdManagerImpl getBannerAd" } + Logger.v { "AdManagerImpl getBannerAd" } val adView = AdView(context).apply { val adWidthPixels = if (width == 0) { @@ -64,7 +63,7 @@ internal class AdManagerImpl : AdManager { activity: Activity, adId: String ) { - Logger.i { "AdManagerImpl showInterstitialAd" } + Logger.v { "AdManagerImpl showInterstitialAd" } InterstitialAd.load( activity, @@ -73,12 +72,12 @@ internal class AdManagerImpl : AdManager { object : InterstitialAdLoadCallback() { override fun onAdFailedToLoad(adError: LoadAdError) { super.onAdFailedToLoad(adError) - Logger.w { "AdManagerImpl showInterstitialAd onAdFailedToLoad ${adError.message}" } + Logger.e { "AdManagerImpl showInterstitialAd onAdFailedToLoad ${adError.message}" } } override fun onAdLoaded(interstitialAd: InterstitialAd) { super.onAdLoaded(interstitialAd) - Logger.i { "AdManagerImpl showInterstitialAd onAdLoaded" } + Logger.v { "AdManagerImpl showInterstitialAd onAdLoaded" } interstitialAd.show(activity) } } @@ -89,10 +88,9 @@ internal class AdManagerImpl : AdManager { activity: Activity, adId: String, onAdFailedToLoad: () -> Unit, - onAdLoaded: () -> Unit, onReward: () -> Unit ) { - Logger.i { "AdManagerImpl showRewardedAd" } + Logger.v { "AdManagerImpl showRewardedAd" } RewardedAd.load( activity, @@ -101,17 +99,16 @@ internal class AdManagerImpl : AdManager { object : RewardedAdLoadCallback() { override fun onAdFailedToLoad(adError: LoadAdError) { super.onAdFailedToLoad(adError) - Logger.w { "AdManagerImpl showRewardedAd onAdFailedToLoad ${adError.message}" } + Logger.e { "AdManagerImpl showRewardedAd onAdFailedToLoad ${adError.message}" } onAdFailedToLoad() } override fun onAdLoaded(rewardedAd: RewardedAd) { super.onAdLoaded(rewardedAd) - Logger.i { "AdManagerImpl showRewardedAd onAdLoaded" } - onAdLoaded() + Logger.v { "AdManagerImpl showRewardedAd onAdLoaded" } rewardedAd.show(activity) { - Logger.i { "AdManagerImpl showRewardedAd onUserEarnedReward" } + Logger.v { "AdManagerImpl showRewardedAd onUserEarnedReward" } onReward() } } diff --git a/android/core/ad/src/google/kotlin/com/oztechan/ccc/android/core/ad/Ads.kt b/android/core/ad/src/google/kotlin/com/oztechan/ccc/android/core/ad/Ads.kt index 51f91a885f..d1e3fd55ed 100644 --- a/android/core/ad/src/google/kotlin/com/oztechan/ccc/android/core/ad/Ads.kt +++ b/android/core/ad/src/google/kotlin/com/oztechan/ccc/android/core/ad/Ads.kt @@ -5,6 +5,6 @@ import co.touchlab.kermit.Logger import com.google.android.gms.ads.MobileAds fun initAds(context: Context) { - Logger.i { "Ads initAds" } + Logger.v { "Ads initAds" } MobileAds.initialize(context) } diff --git a/android/core/ad/src/huawei/kotlin/com/oztechan/ccc/android/core/ad/AdManagerImpl.kt b/android/core/ad/src/huawei/kotlin/com/oztechan/ccc/android/core/ad/AdManagerImpl.kt index c1796c79ee..b313dfd167 100644 --- a/android/core/ad/src/huawei/kotlin/com/oztechan/ccc/android/core/ad/AdManagerImpl.kt +++ b/android/core/ad/src/huawei/kotlin/com/oztechan/ccc/android/core/ad/AdManagerImpl.kt @@ -21,7 +21,7 @@ internal class AdManagerImpl : AdManager { } init { - Logger.i { "AdManagerImpl init" } + Logger.v { "AdManagerImpl init" } HwAds.setVideoVolume(0f) HwAds.setVideoMuted(true) } @@ -32,7 +32,7 @@ internal class AdManagerImpl : AdManager { adId: String, onAdLoaded: (Int?) -> Unit ): BannerAdView { - Logger.i { "AdManagerImpl getBannerAd" } + Logger.v { "AdManagerImpl getBannerAd" } val adView = BannerView(context).apply { this.adId = adId @@ -53,18 +53,18 @@ internal class AdManagerImpl : AdManager { activity: Activity, adId: String ) { - Logger.i { "AdManagerImpl showInterstitialAd" } + Logger.v { "AdManagerImpl showInterstitialAd" } InterstitialAd(activity).apply { this.adId = adId adListener = object : AdListener() { override fun onAdFailed(adError: Int) { super.onAdFailed(adError) - Logger.w { "AdManagerImpl showInterstitialAd onAdFailed $adError" } + Logger.e { "AdManagerImpl showInterstitialAd onAdFailed $adError" } } override fun onAdLoaded() { super.onAdLoaded() - Logger.i { "AdManagerImpl showInterstitialAd onAdLoaded" } + Logger.v { "AdManagerImpl showInterstitialAd onAdLoaded" } show(activity) } } @@ -76,10 +76,9 @@ internal class AdManagerImpl : AdManager { activity: Activity, adId: String, onAdFailedToLoad: () -> Unit, - onAdLoaded: () -> Unit, onReward: () -> Unit ) { - Logger.i { "AdManagerImpl showRewardedAd" } + Logger.v { "AdManagerImpl showRewardedAd" } RewardAd(activity, adId).apply { loadAd( @@ -87,21 +86,20 @@ internal class AdManagerImpl : AdManager { object : RewardAdLoadListener() { override fun onRewardAdFailedToLoad(adError: Int) { super.onRewardAdFailedToLoad(adError) - Logger.w { "AdManagerImpl showRewardedAd onRewardAdFailedToLoad $adError" } + Logger.e { "AdManagerImpl showRewardedAd onRewardAdFailedToLoad $adError" } onAdFailedToLoad() } override fun onRewardedLoaded() { super.onRewardedLoaded() - Logger.i { "AdManagerImpl showRewardedAd onRewardedLoaded" } - onAdLoaded() + Logger.v { "AdManagerImpl showRewardedAd onRewardedLoaded" } show( activity, object : RewardAdStatusListener() { override fun onRewarded(reward: Reward?) { super.onRewarded(reward) - Logger.i { "AdManagerImpl showRewardedAd onRewardedLoaded onRewarded" } + Logger.v { "AdManagerImpl showRewardedAd onRewardedLoaded onRewarded" } onReward() } } diff --git a/android/core/ad/src/huawei/kotlin/com/oztechan/ccc/android/core/ad/Ads.kt b/android/core/ad/src/huawei/kotlin/com/oztechan/ccc/android/core/ad/Ads.kt index 6f02b0c1b2..82efeab88d 100644 --- a/android/core/ad/src/huawei/kotlin/com/oztechan/ccc/android/core/ad/Ads.kt +++ b/android/core/ad/src/huawei/kotlin/com/oztechan/ccc/android/core/ad/Ads.kt @@ -5,6 +5,6 @@ import co.touchlab.kermit.Logger import com.huawei.hms.ads.HwAds fun initAds(context: Context) { - Logger.i { "Ads initAds" } + Logger.v { "Ads initAds" } HwAds.init(context) } diff --git a/android/core/ad/src/main/kotlin/com/oztechan/ccc/android/core/ad/AdManager.kt b/android/core/ad/src/main/kotlin/com/oztechan/ccc/android/core/ad/AdManager.kt index e84a0d651c..96769b31f6 100644 --- a/android/core/ad/src/main/kotlin/com/oztechan/ccc/android/core/ad/AdManager.kt +++ b/android/core/ad/src/main/kotlin/com/oztechan/ccc/android/core/ad/AdManager.kt @@ -21,7 +21,6 @@ interface AdManager { activity: Activity, adId: String, onAdFailedToLoad: () -> Unit, - onAdLoaded: () -> Unit, onReward: () -> Unit ) } diff --git a/android/core/billing/android-core-billing.gradle.kts b/android/core/billing/android-core-billing.gradle.kts index cab6b904ef..2009e21c0b 100644 --- a/android/core/billing/android-core-billing.gradle.kts +++ b/android/core/billing/android-core-billing.gradle.kts @@ -2,7 +2,6 @@ import config.DeviceFlavour import config.DeviceFlavour.Companion.implementation plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(android.get().pluginId) @@ -22,7 +21,6 @@ android { } DeviceFlavour.apply { - @Suppress("UnstableApiUsage") flavorDimensions.addAll(listOf(flavorDimension)) productFlavors { diff --git a/android/core/billing/src/google/kotlin/com/oztechan/ccc/android/core/billing/BillingManagerImpl.kt b/android/core/billing/src/google/kotlin/com/oztechan/ccc/android/core/billing/BillingManagerImpl.kt index 76f0a83f9f..41302c59af 100644 --- a/android/core/billing/src/google/kotlin/com/oztechan/ccc/android/core/billing/BillingManagerImpl.kt +++ b/android/core/billing/src/google/kotlin/com/oztechan/ccc/android/core/billing/BillingManagerImpl.kt @@ -2,7 +2,7 @@ package com.oztechan.ccc.android.core.billing import android.app.Activity import android.content.Context -import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.LifecycleOwner import co.touchlab.kermit.Logger import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.AcknowledgePurchaseResponseListener @@ -21,11 +21,12 @@ import com.android.billingclient.api.QueryPurchaseHistoryParams import com.github.submob.scopemob.whether import com.oztechan.ccc.android.core.billing.mapper.toProductDetailsModel import com.oztechan.ccc.android.core.billing.mapper.toPurchaseHistoryRecordModel -import kotlinx.coroutines.CoroutineScope +import com.oztechan.ccc.android.core.billing.util.launchWithLifeCycle import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.launch +// Billing will not work on debug builds +// .debug suffix needs to be removed in app-level build.gradle and google-services.json internal class BillingManagerImpl(private val context: Context) : BillingManager, AcknowledgePurchaseResponseListener, @@ -35,7 +36,7 @@ internal class BillingManagerImpl(private val context: Context) : ProductDetailsResponseListener { private lateinit var billingClient: BillingClient - private lateinit var scope: CoroutineScope + private lateinit var lifecycleOwner: LifecycleOwner private lateinit var productList: List private lateinit var productDetailList: List @@ -45,12 +46,12 @@ internal class BillingManagerImpl(private val context: Context) : override val effect = _effect.asSharedFlow() override fun startConnection( - lifecycleScope: LifecycleCoroutineScope, + lifecycleOwner: LifecycleOwner, skuList: List ) { - Logger.i { "BillingManagerImpl startConnection" } + Logger.v { "BillingManagerImpl startConnection" } - this.scope = lifecycleScope + this.lifecycleOwner = lifecycleOwner this.productList = skuList.map { QueryProductDetailsParams.Product.newBuilder() .setProductId(it) @@ -68,16 +69,17 @@ internal class BillingManagerImpl(private val context: Context) : } override fun endConnection() { - Logger.i { "BillingManagerImpl endConnection" } + Logger.v { "BillingManagerImpl endConnection" } billingClient.endConnection() } override fun launchBillingFlow(activity: Activity, skuId: String) { - Logger.i { "BillingManagerImpl launchBillingFlow" } + Logger.v { "BillingManagerImpl launchBillingFlow" } productDetailList .firstOrNull { it.productId == skuId } ?.let { - val offerToken = it.subscriptionOfferDetails?.get(productDetailList.indexOf(it))?.offerToken.orEmpty() + val offerToken = + it.subscriptionOfferDetails?.get(productDetailList.indexOf(it))?.offerToken.orEmpty() val productDetailsParamsList = listOf( BillingFlowParams.ProductDetailsParams.newBuilder() @@ -95,18 +97,22 @@ internal class BillingManagerImpl(private val context: Context) : } override fun acknowledgePurchase() { - Logger.i { "BillingManagerImpl acknowledgePurchase" } + Logger.v { "BillingManagerImpl acknowledgePurchase" } acknowledgePurchaseParams?.let { billingClient.acknowledgePurchase(it, this) } } override fun onAcknowledgePurchaseResponse(billingResult: BillingResult) { - Logger.i { "BillingManagerImpl onAcknowledgePurchaseResponse ${billingResult.responseCode}" } + Logger.v { "BillingManagerImpl onAcknowledgePurchaseResponse ${billingResult.responseCode}" } if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - scope.launch { + lifecycleOwner.launchWithLifeCycle { _effect.emit(BillingEffect.SuccessfulPurchase) } + } else { + lifecycleOwner.launchWithLifeCycle { + _effect.emit(BillingEffect.BillingUnavailable) + } } } @@ -114,7 +120,7 @@ internal class BillingManagerImpl(private val context: Context) : billingResult: BillingResult, purchaseList: MutableList? ) { - Logger.i { "BillingManagerImpl onPurchasesUpdated ${billingResult.responseCode}" } + Logger.v { "BillingManagerImpl onPurchasesUpdated ${billingResult.responseCode}" } purchaseList?.firstOrNull() ?.also { @@ -125,14 +131,16 @@ internal class BillingManagerImpl(private val context: Context) : ?.products ?.firstOrNull() ?.let { - scope.launch { + lifecycleOwner.launchWithLifeCycle { _effect.emit(BillingEffect.UpdatePremiumEndDate(it)) } - } + } ?: lifecycleOwner.launchWithLifeCycle { + _effect.emit(BillingEffect.BillingUnavailable) + } } override fun onBillingSetupFinished(billingResult: BillingResult) { - Logger.i { "BillingManagerImpl onBillingSetupFinished ${billingResult.responseCode}" } + Logger.v { "BillingManagerImpl onBillingSetupFinished ${billingResult.responseCode}" } val queryPurchaseHistoryParams = QueryPurchaseHistoryParams.newBuilder() .setProductType(BillingClient.ProductType.INAPP) @@ -149,29 +157,34 @@ internal class BillingManagerImpl(private val context: Context) : .build() queryProductDetailsAsync(queryProductDetailsParams, this@BillingManagerImpl) + } ?: lifecycleOwner.launchWithLifeCycle { + _effect.emit(BillingEffect.BillingUnavailable) } } override fun onBillingServiceDisconnected() { - Logger.i { "BillingManagerImpl onBillingServiceDisconnected" } + Logger.v { "BillingManagerImpl onBillingServiceDisconnected" } + lifecycleOwner.launchWithLifeCycle { + _effect.emit(BillingEffect.BillingUnavailable) + } } override fun onProductDetailsResponse( billingResult: BillingResult, productDetasilList: MutableList ) { - Logger.i { "BillingManagerImpl onProductDetailsResponse ${billingResult.responseCode}" } + Logger.v { "BillingManagerImpl onProductDetailsResponse ${billingResult.responseCode}" } - scope.launch { + lifecycleOwner.launchWithLifeCycle { productDetasilList.whether { billingResult.responseCode == BillingClient.BillingResponseCode.OK - }?.let { detailsList -> - productDetailList = detailsList + }.let { detailsList -> + productDetailList = detailsList.orEmpty() detailsList - .map { it.toProductDetailsModel() } + ?.map { it.toProductDetailsModel() } .let { - _effect.emit(BillingEffect.AddPurchaseMethods(it)) + _effect.emit(BillingEffect.AddPurchaseMethods(it.orEmpty())) } } } @@ -181,12 +194,12 @@ internal class BillingManagerImpl(private val context: Context) : billingResult: BillingResult, purchaseHistoryList: MutableList? ) { - Logger.i { "BillingManagerImpl onPurchaseHistoryResponse ${billingResult.responseCode}" } + Logger.v { "BillingManagerImpl onPurchaseHistoryResponse ${billingResult.responseCode}" } purchaseHistoryList ?.map { it.toPurchaseHistoryRecordModel() } ?.let { - scope.launch { + lifecycleOwner.launchWithLifeCycle { _effect.emit(BillingEffect.RestorePurchase(it)) } } diff --git a/android/core/billing/src/huawei/kotlin/com/oztechan/ccc/android/core/billing/BillingManagerImpl.kt b/android/core/billing/src/huawei/kotlin/com/oztechan/ccc/android/core/billing/BillingManagerImpl.kt index 8ccec89604..669fa728ba 100644 --- a/android/core/billing/src/huawei/kotlin/com/oztechan/ccc/android/core/billing/BillingManagerImpl.kt +++ b/android/core/billing/src/huawei/kotlin/com/oztechan/ccc/android/core/billing/BillingManagerImpl.kt @@ -2,7 +2,7 @@ package com.oztechan.ccc.android.core.billing import android.app.Activity import android.content.Context -import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.LifecycleOwner import co.touchlab.kermit.Logger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -14,21 +14,21 @@ internal class BillingManagerImpl(private val context: Context) : BillingManager override val effect = _effect.asSharedFlow() override fun startConnection( - lifecycleScope: LifecycleCoroutineScope, + lifecycleOwner: LifecycleOwner, skuList: List ) { - Logger.i { "BillingManagerImpl startConnection" } + Logger.v { "BillingManagerImpl startConnection" } } override fun endConnection() { - Logger.i { "BillingManagerImpl endConnection" } + Logger.v { "BillingManagerImpl endConnection" } } override fun launchBillingFlow(activity: Activity, skuId: String) { - Logger.i { "BillingManagerImpl launchBillingFlow" } + Logger.v { "BillingManagerImpl launchBillingFlow" } } override fun acknowledgePurchase() { - Logger.i { "BillingManagerImpl acknowledgePurchase" } + Logger.v { "BillingManagerImpl acknowledgePurchase" } } } diff --git a/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/BillingEffect.kt b/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/BillingEffect.kt index 202bfb0b37..47501c1b4f 100644 --- a/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/BillingEffect.kt +++ b/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/BillingEffect.kt @@ -4,7 +4,8 @@ import com.oztechan.ccc.android.core.billing.model.ProductDetails import com.oztechan.ccc.android.core.billing.model.PurchaseHistoryRecord sealed class BillingEffect { - object SuccessfulPurchase : BillingEffect() + data object SuccessfulPurchase : BillingEffect() + data object BillingUnavailable : BillingEffect() data class RestorePurchase( val purchaseHistoryRecordRecordList: List diff --git a/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/BillingManager.kt b/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/BillingManager.kt index 9eb0cfa7b9..368dbee52a 100644 --- a/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/BillingManager.kt +++ b/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/BillingManager.kt @@ -1,14 +1,14 @@ package com.oztechan.ccc.android.core.billing import android.app.Activity -import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.flow.SharedFlow interface BillingManager { val effect: SharedFlow fun startConnection( - lifecycleScope: LifecycleCoroutineScope, + lifecycleOwner: LifecycleOwner, skuList: List ) diff --git a/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/util/CoroutinesUtil.kt b/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/util/CoroutinesUtil.kt new file mode 100644 index 0000000000..d4f7c20e37 --- /dev/null +++ b/android/core/billing/src/main/kotlin/com/oztechan/ccc/android/core/billing/util/CoroutinesUtil.kt @@ -0,0 +1,18 @@ +package com.oztechan.ccc.android.core.billing.util + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch + +internal fun LifecycleOwner.launchWithLifeCycle( + state: Lifecycle.State = Lifecycle.State.STARTED, + block: suspend () -> Unit +) { + lifecycleScope.launch { + repeatOnLifecycle(state) { + block() + } + } +} diff --git a/android/ui/mobile/android-ui-mobile.gradle.kts b/android/ui/mobile/android-ui-mobile.gradle.kts index 94738dd9a2..efd8be6815 100644 --- a/android/ui/mobile/android-ui-mobile.gradle.kts +++ b/android/ui/mobile/android-ui-mobile.gradle.kts @@ -2,7 +2,6 @@ import config.DeviceFlavour import config.DeviceFlavour.Companion.implementation plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(android.get().pluginId) @@ -10,7 +9,6 @@ plugins { } } -@Suppress("UnstableApiUsage") android { ProjectSettings.apply { namespace = Modules.Android.UI.mobile.packageName diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/component/SnackViewHost.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/component/SnackViewHost.kt index b4a41f3092..87a28e68a1 100644 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/component/SnackViewHost.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/component/SnackViewHost.kt @@ -80,7 +80,6 @@ fun SnackViewContent(snackbarData: SnackbarData) { } } -@Suppress("UnrememberedMutableState") @Composable @ThemedPreviews fun SnackViewContentPreview() = Preview { diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/calculator/CalculatorFragment.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/calculator/CalculatorFragment.kt index 21c1eff3d8..e1ed3b5424 100755 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/calculator/CalculatorFragment.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/calculator/CalculatorFragment.kt @@ -32,6 +32,8 @@ import com.oztechan.ccc.client.viewmodel.calculator.CalculatorViewModel import com.oztechan.ccc.client.viewmodel.calculator.util.toValidList import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -63,6 +65,11 @@ class CalculatorFragment : BaseVBFragment() { analyticsManager.trackScreen(ScreenName.Calculator) } + override fun onDestroy() { + Logger.i { "CalculatorFragment onDestroy" } + super.onDestroy() + } + override fun onDestroyView() { Logger.i { "CalculatorFragment onDestroyView" } binding.adViewContainer.destroyBanner() @@ -78,22 +85,24 @@ class CalculatorFragment : BaseVBFragment() { calculatorViewModel.event.onBaseChange(it) } - private fun FragmentCalculatorBinding.initViews() { - adViewContainer.setBannerAd( - adManager = adManager, - adId = if (BuildConfig.DEBUG) { - getString(R.string.banner_ad_unit_id_calculator_debug) - } else { - getString(R.string.banner_ad_unit_id_calculator_release) - }, - shouldShowAd = calculatorViewModel.shouldShowBannerAd() - ) + private fun FragmentCalculatorBinding.initViews() = viewLifecycleOwner.lifecycleScope.launch { recyclerViewMain.adapter = calculatorAdapter } @SuppressLint("SetTextI18n") private fun FragmentCalculatorBinding.observeStates() = calculatorViewModel.state .flowWithLifecycle(lifecycle) + .onStart { + adViewContainer.setBannerAd( + adManager = adManager, + adId = if (BuildConfig.DEBUG) { + getString(R.string.banner_ad_unit_id_calculator_debug) + } else { + getString(R.string.banner_ad_unit_id_calculator_release) + }, + shouldShowAd = calculatorViewModel.state.value.isBannerAdVisible + ) + } .onEach { with(it) { calculatorAdapter.submitList(currencyList.toValidList(calculatorViewModel.state.value.base)) @@ -144,7 +153,7 @@ class CalculatorFragment : BaseVBFragment() { text = R.string.text_paste_request, actionText = R.string.text_paste ) { - calculatorViewModel.pasteToInput(it.context.getFromClipBoard()) + calculatorViewModel.onPasteToInput(it.context.getFromClipBoard()) } } diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/currencies/CurrenciesFragment.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/currencies/CurrenciesFragment.kt index 205e84b82a..040d7c868b 100755 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/currencies/CurrenciesFragment.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/currencies/CurrenciesFragment.kt @@ -32,10 +32,10 @@ import com.oztechan.ccc.client.viewmodel.currencies.CurrenciesEffect import com.oztechan.ccc.client.viewmodel.currencies.CurrenciesViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -@Suppress("TooManyFunctions") class CurrenciesFragment : BaseVBFragment() { private val analyticsManager: AnalyticsManager by inject() @@ -65,41 +65,42 @@ class CurrenciesFragment : BaseVBFragment() { } private fun FragmentCurrenciesBinding.initViews() { - adViewContainer.setBannerAd( - adManager = adManager, - adId = if (BuildConfig.DEBUG) { - getString(R.string.banner_ad_unit_id_currencies_debug) - } else { - getString(R.string.banner_ad_unit_id_currencies_release) - }, - shouldShowAd = currenciesViewModel.shouldShowBannerAd() - ) - setSpanByOrientation(resources.configuration.orientation) with(recyclerViewCurrencies) { setHasFixedSize(true) adapter = currenciesAdapter } - - btnDone.visibleIf(currenciesViewModel.isFirstRun()) - txtSelectCurrencies.visibleIf(currenciesViewModel.isFirstRun()) } private fun FragmentCurrenciesBinding.observeStates() = currenciesViewModel.state .flowWithLifecycle(lifecycle) + .onStart { + adViewContainer.setBannerAd( + adManager = adManager, + adId = if (BuildConfig.DEBUG) { + getString(R.string.banner_ad_unit_id_currencies_debug) + } else { + getString(R.string.banner_ad_unit_id_currencies_release) + }, + shouldShowAd = currenciesViewModel.state.value.isBannerAdVisible + ) + } .onEach { with(it) { currenciesAdapter.submitList(currencyList) loadingView.visibleIf(loading, true) + btnDone.visibleIf(isOnboardingVisible) + txtSelectCurrencies.visibleIf(isOnboardingVisible) + with(layoutCurrenciesToolbar) { searchView.visibleIf(!selectionVisibility) txtCurrenciesToolbar.visibleIf(!selectionVisibility) btnSelectAll.visibleIf(selectionVisibility) btnDeSelectAll.visibleIf(selectionVisibility) - backButton.visibleIf(!currenciesViewModel.isFirstRun() || selectionVisibility) + backButton.visibleIf(!isOnboardingVisible || selectionVisibility) backButton.setBackgroundResource( if (selectionVisibility) R.drawable.ic_close else R.drawable.ic_back @@ -162,10 +163,8 @@ class CurrenciesFragment : BaseVBFragment() { override fun onResume() { super.onResume() - analyticsManager.trackScreen(ScreenName.Currencies) Logger.i { "CurrenciesFragment onResume" } - currenciesViewModel.hideSelectionVisibility() - currenciesViewModel.event.onQueryChange("") + analyticsManager.trackScreen(ScreenName.Currencies) } override fun onConfigurationChanged(newConfig: Configuration) { diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/main/ComposeMainActivity.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/main/ComposeMainActivity.kt index 33c85ea0d7..319ffb6a1c 100644 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/main/ComposeMainActivity.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/main/ComposeMainActivity.kt @@ -11,7 +11,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.oztechan.ccc.android.ui.mobile.content.selectcurrency.SelectCurrencyView -import com.oztechan.ccc.android.ui.mobile.content.watchers.WatchersView +import com.oztechan.ccc.android.ui.mobile.content.watchers.WatchersRootView import com.oztechan.ccc.android.ui.mobile.theme.AppTheme class ComposeMainActivity : ComponentActivity() { @@ -29,7 +29,7 @@ class ComposeMainActivity : ComponentActivity() { startDestination = "watchers" ) { composable("watchers") { - WatchersView() + WatchersRootView() } composable("select_currency") { SelectCurrencyView() diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/main/MainActivity.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/main/MainActivity.kt index af07e5b2b5..37acae6c32 100755 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/main/MainActivity.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/main/MainActivity.kt @@ -45,17 +45,24 @@ class MainActivity : BaseActivity() { installSplashScreen() super.onCreate(savedInstanceState) Logger.i { "MainActivity onCreate" } - - // if dark mode is supported use theming according to user preference - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - AppCompatDelegate.setDefaultNightMode(getThemeMode(mainViewModel.getAppTheme())) - } - setContentView(R.layout.activity_main) - checkDestination() + observeStates() observeEffects() } + private fun observeStates() = mainViewModel.state + .flowWithLifecycle(lifecycle) + .onEach { + with(it) { + setDestination(if (shouldOnboardUser) R.id.sliderFragment else R.id.calculatorFragment) + + // if dark mode is supported use theming according to user preference + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + AppCompatDelegate.setDefaultNightMode(getThemeMode(it.appTheme)) + } + } + }.launchIn(lifecycleScope) + private fun observeEffects() = mainViewModel.effect .flowWithLifecycle(lifecycle) .onEach { viewEffect -> @@ -71,7 +78,10 @@ class MainActivity : BaseActivity() { ) MainEffect.RequestReview -> requestAppReview(this) - is MainEffect.AppUpdateEffect -> showAppUpdateDialog(viewEffect.isCancelable, viewEffect.marketLink) + is MainEffect.AppUpdateEffect -> showAppUpdateDialog( + viewEffect.isCancelable, + viewEffect.marketLink + ) } }.launchIn(lifecycleScope) @@ -84,15 +94,9 @@ class MainActivity : BaseActivity() { startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(marketLink))) } - private fun checkDestination() = with(getNavigationController()) { + private fun setDestination(fragmentId: Int) = with(getNavigationController()) { graph = navInflater.inflate(R.navigation.main_graph).apply { - setStartDestination( - if (mainViewModel.isFistRun()) { - R.id.sliderFragment - } else { - R.id.calculatorFragment - } - ) + setStartDestination(fragmentId) } } diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/premium/PremiumBottomSheet.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/premium/PremiumBottomSheet.kt index 7e302afdc3..97a6522008 100644 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/premium/PremiumBottomSheet.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/premium/PremiumBottomSheet.kt @@ -31,7 +31,6 @@ import kotlinx.coroutines.flow.onEach import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -@Suppress("TooManyFunctions") class PremiumBottomSheet : BaseVBBottomSheetDialogFragment() { private val analyticsManager: AnalyticsManager by inject() @@ -48,7 +47,10 @@ class PremiumBottomSheet : BaseVBBottomSheetDialogFragment Logger.i { "PremiumBottomSheet observeEffects ${viewEffect::class.simpleName}" } when (viewEffect) { - is PremiumEffect.LaunchActivatePremiumFlow -> { - if (viewEffect.premiumType == PremiumType.VIDEO) { - activity?.showDialog( - title = R.string.txt_premium, - message = R.string.txt_premium_text, - positiveButton = R.string.txt_watch - ) { - premiumViewModel.showLoadingView(true) - showRewardedAd() - } - } else { - billingManager.launchBillingFlow(requireActivity(), viewEffect.premiumType.data.id) + is PremiumEffect.LaunchActivatePremiumFlow -> if (viewEffect.premiumType == PremiumType.VIDEO) { + activity?.showDialog( + title = R.string.txt_premium, + message = R.string.txt_premium_text, + positiveButton = R.string.txt_watch + ) { + showRewardedAd() } + } else { + billingManager.launchBillingFlow( + requireActivity(), + viewEffect.premiumType.data.id + ) } - is PremiumEffect.PremiumActivated -> { - if (viewEffect.premiumType == PremiumType.VIDEO || viewEffect.isRestorePurchase) { - restartActivity() - } else { - billingManager.acknowledgePurchase() - } + is PremiumEffect.PremiumActivated -> if ( + viewEffect.premiumType == PremiumType.VIDEO || + viewEffect.isRestorePurchase + ) { + restartActivity() + } else { + billingManager.acknowledgePurchase() } } }.launchIn(viewLifecycleOwner.lifecycleScope) @@ -116,17 +119,19 @@ class PremiumBottomSheet : BaseVBBottomSheetDialogFragment restartActivity() - is BillingEffect.RestorePurchase -> premiumViewModel.restorePurchase( + is BillingEffect.RestorePurchase -> premiumViewModel.event.onRestorePurchase( viewEffect.purchaseHistoryRecordRecordList.toOldPurchaseList() ) - is BillingEffect.AddPurchaseMethods -> premiumViewModel.addPurchaseMethods( + is BillingEffect.AddPurchaseMethods -> premiumViewModel.event.onAddPurchaseMethods( viewEffect.productDetailsList.toPremiumDataList() ) - is BillingEffect.UpdatePremiumEndDate -> premiumViewModel.updatePremiumEndDate( + is BillingEffect.UpdatePremiumEndDate -> premiumViewModel.onPremiumActivated( PremiumType.getById(viewEffect.id) ) + + BillingEffect.BillingUnavailable -> premiumViewModel.event.onPremiumActivationFailed() } }.launchIn(viewLifecycleOwner.lifecycleScope) @@ -139,14 +144,11 @@ class PremiumBottomSheet : BaseVBBottomSheetDialogFragment() { @Suppress("LongMethod") private fun FragmentSettingsBinding.initViews() { - adViewContainer.setBannerAd( - adManager = adManager, - adId = if (BuildConfig.DEBUG) { - getString(R.string.banner_ad_unit_id_settings_debug) - } else { - getString(R.string.banner_ad_unit_id_settings_release) - }, - shouldShowAd = settingsViewModel.shouldShowBannerAd() - ) with(itemCurrencies) { imgSettingsItem.setBackgroundResource(R.drawable.ic_currency) settingsItemTitle.text = getString(R.string.settings_item_currencies_title) @@ -139,6 +131,17 @@ class SettingsFragment : BaseVBFragment() { private fun FragmentSettingsBinding.observeStates() = settingsViewModel.state .flowWithLifecycle(lifecycle) + .onStart { + adViewContainer.setBannerAd( + adManager = adManager, + adId = if (BuildConfig.DEBUG) { + getString(R.string.banner_ad_unit_id_settings_debug) + } else { + getString(R.string.banner_ad_unit_id_settings_release) + }, + shouldShowAd = settingsViewModel.state.value.isBannerAdVisible + ) + } .onEach { with(it) { itemCurrencies.settingsItemValue.text = requireContext().getString( @@ -234,16 +237,15 @@ class SettingsFragment : BaseVBFragment() { Logger.i { "SettingsFragment onResume" } } - private fun changeTheme() = AppTheme.getThemeByValue(settingsViewModel.getAppTheme()) - ?.let { currentThemeType -> - activity?.showSingleChoiceDialog( - getString(R.string.title_dialog_choose_theme), - AppTheme.values().map { it.themeName }.toTypedArray(), - currentThemeType.ordinal - ) { index -> - AppTheme.getThemeByOrdinal(index)?.let { settingsViewModel.updateTheme(it) } - } + private fun changeTheme() { + activity?.showSingleChoiceDialog( + getString(R.string.title_dialog_choose_theme), + AppTheme.values().map { it.themeName }.toTypedArray(), + settingsViewModel.state.value.appThemeType.ordinal + ) { index -> + AppTheme.getThemeByOrdinal(index)?.let { settingsViewModel.event.onThemeChange(it) } } + } private fun showPrecisionDialog() = activity?.showSingleChoiceDialog( R.string.title_dialog_choose_precision, diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/watchers/WatchersRootView.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/watchers/WatchersRootView.kt new file mode 100644 index 0000000000..ac04314d2c --- /dev/null +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/watchers/WatchersRootView.kt @@ -0,0 +1,41 @@ +package com.oztechan.ccc.android.ui.mobile.content.watchers + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.navigation.NavHostController +import co.touchlab.kermit.Logger +import com.oztechan.ccc.android.ui.mobile.component.SnackViewHost +import com.oztechan.ccc.client.viewmodel.watchers.WatchersEffect +import com.oztechan.ccc.client.viewmodel.watchers.WatchersViewModel +import org.koin.androidx.compose.koinViewModel + +@Composable +fun NavHostController.WatchersRootView( + vm: WatchersViewModel = koinViewModel(), +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(key1 = vm.effect) { + vm.effect.collect { + Logger.i { "WatchersRootView observeEffects ${it::class.simpleName}" } + when (it) { + WatchersEffect.Back -> popBackStack() + is WatchersEffect.SelectBase -> navigate("select_currency") + is WatchersEffect.SelectTarget -> navigate("select_currency") + WatchersEffect.InvalidInput -> snackbarHostState.showSnackbar(it::class.simpleName.orEmpty()) + WatchersEffect.MaximumNumberOfWatchers -> snackbarHostState.showSnackbar(it::class.simpleName.orEmpty()) + WatchersEffect.TooBigInput -> snackbarHostState.showSnackbar(it::class.simpleName.orEmpty()) + } + } + } + + SnackViewHost(snackbarHostState) { + WatchersView( + state = vm.state.collectAsState(), + event = vm.event + ) + } +} diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/watchers/WatchersView.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/watchers/WatchersView.kt index 7bc78bc84f..cc7cd5f44a 100644 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/watchers/WatchersView.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/content/watchers/WatchersView.kt @@ -9,66 +9,28 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.navigation.NavHostController -import co.touchlab.kermit.Logger import com.oztechan.ccc.android.ui.mobile.R import com.oztechan.ccc.android.ui.mobile.annotations.ThemedPreviews import com.oztechan.ccc.android.ui.mobile.component.ImageView import com.oztechan.ccc.android.ui.mobile.component.Preview -import com.oztechan.ccc.android.ui.mobile.component.SnackViewHost import com.oztechan.ccc.android.ui.mobile.util.toColor import com.oztechan.ccc.android.ui.mobile.util.toPainter import com.oztechan.ccc.android.ui.mobile.util.toText -import com.oztechan.ccc.client.viewmodel.watchers.WatchersEffect import com.oztechan.ccc.client.viewmodel.watchers.WatchersEvent import com.oztechan.ccc.client.viewmodel.watchers.WatchersState -import com.oztechan.ccc.client.viewmodel.watchers.WatchersViewModel import com.oztechan.ccc.common.core.model.Watcher -import org.koin.androidx.compose.koinViewModel @Composable -fun NavHostController.WatchersView( - vm: WatchersViewModel = koinViewModel(), -) { - val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(key1 = vm.effect) { - vm.effect.collect { - Logger.i { "WatchersView observeEffects ${it::class.simpleName}" } - when (it) { - WatchersEffect.Back -> popBackStack() - is WatchersEffect.SelectBase -> navigate("select_currency") - is WatchersEffect.SelectTarget -> navigate("select_currency") - WatchersEffect.InvalidInput -> snackbarHostState.showSnackbar(it.javaClass.simpleName) - WatchersEffect.MaximumNumberOfWatchers -> snackbarHostState.showSnackbar(it.javaClass.simpleName) - WatchersEffect.TooBigInput -> snackbarHostState.showSnackbar(it.javaClass.simpleName) - } - } - } - - SnackViewHost(snackbarHostState) { - WatchersViewContent( - state = vm.state.collectAsState(), - event = vm.event - ) - } -} - -@Composable -fun WatchersViewContent( +fun WatchersView( state: State, event: WatchersEvent ) { @@ -128,13 +90,13 @@ fun WatchersViewContent( } } -@Suppress("UnrememberedMutableState") @Composable @ThemedPreviews -fun WatchersViewContentPreview() = Preview { - WatchersViewContent( - state = mutableStateOf( +fun WatchersViewPreview() = Preview { + WatchersView( + state = rememberUpdatedState( WatchersState( + isBannerAdVisible = false, watcherList = listOf( Watcher(id = 0, base = "EUR", target = "USD", isGreater = false, rate = 123.0), Watcher(id = 0, base = "USD", target = "EUR", isGreater = false, rate = 123.0) diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/util/Dialog.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/util/Dialog.kt index 301ec1d14c..d96c15f975 100644 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/util/Dialog.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/util/Dialog.kt @@ -11,7 +11,6 @@ import com.github.submob.scopemob.inCase import com.github.submob.scopemob.whetherNot import com.oztechan.ccc.android.ui.mobile.R -@Suppress("LongParameterList") fun Activity.showDialog( title: String, message: String, @@ -30,7 +29,6 @@ fun Activity.showDialog( setNegativeButton(getString(android.R.string.cancel), null) }?.show() -@Suppress("LongParameterList") fun Activity.showDialog( title: Int, message: Int, @@ -45,7 +43,6 @@ fun Activity.showDialog( function = function ) -@Suppress("LongParameterList") fun Activity.showSingleChoiceDialog( title: String, items: Array, @@ -58,7 +55,6 @@ fun Activity.showSingleChoiceDialog( dialog.dismiss() }?.show() -@Suppress("LongParameterList") fun Activity.showSingleChoiceDialog( title: Int, items: Array, diff --git a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/util/SnackBar.kt b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/util/SnackBar.kt index 53e67fbe5c..9c28c5ead6 100644 --- a/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/util/SnackBar.kt +++ b/android/ui/mobile/src/main/kotlin/com/oztechan/ccc/android/ui/mobile/util/SnackBar.kt @@ -12,7 +12,7 @@ import co.touchlab.kermit.Logger import com.google.android.material.snackbar.Snackbar import com.oztechan.ccc.android.ui.mobile.R -@Suppress("LongParameterList", "NestedBlockDepth") +@Suppress("NestedBlockDepth") fun View?.showSnack( text: String = "", actionText: String = "", @@ -52,7 +52,6 @@ fun View?.showSnack( }.show() } -@Suppress("LongParameterList") fun View?.showSnack( text: Int? = null, actionText: Int? = null, diff --git a/android/ui/widget/android-ui-widget.gradle.kts b/android/ui/widget/android-ui-widget.gradle.kts index 8947b88ed8..e7a4da11a1 100644 --- a/android/ui/widget/android-ui-widget.gradle.kts +++ b/android/ui/widget/android-ui-widget.gradle.kts @@ -1,12 +1,10 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(android.get().pluginId) } } -@Suppress("UnstableApiUsage") android { ProjectSettings.apply { namespace = Modules.Android.UI.widget.packageName diff --git a/android/ui/widget/src/main/kotlin/com/oztechan/ccc/android/ui/widget/AppWidgetReceiver.kt b/android/ui/widget/src/main/kotlin/com/oztechan/ccc/android/ui/widget/AppWidgetReceiver.kt index 4aed4d309e..5cbf52f770 100644 --- a/android/ui/widget/src/main/kotlin/com/oztechan/ccc/android/ui/widget/AppWidgetReceiver.kt +++ b/android/ui/widget/src/main/kotlin/com/oztechan/ccc/android/ui/widget/AppWidgetReceiver.kt @@ -48,6 +48,7 @@ class AppWidgetReceiver : GlanceAppWidgetReceiver(), KoinComponent { private fun refreshData(context: Context) = runBlocking { GlanceAppWidgetManager(context) .getGlanceIds(AppWidget::class.java) + .iterator() .forEach { glanceAppWidget.update(context, it) } } } diff --git a/android/ui/widget/src/main/kotlin/com/oztechan/ccc/android/ui/widget/content/WidgetView.kt b/android/ui/widget/src/main/kotlin/com/oztechan/ccc/android/ui/widget/content/WidgetView.kt index 8292b7abbe..cce7f79af9 100644 --- a/android/ui/widget/src/main/kotlin/com/oztechan/ccc/android/ui/widget/content/WidgetView.kt +++ b/android/ui/widget/src/main/kotlin/com/oztechan/ccc/android/ui/widget/content/WidgetView.kt @@ -38,7 +38,7 @@ fun WidgetView( onNextClick = event::onNextClick ) - state.currencyList.forEach { + state.currencyList.iterator().forEach { WidgetItem(item = it) } } else { diff --git a/android/viewmodel/widget/android-viewmodel-widget.gradle.kts b/android/viewmodel/widget/android-viewmodel-widget.gradle.kts index 84e98f9174..3cf6f605e8 100644 --- a/android/viewmodel/widget/android-viewmodel-widget.gradle.kts +++ b/android/viewmodel/widget/android-viewmodel-widget.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(android.get().pluginId) diff --git a/android/viewmodel/widget/src/main/kotlin/com/oztechan/ccc/android/viewmodel/widget/WidgetSEED.kt b/android/viewmodel/widget/src/main/kotlin/com/oztechan/ccc/android/viewmodel/widget/WidgetSEED.kt index 8f5776943b..8e70af5f62 100644 --- a/android/viewmodel/widget/src/main/kotlin/com/oztechan/ccc/android/viewmodel/widget/WidgetSEED.kt +++ b/android/viewmodel/widget/src/main/kotlin/com/oztechan/ccc/android/viewmodel/widget/WidgetSEED.kt @@ -24,7 +24,7 @@ interface WidgetEvent : BaseEvent { // Effect sealed class WidgetEffect : BaseEffect() { - object OpenApp : WidgetEffect() + data object OpenApp : WidgetEffect() } // Data diff --git a/android/viewmodel/widget/src/test/kotlin/com/oztechan/ccc/android/viewmodel/widget/WidgetViewModelTest.kt b/android/viewmodel/widget/src/test/kotlin/com/oztechan/ccc/android/viewmodel/widget/WidgetViewModelTest.kt index 75599b74d0..6e0829b5c3 100644 --- a/android/viewmodel/widget/src/test/kotlin/com/oztechan/ccc/android/viewmodel/widget/WidgetViewModelTest.kt +++ b/android/viewmodel/widget/src/test/kotlin/com/oztechan/ccc/android/viewmodel/widget/WidgetViewModelTest.kt @@ -13,11 +13,12 @@ import com.oztechan.ccc.client.storage.calculation.CalculationStorage import com.oztechan.ccc.common.core.model.Conversion import com.oztechan.ccc.common.core.model.Currency import io.mockative.Mock -import io.mockative.anything +import io.mockative.any import io.mockative.classOf +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.configure -import io.mockative.eq -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.Dispatchers @@ -30,10 +31,10 @@ import kotlin.random.Random import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.time.Duration.Companion.days -@Suppress("OPT_IN_USAGE") class WidgetViewModelTest { private val viewModel: WidgetViewModel by lazy { @@ -46,7 +47,8 @@ class WidgetViewModelTest { } @Mock - private val calculationStorage = configure(mock(classOf())) { stubsUnitByDefault = true } + private val calculationStorage = + configure(mock(classOf())) { stubsUnitByDefault = true } @Mock private val backendApiService = mock(classOf()) @@ -67,98 +69,89 @@ class WidgetViewModelTest { Currency(code = lastBase, name = "Turkish Lira", symbol = "₺", isActive = true) ) - private val conversion = Conversion(base = base, eur = 1.111111, usd = 2.222222, `try` = 3.333333) + private val conversion = + Conversion(base = base, eur = 1.111111, usd = 2.222222, `try` = 3.333333) @BeforeTest fun setup() { + @Suppress("OPT_IN_USAGE") Dispatchers.setMain(UnconfinedTestDispatcher()) Logger.setLogWriters(CommonWriter()) val mockEndDate = Random.nextLong() - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(mockEndDate) + every { appStorage.premiumEndDate } + .returns(mockEndDate) - given(calculationStorage) - .invocation { currentBase } - .thenReturn(base) + every { calculationStorage.currentBase } + .returns(base) - given(calculationStorage) - .invocation { precision } - .thenReturn(3) + every { calculationStorage.precision } + .returns(3) runTest { - given(backendApiService) - .coroutine { getConversion(base) } - .thenReturn(conversion) + coEvery { backendApiService.getConversion(base) } + .returns(conversion) - given(currencyDataSource) - .coroutine { getActiveCurrencies() } - .thenReturn(activeCurrencyList) + coEvery { currencyDataSource.getActiveCurrencies() } + .returns(activeCurrencyList) } } -// @Test -// fun `ArrayIndexOutOfBoundsException never thrown`() = runTest { -// // first currency -// given(calculationStorage) -// .invocation { currentBase } -// .thenReturn(firstBase) -// -// given(backendApiService) -// .coroutine { getConversion(firstBase) } -// .thenReturn(conversion) -// -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick() -// } -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick(true) -// } -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick(false) -// } -// -// // middle currency -// given(calculationStorage) -// .invocation { currentBase } -// .thenReturn(base) -// -// given(backendApiService) -// .coroutine { getConversion(base) } -// .thenReturn(conversion) -// -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick() -// } -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick(true) -// } -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick(false) -// } -// -// // last currency -// given(calculationStorage) -// .invocation { currentBase } -// .thenReturn(lastBase) -// -// given(backendApiService) -// .coroutine { getConversion(lastBase) } -// .thenReturn(conversion) -// -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick() -// } -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick(true) -// } -// repeat(activeCurrencyList.count()) { -// viewModel.event.onRefreshClick(false) -// } -// } + @Test + fun `ArrayIndexOutOfBoundsException never thrown`() = runTest { + // first currency + every { calculationStorage.currentBase } + .returns(firstBase) + + coEvery { backendApiService.getConversion(firstBase) } + .returns(conversion) + + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onRefreshClick() + } + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onNextClick() + } + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onPreviousClick() + } + + // middle currency + every { calculationStorage.currentBase } + .returns(base) + + coEvery { backendApiService.getConversion(base) } + .returns(conversion) + + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onRefreshClick() + } + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onNextClick() + } + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onPreviousClick() + } + + // last currency + every { calculationStorage.currentBase } + .returns(lastBase) + + coEvery { backendApiService.getConversion(lastBase) } + .returns(conversion) + + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onRefreshClick() + } + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onNextClick() + } + repeat(activeCurrencyList.count() + 1) { + viewModel.event.onPreviousClick() + } + } @Test fun `init sets isPremium and currentBase`() = runTest { @@ -171,133 +164,144 @@ class WidgetViewModelTest { @Test fun `if user is premium api call and db query are invoked`() = runTest { - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.days.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.days.inWholeMilliseconds) viewModel.event.onRefreshClick() - verify(backendApiService) - .coroutine { getConversion(base) } + coVerify { backendApiService.getConversion(base) } .wasInvoked() - verify(currencyDataSource) - .coroutine { getActiveCurrencies() } + coVerify { currencyDataSource.getActiveCurrencies() } .wasInvoked() } @Test fun `if user is not premium no api call and db query are not invoked`() = runTest { - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.days.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.days.inWholeMilliseconds) viewModel.event.onRefreshClick() - verify(backendApiService) - .coroutine { getConversion(base) } + coVerify { backendApiService.getConversion(base) } .wasNotInvoked() - verify(currencyDataSource) - .coroutine { getActiveCurrencies() } + coVerify { currencyDataSource.getActiveCurrencies() } .wasNotInvoked() } @Test - fun `when onRefreshClick called all the conversion rates for currentBase is calculated`() = runTest { - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.days.inWholeMilliseconds) - - viewModel.state.onSubscription { - viewModel.event.onRefreshClick() - }.firstOrNull().let { - assertNotNull(it) - it.currencyList.forEach { currency -> - conversion.getRateFromCode(currency.code).let { rate -> - assertNotNull(rate) - assertEquals(rate.getFormatted(calculationStorage.precision), currency.rate) + fun `when onRefreshClick called all the conversion rates for currentBase is calculated`() = + runTest { + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.days.inWholeMilliseconds) + + viewModel.state.onSubscription { + viewModel.event.onRefreshClick() + }.firstOrNull().let { + assertNotNull(it) + it.currencyList.forEach { currency -> + conversion.getRateFromCode(currency.code).let { rate -> + assertNotNull(rate) + assertEquals(rate.getFormatted(calculationStorage.precision), currency.rate) + } } } } - } @Test fun `when onRefreshClick called with null, base is not updated`() = runTest { // to not invoke getFreshWidgetData - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.days.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.days.inWholeMilliseconds) viewModel.event.onRefreshClick() - verify(currencyDataSource) - .coroutine { getActiveCurrencies() } + coVerify { currencyDataSource.getActiveCurrencies() } .wasNotInvoked() - verify(calculationStorage) - .setter(calculationStorage::currentBase) - .with(eq(anything())) + verify { calculationStorage.currentBase = any() } .wasNotInvoked() } -// @Test -// fun `when onRefreshClick called with true, base is updated next or the first active currency`() = runTest { -// // to not invoke getFreshWidgetData -// given(appStorage) -// .invocation { premiumEndDate } -// .thenReturn(nowAsLong() - 1.days.inWholeMilliseconds) -// -// viewModel.event.onRefreshClick(true) -// -// verify(currencyDataSource) -// .coroutine { getActiveCurrencies() } -// .wasInvoked() -// -// verify(calculationStorage) -// .setter(calculationStorage::currentBase) -// .with(eq(firstBase)) -// .wasInvoked() -// -// viewModel.event.onRefreshClick(true) -// -// verify(currencyDataSource) -// .coroutine { getActiveCurrencies() } -// .wasInvoked() -// -// verify(calculationStorage) -// .setter(calculationStorage::currentBase) -// .with(eq(lastBase)) -// .wasInvoked() -// } - -// @Test -// fun `when onRefreshClick called with false, base is updated previous or the last active currency`() = runTest { -// // to not invoke getFreshWidgetData -// given(appStorage) -// .invocation { premiumEndDate } -// .thenReturn(nowAsLong() - 1.days.inWholeMilliseconds) -// -// viewModel.event.onRefreshClick(false) -// -// verify(currencyDataSource) -// .coroutine { getActiveCurrencies() } -// .wasInvoked() -// -// verify(calculationStorage) -// .setter(calculationStorage::currentBase) -// .with(eq(lastBase)) -// .wasInvoked() -// -// viewModel.event.onRefreshClick(false) -// -// verify(currencyDataSource) -// .coroutine { getActiveCurrencies() } -// .wasInvoked() -// -// verify(calculationStorage) -// .setter(calculationStorage::currentBase) -// .with(eq(firstBase)) -// .wasInvoked() -// } + // region Event + @Test + fun onNextClick() = runTest { + // when onNextClick, base is updated next or the first active currency + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.days.inWholeMilliseconds) + + viewModel.event.onNextClick() + + coVerify { currencyDataSource.getActiveCurrencies() } + .wasInvoked() + + verify { calculationStorage.currentBase = lastBase } + .wasInvoked() + + every { calculationStorage.currentBase } + .returns(lastBase) + + viewModel.event.onNextClick() + + coVerify { currencyDataSource.getActiveCurrencies() } + .wasInvoked() + + verify { calculationStorage.currentBase = firstBase } + .wasInvoked() + } + + @Test + fun onPreviousClick() = runTest { + // when onRefreshClick, base is updated previous or the last active currency + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.days.inWholeMilliseconds) + + viewModel.event.onPreviousClick() + + coVerify { currencyDataSource.getActiveCurrencies() } + .wasInvoked() + + verify { calculationStorage.currentBase = firstBase } + .wasInvoked() + + every { calculationStorage.currentBase } + .returns(firstBase) + + viewModel.event.onPreviousClick() + + coVerify { currencyDataSource.getActiveCurrencies() } + .wasInvoked() + + verify { calculationStorage.currentBase = lastBase } + .wasInvoked() + } + + @Test + fun onRefreshClick() = runTest { + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.days.inWholeMilliseconds) + + viewModel.event.onRefreshClick() + + coVerify { backendApiService.getConversion(base) } + .wasInvoked() + + coVerify { currencyDataSource.getActiveCurrencies() } + .wasInvoked() + + verify { calculationStorage.currentBase } + .wasInvoked() + } + + @Test + fun onOpenAppClick() = runTest { + viewModel.effect.onSubscription { + viewModel.event.onOpenAppClick() + }.firstOrNull().let { + assertNotNull(it) + assertIs(it) + } + } + // endregion } diff --git a/backend/app/backend-app.gradle.kts b/backend/app/backend-app.gradle.kts index 1a82501102..06f17e5d29 100644 --- a/backend/app/backend-app.gradle.kts +++ b/backend/app/backend-app.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { application id(jvm.get().pluginId) diff --git a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/APIModule.kt b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/APIModule.kt index 6f6d6879e6..ce7ce79e62 100644 --- a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/APIModule.kt +++ b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/APIModule.kt @@ -20,7 +20,7 @@ import org.koin.ktor.ext.inject @Suppress("unused") internal fun Application.apiModule() { - Logger.i { "APIModuleKt Application.apiModule" } + Logger.v { "APIModuleKt Application.apiModule" } install(ContentNegotiation) { json() diff --git a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/KoinModule.kt b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/KoinModule.kt index 499e9e45a7..f4df4bc0c5 100644 --- a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/KoinModule.kt +++ b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/KoinModule.kt @@ -15,7 +15,7 @@ import org.koin.ktor.plugin.Koin @Suppress("unused") internal fun Application.koinModule() { - Logger.i { "KoinModuleKt Application.koinModule" } + Logger.v { "KoinModuleKt Application.koinModule" } install(Koin) { modules( @@ -38,6 +38,6 @@ internal fun Application.koinModule() { // endregion ) }.also { - Logger.i { "Koin initialised" } + Logger.v { "Koin initialised" } } } diff --git a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/LoggerModule.kt b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/LoggerModule.kt index 1046e534cd..b7b3288150 100644 --- a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/LoggerModule.kt +++ b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/LoggerModule.kt @@ -6,5 +6,5 @@ import io.ktor.server.application.Application @Suppress("unused", "UnusedReceiverParameter") internal fun Application.loggerModule() = initLogger().also { - Logger.i { "LoggerModuleKt Application.loggerModule" } + Logger.v { "LoggerModuleKt Application.loggerModule" } } diff --git a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/SyncModule.kt b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/SyncModule.kt index 4a2ac8315b..be105439fe 100644 --- a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/SyncModule.kt +++ b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/module/SyncModule.kt @@ -15,7 +15,7 @@ import kotlin.time.Duration.Companion.hours @Suppress("unused") internal fun Application.syncModule() { - Logger.i { "SyncModuleKt Application.syncModule" } + Logger.v { "SyncModuleKt Application.syncModule" } val syncController: SyncController by inject() val globalScope: CoroutineScope by inject() diff --git a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/CurrencyRoute.kt b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/CurrencyRoute.kt index bd0bac990a..6e5ac26133 100644 --- a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/CurrencyRoute.kt +++ b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/CurrencyRoute.kt @@ -18,10 +18,10 @@ private const val PARAMETER_BASE = "base" internal suspend fun Route.getCurrencyByName( apiController: APIController ) = get(PATH_BY_BASE) { - Logger.i { "GET Request $PATH_BY_BASE" } + Logger.v { "GET Request $PATH_BY_BASE" } call.parameters[PARAMETER_BASE]?.let { base -> - Logger.i { "Parameter: $PARAMETER_BASE $base" } + Logger.v { "Parameter: $PARAMETER_BASE $base" } apiController.getExchangeRateByBase(base) ?.let { call.respond(HttpStatusCode.OK, it) } diff --git a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/ErrorRoute.kt b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/ErrorRoute.kt index 0826a939e7..5c733ccc84 100644 --- a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/ErrorRoute.kt +++ b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/ErrorRoute.kt @@ -17,7 +17,7 @@ private const val PATH_ERROR = "/error" private const val ERROR_HTML = "error.html" internal suspend fun Route.getError() = get(PATH_ERROR) { - Logger.i { "GET Request $PATH_ERROR" } + Logger.v { "GET Request $PATH_ERROR" } javaClass.classLoader?.getResource(ERROR_HTML)?.readText()?.let { resource -> call.respondText( diff --git a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/RootRoute.kt b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/RootRoute.kt index 5b98442f4e..7d36d87bfc 100644 --- a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/RootRoute.kt +++ b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/RootRoute.kt @@ -17,7 +17,7 @@ private const val PATH_ROOT = "/" private const val INDEX_HTML = "index.html" internal suspend fun Route.getRoot() = get(PATH_ROOT) { - Logger.i { "GET Request $PATH_ROOT" } + Logger.v { "GET Request $PATH_ROOT" } javaClass.classLoader?.getResource(INDEX_HTML)?.readText()?.let { resource -> call.respondText( diff --git a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/VersionRoute.kt b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/VersionRoute.kt index 3d865e3e2f..df62b6c574 100644 --- a/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/VersionRoute.kt +++ b/backend/app/src/main/kotlin/com/oztechan/ccc/backend/app/routes/VersionRoute.kt @@ -11,7 +11,7 @@ import io.ktor.server.routing.get private const val PATH_VERSION = "/version" internal suspend fun Route.getVersion() = get(PATH_VERSION) { - Logger.i { "GET Request $PATH_VERSION" } + Logger.v { "GET Request $PATH_VERSION" } call.respondText( text = "Version: ${javaClass.`package`.implementationVersion}", diff --git a/backend/controller/api/backend-controller-api.gradle.kts b/backend/controller/api/backend-controller-api.gradle.kts index 391f76443e..c949a3e28d 100644 --- a/backend/controller/api/backend-controller-api.gradle.kts +++ b/backend/controller/api/backend-controller-api.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(jvm.get().pluginId) alias(ksp) diff --git a/backend/controller/api/src/main/kotlin/com/oztechan/ccc/backend/controller/api/APIControllerImpl.kt b/backend/controller/api/src/main/kotlin/com/oztechan/ccc/backend/controller/api/APIControllerImpl.kt index c4114b7cc3..7132ec4fe7 100644 --- a/backend/controller/api/src/main/kotlin/com/oztechan/ccc/backend/controller/api/APIControllerImpl.kt +++ b/backend/controller/api/src/main/kotlin/com/oztechan/ccc/backend/controller/api/APIControllerImpl.kt @@ -13,7 +13,7 @@ internal class APIControllerImpl( private val conversionDataSource: ConversionDataSource ) : APIController { override suspend fun getExchangeRateByBase(base: String): ExchangeRate? { - Logger.i { "ServerControllerImpl getExchangeRateByBase" } + Logger.v { "ServerControllerImpl getExchangeRateByBase" } return conversionDataSource .getConversionByBase(base.uppercase()) ?.toExchangeRateAPIModel() diff --git a/backend/controller/api/src/test/kotlin/com/oztechan/ccc/backend/controller/api/APIControllerTest.kt b/backend/controller/api/src/test/kotlin/com/oztechan/ccc/backend/controller/api/APIControllerTest.kt index 88084cb4fb..46d91e3e3e 100644 --- a/backend/controller/api/src/test/kotlin/com/oztechan/ccc/backend/controller/api/APIControllerTest.kt +++ b/backend/controller/api/src/test/kotlin/com/oztechan/ccc/backend/controller/api/APIControllerTest.kt @@ -5,14 +5,13 @@ import com.oztechan.ccc.common.core.model.Conversion import com.oztechan.ccc.common.datasource.conversion.ConversionDataSource import io.mockative.Mock import io.mockative.classOf -import io.mockative.given +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.mock -import io.mockative.verify import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -@Suppress("OPT_IN_USAGE") internal class APIControllerTest { private val subject: APIController by lazy { APIControllerImpl(conversionDataSource) @@ -27,14 +26,12 @@ internal class APIControllerTest { val base = "EUR" val result = Conversion(base) - given(conversionDataSource) - .coroutine { getConversionByBase(base) } - .thenReturn(result) + coEvery { conversionDataSource.getConversionByBase(base) } + .returns(result) assertEquals(result.toExchangeRateAPIModel(), subject.getExchangeRateByBase(base)) - verify(conversionDataSource) - .coroutine { getConversionByBase(base) } + coVerify { conversionDataSource.getConversionByBase(base) } .wasInvoked() } @@ -44,14 +41,12 @@ internal class APIControllerTest { val base = "eur" val result = Conversion(base.uppercase()) - given(conversionDataSource) - .coroutine { getConversionByBase(base.uppercase()) } - .thenReturn(result) + coEvery { conversionDataSource.getConversionByBase(base.uppercase()) } + .returns(result) assertEquals(result.toExchangeRateAPIModel(), subject.getExchangeRateByBase(base)) - verify(conversionDataSource) - .coroutine { getConversionByBase(base.uppercase()) } + coVerify { conversionDataSource.getConversionByBase(base.uppercase()) } .wasInvoked() } } diff --git a/backend/controller/sync/backend-controller-sync.gradle.kts b/backend/controller/sync/backend-controller-sync.gradle.kts index 2ce8c99037..4e0099b7b0 100644 --- a/backend/controller/sync/backend-controller-sync.gradle.kts +++ b/backend/controller/sync/backend-controller-sync.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") id(libs.plugins.jvm.get().pluginId) } diff --git a/backend/controller/sync/src/main/kotlin/com/oztechan/ccc/backend/controller/sync/SyncControllerImpl.kt b/backend/controller/sync/src/main/kotlin/com/oztechan/ccc/backend/controller/sync/SyncControllerImpl.kt index f67355382a..3e712fa67c 100644 --- a/backend/controller/sync/src/main/kotlin/com/oztechan/ccc/backend/controller/sync/SyncControllerImpl.kt +++ b/backend/controller/sync/src/main/kotlin/com/oztechan/ccc/backend/controller/sync/SyncControllerImpl.kt @@ -16,17 +16,17 @@ internal class SyncControllerImpl( ) : SyncController { override suspend fun syncPrimaryCurrencies() { - Logger.i { "SyncControllerImpl syncPrimaryCurrencies" } + Logger.v { "SyncControllerImpl syncPrimaryCurrencies" } CurrencyType.getPrimaryCurrencies().syncCrossAPI() } override suspend fun syncSecondaryCurrencies() { - Logger.i { "SyncControllerImpl syncSecondaryCurrencies" } + Logger.v { "SyncControllerImpl syncSecondaryCurrencies" } CurrencyType.getSecondaryCurrencies().syncCrossAPI() } override suspend fun syncTertiaryCurrencies() { - Logger.i { "SyncControllerImpl syncTertiaryCurrencies" } + Logger.v { "SyncControllerImpl syncTertiaryCurrencies" } CurrencyType.getTertiaryCurrencies().syncCrossAPI() } @@ -36,12 +36,12 @@ internal class SyncControllerImpl( // non premium call for filling null values runCatching { freeApiService.getConversion(currencyType.name) } - .onFailure { Logger.e(it) { it.message.toString() } } + .onFailure { Logger.w(it) { it.message.toString() } } .onSuccess { freeConversion -> // premium api call runCatching { premiumApiService.getConversion(currencyType.name) } - .onFailure { Logger.e(it) { it.message.toString() } } + .onFailure { Logger.w(it) { it.message.toString() } } .onSuccess { premiumConversion -> conversionDataSource.insertConversion( premiumConversion.fillMissingRatesWith(freeConversion) @@ -51,14 +51,14 @@ internal class SyncControllerImpl( } override suspend fun syncUnPopularCurrencies() { - Logger.i { "SyncControllerImpl syncUnPopularCurrencies" } + Logger.v { "SyncControllerImpl syncUnPopularCurrencies" } CurrencyType.getNonPopularCurrencies().forEach { currencyType -> delay(1.seconds.inWholeMilliseconds) runCatching { freeApiService.getConversion(currencyType.name) } - .onFailure { Logger.e(it) { it.message.toString() } } + .onFailure { Logger.w(it) { it.message.toString() } } .onSuccess { conversionDataSource.insertConversion(it) } } } diff --git a/backend/service/free/backend-service-free.gradle.kts b/backend/service/free/backend-service-free.gradle.kts index 1ec33987ed..a2c90fde38 100644 --- a/backend/service/free/backend-service-free.gradle.kts +++ b/backend/service/free/backend-service-free.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(jvm.get().pluginId) alias(ksp) diff --git a/backend/service/free/src/test/kotlin/com/oztechan/ccc/backend/service/free/FreeApiServiceTest.kt b/backend/service/free/src/test/kotlin/com/oztechan/ccc/backend/service/free/FreeApiServiceTest.kt index 0f781c6312..2b7ebeb43f 100644 --- a/backend/service/free/src/test/kotlin/com/oztechan/ccc/backend/service/free/FreeApiServiceTest.kt +++ b/backend/service/free/src/test/kotlin/com/oztechan/ccc/backend/service/free/FreeApiServiceTest.kt @@ -6,9 +6,9 @@ import com.oztechan.ccc.common.core.network.model.Conversion import com.oztechan.ccc.common.core.network.model.ExchangeRate import io.mockative.Mock import io.mockative.classOf -import io.mockative.given +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.mock -import io.mockative.verify import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -17,10 +17,10 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue -@Suppress("OPT_IN_USAGE") internal class FreeApiServiceTest { private val subject: FreeApiService by lazy { + @Suppress("OPT_IN_USAGE") FreeApiServiceImpl(freeApi, UnconfinedTestDispatcher()) } @@ -38,16 +38,14 @@ internal class FreeApiServiceTest { assertTrue { it.isFailure } } - verify(freeApi) - .coroutine { freeApi.getExchangeRate("") } + coVerify { freeApi.getExchangeRate("") } .wasNotInvoked() } @Test fun `getConversion error`() = runTest { - given(freeApi) - .coroutine { freeApi.getExchangeRate(base) } - .thenThrow(throwable) + coEvery { freeApi.getExchangeRate(base) } + .throws(throwable) runCatching { subject.getConversion(base) }.let { assertFalse { it.isSuccess } @@ -58,16 +56,14 @@ internal class FreeApiServiceTest { assertEquals(throwable.message, it.exceptionOrNull()!!.cause!!.message) } - verify(freeApi) - .coroutine { getExchangeRate(base) } + coVerify { freeApi.getExchangeRate(base) } .wasInvoked() } @Test fun `getConversion success`() = runTest { - given(freeApi) - .coroutine { freeApi.getExchangeRate(base) } - .thenReturn(exchangeRate) + coEvery { freeApi.getExchangeRate(base) } + .returns(exchangeRate) runCatching { subject.getConversion(base) }.let { assertTrue { it.isSuccess } @@ -76,8 +72,7 @@ internal class FreeApiServiceTest { assertEquals(exchangeRate.toConversionModel(), it.getOrNull()) } - verify(freeApi) - .coroutine { getExchangeRate(base) } + coVerify { freeApi.getExchangeRate(base) } .wasInvoked() } } diff --git a/backend/service/premium/backend-service-premium.gradle.kts b/backend/service/premium/backend-service-premium.gradle.kts index 1ec33987ed..a2c90fde38 100644 --- a/backend/service/premium/backend-service-premium.gradle.kts +++ b/backend/service/premium/backend-service-premium.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(jvm.get().pluginId) alias(ksp) diff --git a/backend/service/premium/src/test/kotlin/com/oztechan/ccc/backend/service/premium/PremiumApiServiceTest.kt b/backend/service/premium/src/test/kotlin/com/oztechan/ccc/backend/service/premium/PremiumApiServiceTest.kt index f4839ff677..f1eeea4293 100644 --- a/backend/service/premium/src/test/kotlin/com/oztechan/ccc/backend/service/premium/PremiumApiServiceTest.kt +++ b/backend/service/premium/src/test/kotlin/com/oztechan/ccc/backend/service/premium/PremiumApiServiceTest.kt @@ -10,9 +10,9 @@ import com.oztechan.ccc.common.core.network.model.Conversion import com.oztechan.ccc.common.core.network.model.ExchangeRate import io.mockative.Mock import io.mockative.classOf -import io.mockative.given +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.mock -import io.mockative.verify import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.test.Test @@ -21,10 +21,10 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue -@Suppress("OPT_IN_USAGE") internal class PremiumApiServiceTest { private val subject: PremiumApiService by lazy { + @Suppress("OPT_IN_USAGE") PremiumApiServiceImpl(premiumAPI, UnconfinedTestDispatcher()) } @@ -42,16 +42,14 @@ internal class PremiumApiServiceTest { assertTrue { it.isFailure } } - verify(premiumAPI) - .coroutine { premiumAPI.getExchangeRate("") } + coVerify { premiumAPI.getExchangeRate("") } .wasNotInvoked() } @Test fun `getConversion error`() = runTest { - given(premiumAPI) - .coroutine { premiumAPI.getExchangeRate(base) } - .thenThrow(throwable) + coEvery { premiumAPI.getExchangeRate(base) } + .throws(throwable) runCatching { subject.getConversion(base) }.let { assertFalse { it.isSuccess } @@ -62,16 +60,14 @@ internal class PremiumApiServiceTest { assertEquals(throwable.message, it.exceptionOrNull()!!.cause!!.message) } - verify(premiumAPI) - .coroutine { getExchangeRate(base) } + coVerify { premiumAPI.getExchangeRate(base) } .wasInvoked() } @Test fun `getConversion success`() = runTest { - given(premiumAPI) - .coroutine { premiumAPI.getExchangeRate(base) } - .thenReturn(exchangeRate) + coEvery { premiumAPI.getExchangeRate(base) } + .returns(exchangeRate) runCatching { subject.getConversion(base) }.let { assertTrue { it.isSuccess } @@ -80,8 +76,7 @@ internal class PremiumApiServiceTest { assertEquals(exchangeRate.toConversionModel(), it.getOrNull()) } - verify(premiumAPI) - .coroutine { getExchangeRate(base) } + coVerify { premiumAPI.getExchangeRate(base) } .wasInvoked() } } diff --git a/buildSrc/src/main/kotlin/ProjectSettings.kt b/buildSrc/src/main/kotlin/ProjectSettings.kt index 13378584f4..7846034e69 100644 --- a/buildSrc/src/main/kotlin/ProjectSettings.kt +++ b/buildSrc/src/main/kotlin/ProjectSettings.kt @@ -23,7 +23,7 @@ object ProjectSettings { const val ANDROID_APP_ID = "mustafaozhan.github.com.mycurrencies" const val HUAWEI_APP_ID = "com.oztechan.ccc.huawei" - const val COMPILE_SDK_VERSION = 33 + const val COMPILE_SDK_VERSION = 34 const val MIN_SDK_VERSION = 21 const val TARGET_SDK_VERSION = 33 diff --git a/client/configservice/ad/client-configservice-ad.gradle.kts b/client/configservice/ad/client-configservice-ad.gradle.kts index 0419f94ba2..691624d0fd 100644 --- a/client/configservice/ad/client-configservice-ad.gradle.kts +++ b/client/configservice/ad/client-configservice-ad.gradle.kts @@ -1,14 +1,15 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(multiplatform.get().pluginId) - id(kotlinXSerialization.get().pluginId) } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -16,13 +17,9 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { - libs.common.apply { - implementation(ktorJson) - implementation(koinCore) - } + implementation(libs.common.koinCore) implementation(project(Modules.Client.Core.remoteConfig)) } } @@ -31,28 +28,6 @@ kotlin { implementation(libs.common.test) } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/AdConfigServiceImpl.kt b/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/AdConfigServiceImpl.kt index 909fa6bbe3..378cc18ea2 100644 --- a/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/AdConfigServiceImpl.kt +++ b/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/AdConfigServiceImpl.kt @@ -2,23 +2,20 @@ package com.oztechan.ccc.client.configservice.ad import com.oztechan.ccc.client.configservice.ad.mapper.toAdConfigModel import com.oztechan.ccc.client.core.remoteconfig.BaseConfigService -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import com.oztechan.ccc.client.configservice.ad.AdConfig as AdConfigRCModel +import com.oztechan.ccc.client.core.remoteconfig.util.parseToObject import com.oztechan.ccc.client.configservice.ad.model.AdConfig as AdConfigModel +import com.oztechan.ccc.client.core.remoteconfig.model.AdConfig as AdConfigRCModel internal class AdConfigServiceImpl : BaseConfigService( - KEY_AD_CONFIG, - AdConfigRCModel().toAdConfigModel() + AdConfigRCModel().toAdConfigModel(), + KEY_AD_CONFIG ), AdConfigService { - override fun decode( - value: String - ) = Json - .decodeFromString(value) - .toAdConfigModel() + override fun String?.decode() = parseToObject() + ?.toAdConfigModel() + ?: default companion object { private const val KEY_AD_CONFIG = "ad_config" diff --git a/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/mapper/AdConfigMapper.kt b/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/mapper/AdConfigMapper.kt index 8373508c60..af8f479e41 100644 --- a/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/mapper/AdConfigMapper.kt +++ b/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/mapper/AdConfigMapper.kt @@ -1,7 +1,7 @@ package com.oztechan.ccc.client.configservice.ad.mapper -import com.oztechan.ccc.client.configservice.ad.AdConfig as AdConfigRCModel import com.oztechan.ccc.client.configservice.ad.model.AdConfig as AdConfigModel +import com.oztechan.ccc.client.core.remoteconfig.model.AdConfig as AdConfigRCModel internal fun AdConfigRCModel.toAdConfigModel() = AdConfigModel( bannerAdSessionCount = bannerAdSessionCount, diff --git a/client/configservice/ad/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/ad/mapper/AdConfigMapperTest.kt b/client/configservice/ad/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/ad/mapper/AdConfigMapperTest.kt index 0794e89585..7aa057832e 100644 --- a/client/configservice/ad/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/ad/mapper/AdConfigMapperTest.kt +++ b/client/configservice/ad/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/ad/mapper/AdConfigMapperTest.kt @@ -2,7 +2,7 @@ package com.oztechan.ccc.client.configservice.ad.mapper import kotlin.test.Test import kotlin.test.assertEquals -import com.oztechan.ccc.client.configservice.ad.AdConfig as AdConfigRCModel +import com.oztechan.ccc.client.core.remoteconfig.model.AdConfig as AdConfigRCModel class AdConfigMapperTest { diff --git a/client/configservice/review/client-configservice-review.gradle.kts b/client/configservice/review/client-configservice-review.gradle.kts index e2256980b1..8ca5422d59 100644 --- a/client/configservice/review/client-configservice-review.gradle.kts +++ b/client/configservice/review/client-configservice-review.gradle.kts @@ -1,14 +1,15 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(multiplatform.get().pluginId) - id(kotlinXSerialization.get().pluginId) } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -16,13 +17,9 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { - libs.common.apply { - implementation(ktorJson) - implementation(koinCore) - } + implementation(libs.common.koinCore) implementation(project(Modules.Client.Core.remoteConfig)) } } @@ -31,28 +28,6 @@ kotlin { implementation(libs.common.test) } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/ReviewConfigServiceImpl.kt b/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/ReviewConfigServiceImpl.kt index 6ae8e055b9..0efd29f5b8 100644 --- a/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/ReviewConfigServiceImpl.kt +++ b/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/ReviewConfigServiceImpl.kt @@ -2,23 +2,20 @@ package com.oztechan.ccc.client.configservice.review import com.oztechan.ccc.client.configservice.review.mapper.toReviewConfigModel import com.oztechan.ccc.client.core.remoteconfig.BaseConfigService -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import com.oztechan.ccc.client.configservice.review.ReviewConfig as ReviewConfigRCModel +import com.oztechan.ccc.client.core.remoteconfig.util.parseToObject import com.oztechan.ccc.client.configservice.review.model.ReviewConfig as ReviewConfigModel +import com.oztechan.ccc.client.core.remoteconfig.model.ReviewConfig as ReviewConfigRCModel internal class ReviewConfigServiceImpl : BaseConfigService( - KEY_AD_CONFIG, - ReviewConfigRCModel().toReviewConfigModel() + ReviewConfigRCModel().toReviewConfigModel(), + KEY_AD_CONFIG ), ReviewConfigService { - override fun decode( - value: String - ) = Json - .decodeFromString(value) - .toReviewConfigModel() + override fun String?.decode() = parseToObject() + ?.toReviewConfigModel() + ?: default companion object { private const val KEY_AD_CONFIG = "review_config" diff --git a/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/mapper/ReviewConfigMapper.kt b/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/mapper/ReviewConfigMapper.kt index f55909e81e..9e5b780b8e 100644 --- a/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/mapper/ReviewConfigMapper.kt +++ b/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/mapper/ReviewConfigMapper.kt @@ -1,7 +1,7 @@ package com.oztechan.ccc.client.configservice.review.mapper -import com.oztechan.ccc.client.configservice.review.ReviewConfig as ReviewConfigRCModel import com.oztechan.ccc.client.configservice.review.model.ReviewConfig as ReviewConfigModel +import com.oztechan.ccc.client.core.remoteconfig.model.ReviewConfig as ReviewConfigRCModel internal fun ReviewConfigRCModel.toReviewConfigModel() = ReviewConfigModel( appReviewSessionCount = appReviewSessionCount, diff --git a/client/configservice/review/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/review/mapper/ReviewConfigMapperTest.kt b/client/configservice/review/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/review/mapper/ReviewConfigMapperTest.kt index 5bcb6fba29..eddd7a6fcc 100644 --- a/client/configservice/review/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/review/mapper/ReviewConfigMapperTest.kt +++ b/client/configservice/review/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/review/mapper/ReviewConfigMapperTest.kt @@ -2,7 +2,7 @@ package com.oztechan.ccc.client.configservice.review.mapper import kotlin.test.Test import kotlin.test.assertEquals -import com.oztechan.ccc.client.configservice.review.ReviewConfig as AppReviewRCModel +import com.oztechan.ccc.client.core.remoteconfig.model.ReviewConfig as AppReviewRCModel class ReviewConfigMapperTest { diff --git a/client/configservice/update/client-configservice-update.gradle.kts b/client/configservice/update/client-configservice-update.gradle.kts index cffa4d165b..8bf182c0cc 100644 --- a/client/configservice/update/client-configservice-update.gradle.kts +++ b/client/configservice/update/client-configservice-update.gradle.kts @@ -1,14 +1,15 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(multiplatform.get().pluginId) - id(kotlinXSerialization.get().pluginId) } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -16,13 +17,9 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { - libs.common.apply { - implementation(ktorJson) - implementation(koinCore) - } + implementation(libs.common.koinCore) implementation(project(Modules.Client.Core.remoteConfig)) } } @@ -31,28 +28,6 @@ kotlin { implementation(libs.common.test) } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/UpdateConfigServiceImpl.kt b/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/UpdateConfigServiceImpl.kt index 3331de4447..7361ce74fe 100644 --- a/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/UpdateConfigServiceImpl.kt +++ b/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/UpdateConfigServiceImpl.kt @@ -2,23 +2,20 @@ package com.oztechan.ccc.client.configservice.update import com.oztechan.ccc.client.configservice.update.mapper.toUpdateConfigModel import com.oztechan.ccc.client.core.remoteconfig.BaseConfigService -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json -import com.oztechan.ccc.client.configservice.update.UpdateConfig as UpdateConfigRCModel +import com.oztechan.ccc.client.core.remoteconfig.util.parseToObject import com.oztechan.ccc.client.configservice.update.model.UpdateConfig as UpdateConfigModel +import com.oztechan.ccc.client.core.remoteconfig.model.UpdateConfig as UpdateConfigRCModel internal class UpdateConfigServiceImpl : BaseConfigService( + UpdateConfigRCModel().toUpdateConfigModel(), KEY_AD_CONFIG, - UpdateConfigRCModel().toUpdateConfigModel() ), UpdateConfigService { - override fun decode( - value: String - ) = Json - .decodeFromString(value) - .toUpdateConfigModel() + override fun String?.decode() = parseToObject() + ?.toUpdateConfigModel() + ?: default companion object { private const val KEY_AD_CONFIG = "update_config" diff --git a/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/mapper/UpdateConfigMapper.kt b/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/mapper/UpdateConfigMapper.kt index c1afb81558..f2db96a16c 100644 --- a/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/mapper/UpdateConfigMapper.kt +++ b/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/mapper/UpdateConfigMapper.kt @@ -1,7 +1,7 @@ package com.oztechan.ccc.client.configservice.update.mapper -import com.oztechan.ccc.client.configservice.update.UpdateConfig as UpdateConfigRCModel import com.oztechan.ccc.client.configservice.update.model.UpdateConfig as UpdateConfigModel +import com.oztechan.ccc.client.core.remoteconfig.model.UpdateConfig as UpdateConfigRCModel internal fun UpdateConfigRCModel.toUpdateConfigModel() = UpdateConfigModel( updateLatestVersion = updateLatestVersion, diff --git a/client/configservice/update/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/update/mapper/UpdateConfigMapperTest.kt b/client/configservice/update/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/update/mapper/UpdateConfigMapperTest.kt index c3e74e122b..31c041f02e 100644 --- a/client/configservice/update/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/update/mapper/UpdateConfigMapperTest.kt +++ b/client/configservice/update/src/commonTest/kotlin/com/oztechan/ccc/client/configservice/update/mapper/UpdateConfigMapperTest.kt @@ -2,7 +2,7 @@ package com.oztechan.ccc.client.configservice.update.mapper import kotlin.test.Test import kotlin.test.assertEquals -import com.oztechan.ccc.client.configservice.update.UpdateConfig as AppUpdateRCModel +import com.oztechan.ccc.client.core.remoteconfig.model.UpdateConfig as AppUpdateRCModel class UpdateConfigMapperTest { diff --git a/client/core/analytics/client-core-analytics.gradle.kts b/client/core/analytics/client-core-analytics.gradle.kts index c8c3a4a362..cd403c5b98 100644 --- a/client/core/analytics/client-core-analytics.gradle.kts +++ b/client/core/analytics/client-core-analytics.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -15,7 +17,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { implementation(libs.common.koinCore) @@ -26,7 +27,6 @@ kotlin { implementation(libs.common.test) } } - val androidMain by getting { dependencies { libs.android.apply { @@ -35,26 +35,6 @@ kotlin { } } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/AndroidAnalytics.kt b/client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/Analytics.kt similarity index 100% rename from client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/AndroidAnalytics.kt rename to client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/Analytics.kt diff --git a/client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/AnalyticsManagerImpl.kt b/client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/AnalyticsManagerImpl.kt index c13384d898..03dfb388a9 100644 --- a/client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/AnalyticsManagerImpl.kt +++ b/client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/AnalyticsManagerImpl.kt @@ -32,9 +32,11 @@ internal class AnalyticsManagerImpl( override fun trackEvent(event: Event) { firebaseAnalytics.logEvent(event.key) { - event.getParams()?.forEach { - param(it.key, it.value) - } + event.getParams() + ?.iterator() + ?.forEach { + param(it.key, it.value) + } } } } diff --git a/client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/di/AndroidClientCoreAnalyticsModule.kt b/client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/di/ClientCoreAnalyticsModule.android.kt similarity index 100% rename from client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/di/AndroidClientCoreAnalyticsModule.kt rename to client/core/analytics/src/androidMain/kotlin/com/oztechan/ccc/client/core/analytics/di/ClientCoreAnalyticsModule.android.kt diff --git a/client/core/analytics/src/commonMain/kotlin/com/oztechan/ccc/client/core/analytics/model/Event.kt b/client/core/analytics/src/commonMain/kotlin/com/oztechan/ccc/client/core/analytics/model/Event.kt index fd30c8977a..d5f5a865ab 100644 --- a/client/core/analytics/src/commonMain/kotlin/com/oztechan/ccc/client/core/analytics/model/Event.kt +++ b/client/core/analytics/src/commonMain/kotlin/com/oztechan/ccc/client/core/analytics/model/Event.kt @@ -3,9 +3,9 @@ package com.oztechan.ccc.client.core.analytics.model sealed class Event(val key: String) { data class BaseChange(val base: Param.Base) : Event("base_change") data class ShowConversion(val base: Param.Base) : Event("show_conversion") - object OfflineSync : Event("offline_sync") - object CopyClipboard : Event("copy_clipboard") - object PasteFromClipboard : Event("paste_from_clipboard") + data object OfflineSync : Event("offline_sync") + data object CopyClipboard : Event("copy_clipboard") + data object PasteFromClipboard : Event("paste_from_clipboard") fun getParams(): Map? = when (this) { is ShowConversion -> mapOf(base.key to base.value) diff --git a/client/core/analytics/src/commonMain/kotlin/com/oztechan/ccc/client/core/analytics/model/ScreenName.kt b/client/core/analytics/src/commonMain/kotlin/com/oztechan/ccc/client/core/analytics/model/ScreenName.kt index 4fdc5f657d..b370d6d20f 100644 --- a/client/core/analytics/src/commonMain/kotlin/com/oztechan/ccc/client/core/analytics/model/ScreenName.kt +++ b/client/core/analytics/src/commonMain/kotlin/com/oztechan/ccc/client/core/analytics/model/ScreenName.kt @@ -1,12 +1,12 @@ package com.oztechan.ccc.client.core.analytics.model sealed class ScreenName { - object Calculator : ScreenName() - object SelectCurrency : ScreenName() - object Currencies : ScreenName() - object Settings : ScreenName() - object Watchers : ScreenName() - object Premium : ScreenName() + data object Calculator : ScreenName() + data object SelectCurrency : ScreenName() + data object Currencies : ScreenName() + data object Settings : ScreenName() + data object Watchers : ScreenName() + data object Premium : ScreenName() data class Slider(val position: Int) : ScreenName() fun getScreenName() = when (this) { diff --git a/client/core/analytics/src/iosMain/kotlin/com/oztechan/ccc/client/core/analytics/di/IOSClientCoreAnalyticsModule.kt b/client/core/analytics/src/iosMain/kotlin/com/oztechan/ccc/client/core/analytics/di/ClientCoreAnalyticsModule.ios.kt similarity index 100% rename from client/core/analytics/src/iosMain/kotlin/com/oztechan/ccc/client/core/analytics/di/IOSClientCoreAnalyticsModule.kt rename to client/core/analytics/src/iosMain/kotlin/com/oztechan/ccc/client/core/analytics/di/ClientCoreAnalyticsModule.ios.kt diff --git a/client/core/persistence/client-core-persistence.gradle.kts b/client/core/persistence/client-core-persistence.gradle.kts index 381b303c76..1e6b6411eb 100644 --- a/client/core/persistence/client-core-persistence.gradle.kts +++ b/client/core/persistence/client-core-persistence.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -31,28 +33,6 @@ kotlin { } } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/core/persistence/src/androidMain/kotlin/com/oztechan/ccc/client/core/persistence/di/AndroidClientCorePersistenceModule.kt b/client/core/persistence/src/androidMain/kotlin/com/oztechan/ccc/client/core/persistence/di/ClientCorePersistenceModule.android.kt similarity index 100% rename from client/core/persistence/src/androidMain/kotlin/com/oztechan/ccc/client/core/persistence/di/AndroidClientCorePersistenceModule.kt rename to client/core/persistence/src/androidMain/kotlin/com/oztechan/ccc/client/core/persistence/di/ClientCorePersistenceModule.android.kt diff --git a/client/core/persistence/src/commonTest/kotlin/com/oztechan/ccc/client/core/persistence/PersistenceTest.kt b/client/core/persistence/src/commonTest/kotlin/com/oztechan/ccc/client/core/persistence/PersistenceTest.kt index e8d9808e27..cacd3beb0b 100644 --- a/client/core/persistence/src/commonTest/kotlin/com/oztechan/ccc/client/core/persistence/PersistenceTest.kt +++ b/client/core/persistence/src/commonTest/kotlin/com/oztechan/ccc/client/core/persistence/PersistenceTest.kt @@ -5,7 +5,7 @@ import com.russhwolf.settings.Settings import io.mockative.Mock import io.mockative.classOf import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlin.random.Random @@ -31,21 +31,16 @@ internal class PersistenceTest { @Test fun `getValue returns the same type`() { - given(settings) - .invocation { getFloat(key, mockFloat) } - .thenReturn(mockFloat) - given(settings) - .invocation { getBoolean(key, mockBoolean) } - .thenReturn(mockBoolean) - given(settings) - .invocation { getInt(key, mockInt) } - .thenReturn(mockInt) - given(settings) - .invocation { getString(key, mockString) } - .thenReturn(mockString) - given(settings) - .invocation { getLong(key, mockLong) } - .thenReturn(mockLong) + every { settings.getFloat(key, mockFloat) } + .returns(mockFloat) + every { settings.getBoolean(key, mockBoolean) } + .returns(mockBoolean) + every { settings.getInt(key, mockInt) } + .returns(mockInt) + every { settings.getString(key, mockString) } + .returns(mockString) + every { settings.getLong(key, mockLong) } + .returns(mockLong) assertEquals(mockFloat, persistence.getValue(key, mockFloat)) assertEquals(mockBoolean, persistence.getValue(key, mockBoolean)) @@ -53,20 +48,15 @@ internal class PersistenceTest { assertEquals(mockString, persistence.getValue(key, mockString)) assertEquals(mockLong, persistence.getValue(key, mockLong)) - verify(settings) - .invocation { settings.getFloat(key, mockFloat) } + verify { settings.getFloat(key, mockFloat) } .wasInvoked() - verify(settings) - .invocation { settings.getBoolean(key, mockBoolean) } + verify { settings.getBoolean(key, mockBoolean) } .wasInvoked() - verify(settings) - .invocation { settings.getInt(key, mockInt) } + verify { settings.getInt(key, mockInt) } .wasInvoked() - verify(settings) - .invocation { settings.getString(key, mockString) } + verify { settings.getString(key, mockString) } .wasInvoked() - verify(settings) - .invocation { settings.getLong(key, mockLong) } + verify { settings.getLong(key, mockLong) } .wasInvoked() } @@ -78,20 +68,15 @@ internal class PersistenceTest { persistence.setValue(key, mockString) persistence.setValue(key, mockLong) - verify(settings) - .invocation { settings.putFloat(key, mockFloat) } + verify { settings.putFloat(key, mockFloat) } .wasInvoked() - verify(settings) - .invocation { settings.putBoolean(key, mockBoolean) } + verify { settings.putBoolean(key, mockBoolean) } .wasInvoked() - verify(settings) - .invocation { settings.putInt(key, mockInt) } + verify { settings.putInt(key, mockInt) } .wasInvoked() - verify(settings) - .invocation { settings.putString(key, mockString) } + verify { settings.putString(key, mockString) } .wasInvoked() - verify(settings) - .invocation { settings.putLong(key, mockLong) } + verify { settings.putLong(key, mockLong) } .wasInvoked() } diff --git a/client/core/persistence/src/iosMain/kotlin/com/oztechan/ccc/client/core/persistence/di/IOSClientCorePersistenceModule.kt b/client/core/persistence/src/iosMain/kotlin/com/oztechan/ccc/client/core/persistence/di/ClientCorePersistenceModule.ios.kt similarity index 100% rename from client/core/persistence/src/iosMain/kotlin/com/oztechan/ccc/client/core/persistence/di/IOSClientCorePersistenceModule.kt rename to client/core/persistence/src/iosMain/kotlin/com/oztechan/ccc/client/core/persistence/di/ClientCorePersistenceModule.ios.kt diff --git a/client/core/remoteconfig/client-core-remoteconfig.gradle.kts b/client/core/remoteconfig/client-core-remoteconfig.gradle.kts index 7af1ee3f61..2976f3ea5e 100644 --- a/client/core/remoteconfig/client-core-remoteconfig.gradle.kts +++ b/client/core/remoteconfig/client-core-remoteconfig.gradle.kts @@ -1,13 +1,16 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(multiplatform.get().pluginId) + id(kotlinXSerialization.get().pluginId) } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -15,39 +18,24 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { - implementation(libs.common.kermit) + libs.common.apply { + implementation(ktorJson) + implementation(kermit) + } + } + } + val commonTest by getting { + dependencies { + implementation(libs.common.test) } } - val commonTest by getting - val androidMain by getting { dependencies { implementation(libs.android.firebaseRemoteConfig) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/core/remoteconfig/src/androidMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt b/client/core/remoteconfig/src/androidMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt index b4877d3249..2999c78351 100644 --- a/client/core/remoteconfig/src/androidMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt +++ b/client/core/remoteconfig/src/androidMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt @@ -5,47 +5,43 @@ import com.google.firebase.ktx.Firebase import com.google.firebase.remoteconfig.FirebaseRemoteConfigSettings import com.google.firebase.remoteconfig.ktx.remoteConfig -actual abstract class BaseConfigService -actual constructor( +actual abstract class BaseConfigService actual constructor( + default: T, configKey: String, - default: T ) { + actual var config: T - actual abstract fun decode(value: String): T + actual val default: T + + actual abstract fun String?.decode(): T init { - Logger.d { "${this::class.simpleName} init" } + Logger.v { "${this::class.simpleName} init" } + + this.default = default Firebase.remoteConfig.apply { // get cache or default config = getString(configKey) .takeIf { it.isNotEmpty() } - ?.let { updateConfig(getString(it), default) } + ?.let { it.decode() } ?: default setConfigSettingsAsync(FirebaseRemoteConfigSettings.Builder().build()) fetchAndActivate().addOnCompleteListener { if (it.isSuccessful) { - Logger.i("${this::class.simpleName} Remote config updated from server") + Logger.v("${this::class.simpleName} Remote config updated from server") // get remote - config = updateConfig(getString(configKey), default) + config = getString(configKey).decode() // cache setDefaultsAsync(mapOf(configKey to config)) } else { - Logger.i("${this::class.simpleName} Remote config is not updated, using cached value") + Logger.v("${this::class.simpleName} Remote config is not updated, using cached value") } } } } - - @Suppress("TooGenericExceptionCaught") - private fun updateConfig(value: String, default: T): T = try { - decode(value) - } catch (exception: Exception) { - Logger.e(exception) { "${this::class.simpleName} Remote config is not updated, using default" } - default - } } diff --git a/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt index ebdbbff4c4..38d526d102 100644 --- a/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt +++ b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt @@ -1,10 +1,10 @@ package com.oztechan.ccc.client.core.remoteconfig expect abstract class BaseConfigService( - configKey: String, - default: T + default: T, + configKey: String ) { var config: T - - abstract fun decode(value: String): T + val default: T + abstract fun String?.decode(): T } diff --git a/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/AdConfig.kt b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/AdConfig.kt similarity index 84% rename from client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/AdConfig.kt rename to client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/AdConfig.kt index 2498baeb4a..e5b40de056 100644 --- a/client/configservice/ad/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/ad/AdConfig.kt +++ b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/AdConfig.kt @@ -1,10 +1,10 @@ -package com.oztechan.ccc.client.configservice.ad +package com.oztechan.ccc.client.core.remoteconfig.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class AdConfig( +data class AdConfig( @SerialName("banner_ad_session_count") val bannerAdSessionCount: Int = 2, @SerialName("interstitial_ad_session_count") val interstitialAdSessionCount: Int = 5, @SerialName("interstitial_ad_initial_delay") val interstitialAdInitialDelay: Long = 60000, diff --git a/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/ReviewConfig.kt b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/ReviewConfig.kt similarity index 75% rename from client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/ReviewConfig.kt rename to client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/ReviewConfig.kt index 342b0e3b97..f3ba03933a 100644 --- a/client/configservice/review/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/review/ReviewConfig.kt +++ b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/ReviewConfig.kt @@ -1,10 +1,10 @@ -package com.oztechan.ccc.client.configservice.review +package com.oztechan.ccc.client.core.remoteconfig.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class ReviewConfig( +data class ReviewConfig( @SerialName("app_review_session_count") val appReviewSessionCount: Int = 3, @SerialName("app_review_dialog_delay") val appReviewDialogDelay: Long = 20000 ) diff --git a/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/UpdateConfig.kt b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/UpdateConfig.kt similarity index 73% rename from client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/UpdateConfig.kt rename to client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/UpdateConfig.kt index f866b3981a..880b98b15f 100644 --- a/client/configservice/update/src/commonMain/kotlin/com/oztechan/ccc/client/configservice/update/UpdateConfig.kt +++ b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/model/UpdateConfig.kt @@ -1,10 +1,10 @@ -package com.oztechan.ccc.client.configservice.update +package com.oztechan.ccc.client.core.remoteconfig.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class UpdateConfig( +data class UpdateConfig( @SerialName("update_latest_version") val updateLatestVersion: Int = 0, @SerialName("update_force_version") val updateForceVersion: Int = 0 ) diff --git a/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/util/Parser.kt b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/util/Parser.kt new file mode 100644 index 0000000000..cf7dcc3f59 --- /dev/null +++ b/client/core/remoteconfig/src/commonMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/util/Parser.kt @@ -0,0 +1,17 @@ +package com.oztechan.ccc.client.core.remoteconfig.util + +import co.touchlab.kermit.Logger +import kotlinx.serialization.json.Json + +@Suppress("TooGenericExceptionCaught") +inline fun String?.parseToObject(): T? = if (!isNullOrEmpty()) { + try { + Json.decodeFromString(this) + } catch (exception: Exception) { + Logger.e(exception) { "Parsing exception" } + null + } +} else { + Logger.a { "Not parse-able string" } + null +} diff --git a/client/core/remoteconfig/src/commonTest/kotlin/com/oztechan/ccc/client/core/remoteconfig/util/ParserTest.kt b/client/core/remoteconfig/src/commonTest/kotlin/com/oztechan/ccc/client/core/remoteconfig/util/ParserTest.kt new file mode 100644 index 0000000000..c8a293fa6a --- /dev/null +++ b/client/core/remoteconfig/src/commonTest/kotlin/com/oztechan/ccc/client/core/remoteconfig/util/ParserTest.kt @@ -0,0 +1,25 @@ +package com.oztechan.ccc.client.core.remoteconfig.util + +import co.touchlab.kermit.CommonWriter +import co.touchlab.kermit.Logger +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertNull + +class ParserTest { + + @BeforeTest + fun setup() { + Logger.setLogWriters(CommonWriter()) + } + + @Test + fun `parseToObject returns null when invoked with null`() { + assertNull(null.parseToObject()) + } + + @Test + fun `parseToObject returns null when invoked with empty String`() { + assertNull("".parseToObject()) + } +} diff --git a/client/core/remoteconfig/src/iosMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt b/client/core/remoteconfig/src/iosMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt index 138ef00a2f..4e1bb97154 100644 --- a/client/core/remoteconfig/src/iosMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt +++ b/client/core/remoteconfig/src/iosMain/kotlin/com/oztechan/ccc/client/core/remoteconfig/BaseConfigService.kt @@ -3,15 +3,19 @@ package com.oztechan.ccc.client.core.remoteconfig import co.touchlab.kermit.Logger actual abstract class BaseConfigService actual constructor( + default: T, configKey: String, - default: T ) { + actual var config: T - actual abstract fun decode(value: String): T + actual val default: T + + actual abstract fun String?.decode(): T init { - Logger.d { "${this::class.simpleName} init" } + Logger.v { "${this::class.simpleName} init" } + this.default = default config = default } } diff --git a/client/core/res/client-core-res.gradle.kts b/client/core/res/client-core-res.gradle.kts index 2f511acb20..cb98ae046f 100644 --- a/client/core/res/client-core-res.gradle.kts +++ b/client/core/res/client-core-res.gradle.kts @@ -1,7 +1,6 @@ import io.gitlab.arturbosch.detekt.Detekt plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(cocoapods.get().pluginId) @@ -11,7 +10,10 @@ plugins { } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -42,28 +44,6 @@ kotlin { implementation(libs.common.test) } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } @@ -78,6 +58,11 @@ android { targetCompatibility = JAVA_VERSION } } + + // Todo https://github.com/icerockdev/moko-resources/issues/510 + sourceSets { + getByName("main").java.srcDirs("build/generated/moko/androidMain/src") + } } multiplatformResources { diff --git a/client/core/res/src/androidMain/kotlin/com/oztechan/ccc/client/core/res/AndroidResources.kt b/client/core/res/src/androidMain/kotlin/com/oztechan/ccc/client/core/res/Resources.android.kt similarity index 100% rename from client/core/res/src/androidMain/kotlin/com/oztechan/ccc/client/core/res/AndroidResources.kt rename to client/core/res/src/androidMain/kotlin/com/oztechan/ccc/client/core/res/Resources.android.kt diff --git a/client/core/res/src/commonMain/resources/MR/base/strings.xml b/client/core/res/src/commonMain/resources/MR/base/strings.xml index 506086fdfc..609cebed66 100644 --- a/client/core/res/src/commonMain/resources/MR/base/strings.xml +++ b/client/core/res/src/commonMain/resources/MR/base/strings.xml @@ -107,7 +107,9 @@ Expired\n%s Will expire\n%s More options are coming - You already have premium + You already have premium, if you still have ads + please restart the app. + Precision Max decimal digits diff --git a/client/core/res/src/iosMain/kotlin/com/oztechan/ccc/client/core/res/IOSResources.kt b/client/core/res/src/iosMain/kotlin/com/oztechan/ccc/client/core/res/Resources.ios.kt similarity index 100% rename from client/core/res/src/iosMain/kotlin/com/oztechan/ccc/client/core/res/IOSResources.kt rename to client/core/res/src/iosMain/kotlin/com/oztechan/ccc/client/core/res/Resources.ios.kt diff --git a/client/core/shared/client-core-shared.gradle.kts b/client/core/shared/client-core-shared.gradle.kts index da6c393a73..6daad745b3 100644 --- a/client/core/shared/client-core-shared.gradle.kts +++ b/client/core/shared/client-core-shared.gradle.kts @@ -1,8 +1,10 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") id(libs.plugins.multiplatform.get().pluginId) } kotlin { + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + iosX64() iosArm64() iosSimulatorArm64() @@ -22,24 +24,5 @@ kotlin { implementation(libs.common.test) } } - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/core/shared/src/commonMain/kotlin/com/oztechan/ccc/client/core/shared/Device.kt b/client/core/shared/src/commonMain/kotlin/com/oztechan/ccc/client/core/shared/Device.kt index d9c545c182..a99a50376a 100644 --- a/client/core/shared/src/commonMain/kotlin/com/oztechan/ccc/client/core/shared/Device.kt +++ b/client/core/shared/src/commonMain/kotlin/com/oztechan/ccc/client/core/shared/Device.kt @@ -24,7 +24,7 @@ sealed class Device( ) } - object IOS : Device( + data object IOS : Device( name = "ios", marketLink = "https://apps.apple.com/us/app/currency-converter-calculator/id1617484510" ) diff --git a/client/core/shared/src/commonMain/kotlin/com/oztechan/ccc/client/core/shared/model/AppTheme.kt b/client/core/shared/src/commonMain/kotlin/com/oztechan/ccc/client/core/shared/model/AppTheme.kt index 85e7958b86..d4ddec3d8b 100644 --- a/client/core/shared/src/commonMain/kotlin/com/oztechan/ccc/client/core/shared/model/AppTheme.kt +++ b/client/core/shared/src/commonMain/kotlin/com/oztechan/ccc/client/core/shared/model/AppTheme.kt @@ -5,7 +5,6 @@ package com.oztechan.ccc.client.core.shared.model import com.oztechan.ccc.client.core.shared.Device -@Suppress("MagicNumber") enum class AppTheme(val themeName: String, val themeValue: Int) { LIGHT("Light", 1), DARK("Dark", 2), diff --git a/client/core/shared/src/iosMain/kotlin/com/oztechan/ccc/client/core/shared/util/IOSFormatUtil.kt b/client/core/shared/src/iosMain/kotlin/com/oztechan/ccc/client/core/shared/util/FormatUtil.ios.kt similarity index 100% rename from client/core/shared/src/iosMain/kotlin/com/oztechan/ccc/client/core/shared/util/IOSFormatUtil.kt rename to client/core/shared/src/iosMain/kotlin/com/oztechan/ccc/client/core/shared/util/FormatUtil.ios.kt diff --git a/client/core/shared/src/jvmMain/kotlin/com/oztechan/ccc/client/core/shared/util/JVMFormatUtil.kt b/client/core/shared/src/jvmMain/kotlin/com/oztechan/ccc/client/core/shared/util/FormatUtil.jvm.kt similarity index 100% rename from client/core/shared/src/jvmMain/kotlin/com/oztechan/ccc/client/core/shared/util/JVMFormatUtil.kt rename to client/core/shared/src/jvmMain/kotlin/com/oztechan/ccc/client/core/shared/util/FormatUtil.jvm.kt diff --git a/client/core/viewmodel/client-core-viewmodel.gradle.kts b/client/core/viewmodel/client-core-viewmodel.gradle.kts index 6236f57043..f09783ecb3 100644 --- a/client/core/viewmodel/client-core-viewmodel.gradle.kts +++ b/client/core/viewmodel/client-core-viewmodel.gradle.kts @@ -1,12 +1,14 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -23,8 +25,6 @@ kotlin { } } } - val commonTest by getting - val androidMain by getting { dependencies { libs.android.apply { @@ -33,26 +33,6 @@ kotlin { } } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/core/viewmodel/src/androidMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt b/client/core/viewmodel/src/androidMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt index f2fb745e8f..92c79cfff2 100644 --- a/client/core/viewmodel/src/androidMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt +++ b/client/core/viewmodel/src/androidMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt @@ -9,7 +9,6 @@ import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope -@Suppress("EmptyDefaultConstructor") actual open class BaseViewModel actual constructor() : ViewModel() { protected actual val viewModelScope: CoroutineScope by lazy { @@ -19,9 +18,4 @@ actual open class BaseViewModel actual constructor() : ViewModel() { init { Logger.d { "${this::class.simpleName} init" } } - - actual override fun onCleared() { - Logger.d { "${this::class.simpleName} onCleared" } - super.onCleared() - } } diff --git a/client/core/viewmodel/src/androidMain/kotlin/com/oztechan/ccc/client/core/viewmodel/di/AndroidViewModelDefinition.kt b/client/core/viewmodel/src/androidMain/kotlin/com/oztechan/ccc/client/core/viewmodel/di/ViewModelDefinition.android.kt similarity index 100% rename from client/core/viewmodel/src/androidMain/kotlin/com/oztechan/ccc/client/core/viewmodel/di/AndroidViewModelDefinition.kt rename to client/core/viewmodel/src/androidMain/kotlin/com/oztechan/ccc/client/core/viewmodel/di/ViewModelDefinition.android.kt diff --git a/client/core/viewmodel/src/commonMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt b/client/core/viewmodel/src/commonMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt index ea74319d1c..e9da0550b1 100644 --- a/client/core/viewmodel/src/commonMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt +++ b/client/core/viewmodel/src/commonMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt @@ -6,8 +6,6 @@ package com.oztechan.ccc.client.core.viewmodel import kotlinx.coroutines.CoroutineScope -@Suppress("EmptyDefaultConstructor") expect open class BaseViewModel() { protected val viewModelScope: CoroutineScope - protected open fun onCleared() } diff --git a/client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt b/client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt index 8c7e232353..77422d3891 100644 --- a/client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt +++ b/client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/BaseViewModel.kt @@ -7,9 +7,7 @@ package com.oztechan.ccc.client.core.viewmodel import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope -import kotlinx.coroutines.cancel -@Suppress("EmptyDefaultConstructor", "unused") actual open class BaseViewModel actual constructor() { protected actual val viewModelScope: CoroutineScope = MainScope() @@ -17,9 +15,4 @@ actual open class BaseViewModel actual constructor() { init { Logger.d { "${this::class.simpleName} init" } } - - protected actual open fun onCleared() { - Logger.d { "${this::class.simpleName} onCleared" } - viewModelScope.cancel() - } } diff --git a/client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/di/IOSViewModelDefinition.kt b/client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/di/ViewModelDefinition.ios.kt similarity index 100% rename from client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/di/IOSViewModelDefinition.kt rename to client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/di/ViewModelDefinition.ios.kt diff --git a/client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/util/IOSCoroutineUtil.kt b/client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/util/CoroutineUtil.kt similarity index 100% rename from client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/util/IOSCoroutineUtil.kt rename to client/core/viewmodel/src/iosMain/kotlin/com/oztechan/ccc/client/core/viewmodel/util/CoroutineUtil.kt diff --git a/client/datasource/currency/client-datasource-currency.gradle.kts b/client/datasource/currency/client-datasource-currency.gradle.kts index e7b4107aa7..54512438b6 100644 --- a/client/datasource/currency/client-datasource-currency.gradle.kts +++ b/client/datasource/currency/client-datasource-currency.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -8,7 +7,10 @@ plugins { } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -16,7 +18,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { libs.common.apply { @@ -40,28 +41,6 @@ kotlin { } } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/datasource/currency/src/commonTest/kotlin/com/oztechan/ccc/client/datasource/currency/CurrencyDataSourceTest.kt b/client/datasource/currency/src/commonTest/kotlin/com/oztechan/ccc/client/datasource/currency/CurrencyDataSourceTest.kt index 3a7dd2ff87..4ceae8f35b 100644 --- a/client/datasource/currency/src/commonTest/kotlin/com/oztechan/ccc/client/datasource/currency/CurrencyDataSourceTest.kt +++ b/client/datasource/currency/src/commonTest/kotlin/com/oztechan/ccc/client/datasource/currency/CurrencyDataSourceTest.kt @@ -11,7 +11,7 @@ import com.squareup.sqldelight.db.SqlDriver import io.mockative.Mock import io.mockative.classOf import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -20,15 +20,16 @@ import kotlin.random.Random import kotlin.test.BeforeTest import kotlin.test.Test -@Suppress("OPT_IN_USAGE") internal class CurrencyDataSourceTest { private val subject: CurrencyDataSource by lazy { + @Suppress("OPT_IN_USAGE") CurrencyDataSourceImpl(currencyQueries, UnconfinedTestDispatcher()) } @Mock - private val currencyQueries = configure(mock(classOf())) { stubsUnitByDefault = true } + private val currencyQueries = + configure(mock(classOf())) { stubsUnitByDefault = true } @Mock private val sqlDriver = mock(classOf()) @@ -45,57 +46,49 @@ internal class CurrencyDataSourceTest { fun setup() { Logger.setLogWriters(CommonWriter()) - given(sqlDriver) - .invocation { executeQuery(-1, "", 0, null) } - .thenReturn(sqlCursor) + every { sqlDriver.executeQuery(-1, "", 0, null) } + .returns(sqlCursor) - given(sqlCursor) - .invocation { next() } - .thenReturn(false) + every { sqlCursor.next() } + .returns(false) } @Test fun getCurrenciesFlow() { - given(currencyQueries) - .invocation { getCurrencies() } - .thenReturn(query) + every { currencyQueries.getCurrencies() } + .returns(query) runTest { subject.getCurrenciesFlow() } - verify(currencyQueries) - .invocation { getCurrencies() } + verify { currencyQueries.getCurrencies() } .wasInvoked() } @Test fun getActiveCurrenciesFlow() { - given(currencyQueries) - .invocation { getActiveCurrencies() } - .thenReturn(query) + every { currencyQueries.getActiveCurrencies() } + .returns(query) runTest { subject.getActiveCurrenciesFlow() } - verify(currencyQueries) - .invocation { getActiveCurrencies() } + verify { currencyQueries.getActiveCurrencies() } .wasInvoked() } @Test fun getActiveCurrencies() { - given(currencyQueries) - .invocation { getActiveCurrencies() } - .thenReturn(query) + every { currencyQueries.getActiveCurrencies() } + .returns(query) runTest { subject.getActiveCurrencies() } - verify(currencyQueries) - .invocation { getActiveCurrencies() } + verify { currencyQueries.getActiveCurrencies() } .wasInvoked() } @@ -108,8 +101,7 @@ internal class CurrencyDataSourceTest { subject.updateCurrencyStateByCode(mockCode, mockState) } - verify(currencyQueries) - .invocation { updateCurrencyStateByCode(mockState.toLong(), mockCode) } + verify { currencyQueries.updateCurrencyStateByCode(mockState.toLong(), mockCode) } .wasInvoked() } @@ -121,23 +113,20 @@ internal class CurrencyDataSourceTest { subject.updateCurrencyStates(mockState) } - verify(currencyQueries) - .invocation { updateCurrencyStates(mockState.toLong()) } + verify { currencyQueries.updateCurrencyStates(mockState.toLong()) } .wasInvoked() } @Test fun getCurrencyByCode() { - given(currencyQueries) - .invocation { getCurrencyByCode(currency.code) } - .thenReturn(query) + every { currencyQueries.getCurrencyByCode(currency.code) } + .returns(query) runTest { subject.getCurrencyByCode(currency.code) } - verify(currencyQueries) - .invocation { getCurrencyByCode(currency.code) } + verify { currencyQueries.getCurrencyByCode(currency.code) } .wasInvoked() } } diff --git a/client/datasource/watcher/client-datasource-watcher.gradle.kts b/client/datasource/watcher/client-datasource-watcher.gradle.kts index 59683b04bc..bd2dd0c6c2 100644 --- a/client/datasource/watcher/client-datasource-watcher.gradle.kts +++ b/client/datasource/watcher/client-datasource-watcher.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -8,7 +7,10 @@ plugins { } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -16,7 +18,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { libs.common.apply { @@ -40,28 +41,6 @@ kotlin { } } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/datasource/watcher/src/commonTest/kotlin/com/oztechan/ccc/client/datasource/watcher/WatcherDataSourceTest.kt b/client/datasource/watcher/src/commonTest/kotlin/com/oztechan/ccc/client/datasource/watcher/WatcherDataSourceTest.kt index 228677f643..ee9f3051c8 100644 --- a/client/datasource/watcher/src/commonTest/kotlin/com/oztechan/ccc/client/datasource/watcher/WatcherDataSourceTest.kt +++ b/client/datasource/watcher/src/commonTest/kotlin/com/oztechan/ccc/client/datasource/watcher/WatcherDataSourceTest.kt @@ -11,7 +11,7 @@ import com.squareup.sqldelight.db.SqlDriver import io.mockative.Mock import io.mockative.classOf import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -20,15 +20,16 @@ import kotlin.random.Random import kotlin.test.BeforeTest import kotlin.test.Test -@Suppress("OPT_IN_USAGE") internal class WatcherDataSourceTest { private val subject: WatcherDataSource by lazy { + @Suppress("OPT_IN_USAGE") WatcherDataSourceImpl(watcherQueries, UnconfinedTestDispatcher()) } @Mock - private val watcherQueries = configure(mock(classOf())) { stubsUnitByDefault = true } + private val watcherQueries = + configure(mock(classOf())) { stubsUnitByDefault = true } @Mock private val sqlDriver = mock(classOf()) @@ -48,25 +49,21 @@ internal class WatcherDataSourceTest { fun setup() { Logger.setLogWriters(CommonWriter()) - given(sqlDriver) - .invocation { executeQuery(-1, "", 0, null) } - .thenReturn(sqlCursor) + every { sqlDriver.executeQuery(-1, "", 0, null) } + .returns(sqlCursor) - given(sqlCursor) - .invocation { next() } - .thenReturn(false) + every { sqlCursor.next() } + .returns(false) } @Test fun getWatchersFlow() = runTest { - given(watcherQueries) - .invocation { getWatchers() } - .then { query } + every { watcherQueries.getWatchers() } + .returns(query) subject.getWatchersFlow() - verify(watcherQueries) - .coroutine { getWatchers() } + verify { watcherQueries.getWatchers() } .wasInvoked() } @@ -74,21 +71,18 @@ internal class WatcherDataSourceTest { fun addWatcher() = runTest { subject.addWatcher(base, target) - verify(watcherQueries) - .invocation { addWatcher(base, target) } + verify { watcherQueries.addWatcher(base, target) } .wasInvoked() } @Test fun getWatchers() = runTest { - given(watcherQueries) - .invocation { getWatchers() } - .then { query } + every { watcherQueries.getWatchers() } + .returns(query) subject.getWatchers() - verify(watcherQueries) - .coroutine { getWatchers() } + verify { watcherQueries.getWatchers() } .wasInvoked() } @@ -96,8 +90,7 @@ internal class WatcherDataSourceTest { fun deleteWatcher() = runTest { subject.deleteWatcher(id) - verify(watcherQueries) - .invocation { deleteWatcher(id) } + verify { watcherQueries.deleteWatcher(id) } .wasInvoked() } @@ -105,8 +98,7 @@ internal class WatcherDataSourceTest { fun updateWatcherBaseById() = runTest { subject.updateWatcherBaseById(base, id) - verify(watcherQueries) - .invocation { updateWatcherBaseById(base, id) } + verify { watcherQueries.updateWatcherBaseById(base, id) } .wasInvoked() } @@ -114,8 +106,7 @@ internal class WatcherDataSourceTest { fun updateWatcherTargetById() = runTest { subject.updateWatcherTargetById(target, id) - verify(watcherQueries) - .invocation { updateWatcherTargetById(target, id) } + verify { watcherQueries.updateWatcherTargetById(target, id) } .wasInvoked() } @@ -124,8 +115,7 @@ internal class WatcherDataSourceTest { val relation = Random.nextBoolean() subject.updateWatcherRelationById(relation, id) - verify(watcherQueries) - .invocation { updateWatcherRelationById(relation.toLong(), id) } + verify { watcherQueries.updateWatcherRelationById(relation.toLong(), id) } .wasInvoked() } @@ -135,8 +125,7 @@ internal class WatcherDataSourceTest { subject.updateWatcherRateById(rate, id) - verify(watcherQueries) - .invocation { updateWatcherRateById(rate, id) } + verify { watcherQueries.updateWatcherRateById(rate, id) } .wasInvoked() } } diff --git a/client/repository/adcontrol/client-repository-adcontrol.gradle.kts b/client/repository/adcontrol/client-repository-adcontrol.gradle.kts index b4c0d4aa96..e40d79e0bb 100644 --- a/client/repository/adcontrol/client-repository-adcontrol.gradle.kts +++ b/client/repository/adcontrol/client-repository-adcontrol.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(multiplatform.get().pluginId) @@ -8,7 +7,10 @@ plugins { } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -16,7 +18,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { implementation(libs.common.koinCore) @@ -33,28 +34,6 @@ kotlin { } } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/repository/adcontrol/src/commonTest/kotlin/com/oztechan/ccc/client/repository/adcontrol/AdControlRepositoryTest.kt b/client/repository/adcontrol/src/commonTest/kotlin/com/oztechan/ccc/client/repository/adcontrol/AdControlRepositoryTest.kt index 03df44ab6b..ed906268aa 100644 --- a/client/repository/adcontrol/src/commonTest/kotlin/com/oztechan/ccc/client/repository/adcontrol/AdControlRepositoryTest.kt +++ b/client/repository/adcontrol/src/commonTest/kotlin/com/oztechan/ccc/client/repository/adcontrol/AdControlRepositoryTest.kt @@ -6,7 +6,7 @@ import com.oztechan.ccc.client.core.shared.util.nowAsLong import com.oztechan.ccc.client.storage.app.AppStorage import io.mockative.Mock import io.mockative.classOf -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlin.random.Random @@ -16,7 +16,6 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds -@Suppress("TooManyFunctions") internal class AdControlRepositoryTest { private val subject: AdControlRepository by lazy { @@ -33,372 +32,295 @@ internal class AdControlRepositoryTest { @BeforeTest fun setup() { - given(adConfigService) - .invocation { config } - .thenReturn(AdConfig(mockedSessionCount, mockedSessionCount, 0L, 0L)) + every { adConfigService.config } + .returns(AdConfig(mockedSessionCount, mockedSessionCount, 0L, 0L)) } @Test fun `shouldShowBannerAd is false when firstRun and not premiumExpired and sessionCount smaller than banner 000`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount - 1L) + every { appStorage.sessionCount } + .returns(mockedSessionCount - 1L) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.seconds.inWholeMilliseconds) - given(appStorage) - .invocation { firstRun } - .thenReturn(true) + every { appStorage.firstRun } + .returns(true) assertFalse { subject.shouldShowBannerAd() } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasNotInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasNotInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasNotInvoked() } @Test fun `shouldShowBannerAd is false when not firstRun + not premiumExpired + sessionCount smaller than banner 100`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount - 1L) + every { appStorage.sessionCount } + .returns(mockedSessionCount - 1L) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.seconds.inWholeMilliseconds) - given(appStorage) - .invocation { firstRun } - .thenReturn(false) + every { appStorage.firstRun } + .returns(false) assertFalse { subject.shouldShowBannerAd() } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasNotInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasNotInvoked() } @Test fun `shouldShowBannerAd is false when firstRun + premiumExpired + sessionCount smaller than banner 010`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount - 1L) + every { appStorage.sessionCount } + .returns(mockedSessionCount - 1L) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.seconds.inWholeMilliseconds) - given(appStorage) - .invocation { firstRun } - .thenReturn(true) + every { appStorage.firstRun } + .returns(true) assertFalse { subject.shouldShowBannerAd() } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasNotInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasNotInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasNotInvoked() } @Test fun `shouldShowBannerAd is false when firstRun + not premiumExpired + sessionCount bigger than banner 001`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount + 1L) + every { appStorage.sessionCount } + .returns(mockedSessionCount + 1L) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.seconds.inWholeMilliseconds) - given(appStorage) - .invocation { firstRun } - .thenReturn(true) + every { appStorage.firstRun } + .returns(true) assertFalse { subject.shouldShowBannerAd() } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasNotInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasNotInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasNotInvoked() } @Test fun `shouldShowBannerAd is false when firstRun + premiumExpired + sessionCount bigger than banner 011`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount + 1L) + every { appStorage.sessionCount } + .returns(mockedSessionCount + 1L) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.seconds.inWholeMilliseconds) - given(appStorage) - .invocation { firstRun } - .thenReturn(true) + every { appStorage.firstRun } + .returns(true) assertFalse { subject.shouldShowBannerAd() } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasNotInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasNotInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasNotInvoked() } @Test fun `shouldShowBannerAd is false when not firstRun + not premiumExpired + sessionCount bigger than banner 101`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount + 1L) + every { appStorage.sessionCount } + .returns(mockedSessionCount + 1L) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.seconds.inWholeMilliseconds) - given(appStorage) - .invocation { firstRun } - .thenReturn(false) + every { appStorage.firstRun } + .returns(false) assertFalse { subject.shouldShowBannerAd() } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasNotInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasNotInvoked() } @Test fun `shouldShowBannerAd is false when not firstRun + premiumExpired + sessionCount smaller than banner 110`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount - 1L) + every { appStorage.sessionCount } + .returns(mockedSessionCount - 1L) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.seconds.inWholeMilliseconds) - given(appStorage) - .invocation { firstRun } - .thenReturn(false) + every { appStorage.firstRun } + .returns(false) assertFalse { subject.shouldShowBannerAd() } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasInvoked() } @Test fun `shouldShowBannerAd is true when not firstRun + premiumExpired + sessionCount bigger than banner 111`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount + 1L) + every { appStorage.sessionCount } + .returns(mockedSessionCount + 1L) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.seconds.inWholeMilliseconds) - given(appStorage) - .invocation { firstRun } - .thenReturn(false) + every { appStorage.firstRun } + .returns(false) assertTrue { subject.shouldShowBannerAd() } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasInvoked() } @Test fun `shouldShowInterstitialAd returns false when session count bigger than remote and premiumNotExpired 01`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount.toLong() + 1) + every { appStorage.sessionCount } + .returns(mockedSessionCount.toLong() + 1) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.seconds.inWholeMilliseconds) assertFalse { subject.shouldShowInterstitialAd() } - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() - verify(adConfigService) - .invocation { config.interstitialAdSessionCount } + verify { adConfigService.config.interstitialAdSessionCount } .wasNotInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasNotInvoked() } @Test fun `shouldShowInterstitialAd returns true when session count bigger than remote and premiumExpired 11`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount.toLong() + 1) + every { appStorage.sessionCount } + .returns(mockedSessionCount.toLong() + 1) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.seconds.inWholeMilliseconds) assertTrue { subject.shouldShowInterstitialAd() } - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasInvoked() } @Test fun `shouldShowInterstitialAd returns false when session count smaller than remote and premiumExpired 00`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount.toLong() - 1) + every { appStorage.sessionCount } + .returns(mockedSessionCount.toLong() - 1) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.seconds.inWholeMilliseconds) assertFalse { subject.shouldShowInterstitialAd() } - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasNotInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasNotInvoked() } @Test fun `shouldShowInterstitialAd returns false when session count smaller than remote and premiumNotExpired 10`() { - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockedSessionCount.toLong() - 1) + every { appStorage.sessionCount } + .returns(mockedSessionCount.toLong() - 1) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.seconds.inWholeMilliseconds) assertFalse { subject.shouldShowInterstitialAd() } - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() - verify(adConfigService) - .invocation { config } + verify { adConfigService.config } .wasInvoked() - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasInvoked() } } diff --git a/client/repository/appconfig/client-repository-appconfig.gradle.kts b/client/repository/appconfig/client-repository-appconfig.gradle.kts index 30531b87e3..8bdbe0a034 100644 --- a/client/repository/appconfig/client-repository-appconfig.gradle.kts +++ b/client/repository/appconfig/client-repository-appconfig.gradle.kts @@ -3,7 +3,6 @@ import com.codingfeline.buildkonfig.compiler.FieldSpec.Type.STRING import com.codingfeline.buildkonfig.gradle.BuildKonfigExtension plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(androidLib.get().pluginId) id(multiplatform.get().pluginId) @@ -13,7 +12,10 @@ plugins { } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -21,7 +23,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { implementation(libs.common.koinCore) @@ -43,28 +44,6 @@ kotlin { } } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/repository/appconfig/src/commonTest/kotlin/com/oztechan/ccc/client/repository/appconfig/AppConfigRepositoryTest.kt b/client/repository/appconfig/src/commonTest/kotlin/com/oztechan/ccc/client/repository/appconfig/AppConfigRepositoryTest.kt index e27731fbd7..b3b39abc03 100644 --- a/client/repository/appconfig/src/commonTest/kotlin/com/oztechan/ccc/client/repository/appconfig/AppConfigRepositoryTest.kt +++ b/client/repository/appconfig/src/commonTest/kotlin/com/oztechan/ccc/client/repository/appconfig/AppConfigRepositoryTest.kt @@ -8,7 +8,7 @@ import com.oztechan.ccc.client.core.shared.Device import com.oztechan.ccc.client.storage.app.AppStorage import io.mockative.Mock import io.mockative.classOf -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlin.random.Random @@ -19,7 +19,6 @@ import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue -@Suppress("TooManyFunctions") internal class AppConfigRepositoryTest { private val subject: AppConfigRepository by lazy { @@ -49,72 +48,62 @@ internal class AppConfigRepositoryTest { @Test fun `checkAppUpdate should return true when force + current version bigger than current version`() { - given(updateConfigService) - .invocation { config } - .then { UpdateConfig(BuildKonfig.versionCode + 1, BuildKonfig.versionCode + 1) } + every { updateConfigService.config } + .returns(UpdateConfig(BuildKonfig.versionCode + 1, BuildKonfig.versionCode + 1)) subject.checkAppUpdate(false).let { assertNotNull(it) assertTrue { it } } - verify(updateConfigService) - .invocation { config } + verify { updateConfigService.config } .wasInvoked() } @Test fun `checkAppUpdate should return false when forceVersion less than current + updateVersion bigger than current`() { - given(updateConfigService) - .invocation { config } - .then { UpdateConfig(BuildKonfig.versionCode + 1, BuildKonfig.versionCode - 1) } + every { updateConfigService.config } + .returns(UpdateConfig(BuildKonfig.versionCode + 1, BuildKonfig.versionCode - 1)) subject.checkAppUpdate(false).let { assertNotNull(it) assertFalse { it } } - verify(updateConfigService) - .invocation { config } + verify { updateConfigService.config } .wasInvoked() } @Test fun `checkAppUpdate should return null when update is less than current version`() { - given(updateConfigService) - .invocation { config } - .then { UpdateConfig(BuildKonfig.versionCode - 1, Random.nextInt()) } + every { updateConfigService.config } + .returns(UpdateConfig(BuildKonfig.versionCode - 1, Random.nextInt())) assertNull(subject.checkAppUpdate(false)) - verify(updateConfigService) - .invocation { config } + verify { updateConfigService.config } .wasInvoked() } @Test fun `checkAppUpdate should return null when update version is equal to current version`() { - given(updateConfigService) - .invocation { config } - .then { UpdateConfig(BuildKonfig.versionCode, Random.nextInt()) } + every { updateConfigService.config } + .returns(UpdateConfig(BuildKonfig.versionCode, Random.nextInt())) assertNull(subject.checkAppUpdate(false)) - verify(updateConfigService) - .invocation { config } + verify { updateConfigService.config } .wasInvoked() } @Test fun `checkAppUpdate should return null when it is already shown`() { - given(updateConfigService) - .invocation { config } - .then { UpdateConfig(BuildKonfig.versionCode + 1, BuildKonfig.versionCode + 1) } + every { updateConfigService.config } + .returns(UpdateConfig(BuildKonfig.versionCode + 1, BuildKonfig.versionCode + 1)) assertNull(subject.checkAppUpdate(true)) - verify(updateConfigService) - .invocation { config } + verify { updateConfigService.config } .wasInvoked() } @@ -122,22 +111,18 @@ internal class AppConfigRepositoryTest { fun `shouldShowAppReview should return true when sessionCount is biggerThan remote sessionCount`() { val mockInteger = Random.nextInt() - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(appReviewSessionCount = mockInteger, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(appReviewSessionCount = mockInteger, 0L)) - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockInteger.toLong() + 1) + every { appStorage.sessionCount } + .returns(mockInteger.toLong() + 1) assertTrue { subject.shouldShowAppReview() } - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasInvoked() - verify(reviewConfigService) - .invocation { config } + verify { reviewConfigService.config } .wasInvoked() } @@ -145,22 +130,18 @@ internal class AppConfigRepositoryTest { fun `shouldShowAppReview should return false when sessionCount is less than remote sessionCount`() { val mockInteger = Random.nextInt() - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(appReviewSessionCount = mockInteger, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(appReviewSessionCount = mockInteger, 0L)) - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockInteger.toLong() - 1) + every { appStorage.sessionCount } + .returns(mockInteger.toLong() - 1) assertFalse { subject.shouldShowAppReview() } - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasInvoked() - verify(reviewConfigService) - .invocation { config } + verify { reviewConfigService.config } .wasInvoked() } @@ -168,22 +149,18 @@ internal class AppConfigRepositoryTest { fun `shouldShowAppReview should return false when sessionCount is equal to remote sessionCount`() { val mockInteger = Random.nextInt() - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(appReviewSessionCount = mockInteger, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(appReviewSessionCount = mockInteger, 0L)) - given(appStorage) - .invocation { sessionCount } - .thenReturn(mockInteger.toLong()) + every { appStorage.sessionCount } + .returns(mockInteger.toLong()) assertFalse { subject.shouldShowAppReview() } - verify(appStorage) - .invocation { sessionCount } + verify { appStorage.sessionCount } .wasInvoked() - verify(reviewConfigService) - .invocation { config } + verify { reviewConfigService.config } .wasInvoked() } diff --git a/client/service/backend/client-service-backend.gradle.kts b/client/service/backend/client-service-backend.gradle.kts index e00cf16f16..6868e53595 100644 --- a/client/service/backend/client-service-backend.gradle.kts +++ b/client/service/backend/client-service-backend.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -8,7 +7,10 @@ plugins { } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -16,7 +18,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { libs.common.apply { @@ -40,28 +41,6 @@ kotlin { } } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/service/backend/src/commonTest/kotlin/com/oztechan/ccc/client/service/backend/BackendApiServiceTest.kt b/client/service/backend/src/commonTest/kotlin/com/oztechan/ccc/client/service/backend/BackendApiServiceTest.kt index b2307df715..78401b9fc3 100644 --- a/client/service/backend/src/commonTest/kotlin/com/oztechan/ccc/client/service/backend/BackendApiServiceTest.kt +++ b/client/service/backend/src/commonTest/kotlin/com/oztechan/ccc/client/service/backend/BackendApiServiceTest.kt @@ -8,9 +8,9 @@ import com.oztechan.ccc.common.core.network.model.Conversion import com.oztechan.ccc.common.core.network.model.ExchangeRate import io.mockative.Mock import io.mockative.classOf -import io.mockative.given +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.mock -import io.mockative.verify import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest @@ -20,10 +20,10 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue -@Suppress("OPT_IN_USAGE") internal class BackendApiServiceTest { private val subject: BackendApiService by lazy { + @Suppress("OPT_IN_USAGE") BackendApiServiceImpl(backendApi, UnconfinedTestDispatcher()) } @@ -46,16 +46,14 @@ internal class BackendApiServiceTest { assertTrue { it.isFailure } } - verify(backendApi) - .coroutine { backendApi.getExchangeRate("") } + coVerify { backendApi.getExchangeRate("") } .wasNotInvoked() } @Test fun `getConversion error`() = runTest { - given(backendApi) - .coroutine { backendApi.getExchangeRate(base) } - .thenThrow(throwable) + coEvery { backendApi.getExchangeRate(base) } + .throws(throwable) runCatching { subject.getConversion(base) }.let { assertFalse { it.isSuccess } @@ -66,16 +64,14 @@ internal class BackendApiServiceTest { assertEquals(throwable.message, it.exceptionOrNull()!!.cause!!.message) } - verify(backendApi) - .coroutine { getExchangeRate(base) } + coVerify { backendApi.getExchangeRate(base) } .wasInvoked() } @Test fun `getConversion success`() = runTest { - given(backendApi) - .coroutine { backendApi.getExchangeRate(base) } - .thenReturn(exchangeRate) + coEvery { backendApi.getExchangeRate(base) } + .returns(exchangeRate) runCatching { subject.getConversion(base) }.let { assertTrue { it.isSuccess } @@ -84,8 +80,7 @@ internal class BackendApiServiceTest { assertEquals(exchangeRate.toConversionModel(), it.getOrNull()) } - verify(backendApi) - .coroutine { getExchangeRate(base) } + coVerify { backendApi.getExchangeRate(base) } .wasInvoked() } } diff --git a/client/storage/app/client-storage-app.gradle.kts b/client/storage/app/client-storage-app.gradle.kts index 55c674048c..85d84797f1 100644 --- a/client/storage/app/client-storage-app.gradle.kts +++ b/client/storage/app/client-storage-app.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -29,27 +31,6 @@ kotlin { } } } - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/storage/app/src/commonTest/kotlin/com/oztechan/ccc/client/storage/app/AppStorageTest.kt b/client/storage/app/src/commonTest/kotlin/com/oztechan/ccc/client/storage/app/AppStorageTest.kt index c4a4ac1070..724b462ba0 100644 --- a/client/storage/app/src/commonTest/kotlin/com/oztechan/ccc/client/storage/app/AppStorageTest.kt +++ b/client/storage/app/src/commonTest/kotlin/com/oztechan/ccc/client/storage/app/AppStorageTest.kt @@ -12,14 +12,13 @@ import com.oztechan.ccc.client.storage.app.AppStorageImpl.Companion.KEY_SESSION_ import io.mockative.Mock import io.mockative.classOf import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals -@Suppress("TooManyFunctions") internal class AppStorageTest { private val subject: AppStorage by lazy { @@ -32,53 +31,45 @@ internal class AppStorageTest { // defaults @Test fun `default firstRun`() { - given(persistence) - .invocation { getValue(KEY_FIRST_RUN, DEFAULT_FIRST_RUN) } - .thenReturn(DEFAULT_FIRST_RUN) + every { persistence.getValue(KEY_FIRST_RUN, DEFAULT_FIRST_RUN) } + .returns(DEFAULT_FIRST_RUN) assertEquals(DEFAULT_FIRST_RUN, subject.firstRun) - verify(persistence) - .invocation { getValue(KEY_FIRST_RUN, DEFAULT_FIRST_RUN) } + verify { persistence.getValue(KEY_FIRST_RUN, DEFAULT_FIRST_RUN) } .wasInvoked() } @Test fun `default appTheme`() { - given(persistence) - .invocation { getValue(KEY_APP_THEME, DEFAULT_APP_THEME) } - .thenReturn(DEFAULT_APP_THEME) + every { persistence.getValue(KEY_APP_THEME, DEFAULT_APP_THEME) } + .returns(DEFAULT_APP_THEME) assertEquals(DEFAULT_APP_THEME, subject.appTheme) - verify(persistence) - .invocation { getValue(KEY_APP_THEME, DEFAULT_APP_THEME) } + verify { persistence.getValue(KEY_APP_THEME, DEFAULT_APP_THEME) } .wasInvoked() } @Test fun `default premiumEndDate`() { - given(persistence) - .invocation { getValue(KEY_PREMIUM_END_DATE, DEFAULT_PREMIUM_END_DATE) } - .thenReturn(DEFAULT_PREMIUM_END_DATE) + every { persistence.getValue(KEY_PREMIUM_END_DATE, DEFAULT_PREMIUM_END_DATE) } + .returns(DEFAULT_PREMIUM_END_DATE) assertEquals(DEFAULT_PREMIUM_END_DATE, subject.premiumEndDate) - verify(persistence) - .invocation { getValue(KEY_PREMIUM_END_DATE, DEFAULT_PREMIUM_END_DATE) } + verify { persistence.getValue(KEY_PREMIUM_END_DATE, DEFAULT_PREMIUM_END_DATE) } .wasInvoked() } @Test fun `default sessionCount`() { - given(persistence) - .invocation { getValue(KEY_SESSION_COUNT, DEFAULT_SESSION_COUNT) } - .thenReturn(DEFAULT_SESSION_COUNT) + every { persistence.getValue(KEY_SESSION_COUNT, DEFAULT_SESSION_COUNT) } + .returns(DEFAULT_SESSION_COUNT) assertEquals(DEFAULT_SESSION_COUNT, subject.sessionCount) - verify(persistence) - .invocation { getValue(KEY_SESSION_COUNT, DEFAULT_SESSION_COUNT) } + verify { persistence.getValue(KEY_SESSION_COUNT, DEFAULT_SESSION_COUNT) } .wasInvoked() } @@ -88,8 +79,7 @@ internal class AppStorageTest { val mockedValue = Random.nextBoolean() subject.firstRun = mockedValue - verify(persistence) - .invocation { setValue(KEY_FIRST_RUN, mockedValue) } + verify { persistence.setValue(KEY_FIRST_RUN, mockedValue) } .wasInvoked() } @@ -98,8 +88,7 @@ internal class AppStorageTest { val mockValue = Random.nextInt() subject.appTheme = mockValue - verify(persistence) - .invocation { setValue(KEY_APP_THEME, mockValue) } + verify { persistence.setValue(KEY_APP_THEME, mockValue) } .wasInvoked() } @@ -108,8 +97,7 @@ internal class AppStorageTest { val mockValue = Random.nextLong() subject.premiumEndDate = mockValue - verify(persistence) - .invocation { setValue(KEY_PREMIUM_END_DATE, mockValue) } + verify { persistence.setValue(KEY_PREMIUM_END_DATE, mockValue) } .wasInvoked() } @@ -118,8 +106,7 @@ internal class AppStorageTest { val mockValue = Random.nextLong() subject.sessionCount = mockValue - verify(persistence) - .invocation { setValue(KEY_SESSION_COUNT, mockValue) } + verify { persistence.setValue(KEY_SESSION_COUNT, mockValue) } .wasInvoked() } } diff --git a/client/storage/calculation/client-storage-calculation.gradle.kts b/client/storage/calculation/client-storage-calculation.gradle.kts index ecf9518eda..9caa8c1b0b 100644 --- a/client/storage/calculation/client-storage-calculation.gradle.kts +++ b/client/storage/calculation/client-storage-calculation.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -29,28 +31,6 @@ kotlin { } } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/storage/calculation/src/commonTest/kotlin/com/oztechan/ccc/client/storage/calculation/CalculationStorageTest.kt b/client/storage/calculation/src/commonTest/kotlin/com/oztechan/ccc/client/storage/calculation/CalculationStorageTest.kt index 0b4c06996d..d37a563dde 100644 --- a/client/storage/calculation/src/commonTest/kotlin/com/oztechan/ccc/client/storage/calculation/CalculationStorageTest.kt +++ b/client/storage/calculation/src/commonTest/kotlin/com/oztechan/ccc/client/storage/calculation/CalculationStorageTest.kt @@ -10,14 +10,13 @@ import com.oztechan.ccc.client.storage.calculation.CalculationStorageImpl.Compan import io.mockative.Mock import io.mockative.classOf import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals -@Suppress("TooManyFunctions") internal class CalculationStorageTest { private val subject: CalculationStorage by lazy { CalculationStorageImpl(persistence) @@ -29,40 +28,34 @@ internal class CalculationStorageTest { // defaults @Test fun `default currentBase`() { - given(persistence) - .invocation { getValue(KEY_CURRENT_BASE, DEFAULT_CURRENT_BASE) } - .thenReturn(DEFAULT_CURRENT_BASE) + every { persistence.getValue(KEY_CURRENT_BASE, DEFAULT_CURRENT_BASE) } + .returns(DEFAULT_CURRENT_BASE) assertEquals(DEFAULT_CURRENT_BASE, subject.currentBase) - verify(persistence) - .invocation { getValue(KEY_CURRENT_BASE, DEFAULT_CURRENT_BASE) } + verify { persistence.getValue(KEY_CURRENT_BASE, DEFAULT_CURRENT_BASE) } .wasInvoked() } @Test fun `default precision`() { - given(persistence) - .invocation { getValue(KEY_PRECISION, DEFAULT_PRECISION) } - .thenReturn(DEFAULT_PRECISION) + every { persistence.getValue(KEY_PRECISION, DEFAULT_PRECISION) } + .returns(DEFAULT_PRECISION) assertEquals(DEFAULT_PRECISION, subject.precision) - verify(persistence) - .invocation { getValue(KEY_PRECISION, DEFAULT_PRECISION) } + verify { persistence.getValue(KEY_PRECISION, DEFAULT_PRECISION) } .wasInvoked() } @Test fun `default lastInput`() { - given(persistence) - .invocation { getValue(KEY_LAST_INPUT, DEFAULT_LAST_INPUT) } - .thenReturn(DEFAULT_LAST_INPUT) + every { persistence.getValue(KEY_LAST_INPUT, DEFAULT_LAST_INPUT) } + .returns(DEFAULT_LAST_INPUT) assertEquals(DEFAULT_LAST_INPUT, subject.lastInput) - verify(persistence) - .invocation { getValue(KEY_LAST_INPUT, DEFAULT_LAST_INPUT) } + verify { persistence.getValue(KEY_LAST_INPUT, DEFAULT_LAST_INPUT) } .wasInvoked() } @@ -72,8 +65,7 @@ internal class CalculationStorageTest { val mockValue = "mock" subject.currentBase = mockValue - verify(persistence) - .invocation { setValue(KEY_CURRENT_BASE, mockValue) } + verify { persistence.setValue(KEY_CURRENT_BASE, mockValue) } .wasInvoked() } @@ -82,8 +74,7 @@ internal class CalculationStorageTest { val mockValue = Random.nextInt() subject.precision = mockValue - verify(persistence) - .invocation { setValue(KEY_PRECISION, mockValue) } + verify { persistence.setValue(KEY_PRECISION, mockValue) } .wasInvoked() } @@ -92,8 +83,7 @@ internal class CalculationStorageTest { val mockValue = "mock" subject.lastInput = mockValue - verify(persistence) - .invocation { setValue(KEY_LAST_INPUT, mockValue) } + verify { persistence.setValue(KEY_LAST_INPUT, mockValue) } .wasInvoked() } } diff --git a/client/viewmodel/calculator/client-viewmodel-calculator.gradle.kts b/client/viewmodel/calculator/client-viewmodel-calculator.gradle.kts index 946f9c9b0b..e3deac41f1 100644 --- a/client/viewmodel/calculator/client-viewmodel-calculator.gradle.kts +++ b/client/viewmodel/calculator/client-viewmodel-calculator.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -61,32 +63,11 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.lifecycleViewmodel) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorSEED.kt b/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorSEED.kt index ff7392b5f4..9e8d45ab91 100644 --- a/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorSEED.kt +++ b/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorSEED.kt @@ -11,6 +11,7 @@ import com.oztechan.ccc.common.core.model.Currency // State data class CalculatorState( + val isBannerAdVisible: Boolean, val input: String = "", val base: String = "", val currencyList: List = listOf(), @@ -28,7 +29,7 @@ interface CalculatorEvent : BaseEvent { fun onItemAmountLongClick(amount: String) fun onOutputLongClick() fun onInputLongClick() - fun pasteToInput(text: String) + fun onPasteToInput(text: String) fun onBarClick() fun onSettingsClicked() fun onBaseChange(base: String) @@ -36,13 +37,13 @@ interface CalculatorEvent : BaseEvent { // Effect sealed class CalculatorEffect : BaseEffect() { - object Error : CalculatorEffect() - object FewCurrency : CalculatorEffect() - object OpenBar : CalculatorEffect() - object TooBigInput : CalculatorEffect() - object TooBigOutput : CalculatorEffect() - object OpenSettings : CalculatorEffect() - object ShowPasteRequest : CalculatorEffect() + data object Error : CalculatorEffect() + data object FewCurrency : CalculatorEffect() + data object OpenBar : CalculatorEffect() + data object TooBigInput : CalculatorEffect() + data object TooBigOutput : CalculatorEffect() + data object OpenSettings : CalculatorEffect() + data object ShowPasteRequest : CalculatorEffect() data class CopyToClipboard(val amount: String) : CalculatorEffect() data class ShowConversion(val text: String, val code: String) : CalculatorEffect() } diff --git a/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModel.kt b/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModel.kt index 52ea191898..9a74ee324b 100644 --- a/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModel.kt +++ b/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModel.kt @@ -51,11 +51,12 @@ class CalculatorViewModel( private val backendApiService: BackendApiService, private val currencyDataSource: CurrencyDataSource, private val conversionDataSource: ConversionDataSource, - private val adControlRepository: AdControlRepository, + adControlRepository: AdControlRepository, private val analyticsManager: AnalyticsManager ) : BaseSEEDViewModel(), CalculatorEvent { // region SEED - private val _state = MutableStateFlow(CalculatorState()) + private val _state = + MutableStateFlow(CalculatorState(isBannerAdVisible = adControlRepository.shouldShowBannerAd())) override val state = _state.asStateFlow() private val _effect = MutableSharedFlow() @@ -136,7 +137,7 @@ class CalculatorViewModel( )?.let { calculateConversions(it, ConversionState.Offline(it.date)) } ?: run { - Logger.w(Exception("No offline conversion")) { this@CalculatorViewModel::class.simpleName.toString() } + Logger.w { "No offline conversion found in the DB" } _effect.emit(CalculatorEffect.Error) @@ -200,8 +201,6 @@ class CalculatorViewModel( } } - fun shouldShowBannerAd() = adControlRepository.shouldShowBannerAd() - // region Event override fun onKeyPress(key: String) { Logger.d { "CalculatorViewModel onKeyPress $key" } @@ -279,8 +278,8 @@ class CalculatorViewModel( _effect.emit(CalculatorEffect.ShowPasteRequest) } - override fun pasteToInput(text: String) { - Logger.d { "CalculatorViewModel pasteToInput $text" } + override fun onPasteToInput(text: String) { + Logger.d { "CalculatorViewModel onPasteToInput $text" } analyticsManager.trackEvent(Event.PasteFromClipboard) diff --git a/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/model/ConversionState.kt b/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/model/ConversionState.kt index 5e9bcfe922..9fdac961dd 100644 --- a/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/model/ConversionState.kt +++ b/client/viewmodel/calculator/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/calculator/model/ConversionState.kt @@ -8,6 +8,6 @@ sealed class ConversionState { data class Online(val lastUpdate: String?) : ConversionState() data class Cached(val lastUpdate: String?) : ConversionState() data class Offline(val lastUpdate: String?) : ConversionState() - object Error : ConversionState() - object None : ConversionState() + data object Error : ConversionState() + data object None : ConversionState() } diff --git a/client/viewmodel/calculator/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModelTest.kt b/client/viewmodel/calculator/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModelTest.kt index 316011906a..5d54e96010 100644 --- a/client/viewmodel/calculator/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModelTest.kt +++ b/client/viewmodel/calculator/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/calculator/CalculatorViewModelTest.kt @@ -10,6 +10,7 @@ import com.oztechan.ccc.client.core.analytics.model.Event import com.oztechan.ccc.client.core.analytics.model.Param import com.oztechan.ccc.client.core.analytics.model.UserProperty import com.oztechan.ccc.client.core.shared.util.getFormatted +import com.oztechan.ccc.client.core.shared.util.nowAsDateString import com.oztechan.ccc.client.core.shared.util.toStandardDigits import com.oztechan.ccc.client.core.shared.util.toSupportedCharacters import com.oztechan.ccc.client.datasource.currency.CurrencyDataSource @@ -26,8 +27,10 @@ import com.oztechan.ccc.common.core.model.Currency import com.oztechan.ccc.common.datasource.conversion.ConversionDataSource import io.mockative.Mock import io.mockative.classOf +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.Dispatchers @@ -45,7 +48,6 @@ import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull -@Suppress("OPT_IN_USAGE", "TooManyFunctions") internal class CalculatorViewModelTest { private val viewModel: CalculatorViewModel by lazy { @@ -84,68 +86,79 @@ internal class CalculatorViewModelTest { private val currency2 = Currency("EUR", "Dollar", "$", "12345.678", true) private val currencyList = listOf(currency1, currency2) private val conversion = Conversion(currency1.code, "12.12.2121") + private val shouldShowAds = Random.nextBoolean() @BeforeTest fun setup() { Logger.setLogWriters(CommonWriter()) + @Suppress("OPT_IN_USAGE") Dispatchers.setMain(UnconfinedTestDispatcher()) - given(calculationStorage) - .invocation { currentBase } - .thenReturn(currency1.code) + every { calculationStorage.currentBase } + .returns(currency1.code) - given(calculationStorage) - .invocation { lastInput } - .thenReturn("") + every { calculationStorage.lastInput } + .returns("") - given(currencyDataSource) - .invocation { getActiveCurrenciesFlow() } - .thenReturn(flowOf(currencyList)) + every { currencyDataSource.getActiveCurrenciesFlow() } + .returns(flowOf(currencyList)) - given(calculationStorage) - .invocation { precision } - .thenReturn(3) + every { calculationStorage.precision } + .returns(3) + + every { adControlRepository.shouldShowBannerAd() } + .returns(shouldShowAds) runTest { - given(currencyDataSource) - .coroutine { getActiveCurrencies() } - .thenReturn(currencyList) + coEvery { currencyDataSource.getActiveCurrencies() } + .returns(currencyList) - given(conversionDataSource) - .coroutine { getConversionByBase(currency1.code) } - .thenReturn(conversion) + coEvery { conversionDataSource.getConversionByBase(currency1.code) } + .returns(conversion) - given(backendApiService) - .coroutine { getConversion(currency1.code) } - .thenReturn(conversion) + coEvery { backendApiService.getConversion(currency1.code) } + .returns(conversion) - given(currencyDataSource) - .coroutine { getCurrencyByCode(currency1.code) } - .thenReturn(currency1) + coEvery { currencyDataSource.getCurrencyByCode(currency1.code) } + .returns(currency1) } } @Test fun `conversion should be fetched on init`() = runTest { viewModel - verify(backendApiService) - .coroutine { getConversion(currency1.code) } + coVerify { backendApiService.getConversion(currency1.code) } .wasInvoked() assertNotNull(viewModel.data.conversion) } + // init + @Test + fun `init updates states correctly`() = runTest { + viewModel.state.firstOrNull().let { + assertNotNull(it) + assertEquals(currency1.code, it.base) + assertEquals("", it.input) + assertIs(it.conversionState) + assertEquals(ConversionState.Online(nowAsDateString()), it.conversionState) + assertEquals(currencyList, it.currencyList) + assertEquals(shouldShowAds, it.isBannerAdVisible) + } + + verify { adControlRepository.shouldShowBannerAd() } + .wasInvoked() + } + @Test fun `init sets the latest base and input`() = runTest { val mock = "mock" - given(calculationStorage) - .invocation { currentBase } - .thenReturn(currency1.code) + every { calculationStorage.currentBase } + .returns(currency1.code) - given(calculationStorage) - .invocation { lastInput } - .thenReturn(mock) + every { calculationStorage.lastInput } + .returns(mock) viewModel.state.firstOrNull().let { assertNotNull(it) @@ -157,9 +170,8 @@ internal class CalculatorViewModelTest { @Test fun `when api fails and there is conversion in db then conversion rates are calculated`() = runTest { - given(backendApiService) - .coroutine { getConversion(currency1.code) } - .thenThrow(Exception()) + coEvery { backendApiService.getConversion(currency1.code) } + .throws(Exception()) viewModel.state.onSubscription { viewModel.event.onKeyPress("1") // trigger api call @@ -177,20 +189,17 @@ internal class CalculatorViewModelTest { assertEquals(result, it.currencyList) } - verify(conversionDataSource) - .coroutine { getConversionByBase(currency1.code) } + coVerify { conversionDataSource.getConversionByBase(currency1.code) } .wasInvoked() } @Test fun `when api fails and there is no conversion in db then error state displayed`() = runTest { - given(backendApiService) - .coroutine { getConversion(currency1.code) } - .thenThrow(Exception()) + coEvery { backendApiService.getConversion(currency1.code) } + .throws(Exception()) - given(conversionDataSource) - .coroutine { getConversionByBase(currency1.code) } - .thenReturn(null) + coEvery { conversionDataSource.getConversionByBase(currency1.code) } + .returns(null) viewModel.effect.onSubscription { viewModel.event.onKeyPress("1") // trigger api call @@ -204,25 +213,21 @@ internal class CalculatorViewModelTest { } } - verify(conversionDataSource) - .coroutine { getConversionByBase(currency1.code) } + coVerify { conversionDataSource.getConversionByBase(currency1.code) } .wasInvoked() } @Test fun `when api fails and there is no offline and no enough currency few currency effect emitted`() = runTest { - given(backendApiService) - .coroutine { getConversion(currency1.code) } - .thenThrow(Exception()) + coEvery { backendApiService.getConversion(currency1.code) } + .throws(Exception()) - given(conversionDataSource) - .coroutine { getConversionByBase(currency1.code) } - .thenReturn(null) + coEvery { conversionDataSource.getConversionByBase(currency1.code) } + .returns(null) - given(currencyDataSource) - .invocation { getActiveCurrenciesFlow() } - .thenReturn(flowOf(listOf(currency1))) + every { currencyDataSource.getActiveCurrenciesFlow() } + .returns(flowOf(listOf(currency1))) viewModel.effect.onSubscription { viewModel.event.onKeyPress("1") // trigger api call @@ -236,8 +241,7 @@ internal class CalculatorViewModelTest { } } - verify(conversionDataSource) - .coroutine { getConversionByBase(currency1.code) } + coVerify { conversionDataSource.getConversionByBase(currency1.code) } .wasInvoked() } @@ -296,30 +300,11 @@ internal class CalculatorViewModelTest { fun ifUserPropertiesSetCorrect() { viewModel // init - verify(analyticsManager) - .invocation { - setUserProperty( - UserProperty.CurrencyCount( - currencyList.count().toString() - ) - ) - } - .wasInvoked() - } - - @Test - fun shouldShowBannerAd() { - val mockBoolean = Random.nextBoolean() - - given(adControlRepository) - .invocation { shouldShowBannerAd() } - .thenReturn(mockBoolean) - - assertEquals(mockBoolean, viewModel.shouldShowBannerAd()) - - verify(adControlRepository) - .invocation { shouldShowBannerAd() } - .wasInvoked() + verify { + analyticsManager.setUserProperty( + UserProperty.CurrencyCount(currencyList.count().toString()) + ) + }.wasInvoked() } // Event @@ -389,8 +374,7 @@ internal class CalculatorViewModelTest { ) assertEquals(currency1.code, it.code) - verify(analyticsManager) - .invocation { trackEvent(Event.ShowConversion(Param.Base(currency1.code))) } + verify { analyticsManager.trackEvent(Event.ShowConversion(Param.Base(currency1.code))) } .wasInvoked() } } @@ -405,8 +389,7 @@ internal class CalculatorViewModelTest { it ) - verify(analyticsManager) - .invocation { trackEvent(Event.CopyClipboard) } + verify { analyticsManager.trackEvent(Event.CopyClipboard) } .wasInvoked() } } @@ -420,8 +403,7 @@ internal class CalculatorViewModelTest { }.firstOrNull().let { assertEquals(CalculatorEffect.CopyToClipboard(output), it) - verify(analyticsManager) - .invocation { trackEvent(Event.CopyClipboard) } + verify { analyticsManager.trackEvent(Event.CopyClipboard) } .wasInvoked() } } @@ -436,29 +418,27 @@ internal class CalculatorViewModelTest { } @Test - fun pasteToInput() = runTest { + fun onPasteToInput() = runTest { val text = "mock" val text2 = "mock 2" viewModel.state.onSubscription { - viewModel.event.pasteToInput(text) + viewModel.event.onPasteToInput(text) }.firstOrNull().let { assertNotNull(it) assertEquals(text, it.input) - verify(analyticsManager) - .invocation { trackEvent(Event.PasteFromClipboard) } + verify { analyticsManager.trackEvent(Event.PasteFromClipboard) } .wasInvoked() } viewModel.state.onSubscription { - viewModel.event.pasteToInput(text2) + viewModel.event.onPasteToInput(text2) }.firstOrNull().let { assertNotNull(it) assertEquals(text2.toSupportedCharacters(), it.input) - verify(analyticsManager) - .invocation { trackEvent(Event.PasteFromClipboard) } + verify { analyticsManager.trackEvent(Event.PasteFromClipboard) } .wasInvoked() } } @@ -512,13 +492,11 @@ internal class CalculatorViewModelTest { @Test fun onBaseChanged() = runTest { - given(calculationStorage) - .invocation { currentBase } - .thenReturn(currency1.code) + every { calculationStorage.currentBase } + .returns(currency1.code) - given(backendApiService) - .coroutine { getConversion(currency1.code) } - .thenReturn(conversion) + coEvery { backendApiService.getConversion(currency1.code) } + .returns(conversion) viewModel.state.onSubscription { viewModel.event.onBaseChange(currency1.code) @@ -528,12 +506,10 @@ internal class CalculatorViewModelTest { assertEquals(currency1.code, viewModel.data.conversion!!.base) assertEquals(currency1.code, it.base) - verify(analyticsManager) - .invocation { trackEvent(Event.BaseChange(Param.Base(currency1.code))) } + verify { analyticsManager.trackEvent(Event.BaseChange(Param.Base(currency1.code))) } .wasInvoked() - verify(analyticsManager) - .invocation { setUserProperty(UserProperty.BaseCurrency(currency1.code)) } + verify { analyticsManager.setUserProperty(UserProperty.BaseCurrency(currency1.code)) } .wasInvoked() } } diff --git a/client/viewmodel/calculator/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/calculator/util/CalculatorUtilTest.kt b/client/viewmodel/calculator/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/calculator/util/CalculatorUtilTest.kt index 01cc33b105..08801ce07b 100644 --- a/client/viewmodel/calculator/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/calculator/util/CalculatorUtilTest.kt +++ b/client/viewmodel/calculator/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/calculator/util/CalculatorUtilTest.kt @@ -10,7 +10,6 @@ import com.oztechan.ccc.common.core.model.CurrencyType import kotlin.test.Test import kotlin.test.assertEquals -@Suppress("TooManyFunctions") internal class CalculatorUtilTest { @Test diff --git a/client/viewmodel/currencies/client-viewmodel-currencies.gradle.kts b/client/viewmodel/currencies/client-viewmodel-currencies.gradle.kts index 89b61f8459..bcc278d49c 100644 --- a/client/viewmodel/currencies/client-viewmodel-currencies.gradle.kts +++ b/client/viewmodel/currencies/client-viewmodel-currencies.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -55,32 +57,11 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.lifecycleViewmodel) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/viewmodel/currencies/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesSEED.kt b/client/viewmodel/currencies/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesSEED.kt index a62065f489..bfba2bf73d 100644 --- a/client/viewmodel/currencies/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesSEED.kt +++ b/client/viewmodel/currencies/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesSEED.kt @@ -8,6 +8,8 @@ import com.oztechan.ccc.common.core.model.Currency // State data class CurrenciesState( + val isBannerAdVisible: Boolean, + val isOnboardingVisible: Boolean, val currencyList: List = listOf(), val loading: Boolean = true, val selectionVisibility: Boolean = false @@ -25,9 +27,9 @@ interface CurrenciesEvent : BaseEvent { // Effect sealed class CurrenciesEffect : BaseEffect() { - object FewCurrency : CurrenciesEffect() - object OpenCalculator : CurrenciesEffect() - object Back : CurrenciesEffect() + data object FewCurrency : CurrenciesEffect() + data object OpenCalculator : CurrenciesEffect() + data object Back : CurrenciesEffect() data class ChangeBase(val newBase: String) : CurrenciesEffect() } diff --git a/client/viewmodel/currencies/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModel.kt b/client/viewmodel/currencies/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModel.kt index a495167c5f..c85b33524d 100755 --- a/client/viewmodel/currencies/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModel.kt +++ b/client/viewmodel/currencies/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModel.kt @@ -33,11 +33,17 @@ class CurrenciesViewModel( private val appStorage: AppStorage, private val calculationStorage: CalculationStorage, private val currencyDataSource: CurrencyDataSource, - private val adControlRepository: AdControlRepository, + adControlRepository: AdControlRepository, private val analyticsManager: AnalyticsManager -) : BaseSEEDViewModel(), CurrenciesEvent { +) : BaseSEEDViewModel(), + CurrenciesEvent { // region SEED - private val _state = MutableStateFlow(CurrenciesState()) + private val _state = MutableStateFlow( + CurrenciesState( + isBannerAdVisible = adControlRepository.shouldShowBannerAd(), + isOnboardingVisible = appStorage.firstRun + ) + ) override val state = _state.asStateFlow() private val _effect = MutableSharedFlow() @@ -103,14 +109,6 @@ class CurrenciesViewModel( data.query = txt } - fun hideSelectionVisibility() = _state.update { - copy(selectionVisibility = false) - } - - fun shouldShowBannerAd() = adControlRepository.shouldShowBannerAd() - - fun isFirstRun() = appStorage.firstRun - // region Event override fun updateAllCurrenciesState(state: Boolean) = viewModelScope.launchIgnored { Logger.d { "CurrenciesViewModel updateAllCurrenciesState $state" } @@ -130,6 +128,7 @@ class CurrenciesViewModel( ?.let { _effect.emit(CurrenciesEffect.FewCurrency) } ?: run { appStorage.firstRun = false + _state.update { copy(isOnboardingVisible = false) } filterList("") _effect.emit(CurrenciesEffect.OpenCalculator) } diff --git a/client/viewmodel/currencies/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModelTest.kt b/client/viewmodel/currencies/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModelTest.kt index f73c211002..924ddd4bf6 100644 --- a/client/viewmodel/currencies/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModelTest.kt +++ b/client/viewmodel/currencies/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/currencies/CurrenciesViewModelTest.kt @@ -14,8 +14,9 @@ import com.oztechan.ccc.client.storage.calculation.CalculationStorage import com.oztechan.ccc.common.core.model.Currency import io.mockative.Mock import io.mockative.classOf +import io.mockative.coVerify import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.Dispatchers @@ -37,27 +38,35 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds -@Suppress("TooManyFunctions", "OPT_IN_USAGE") internal class CurrenciesViewModelTest { private val viewModel: CurrenciesViewModel by lazy { - CurrenciesViewModel(appStorage, calculationStorage, currencyDataSource, adControlRepository, analyticsManager) + CurrenciesViewModel( + appStorage, + calculationStorage, + currencyDataSource, + adControlRepository, + analyticsManager + ) } @Mock private val appStorage = configure(mock(classOf())) { stubsUnitByDefault = true } @Mock - private val calculationStorage = configure(mock(classOf())) { stubsUnitByDefault = true } + private val calculationStorage = + configure(mock(classOf())) { stubsUnitByDefault = true } @Mock - private val currencyDataSource = configure(mock(classOf())) { stubsUnitByDefault = true } + private val currencyDataSource = + configure(mock(classOf())) { stubsUnitByDefault = true } @Mock private val adControlRepository = mock(classOf()) @Mock - private val analyticsManager = configure(mock(classOf())) { stubsUnitByDefault = true } + private val analyticsManager = + configure(mock(classOf())) { stubsUnitByDefault = true } private var currency1 = Currency("EUR", "Euro", "€", isActive = true) private val currency2 = Currency("USD", "Dollar", "$", isActive = true) @@ -68,32 +77,39 @@ internal class CurrenciesViewModelTest { private val currencyListFlow = flowOf(currencyList) private var dollar = Currency("USD", "American Dollar", "$", "1231") + private val shouldShowAds = Random.nextBoolean() @BeforeTest fun setup() { Logger.setLogWriters(CommonWriter()) + @Suppress("OPT_IN_USAGE") Dispatchers.setMain(UnconfinedTestDispatcher()) - given(currencyDataSource) - .invocation { getCurrenciesFlow() } - .thenReturn(currencyListFlow) + every { currencyDataSource.getCurrenciesFlow() } + .returns(currencyListFlow) - given(appStorage) - .invocation { firstRun } - .thenReturn(false) + every { appStorage.firstRun } + .returns(false) - given(calculationStorage) - .invocation { currentBase } - .thenReturn(currency1.code) + every { calculationStorage.currentBase } + .returns(currency1.code) + + every { adControlRepository.shouldShowBannerAd() } + .returns(shouldShowAds) } // Analytics @Test fun `if user properties set correct`() { viewModel // init - verify(analyticsManager) - .invocation { setUserProperty(UserProperty.CurrencyCount(currencyList.count().toString())) } + verify { + analyticsManager.setUserProperty( + UserProperty.CurrencyCount( + currencyList.count().toString() + ) + ) + } .wasInvoked() } @@ -102,14 +118,18 @@ internal class CurrenciesViewModelTest { fun `user properties should not set if there is no active currency`() { val nonActiveCurrencyList = listOf(Currency("EUR", "Euro", "€", isActive = false)) - given(currencyDataSource) - .invocation { getCurrenciesFlow() } - .thenReturn(flowOf(nonActiveCurrencyList)) + every { currencyDataSource.getCurrenciesFlow() } + .returns(flowOf(nonActiveCurrencyList)) viewModel // init - verify(analyticsManager) - .invocation { setUserProperty(UserProperty.CurrencyCount(nonActiveCurrencyList.count().toString())) } + verify { + analyticsManager.setUserProperty( + UserProperty.CurrencyCount( + nonActiveCurrencyList.count().toString() + ) + ) + } .wasNotInvoked() } @@ -120,112 +140,77 @@ internal class CurrenciesViewModelTest { assertNotNull(it) assertEquals(currencyList, it.currencyList) assertFalse { it.selectionVisibility } + assertFalse { it.isOnboardingVisible } // mocked false assertEquals(currencyList.toMutableList(), viewModel.data.unFilteredList) + assertEquals(shouldShowAds, it.isBannerAdVisible) } - } - @Test - fun `show FewCurrency effect if there is less than MINIMUM_ACTIVE_CURRENCY and not firstRun`() = runTest { - given(currencyDataSource) - .invocation { getCurrenciesFlow() } - .thenReturn( - flow { - delay(1.seconds.inWholeMilliseconds) - emit(listOf(currency1)) - } - ) + verify { adControlRepository.shouldShowBannerAd() } + .wasInvoked() - viewModel.effect.firstOrNull().let { - assertIs(it) - } + verify { appStorage.firstRun } + .wasInvoked() } @Test - fun `don't show FewCurrency effect if there is MINIMUM_ACTIVE_CURRENCY and not firstRun`() = runTest { - given(calculationStorage) - .invocation { currentBase } - .thenReturn("") // in order to get ChangeBase effect, have to have an effect to finish test - - given(currencyDataSource) - .invocation { getCurrenciesFlow() } - .thenReturn( - flow { - delay(1.seconds.inWholeMilliseconds) - emit(listOf(currency1, currency1, currency1)) - } - ) - - viewModel.effect.firstOrNull().let { - assertIs(it) + fun `show FewCurrency effect if there is less than MINIMUM_ACTIVE_CURRENCY and not firstRun`() = + runTest { + every { currencyDataSource.getCurrenciesFlow() } + .returns( + flow { + delay(1.seconds.inWholeMilliseconds) + emit(listOf(currency1)) + } + ) + + viewModel.effect.firstOrNull().let { + assertIs(it) + } } - } @Test - fun `don't show FewCurrency effect if there is less than MINIMUM_ACTIVE_CURRENCY it is firstRun`() = runTest { - given(appStorage) - .invocation { firstRun } - .thenReturn(true) - - given(calculationStorage) - .invocation { currentBase } - .thenReturn("") // in order to get ChangeBase effect, have to have an effect to finish test - - given(currencyDataSource) - .invocation { getCurrenciesFlow() } - .thenReturn( - flow { - delay(1.seconds.inWholeMilliseconds) - emit(listOf(currency1)) - } - ) - - viewModel.effect.firstOrNull().let { - assertIs(it) + fun `don't show FewCurrency effect if there is MINIMUM_ACTIVE_CURRENCY and not firstRun`() = + runTest { + every { calculationStorage.currentBase } + .returns("") // in order to get ChangeBase effect, have to have an effect to finish test + + every { currencyDataSource.getCurrenciesFlow() } + .returns( + flow { + delay(1.seconds.inWholeMilliseconds) + emit(listOf(currency1, currency1, currency1)) + } + ) + + viewModel.effect.firstOrNull().let { + assertIs(it) + } } - } - // public methods @Test - fun hideSelectionVisibility() = runTest { - viewModel.state.onSubscription { - viewModel.hideSelectionVisibility() - }.firstOrNull().let { - assertNotNull(it) - assertFalse { it.selectionVisibility } + fun `don't show FewCurrency effect if there is less than MINIMUM_ACTIVE_CURRENCY it is firstRun`() = + runTest { + every { appStorage.firstRun } + .returns(true) + + every { calculationStorage.currentBase } + .returns("") // in order to get ChangeBase effect, have to have an effect to finish test + + every { currencyDataSource.getCurrenciesFlow() } + .returns( + flow { + delay(1.seconds.inWholeMilliseconds) + emit(listOf(currency1)) + } + ) + + viewModel.effect.firstOrNull().let { + assertIs(it) + } } - } @Test - fun shouldShowBannerAd() { - val mockBoolean = Random.nextBoolean() - - given(adControlRepository) - .invocation { shouldShowBannerAd() } - .thenReturn(mockBoolean) - - assertEquals(mockBoolean, viewModel.shouldShowBannerAd()) - - verify(adControlRepository) - .invocation { shouldShowBannerAd() } - .wasInvoked() - } - - @Test - fun isFirstRun() { - val mockValue = Random.nextBoolean() - given(appStorage) - .invocation { firstRun } - .thenReturn(mockValue) - - assertEquals(mockValue, viewModel.isFirstRun()) - - verify(appStorage) - .invocation { firstRun } - .wasInvoked() - } - - @Test - fun queryGetUpdatedOnFilteringList() { + fun `query is updated when list is filtered`() { val query = "query" // runTest can be removed after kotlin move to new memory management runTest { @@ -235,76 +220,69 @@ internal class CurrenciesViewModelTest { } @Test - fun `verifyCurrentBase should set first active currency base when currentBase is empty`() = runTest { - val firstActiveBase = currency1.code // first active currency - - given(currencyDataSource) - .invocation { getCurrenciesFlow() } - .thenReturn( - flow { - delay(1.seconds.inWholeMilliseconds) - emit(currencyList) - } - ) + fun `verifyCurrentBase should set first active currency base when currentBase is empty`() = + runTest { + val firstActiveBase = currency1.code // first active currency - given(calculationStorage) - .invocation { currentBase } - .thenReturn("") + every { currencyDataSource.getCurrenciesFlow() } + .returns( + flow { + delay(1.seconds.inWholeMilliseconds) + emit(currencyList) + } + ) - viewModel.effect.firstOrNull().let { - assertIs(it) - assertEquals(firstActiveBase, it.newBase) - } + every { calculationStorage.currentBase } + .returns("") - verify(calculationStorage) - .invocation { currentBase = firstActiveBase } - .wasInvoked() - } + viewModel.effect.firstOrNull().let { + assertIs(it) + assertEquals(firstActiveBase, it.newBase) + } + + verify { calculationStorage.currentBase = firstActiveBase } + .wasInvoked() + } @Test - fun `verifyCurrentBase should set first active currency base when currentBase is unset`() = runTest { - currency1 = currency1.copy(isActive = false) // make first item in list not active - - given(currencyDataSource) - .invocation { getCurrenciesFlow() } - .thenReturn( - flow { - delay(1.seconds.inWholeMilliseconds) - emit(listOf(currency1, currency2, currency3)) - } - ) + fun `verifyCurrentBase should set first active currency base when currentBase is unset`() = + runTest { + currency1 = currency1.copy(isActive = false) // make first item in list not active - given(calculationStorage) - .invocation { currentBase } - .thenReturn(currency1.code) // not active one + every { currencyDataSource.getCurrenciesFlow() } + .returns( + flow { + delay(1.seconds.inWholeMilliseconds) + emit(listOf(currency1, currency2, currency3)) + } + ) - viewModel.effect.firstOrNull().let { - assertIs(it) - assertEquals(currency2.code, it.newBase) - } + every { calculationStorage.currentBase } + .returns(currency1.code) // not active one - verify(calculationStorage) - .invocation { currentBase = currency2.code } - .wasInvoked() - } + viewModel.effect.firstOrNull().let { + assertIs(it) + assertEquals(currency2.code, it.newBase) + } + + verify { calculationStorage.currentBase = currency2.code } + .wasInvoked() + } // Event @Test fun updateAllCurrenciesState() { - given(appStorage) - .invocation { firstRun } - .thenReturn(false) + every { appStorage.firstRun } + .returns(false) - given(calculationStorage) - .invocation { currentBase } - .thenReturn("EUR") + every { calculationStorage.currentBase } + .returns("EUR") val mockValue = Random.nextBoolean() viewModel.event.updateAllCurrenciesState(mockValue) runTest { - verify(currencyDataSource) - .coroutine { updateCurrencyStates(mockValue) } + coVerify { currencyDataSource.updateCurrencyStates(mockValue) } .wasInvoked() } } @@ -314,13 +292,9 @@ internal class CurrenciesViewModelTest { viewModel.event.onItemClick(currency1) runTest { - verify(currencyDataSource) - .coroutine { - updateCurrencyStateByCode( - currency1.code, - !currency1.isActive - ) - }.wasInvoked() + coVerify { + currencyDataSource.updateCurrencyStateByCode(currency1.code, !currency1.isActive) + }.wasInvoked() } } @@ -423,6 +397,9 @@ internal class CurrenciesViewModelTest { @Test fun onDoneClick() = runTest { + every { appStorage.firstRun } + .returns(true) + // where there is single currency val dollarActive = dollar.copy(isActive = true) @@ -433,6 +410,7 @@ internal class CurrenciesViewModelTest { }.firstOrNull().let { assertIs(it) assertTrue { viewModel.data.query.isEmpty() } + assertTrue { viewModel.state.value.isOnboardingVisible } } // where there are 2 active currencies @@ -444,8 +422,9 @@ internal class CurrenciesViewModelTest { assertIs(it) assertTrue { viewModel.data.query.isEmpty() } - verify(appStorage) - .invocation { firstRun = false } + assertFalse { viewModel.state.value.isOnboardingVisible } + + verify { appStorage.firstRun = false } .wasInvoked() } @@ -457,7 +436,8 @@ internal class CurrenciesViewModelTest { viewModel.onDoneClick() }.firstOrNull().let { assertIs(it) - assertEquals(true, viewModel.data.query.isEmpty()) + assertTrue { viewModel.data.query.isEmpty() } + assertFalse { viewModel.state.value.isOnboardingVisible } } } } diff --git a/client/viewmodel/main/client-viewmodel-main.gradle.kts b/client/viewmodel/main/client-viewmodel-main.gradle.kts index a480b5f299..1429cf1461 100644 --- a/client/viewmodel/main/client-viewmodel-main.gradle.kts +++ b/client/viewmodel/main/client-viewmodel-main.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -50,32 +52,11 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.lifecycleViewmodel) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/viewmodel/main/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainSEED.kt b/client/viewmodel/main/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainSEED.kt index d61154a666..fcf70031c3 100644 --- a/client/viewmodel/main/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainSEED.kt +++ b/client/viewmodel/main/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainSEED.kt @@ -3,12 +3,19 @@ package com.oztechan.ccc.client.viewmodel.main import com.oztechan.ccc.client.core.viewmodel.BaseData import com.oztechan.ccc.client.core.viewmodel.BaseEffect import com.oztechan.ccc.client.core.viewmodel.BaseEvent +import com.oztechan.ccc.client.core.viewmodel.BaseState import kotlinx.coroutines.Job // State +data class MainState( + var shouldOnboardUser: Boolean, + var appTheme: Int +) : BaseState() + +// Effect sealed class MainEffect : BaseEffect() { - object ShowInterstitialAd : MainEffect() - object RequestReview : MainEffect() + data object ShowInterstitialAd : MainEffect() + data object RequestReview : MainEffect() data class AppUpdateEffect(val isCancelable: Boolean, val marketLink: String) : MainEffect() } diff --git a/client/viewmodel/main/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModel.kt b/client/viewmodel/main/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModel.kt index 542156f5a4..3aa79de6a7 100644 --- a/client/viewmodel/main/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModel.kt +++ b/client/viewmodel/main/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModel.kt @@ -11,14 +11,16 @@ import com.oztechan.ccc.client.core.analytics.model.UserProperty import com.oztechan.ccc.client.core.shared.model.AppTheme import com.oztechan.ccc.client.core.shared.util.isNotPassed import com.oztechan.ccc.client.core.viewmodel.BaseSEEDViewModel -import com.oztechan.ccc.client.core.viewmodel.BaseState +import com.oztechan.ccc.client.core.viewmodel.util.update import com.oztechan.ccc.client.repository.adcontrol.AdControlRepository import com.oztechan.ccc.client.repository.appconfig.AppConfigRepository import com.oztechan.ccc.client.storage.app.AppStorage import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -29,9 +31,15 @@ class MainViewModel( private val adConfigService: AdConfigService, private val adControlRepository: AdControlRepository, analyticsManager: AnalyticsManager, -) : BaseSEEDViewModel(), MainEvent { +) : BaseSEEDViewModel(), MainEvent { // region SEED - override val state: StateFlow? = null + private val _state = MutableStateFlow( + MainState( + shouldOnboardUser = appStorage.firstRun, + appTheme = appStorage.appTheme + ) + ) + override val state: StateFlow = _state.asStateFlow() private val _effect = MutableSharedFlow() override val effect = _effect.asSharedFlow() @@ -43,7 +51,11 @@ class MainViewModel( init { with(analyticsManager) { - setUserProperty(UserProperty.IsPremium(appStorage.premiumEndDate.isNotPassed().toString())) + setUserProperty( + UserProperty.IsPremium( + appStorage.premiumEndDate.isNotPassed().toString() + ) + ) setUserProperty(UserProperty.SessionCount(appStorage.sessionCount.toString())) setUserProperty( UserProperty.AppTheme( @@ -82,7 +94,12 @@ class MainViewModel( private fun checkAppUpdate() { appConfigRepository.checkAppUpdate(data.isAppUpdateShown)?.let { isCancelable -> viewModelScope.launch { - _effect.emit(MainEffect.AppUpdateEffect(isCancelable, appConfigRepository.getMarketLink())) + _effect.emit( + MainEffect.AppUpdateEffect( + isCancelable, + appConfigRepository.getMarketLink() + ) + ) data.isAppUpdateShown = true } } @@ -97,10 +114,6 @@ class MainViewModel( } } - fun isFistRun() = appStorage.firstRun - - fun getAppTheme() = appStorage.appTheme - // region Event override fun onPause() { Logger.d { "MainViewModel onPause" } @@ -111,6 +124,13 @@ class MainViewModel( override fun onResume() { Logger.d { "MainViewModel onResume" } + _state.update { + copy( + shouldOnboardUser = appStorage.firstRun, + appTheme = appStorage.appTheme + ) + } + adjustSessionCount() setupInterstitialAdTimer() checkAppUpdate() diff --git a/client/viewmodel/main/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModelTest.kt b/client/viewmodel/main/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModelTest.kt index 348a937bce..c916d619a7 100644 --- a/client/viewmodel/main/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModelTest.kt +++ b/client/viewmodel/main/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/main/MainViewModelTest.kt @@ -22,7 +22,7 @@ import com.oztechan.ccc.client.storage.app.AppStorage import io.mockative.Mock import io.mockative.classOf import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.Dispatchers @@ -37,11 +37,10 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs -import kotlin.test.assertNull +import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds -@Suppress("TooManyFunctions", "OPT_IN_USAGE") internal class MainViewModelTest { private val viewModel: MainViewModel by lazy { @@ -71,36 +70,37 @@ internal class MainViewModelTest { private val adControlRepository = mock(classOf()) @Mock - private val analyticsManager = configure(mock(classOf())) { stubsUnitByDefault = true } + private val analyticsManager = + configure(mock(classOf())) { stubsUnitByDefault = true } private val appThemeValue = Random.nextInt() private val mockDevice = Device.IOS + private val isFirstRun: Boolean = Random.nextBoolean() @BeforeTest fun setup() { Logger.setLogWriters(CommonWriter()) + @Suppress("OPT_IN_USAGE") Dispatchers.setMain(UnconfinedTestDispatcher()) - given(appStorage) - .invocation { appTheme } - .thenReturn(appThemeValue) + every { appStorage.appTheme } + .returns(appThemeValue) - given(appStorage) - .invocation { premiumEndDate } - .then { nowAsLong() } + every { appStorage.premiumEndDate } + .returns(nowAsLong()) - given(appStorage) - .invocation { sessionCount } - .then { 1L } + every { appStorage.sessionCount } + .returns(1L) - given(appConfigRepository) - .invocation { getDeviceType() } - .then { mockDevice } + every { appConfigRepository.getDeviceType() } + .returns(mockDevice) - given(adControlRepository) - .invocation { shouldShowInterstitialAd() } - .thenReturn(false) + every { adControlRepository.shouldShowInterstitialAd() } + .returns(false) + + every { appStorage.firstRun } + .returns(isFirstRun) } // Analytics @@ -108,52 +108,44 @@ internal class MainViewModelTest { fun ifUserPropertiesSetCorrect() { viewModel // init - verify(analyticsManager) - .invocation { setUserProperty(UserProperty.IsPremium(appStorage.premiumEndDate.isNotPassed().toString())) } + verify { + analyticsManager.setUserProperty( + UserProperty.IsPremium( + appStorage.premiumEndDate.isNotPassed().toString() + ) + ) + } .wasInvoked() - verify(analyticsManager) - .invocation { setUserProperty(UserProperty.SessionCount(appStorage.sessionCount.toString())) } + verify { analyticsManager.setUserProperty(UserProperty.SessionCount(appStorage.sessionCount.toString())) } .wasInvoked() - verify(analyticsManager) - .invocation { - setUserProperty( - UserProperty.AppTheme(AppTheme.getAnalyticsThemeName(appStorage.appTheme, mockDevice)) + verify { + analyticsManager.setUserProperty( + UserProperty.AppTheme( + AppTheme.getAnalyticsThemeName( + appStorage.appTheme, + mockDevice + ) ) - } + ) + } .wasInvoked() - verify(analyticsManager) - .invocation { setUserProperty(UserProperty.DevicePlatform(mockDevice.name)) } + verify { analyticsManager.setUserProperty(UserProperty.DevicePlatform(mockDevice.name)) } .wasInvoked() } - // SEED - @Test - fun `check state is null`() { - assertNull(viewModel.state) - } - - // public methods + // init @Test - fun isFirstRun() { - val boolean: Boolean = Random.nextBoolean() - - given(appStorage) - .invocation { firstRun } - .thenReturn(boolean) - - assertEquals(boolean, viewModel.isFistRun()) + fun `init updates states correctly`() = runTest { + viewModel.state.firstOrNull().let { + assertNotNull(it) + assertEquals(isFirstRun, it.shouldOnboardUser) + assertEquals(appThemeValue, it.appTheme) + } - verify(appStorage) - .invocation { firstRun } + verify { appStorage.firstRun } .wasInvoked() - } - @Test - fun getAppTheme() { - assertEquals(appThemeValue, viewModel.getAppTheme()) - - verify(appStorage) - .invocation { appTheme } + verify { appStorage.appTheme } .wasInvoked() } @@ -169,47 +161,38 @@ internal class MainViewModelTest { fun `onResume adjustSessionCount`() = with(viewModel) { val mockSessionCount = Random.nextLong() - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(0, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(0, 0L)) - given(adConfigService) - .invocation { config } - .then { AdConfig(0, 0, 0L, 0L) } + every { adConfigService.config } + .returns(AdConfig(0, 0, 0L, 0L)) - given(appStorage) - .invocation { sessionCount } - .then { mockSessionCount } + every { appStorage.sessionCount } + .returns(mockSessionCount) - given(appConfigRepository) - .invocation { checkAppUpdate(false) } - .thenReturn(false) + every { appConfigRepository.checkAppUpdate(false) } + .returns(false) - given(appConfigRepository) - .invocation { checkAppUpdate(true) } - .thenReturn(false) + every { appConfigRepository.checkAppUpdate(true) } + .returns(false) - given(appConfigRepository) - .invocation { shouldShowAppReview() } - .then { true } + every { appConfigRepository.shouldShowAppReview() } + .returns(true) - given(appConfigRepository) - .invocation { getMarketLink() } - .then { "" } + every { appConfigRepository.getMarketLink() } + .returns("") assertTrue { data.isNewSession } event.onResume() - verify(appStorage) - .invocation { sessionCount = mockSessionCount + 1 } + verify { appStorage.sessionCount = mockSessionCount + 1 } .wasInvoked() assertFalse { data.isNewSession } event.onResume() - verify(appStorage) - .invocation { sessionCount = mockSessionCount + 1 } + verify { appStorage.sessionCount = mockSessionCount + 1 } .wasNotInvoked() assertFalse { data.isNewSession } @@ -219,33 +202,26 @@ internal class MainViewModelTest { fun `onResume setupInterstitialAdTimer`() = runTest { val mockSessionCount = Random.nextLong() - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(0, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(0, 0L)) - given(adConfigService) - .invocation { config } - .then { AdConfig(0, 0, 0L, 0L) } + every { adConfigService.config } + .returns(AdConfig(0, 0, 0L, 0L)) - given(appStorage) - .invocation { sessionCount } - .then { mockSessionCount } + every { appStorage.sessionCount } + .returns(mockSessionCount) - given(appConfigRepository) - .invocation { checkAppUpdate(false) } - .thenReturn(null) + every { appConfigRepository.checkAppUpdate(false) } + .returns(null) - given(adControlRepository) - .invocation { shouldShowInterstitialAd() } - .thenReturn(true) + every { adControlRepository.shouldShowInterstitialAd() } + .returns(true) - given(appConfigRepository) - .invocation { shouldShowAppReview() } - .then { true } + every { appConfigRepository.shouldShowAppReview() } + .returns(true) - given(appStorage) - .invocation { premiumEndDate } - .then { nowAsLong() - 1.seconds.inWholeMilliseconds } + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.seconds.inWholeMilliseconds) viewModel.effect.onSubscription { viewModel.onResume() @@ -260,166 +236,185 @@ internal class MainViewModelTest { true } - verify(reviewConfigService) - .invocation { config } + verify { reviewConfigService.config } .wasInvoked() - verify(adControlRepository) - .invocation { shouldShowInterstitialAd() } + verify { adControlRepository.shouldShowInterstitialAd() } .wasInvoked() - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } .wasInvoked() } @Test - fun `onResume checkAppUpdate nothing happens when check update returns null`() = with(viewModel) { - val mockSessionCount = Random.nextLong() + fun `onResume checkAppUpdate nothing happens when check update returns null`() = + with(viewModel) { + val mockSessionCount = Random.nextLong() - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(0, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(0, 0L)) - given(adConfigService) - .invocation { config } - .then { AdConfig(0, 0, 0L, 0L) } + every { adConfigService.config } + .returns(AdConfig(0, 0, 0L, 0L)) - given(appStorage) - .invocation { sessionCount } - .then { mockSessionCount } + every { appStorage.sessionCount } + .returns(mockSessionCount) - given(appConfigRepository) - .invocation { checkAppUpdate(false) } - .thenReturn(null) + every { appConfigRepository.checkAppUpdate(false) } + .returns(null) - given(appConfigRepository) - .invocation { shouldShowAppReview() } - .then { true } + every { appConfigRepository.shouldShowAppReview() } + .returns(true) - event.onResume() + event.onResume() - assertFalse { data.isAppUpdateShown } + assertFalse { data.isAppUpdateShown } - verify(appConfigRepository) - .invocation { checkAppUpdate(false) } - .wasInvoked() - } + verify { appConfigRepository.checkAppUpdate(false) } + .wasInvoked() + } @Test - fun `onResume checkAppUpdate app review should ask when check update returns not null`() = runTest { - val mockSessionCount = Random.nextLong() - val mockBoolean = Random.nextBoolean() + fun `onResume checkAppUpdate app review should ask when check update returns not null`() = + runTest { + val mockSessionCount = Random.nextLong() + val mockBoolean = Random.nextBoolean() - given(appStorage) - .invocation { sessionCount } - .then { mockSessionCount } + every { appStorage.sessionCount } + .returns(mockSessionCount) - given(adConfigService) - .invocation { config } - .then { AdConfig(0, 0, 0L, 0L) } + every { adConfigService.config } + .returns(AdConfig(0, 0, 0L, 0L)) - given(appConfigRepository) - .invocation { checkAppUpdate(false) } - .thenReturn(mockBoolean) + every { appConfigRepository.checkAppUpdate(false) } + .returns(mockBoolean) - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(0, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(0, 0L)) - given(appConfigRepository) - .invocation { shouldShowAppReview() } - .then { true } + every { appConfigRepository.shouldShowAppReview() } + .returns(true) - given(appConfigRepository) - .invocation { getMarketLink() } - .then { "" } + every { appConfigRepository.getMarketLink() } + .returns("") - viewModel.effect.onSubscription { - viewModel.onResume() - }.firstOrNull().let { - assertIs(it) - assertEquals(mockBoolean, it.isCancelable) - assertTrue { viewModel.data.isAppUpdateShown } - } + viewModel.effect.onSubscription { + viewModel.onResume() + }.firstOrNull().let { + assertIs(it) + assertEquals(mockBoolean, it.isCancelable) + assertTrue { viewModel.data.isAppUpdateShown } + } - verify(reviewConfigService) - .invocation { config } - .wasInvoked() + verify { reviewConfigService.config } + .wasInvoked() - verify(appConfigRepository) - .invocation { checkAppUpdate(false) } - .wasInvoked() - } + verify { appConfigRepository.checkAppUpdate(false) } + .wasInvoked() + } @Test - fun `onResume checkReview should request review when shouldShowAppReview returns true`() = runTest { - val mockSessionCount = Random.nextLong() + fun `onResume checkReview should request review when shouldShowAppReview returns true`() = + runTest { + val mockSessionCount = Random.nextLong() - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(0, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(0, 0L)) - given(adConfigService) - .invocation { config } - .then { AdConfig(0, 0, 0L, 0L) } + every { adConfigService.config } + .returns(AdConfig(0, 0, 0L, 0L)) - given(appStorage) - .invocation { sessionCount } - .then { mockSessionCount } + every { appStorage.sessionCount } + .returns(mockSessionCount) - given(appConfigRepository) - .invocation { checkAppUpdate(false) } - .thenReturn(null) + every { appConfigRepository.checkAppUpdate(false) } + .returns(null) - given(appConfigRepository) - .invocation { shouldShowAppReview() } - .then { true } + every { appConfigRepository.shouldShowAppReview() } + .returns(true) - viewModel.effect.onSubscription { - viewModel.onResume() - }.firstOrNull().let { - assertIs(it) - } + viewModel.effect.onSubscription { + viewModel.onResume() + }.firstOrNull().let { + assertIs(it) + } - verify(appConfigRepository) - .invocation { shouldShowAppReview() } - .wasInvoked() + verify { appConfigRepository.shouldShowAppReview() } + .wasInvoked() - verify(reviewConfigService) - .invocation { config } - .wasInvoked() - } + verify { reviewConfigService.config } + .wasInvoked() + } @Test fun `onResume checkReview should do nothing when shouldShowAppReview returns false`() = with(viewModel) { val mockSessionCount = Random.nextLong() - given(reviewConfigService) - .invocation { config } - .then { ReviewConfig(0, 0L) } + every { reviewConfigService.config } + .returns(ReviewConfig(0, 0L)) - given(adConfigService) - .invocation { config } - .then { AdConfig(0, 0, 0L, 0L) } + every { adConfigService.config } + .returns(AdConfig(0, 0, 0L, 0L)) - given(appStorage) - .invocation { sessionCount } - .then { mockSessionCount } + every { appStorage.sessionCount } + .returns(mockSessionCount) - given(appConfigRepository) - .invocation { checkAppUpdate(false) } - .thenReturn(null) + every { appConfigRepository.checkAppUpdate(false) } + .returns(null) - given(appConfigRepository) - .invocation { shouldShowAppReview() } - .then { false } + every { appConfigRepository.shouldShowAppReview() } + .returns(false) onResume() - verify(appConfigRepository) - .invocation { shouldShowAppReview() } + verify { appConfigRepository.shouldShowAppReview() } .wasInvoked() } + + @Test + fun `onResume updates the latest states`() = runTest { + every { appConfigRepository.checkAppUpdate(false) } + .returns(false) + + every { appConfigRepository.shouldShowAppReview() } + .returns(true) + + every { adConfigService.config } + .returns(AdConfig(0, 0, 0L, 0L)) + + every { appConfigRepository.getMarketLink() } + .returns("") + + every { reviewConfigService.config } + .returns(ReviewConfig(0, 0L)) + + // init the viewModel + viewModel + + // different states of what has been emitted + val newAppThemeValue = appThemeValue + 10 + val newIsFirstRun = isFirstRun.not() + + every { appStorage.appTheme } + .returns(newAppThemeValue) + + every { appStorage.firstRun } + .returns(newIsFirstRun) + + viewModel.state + .onSubscription { + viewModel.event.onResume() + }.firstOrNull().let { + assertNotNull(it) + assertEquals(newIsFirstRun, it.shouldOnboardUser) + assertEquals(newAppThemeValue, it.appTheme) + } + + verify { appStorage.firstRun } + .wasInvoked() + + verify { appStorage.appTheme } + .wasInvoked() + } } diff --git a/client/viewmodel/premium/client-viewmodel-premium.gradle.kts b/client/viewmodel/premium/client-viewmodel-premium.gradle.kts index d91153d411..5c83dac60c 100644 --- a/client/viewmodel/premium/client-viewmodel-premium.gradle.kts +++ b/client/viewmodel/premium/client-viewmodel-premium.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -47,32 +49,11 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.lifecycleViewmodel) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/viewmodel/premium/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumSEED.kt b/client/viewmodel/premium/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumSEED.kt index 6b0a9cc893..fe4db35397 100644 --- a/client/viewmodel/premium/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumSEED.kt +++ b/client/viewmodel/premium/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumSEED.kt @@ -1,19 +1,31 @@ package com.oztechan.ccc.client.viewmodel.premium +import com.oztechan.ccc.client.core.shared.util.nowAsLong import com.oztechan.ccc.client.core.viewmodel.BaseEffect import com.oztechan.ccc.client.core.viewmodel.BaseEvent import com.oztechan.ccc.client.core.viewmodel.BaseState +import com.oztechan.ccc.client.viewmodel.premium.model.OldPurchase +import com.oztechan.ccc.client.viewmodel.premium.model.PremiumData import com.oztechan.ccc.client.viewmodel.premium.model.PremiumType // State data class PremiumState( val premiumTypes: List = listOf(PremiumType.VIDEO), - val loading: Boolean = false + val loading: Boolean = true ) : BaseState() // Event interface PremiumEvent : BaseEvent { + fun onPremiumActivated( + adType: PremiumType?, + startDate: Long = nowAsLong(), + isRestorePurchase: Boolean = false + ) + + fun onRestorePurchase(oldPurchaseList: List) + fun onAddPurchaseMethods(premiumDataList: List) fun onPremiumItemClick(type: PremiumType) + fun onPremiumActivationFailed() } // Effect diff --git a/client/viewmodel/premium/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumViewModel.kt b/client/viewmodel/premium/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumViewModel.kt index ad64980ab8..23b033f183 100644 --- a/client/viewmodel/premium/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumViewModel.kt +++ b/client/viewmodel/premium/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumViewModel.kt @@ -7,7 +7,6 @@ package com.oztechan.ccc.client.viewmodel.premium import co.touchlab.kermit.Logger import com.github.submob.scopemob.whether import com.oztechan.ccc.client.core.shared.util.isNotPassed -import com.oztechan.ccc.client.core.shared.util.nowAsLong import com.oztechan.ccc.client.core.viewmodel.BaseData import com.oztechan.ccc.client.core.viewmodel.BaseSEEDViewModel import com.oztechan.ccc.client.core.viewmodel.util.launchIgnored @@ -21,7 +20,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch class PremiumViewModel( private val appStorage: AppStorage @@ -38,38 +36,41 @@ class PremiumViewModel( override val data: BaseData? = null // endregion - fun updatePremiumEndDate( + // region Event + override fun onPremiumActivated( adType: PremiumType?, - startDate: Long = nowAsLong(), - isRestorePurchase: Boolean = false - ) = adType?.let { - viewModelScope.launch { + startDate: Long, + isRestorePurchase: Boolean + ) = viewModelScope.launchIgnored { + Logger.d { "PremiumViewModel onPremiumActivated ${adType?.data?.duration.orEmpty()}" } + adType?.let { appStorage.premiumEndDate = it.calculatePremiumEnd(startDate) _effect.emit(PremiumEffect.PremiumActivated(it, isRestorePurchase)) } } - fun restorePurchase(oldPurchaseList: List) = oldPurchaseList - .maxByOrNull { - it.type.calculatePremiumEnd(it.date) - }?.whether( - { type.calculatePremiumEnd(date).isNotPassed() }, - { date > appStorage.premiumEndDate }, - { PremiumType.getPurchaseIds().any { id -> id == type.data.id } } - )?.apply { - updatePremiumEndDate( - adType = PremiumType.getById(type.data.id), - startDate = this.date, - isRestorePurchase = true - ) - } - - fun showLoadingView(shouldShow: Boolean) = _state.update { - copy(loading = shouldShow) + override fun onRestorePurchase(oldPurchaseList: List) { + Logger.d { "PremiumViewModel onRestorePurchase" } + oldPurchaseList + .maxByOrNull { + it.type.calculatePremiumEnd(it.date) + }?.whether( + { type.calculatePremiumEnd(date).isNotPassed() }, + { date > appStorage.premiumEndDate }, + { PremiumType.getPurchaseIds().any { id -> id == type.data.id } } + )?.run { + onPremiumActivated( + adType = PremiumType.getById(type.data.id), + startDate = this.date, + isRestorePurchase = true + ) + _state.update { copy(loading = false) } + } } - fun addPurchaseMethods(premiumDataList: List) = premiumDataList - .forEach { premiumData -> + override fun onAddPurchaseMethods(premiumDataList: List) { + Logger.d { "PremiumViewModel onAddPurchaseMethods" } + premiumDataList.forEach { premiumData -> val tempList = state.value.premiumTypes.toMutableList() PremiumType.getById(premiumData.id) ?.apply { @@ -79,11 +80,23 @@ class PremiumViewModel( tempList.add(it) } tempList.sortBy { it.ordinal } - _state.update { copy(premiumTypes = tempList, loading = false) } + _state.update { copy(premiumTypes = tempList) } + }.also { + _state.update { copy(loading = false) } // in case list is empty, loading will be false } + } override fun onPremiumItemClick(type: PremiumType) = viewModelScope.launchIgnored { Logger.d { "PremiumViewModel onPremiumItemClick ${type.data.duration}" } + _state.update { + copy(loading = type != PremiumType.VIDEO) + } _effect.emit(PremiumEffect.LaunchActivatePremiumFlow(type)) } + + override fun onPremiumActivationFailed() { + Logger.d { "PremiumViewModel onPremiumActivationFailed" } + _state.update { copy(loading = false) } + } + // endregion } diff --git a/client/viewmodel/premium/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumViewModelTest.kt b/client/viewmodel/premium/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumViewModelTest.kt index a6b3e8971b..dccfb57903 100644 --- a/client/viewmodel/premium/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumViewModelTest.kt +++ b/client/viewmodel/premium/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/premium/PremiumViewModelTest.kt @@ -15,7 +15,7 @@ import com.oztechan.ccc.client.viewmodel.premium.util.calculatePremiumEnd import io.mockative.Mock import io.mockative.classOf import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.Dispatchers @@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.onSubscription import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain -import kotlin.random.Random import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -36,7 +35,6 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.seconds -@Suppress("OPT_IN_USAGE") internal class PremiumViewModelTest { private val viewModel: PremiumViewModel by lazy { @@ -50,6 +48,7 @@ internal class PremiumViewModelTest { fun setup() { Logger.setLogWriters(CommonWriter()) + @Suppress("OPT_IN_USAGE") Dispatchers.setMain(UnconfinedTestDispatcher()) } @@ -59,40 +58,37 @@ internal class PremiumViewModelTest { assertNull(viewModel.data) } - // public methods + // Event @Test - fun updatePremiumEndDate() = runTest { - viewModel.updatePremiumEndDate(null) - verify(appStorage) - .invocation { premiumEndDate } + fun onPremiumActivated() = runTest { + viewModel.event.onPremiumActivated(null) + verify { appStorage.premiumEndDate } .wasNotInvoked() PremiumType.values().forEach { premiumType -> val now = nowAsLong() viewModel.effect.onSubscription { - viewModel.updatePremiumEndDate(premiumType, now) + viewModel.event.onPremiumActivated(premiumType, now) }.firstOrNull().let { assertIs(it) assertEquals(premiumType, it.premiumType) assertFalse { it.isRestorePurchase } - verify(appStorage) - .invocation { premiumEndDate = premiumType.calculatePremiumEnd(now) } + verify { appStorage.premiumEndDate = premiumType.calculatePremiumEnd(now) } .wasInvoked() } } } @Test - fun restorePurchase() = runTest { - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(0) + fun onRestorePurchase() = runTest { + every { appStorage.premiumEndDate } + .returns(0) val now = nowAsLong() viewModel.effect.onSubscription { - viewModel.restorePurchase( + viewModel.event.onRestorePurchase( listOf( OldPurchase(now, PremiumType.MONTH), OldPurchase(now, PremiumType.YEAR) @@ -101,72 +97,64 @@ internal class PremiumViewModelTest { }.firstOrNull().let { assertIs(it) assertTrue { it.isRestorePurchase } + assertFalse { viewModel.state.value.loading } - verify(appStorage) - .invocation { premiumEndDate = it.premiumType.calculatePremiumEnd(now) } + verify { appStorage.premiumEndDate = it.premiumType.calculatePremiumEnd(now) } .wasInvoked() } - } - @Test - fun `restorePurchase should fail if all the old purchases out dated`() { - val oldPurchase = OldPurchase(nowAsLong(), PremiumType.MONTH) + // onRestorePurchase shouldn't do anything if all the old purchases out of dated + var oldPurchase = OldPurchase(nowAsLong(), PremiumType.MONTH) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.seconds.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.seconds.inWholeMilliseconds) - viewModel.restorePurchase(listOf(oldPurchase)) + viewModel.event.onRestorePurchase(listOf(oldPurchase)) - verify(appStorage) - .invocation { premiumEndDate = oldPurchase.type.calculatePremiumEnd(oldPurchase.date) } + verify { + appStorage.premiumEndDate = oldPurchase.type.calculatePremiumEnd(oldPurchase.date) + } .wasNotInvoked() - } - @Test - fun `restorePurchase should fail if all the old purchases expired`() { - val oldPurchase = - OldPurchase(nowAsLong() - (32.days.inWholeMilliseconds), PremiumType.MONTH) + // onRestorePurchase shouldn't do anything if the old purchase is already expired + oldPurchase = OldPurchase(nowAsLong() - (32.days.inWholeMilliseconds), PremiumType.MONTH) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(0) + every { appStorage.premiumEndDate } + .returns(0) - viewModel.restorePurchase(listOf(oldPurchase)) + viewModel.event.onRestorePurchase(listOf(oldPurchase)) - verify(appStorage) - .invocation { premiumEndDate = oldPurchase.type.calculatePremiumEnd(oldPurchase.date) } + verify { + appStorage.premiumEndDate = oldPurchase.type.calculatePremiumEnd(oldPurchase.date) + } .wasNotInvoked() } @Test - fun showLoadingView() { - val mockValue = Random.nextBoolean() - - viewModel.showLoadingView(mockValue) - - assertEquals(mockValue, viewModel.state.value.loading) - } + fun onAddPurchaseMethods() = runTest { + // in case called an empty list loading should be false + viewModel.state.onSubscription { + viewModel.event.onAddPurchaseMethods(emptyList()) + }.firstOrNull().let { + assertNotNull(it) + assertFalse { it.loading } + } - @Test - fun addPurchaseMethods() = runTest { PremiumType.values() .map { it.data } .forEach { premiumData -> viewModel.state.onSubscription { - viewModel.addPurchaseMethods(listOf(premiumData)) + viewModel.event.onAddPurchaseMethods(listOf(premiumData)) }.firstOrNull().let { assertNotNull(it) assertTrue { it.premiumTypes.contains(PremiumType.getById(premiumData.id)) } assertFalse { it.loading } } } - } - @Test - fun `addPurchaseMethods for unknown id will not add the item`() = runTest { + // in case called an unknown id item should not be added viewModel.state.onSubscription { - viewModel.addPurchaseMethods(listOf(PremiumData("", "", "unknown"))) + viewModel.event.onAddPurchaseMethods(listOf(PremiumData("", "", "unknown"))) }.firstOrNull().let { assertNotNull(it) println(it.premiumTypes.toString()) @@ -176,7 +164,6 @@ internal class PremiumViewModelTest { } } - // Event @Test fun onPremiumItemClick() = runTest { viewModel.effect.onSubscription { @@ -184,6 +171,7 @@ internal class PremiumViewModelTest { }.firstOrNull().let { assertIs(it) assertEquals(PremiumType.VIDEO, it.premiumType) + assertFalse { viewModel.state.value.loading } } viewModel.effect.onSubscription { @@ -191,6 +179,7 @@ internal class PremiumViewModelTest { }.firstOrNull().let { assertIs(it) assertEquals(PremiumType.MONTH, it.premiumType) + assertTrue { viewModel.state.value.loading } } viewModel.effect.onSubscription { @@ -198,6 +187,7 @@ internal class PremiumViewModelTest { }.firstOrNull().let { assertIs(it) assertEquals(PremiumType.QUARTER, it.premiumType) + assertTrue { viewModel.state.value.loading } } viewModel.effect.onSubscription { @@ -205,6 +195,7 @@ internal class PremiumViewModelTest { }.firstOrNull().let { assertIs(it) assertEquals(PremiumType.HALF_YEAR, it.premiumType) + assertTrue { viewModel.state.value.loading } } viewModel.effect.onSubscription { @@ -212,6 +203,17 @@ internal class PremiumViewModelTest { }.firstOrNull().let { assertIs(it) assertEquals(PremiumType.YEAR, it.premiumType) + assertTrue { viewModel.state.value.loading } + } + } + + @Test + fun onPremiumActivationFailed() = runTest { + viewModel.state.onSubscription { + viewModel.onPremiumActivationFailed() + }.firstOrNull().let { + assertNotNull(it) + assertFalse { it.loading } } } } diff --git a/client/viewmodel/selectcurrency/client-viewmodel-selectcurrency.gradle.kts b/client/viewmodel/selectcurrency/client-viewmodel-selectcurrency.gradle.kts index e0b7552f84..539b578ee8 100644 --- a/client/viewmodel/selectcurrency/client-viewmodel-selectcurrency.gradle.kts +++ b/client/viewmodel/selectcurrency/client-viewmodel-selectcurrency.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -46,32 +48,11 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.lifecycleViewmodel) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/viewmodel/selectcurrency/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/selectcurrency/SelectCurrencySEED.kt b/client/viewmodel/selectcurrency/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/selectcurrency/SelectCurrencySEED.kt index 778cb72fc4..2802d03983 100644 --- a/client/viewmodel/selectcurrency/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/selectcurrency/SelectCurrencySEED.kt +++ b/client/viewmodel/selectcurrency/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/selectcurrency/SelectCurrencySEED.kt @@ -21,5 +21,5 @@ interface SelectCurrencyEvent : BaseEvent { // Effect sealed class SelectCurrencyEffect : BaseEffect() { data class CurrencyChange(val newBase: String) : SelectCurrencyEffect() - object OpenCurrencies : SelectCurrencyEffect() + data object OpenCurrencies : SelectCurrencyEffect() } diff --git a/client/viewmodel/selectcurrency/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/selectcurrency/SelectCurrencyViewModelTest.kt b/client/viewmodel/selectcurrency/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/selectcurrency/SelectCurrencyViewModelTest.kt index c6a862cb12..ea5830d8dd 100644 --- a/client/viewmodel/selectcurrency/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/selectcurrency/SelectCurrencyViewModelTest.kt +++ b/client/viewmodel/selectcurrency/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/selectcurrency/SelectCurrencyViewModelTest.kt @@ -8,7 +8,7 @@ import co.touchlab.kermit.Logger import com.oztechan.ccc.client.datasource.currency.CurrencyDataSource import io.mockative.Mock import io.mockative.classOf -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.Dispatchers @@ -28,7 +28,6 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import com.oztechan.ccc.common.core.model.Currency as CurrencyCommon -@Suppress("OPT_IN_USAGE") internal class SelectCurrencyViewModelTest { private val subject: SelectCurrencyViewModel by lazy { @@ -48,11 +47,11 @@ internal class SelectCurrencyViewModelTest { fun setup() { Logger.setLogWriters(CommonWriter()) + @Suppress("OPT_IN_USAGE") Dispatchers.setMain(UnconfinedTestDispatcher()) - given(currencyDataSource) - .invocation { getActiveCurrenciesFlow() } - .thenReturn(flowOf(currencyListEnough)) + every { currencyDataSource.getActiveCurrenciesFlow() } + .returns(flowOf(currencyListEnough)) } // SEED @@ -64,9 +63,8 @@ internal class SelectCurrencyViewModelTest { // init @Test fun `init updates the states with no enough currency`() = runTest { - given(currencyDataSource) - .invocation { getActiveCurrenciesFlow() } - .thenReturn(flowOf(currencyListNotEnough)) + every { currencyDataSource.getActiveCurrenciesFlow() } + .returns(flowOf(currencyListNotEnough)) subject.state.firstOrNull().let { assertNotNull(it) @@ -75,8 +73,7 @@ internal class SelectCurrencyViewModelTest { assertEquals(currencyListNotEnough, it.currencyList) } - verify(currencyDataSource) - .invocation { getActiveCurrenciesFlow() } + verify { currencyDataSource.getActiveCurrenciesFlow() } .wasInvoked() } @@ -91,8 +88,7 @@ internal class SelectCurrencyViewModelTest { } } - verify(currencyDataSource) - .invocation { getActiveCurrenciesFlow() } + verify { currencyDataSource.getActiveCurrenciesFlow() } .wasInvoked() } diff --git a/client/viewmodel/settings/client-viewmodel-settings.gradle.kts b/client/viewmodel/settings/client-viewmodel-settings.gradle.kts index 41852bbbbb..e6929e9d93 100644 --- a/client/viewmodel/settings/client-viewmodel-settings.gradle.kts +++ b/client/viewmodel/settings/client-viewmodel-settings.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -63,32 +65,11 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.lifecycleViewmodel) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsSEED.kt b/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsSEED.kt index f9d98ba946..1259148fc7 100644 --- a/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsSEED.kt +++ b/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsSEED.kt @@ -9,6 +9,7 @@ import com.oztechan.ccc.client.viewmodel.settings.model.PremiumStatus // State data class SettingsState( + val isBannerAdVisible: Boolean, val activeCurrencyCount: Int = 0, val activeWatcherCount: Int = 0, val appThemeType: AppTheme = AppTheme.SYSTEM_DEFAULT, @@ -32,22 +33,23 @@ interface SettingsEvent : BaseEvent { fun onThemeClick() fun onPrecisionClick() fun onPrecisionSelect(index: Int) + fun onThemeChange(theme: AppTheme) } // Effect sealed class SettingsEffect : BaseEffect() { - object Back : SettingsEffect() - object OpenCurrencies : SettingsEffect() - object OpenWatchers : SettingsEffect() - object FeedBack : SettingsEffect() - object OnGitHub : SettingsEffect() - object Premium : SettingsEffect() - object ThemeDialog : SettingsEffect() - object Synchronising : SettingsEffect() - object Synchronised : SettingsEffect() - object OnlyOneTimeSync : SettingsEffect() - object AlreadyPremium : SettingsEffect() - object SelectPrecision : SettingsEffect() + data object Back : SettingsEffect() + data object OpenCurrencies : SettingsEffect() + data object OpenWatchers : SettingsEffect() + data object FeedBack : SettingsEffect() + data object OnGitHub : SettingsEffect() + data object Premium : SettingsEffect() + data object ThemeDialog : SettingsEffect() + data object Synchronising : SettingsEffect() + data object Synchronised : SettingsEffect() + data object OnlyOneTimeSync : SettingsEffect() + data object AlreadyPremium : SettingsEffect() + data object SelectPrecision : SettingsEffect() data class Share(val marketLink: String) : SettingsEffect() data class SupportUs(val marketLink: String) : SettingsEffect() data class ChangeTheme(val themeValue: Int) : SettingsEffect() diff --git a/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModel.kt b/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModel.kt index 7dbe184abe..0095ad5d6e 100644 --- a/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModel.kt +++ b/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModel.kt @@ -39,12 +39,13 @@ class SettingsViewModel( private val currencyDataSource: CurrencyDataSource, private val conversionDataSource: ConversionDataSource, watcherDataSource: WatcherDataSource, - private val adControlRepository: AdControlRepository, + adControlRepository: AdControlRepository, private val appConfigRepository: AppConfigRepository, private val analyticsManager: AnalyticsManager ) : BaseSEEDViewModel(), SettingsEvent { // region SEED - private val _state = MutableStateFlow(SettingsState()) + private val _state = + MutableStateFlow(SettingsState(isBannerAdVisible = adControlRepository.shouldShowBannerAd())) override val state = _state.asStateFlow() private val _effect = MutableSharedFlow() @@ -88,7 +89,7 @@ class SettingsViewModel( currencyDataSource.getActiveCurrencies() .forEach { (name) -> runCatching { backendApiService.getConversion(name) } - .onFailure { error -> Logger.e(error) { error.message.toString() } } + .onFailure { error -> Logger.w(error) { error.message.toString() } } .onSuccess { conversionDataSource.insertConversion(it) } delay(SYNC_DELAY) @@ -99,16 +100,6 @@ class SettingsViewModel( data.synced = true } - fun updateTheme(theme: AppTheme) = viewModelScope.launchIgnored { - _state.update { copy(appThemeType = theme) } - appStorage.appTheme = theme.themeValue - _effect.emit(SettingsEffect.ChangeTheme(theme.themeValue)) - } - - fun shouldShowBannerAd() = adControlRepository.shouldShowBannerAd() - - fun getAppTheme() = appStorage.appTheme - // region Event override fun onBackClick() = viewModelScope.launchIgnored { Logger.d { "SettingsViewModel onBackClick" } @@ -181,5 +172,12 @@ class SettingsViewModel( calculationStorage.precision = index.indexToNumber() _state.update { copy(precision = index.indexToNumber()) } } + + override fun onThemeChange(theme: AppTheme) = viewModelScope.launchIgnored { + Logger.d { "SettingsViewModel onThemeChange $theme" } + _state.update { copy(appThemeType = theme) } + appStorage.appTheme = theme.themeValue + _effect.emit(SettingsEffect.ChangeTheme(theme.themeValue)) + } // endregion } diff --git a/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/model/PremiumStatus.kt b/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/model/PremiumStatus.kt index 48273cd1c4..7a031d3be6 100644 --- a/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/model/PremiumStatus.kt +++ b/client/viewmodel/settings/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/settings/model/PremiumStatus.kt @@ -1,7 +1,7 @@ package com.oztechan.ccc.client.viewmodel.settings.model sealed class PremiumStatus { - object NeverActivated : PremiumStatus() + data object NeverActivated : PremiumStatus() data class Expired(val at: String) : PremiumStatus() data class Active(val until: String) : PremiumStatus() } diff --git a/client/viewmodel/settings/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModelTest.kt b/client/viewmodel/settings/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModelTest.kt index 48aaebff2c..9025da99be 100644 --- a/client/viewmodel/settings/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModelTest.kt +++ b/client/viewmodel/settings/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/settings/SettingsViewModelTest.kt @@ -25,9 +25,10 @@ import com.oztechan.ccc.common.core.model.Watcher import com.oztechan.ccc.common.datasource.conversion.ConversionDataSource import io.mockative.Mock import io.mockative.classOf +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.configure -import io.mockative.eq -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.Dispatchers @@ -46,7 +47,6 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.days -@Suppress("TooManyFunctions", "OPT_IN_USAGE") internal class SettingsViewModelTest { private val viewModel: SettingsViewModel by lazy { @@ -106,40 +106,38 @@ internal class SettingsViewModelTest { private val mockedPrecision = 3 private val version = "version" + private val shouldShowAds = Random.nextBoolean() @BeforeTest fun setup() { Logger.setLogWriters(CommonWriter()) + @Suppress("OPT_IN_USAGE") Dispatchers.setMain(UnconfinedTestDispatcher()) - given(appStorage) - .invocation { appTheme } - .thenReturn(-1) + every { appStorage.appTheme } + .returns(-1) - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(0) + every { appStorage.premiumEndDate } + .returns(0) - given(calculationStorage) - .invocation { precision } - .thenReturn(mockedPrecision) + every { calculationStorage.precision } + .returns(mockedPrecision) - given(currencyDataSource) - .invocation { getActiveCurrenciesFlow() } - .thenReturn(flowOf(currencyList)) + every { currencyDataSource.getActiveCurrenciesFlow() } + .returns(flowOf(currencyList)) - given(watcherDataSource) - .invocation { getWatchersFlow() } - .then { flowOf(watcherLists) } + every { watcherDataSource.getWatchersFlow() } + .returns(flowOf(watcherLists)) - given(appConfigRepository) - .invocation { getDeviceType() } - .then { Device.IOS } + every { adControlRepository.shouldShowBannerAd() } + .returns(shouldShowAds) - given(appConfigRepository) - .invocation { getVersion() } - .then { version } + every { appConfigRepository.getDeviceType() } + .returns(Device.IOS) + + every { appConfigRepository.getVersion() } + .returns(version) } // init @@ -152,14 +150,17 @@ internal class SettingsViewModelTest { assertEquals(watcherLists.size, it.activeWatcherCount) assertEquals(mockedPrecision, it.precision) assertEquals(version, it.version) + assertEquals(shouldShowAds, it.isBannerAdVisible) } + + verify { adControlRepository.shouldShowBannerAd() } + .wasInvoked() } @Test fun `when premiumEndDate is never set PremiumStatus is NeverActivated`() = runTest { - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(0) + every { appStorage.premiumEndDate } + .returns(0) viewModel.state.firstOrNull().let { assertNotNull(it) @@ -169,9 +170,8 @@ internal class SettingsViewModelTest { @Test fun `when premiumEndDate is passed PremiumStatus is Expired`() = runTest { - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() - 1.days.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() - 1.days.inWholeMilliseconds) viewModel.state.firstOrNull().let { assertNotNull(it) @@ -181,9 +181,8 @@ internal class SettingsViewModelTest { @Test fun `when premiumEndDate is not passed PremiumStatus is Active`() = runTest { - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.days.inWholeMilliseconds) + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.days.inWholeMilliseconds) viewModel.state.firstOrNull().let { assertNotNull(it) @@ -199,13 +198,11 @@ internal class SettingsViewModelTest { val conversion = Conversion(base) val currency = Currency(base, "", "") - given(currencyDataSource) - .coroutine { currencyDataSource.getActiveCurrencies() } - .thenReturn(listOf(currency)) + coEvery { currencyDataSource.getActiveCurrencies() } + .returns(listOf(currency)) - given(backendApiService) - .coroutine { getConversion(base) } - .thenReturn(conversion) + coEvery { backendApiService.getConversion(base) } + .returns(conversion) viewModel.effect.onSubscription { viewModel.event.onSyncClick() @@ -213,12 +210,10 @@ internal class SettingsViewModelTest { assertIs(it) } - verify(conversionDataSource) - .coroutine { conversionDataSource.insertConversion(conversion) } + coVerify { conversionDataSource.insertConversion(conversion) } .wasInvoked() - verify(backendApiService) - .coroutine { backendApiService.getConversion(base) } + coVerify { backendApiService.getConversion(base) } .wasInvoked() } @@ -226,13 +221,11 @@ internal class SettingsViewModelTest { fun `failed synchroniseConversions should pass Synchronised effect`() = runTest { viewModel.data.synced = false - given(currencyDataSource) - .coroutine { currencyDataSource.getActiveCurrencies() } - .thenReturn(currencyList) + coEvery { currencyDataSource.getActiveCurrencies() } + .returns(currencyList) - given(backendApiService) - .coroutine { getConversion("") } - .thenThrow(Exception("test")) + coEvery { backendApiService.getConversion("") } + .throws(Exception("test")) viewModel.effect.onSubscription { viewModel.event.onSyncClick() @@ -240,63 +233,10 @@ internal class SettingsViewModelTest { assertIs(it) } - verify(conversionDataSource) - .coroutine { conversionDataSource.insertConversion(Conversion()) } + coVerify { conversionDataSource.insertConversion(Conversion()) } .wasNotInvoked() } - // public methods - @Test - fun updateTheme() = runTest { - val mockTheme = AppTheme.DARK - - viewModel.effect.onSubscription { - viewModel.updateTheme(mockTheme) - }.firstOrNull().let { - assertEquals(mockTheme, viewModel.state.value.appThemeType) - assertIs(it) - assertEquals(mockTheme.themeValue, it.themeValue) - } - - verify(appStorage) - .invocation { appTheme = mockTheme.themeValue } - .wasInvoked() - - given(appStorage) - .invocation { premiumEndDate } - .thenReturn(nowAsLong() + 1.days.inWholeMilliseconds) - - viewModel.effect.onSubscription { - viewModel.event.onPremiumClick() - }.firstOrNull().let { - assertIs(it) - } - - verify(appStorage) - .invocation { premiumEndDate } - .wasInvoked() - } - - @Test - fun shouldShowBannerAd() { - val mockBoolean = Random.nextBoolean() - - given(adControlRepository) - .invocation { shouldShowBannerAd() } - .thenReturn(mockBoolean) - - assertEquals(mockBoolean, viewModel.shouldShowBannerAd()) - - verify(adControlRepository) - .invocation { shouldShowBannerAd() } - .wasInvoked() - } - - @Test - fun getAppTheme() { - assertEquals(-1, viewModel.getAppTheme()) // already mocked - } - // Event @Test fun onBackClick() = runTest { @@ -338,9 +278,8 @@ internal class SettingsViewModelTest { fun onShareClick() = runTest { val link = "link" - given(appConfigRepository) - .invocation { getMarketLink() } - .then { link } + every { appConfigRepository.getMarketLink() } + .returns(link) viewModel.effect.onSubscription { viewModel.event.onShareClick() @@ -354,9 +293,8 @@ internal class SettingsViewModelTest { fun onSupportUsClick() = runTest { val link = "link" - given(appConfigRepository) - .invocation { getMarketLink() } - .then { link } + every { appConfigRepository.getMarketLink() } + .returns(link) viewModel.effect.onSubscription { viewModel.event.onSupportUsClick() @@ -383,8 +321,19 @@ internal class SettingsViewModelTest { assertIs(it) } - verify(appStorage) - .invocation { premiumEndDate } + verify { appStorage.premiumEndDate } + .wasInvoked() + + every { appStorage.premiumEndDate } + .returns(nowAsLong() + 1.days.inWholeMilliseconds) + + viewModel.effect.onSubscription { + viewModel.event.onPremiumClick() + }.firstOrNull().let { + assertIs(it) + } + + verify { appStorage.premiumEndDate } .wasInvoked() } @@ -399,9 +348,8 @@ internal class SettingsViewModelTest { @Test fun onSyncClick() = runTest { - given(currencyDataSource) - .coroutine { currencyDataSource.getActiveCurrencies() } - .thenReturn(listOf()) + coEvery { currencyDataSource.getActiveCurrencies() } + .returns(listOf()) viewModel.effect.onSubscription { viewModel.event.onSyncClick() @@ -416,8 +364,7 @@ internal class SettingsViewModelTest { assertIs(it) } - verify(analyticsManager) - .invocation { trackEvent(Event.OfflineSync) } + verify { analyticsManager.trackEvent(Event.OfflineSync) } .wasInvoked() } @@ -441,10 +388,25 @@ internal class SettingsViewModelTest { assertEquals(value.indexToNumber(), it.precision) println("-----") - verify(calculationStorage) - .setter(calculationStorage::precision) - .with(eq(value.indexToNumber())) + + verify { calculationStorage.precision = value.indexToNumber() } .wasInvoked() } } + + @Test + fun onThemeChange() = runTest { + val mockTheme = AppTheme.DARK + + viewModel.effect.onSubscription { + viewModel.onThemeChange(mockTheme) + }.firstOrNull().let { + assertEquals(mockTheme, viewModel.state.value.appThemeType) + assertIs(it) + assertEquals(mockTheme.themeValue, it.themeValue) + } + + verify { appStorage.appTheme = mockTheme.themeValue } + .wasInvoked() + } } diff --git a/client/viewmodel/watchers/client-viewmodel-watchers.gradle.kts b/client/viewmodel/watchers/client-viewmodel-watchers.gradle.kts index 03e7e16e57..456f0017ed 100644 --- a/client/viewmodel/watchers/client-viewmodel-watchers.gradle.kts +++ b/client/viewmodel/watchers/client-viewmodel-watchers.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -7,7 +6,10 @@ plugins { } } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -52,32 +54,11 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.lifecycleViewmodel) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/client/viewmodel/watchers/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersSEED.kt b/client/viewmodel/watchers/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersSEED.kt index c8ce55bd2b..753b6db28a 100644 --- a/client/viewmodel/watchers/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersSEED.kt +++ b/client/viewmodel/watchers/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersSEED.kt @@ -7,16 +7,17 @@ import com.oztechan.ccc.client.core.viewmodel.BaseState import com.oztechan.ccc.common.core.model.Watcher data class WatchersState( + val isBannerAdVisible: Boolean, val watcherList: List = emptyList() ) : BaseState() sealed class WatchersEffect : BaseEffect() { - object Back : WatchersEffect() + data object Back : WatchersEffect() data class SelectBase(val watcher: Watcher) : WatchersEffect() data class SelectTarget(val watcher: Watcher) : WatchersEffect() - object TooBigInput : WatchersEffect() - object InvalidInput : WatchersEffect() - object MaximumNumberOfWatchers : WatchersEffect() + data object TooBigInput : WatchersEffect() + data object InvalidInput : WatchersEffect() + data object MaximumNumberOfWatchers : WatchersEffect() } interface WatchersEvent : BaseEvent { diff --git a/client/viewmodel/watchers/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModel.kt b/client/viewmodel/watchers/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModel.kt index 1598829673..78f3998f34 100644 --- a/client/viewmodel/watchers/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModel.kt +++ b/client/viewmodel/watchers/src/commonMain/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModel.kt @@ -25,11 +25,12 @@ import kotlinx.coroutines.launch class WatchersViewModel( private val currencyDataSource: CurrencyDataSource, private val watcherDataSource: WatcherDataSource, - private val adControlRepository: AdControlRepository, + adControlRepository: AdControlRepository, private val analyticsManager: AnalyticsManager ) : BaseSEEDViewModel(), WatchersEvent { // region SEED - private val _state = MutableStateFlow(WatchersState()) + private val _state = + MutableStateFlow(WatchersState(isBannerAdVisible = adControlRepository.shouldShowBannerAd())) override val state = _state.asStateFlow() private val _effect = MutableSharedFlow() @@ -48,8 +49,6 @@ class WatchersViewModel( }.launchIn(viewModelScope) } - fun shouldShowBannerAd() = adControlRepository.shouldShowBannerAd() - // region Event override fun onBackClick() = viewModelScope.launchIgnored { Logger.d { "WatcherViewModel onBackClick" } diff --git a/client/viewmodel/watchers/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModelTest.kt b/client/viewmodel/watchers/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModelTest.kt index fc46d3f7f2..5508cec52a 100644 --- a/client/viewmodel/watchers/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModelTest.kt +++ b/client/viewmodel/watchers/src/commonTest/kotlin/com/oztechan/ccc/client/viewmodel/watchers/WatchersViewModelTest.kt @@ -13,8 +13,10 @@ import com.oztechan.ccc.common.core.model.Currency import com.oztechan.ccc.common.core.model.Watcher import io.mockative.Mock import io.mockative.classOf +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.Dispatchers @@ -31,62 +33,75 @@ import kotlin.test.assertEquals import kotlin.test.assertIs import kotlin.test.assertNotNull -@Suppress("OPT_IN_USAGE", "TooManyFunctions") internal class WatchersViewModelTest { private val viewModel: WatchersViewModel by lazy { - WatchersViewModel(currencyDataSource, watcherDataSource, adControlRepository, analyticsManager) + WatchersViewModel( + currencyDataSource, + watcherDataSource, + adControlRepository, + analyticsManager + ) } @Mock private val currencyDataSource = mock(classOf()) @Mock - private val watcherDataSource = configure(mock(classOf())) { stubsUnitByDefault = true } + private val watcherDataSource = + configure(mock(classOf())) { stubsUnitByDefault = true } @Mock private val adControlRepository = mock(classOf()) @Mock - private val analyticsManager = configure(mock(classOf())) { stubsUnitByDefault = true } + private val analyticsManager = + configure(mock(classOf())) { stubsUnitByDefault = true } private val watcher = Watcher(1, "EUR", "USD", true, 1.1) private val watcherList = listOf(watcher, watcher) + private val shouldShowAds = Random.nextBoolean() @BeforeTest fun setup() { Logger.setLogWriters(CommonWriter()) + @Suppress("OPT_IN_USAGE") Dispatchers.setMain(UnconfinedTestDispatcher()) - given(watcherDataSource) - .invocation { getWatchersFlow() } - .thenReturn(flowOf(watcherList)) + every { watcherDataSource.getWatchersFlow() } + .returns(flowOf(watcherList)) + + every { adControlRepository.shouldShowBannerAd() } + .returns(shouldShowAds) } - // Analytics + // init @Test - fun ifUserPropertiesSetCorrect() { - viewModel // init + fun `init updates states correctly`() = runTest { + viewModel.state.firstOrNull().let { + assertNotNull(it) + assertEquals(shouldShowAds, it.isBannerAdVisible) + assertEquals(watcherList, it.watcherList) + } - verify(analyticsManager) - .invocation { setUserProperty(UserProperty.WatcherCount(watcherList.count().toString())) } + verify { adControlRepository.shouldShowBannerAd() } .wasInvoked() } + // Analytics @Test - fun shouldShowBannerAd() { - val mockBool = Random.nextBoolean() - - given(adControlRepository) - .invocation { shouldShowBannerAd() } - .thenReturn(mockBool) - - assertEquals(mockBool, viewModel.shouldShowBannerAd()) + fun ifUserPropertiesSetCorrect() { + viewModel // init - verify(adControlRepository) - .invocation { shouldShowBannerAd() } + verify { + analyticsManager.setUserProperty( + UserProperty.WatcherCount( + watcherList.count().toString() + ) + ) + } .wasInvoked() } @@ -129,8 +144,7 @@ internal class WatchersViewModelTest { viewModel.event.onBaseChanged(watcher, mockBase) runTest { - verify(watcherDataSource) - .coroutine { updateWatcherBaseById(mockBase, watcher.id) } + coVerify { watcherDataSource.updateWatcherBaseById(mockBase, watcher.id) } .wasInvoked() } } @@ -141,8 +155,7 @@ internal class WatchersViewModelTest { viewModel.event.onTargetChanged(watcher, mockBase) runTest { - verify(watcherDataSource) - .coroutine { updateWatcherTargetById(mockBase, watcher.id) } + coVerify { watcherDataSource.updateWatcherTargetById(mockBase, watcher.id) } .wasInvoked() } } @@ -153,39 +166,32 @@ internal class WatchersViewModelTest { val currency2 = Currency("EUR", "EUR", "", "", true) // when there is no active currency - given(currencyDataSource) - .coroutine { getActiveCurrencies() } - .thenReturn(listOf()) + coEvery { currencyDataSource.getActiveCurrencies() } + .returns(listOf()) - given(watcherDataSource) - .coroutine { getWatchers() } - .thenReturn(listOf()) + coEvery { watcherDataSource.getWatchers() } + .returns(listOf()) viewModel.event.onAddClick() - verify(watcherDataSource) - .coroutine { addWatcher("", "") } + coVerify { watcherDataSource.addWatcher("", "") } .wasInvoked() // when there is few watcher - given(watcherDataSource) - .coroutine { getWatchers() } - .thenReturn(listOf(watcher)) + coEvery { watcherDataSource.getWatchers() } + .returns(listOf(watcher)) - given(currencyDataSource) - .coroutine { getActiveCurrencies() } - .thenReturn(listOf(currency1, currency2)) + coEvery { currencyDataSource.getActiveCurrencies() } + .returns(listOf(currency1, currency2)) viewModel.event.onAddClick() - verify(watcherDataSource) - .coroutine { addWatcher(currency1.code, currency2.code) } + coVerify { watcherDataSource.addWatcher(currency1.code, currency2.code) } .wasInvoked() // when there are so much watcher - given(watcherDataSource) - .coroutine { getWatchers() } - .thenReturn(listOf(watcher, watcher, watcher, watcher, watcher)) + coEvery { watcherDataSource.getWatchers() } + .returns(listOf(watcher, watcher, watcher, watcher, watcher)) viewModel.effect.onSubscription { viewModel.event.onAddClick() @@ -200,8 +206,7 @@ internal class WatchersViewModelTest { viewModel.event.onDeleteClick(watcher) runTest { - verify(watcherDataSource) - .coroutine { deleteWatcher(watcher.id) } + coVerify { watcherDataSource.deleteWatcher(watcher.id) } .wasInvoked() } } @@ -212,8 +217,7 @@ internal class WatchersViewModelTest { viewModel.event.onRelationChange(watcher, mockBoolean) runTest { - verify(watcherDataSource) - .coroutine { updateWatcherRelationById(mockBoolean, watcher.id) } + coVerify { watcherDataSource.updateWatcherRelationById(mockBoolean, watcher.id) } .wasInvoked() } } @@ -224,13 +228,12 @@ internal class WatchersViewModelTest { var rate = "12" assertEquals(rate, viewModel.event.onRateChange(watcher, rate)) - verify(watcherDataSource) - .coroutine { - updateWatcherRateById( - rate.toSupportedCharacters().toStandardDigits().toDoubleOrNull() ?: 0.0, - watcher.id - ) - } + coVerify { + watcherDataSource.updateWatcherRateById( + rate.toSupportedCharacters().toStandardDigits().toDoubleOrNull() ?: 0.0, + watcher.id + ) + } .wasInvoked() // when rate is not valid diff --git a/common/core/database/common-core-database.gradle.kts b/common/core/database/common-core-database.gradle.kts index 567a968a4c..618c448e37 100644 --- a/common/core/database/common-core-database.gradle.kts +++ b/common/core/database/common-core-database.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -8,8 +7,10 @@ plugins { } kotlin { + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() - android() + androidTarget() iosX64() iosArm64() @@ -19,7 +20,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { libs.common.apply { @@ -38,42 +38,21 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.sqlliteDriver) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { + val iosMain by getting { dependencies { implementation(libs.ios.sqlliteDriver) } - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } - val jvmMain by getting { dependencies { implementation(libs.jvm.sqlliteDriver) } } - val jvmTest by getting } } diff --git a/common/core/database/src/androidMain/kotlin/com/oztechan/ccc/common/core/database/di/AndroidCommonCoreDatabaseModule.kt b/common/core/database/src/androidMain/kotlin/com/oztechan/ccc/common/core/database/di/CommonCoreDatabaseModule.android.kt similarity index 100% rename from common/core/database/src/androidMain/kotlin/com/oztechan/ccc/common/core/database/di/AndroidCommonCoreDatabaseModule.kt rename to common/core/database/src/androidMain/kotlin/com/oztechan/ccc/common/core/database/di/CommonCoreDatabaseModule.android.kt diff --git a/common/core/database/src/commonTest/kotlin/com/oztechan/ccc/common/core/database/base/BaseDBDataSourceTest.kt b/common/core/database/src/commonTest/kotlin/com/oztechan/ccc/common/core/database/base/BaseDBDataSourceTest.kt index f13ad45b90..7c668e8018 100644 --- a/common/core/database/src/commonTest/kotlin/com/oztechan/ccc/common/core/database/base/BaseDBDataSourceTest.kt +++ b/common/core/database/src/commonTest/kotlin/com/oztechan/ccc/common/core/database/base/BaseDBDataSourceTest.kt @@ -4,12 +4,10 @@ import com.oztechan.ccc.common.core.database.error.DatabaseException import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -@Suppress("OPT_IN_USAGE") class BaseDBDataSourceTest { + @Suppress("OPT_IN_USAGE") private val subject = object : BaseDBDataSource(UnconfinedTestDispatcher()) { suspend fun query( suspendBlock: suspend () -> T @@ -20,13 +18,9 @@ class BaseDBDataSourceTest { @Test fun `any exception returns DatabaseException`() = runTest { - val exception = Exception("Test exception") - assertFailsWith(DatabaseException::class) { - subject.query { throw exception } - }.let { - assertNotNull(it.cause) - assertEquals(exception.message, it.cause.message) + @Suppress("TooGenericExceptionThrown") + subject.query { throw Exception("Test exception") } } } } diff --git a/common/core/database/src/iosMain/kotlin/com/oztechan/ccc/common/core/database/di/IOSCommonCoreDatabaseModule.kt b/common/core/database/src/iosMain/kotlin/com/oztechan/ccc/common/core/database/di/CommonCoreDatabaseModule.ios.kt similarity index 100% rename from common/core/database/src/iosMain/kotlin/com/oztechan/ccc/common/core/database/di/IOSCommonCoreDatabaseModule.kt rename to common/core/database/src/iosMain/kotlin/com/oztechan/ccc/common/core/database/di/CommonCoreDatabaseModule.ios.kt diff --git a/common/core/database/src/jvmMain/kotlin/com/oztechan/ccc/common/core/database/di/JVMCommonCoreDatabaseModule.kt b/common/core/database/src/jvmMain/kotlin/com/oztechan/ccc/common/core/database/di/CommonCoreDatabaseModule.jvm.kt similarity index 100% rename from common/core/database/src/jvmMain/kotlin/com/oztechan/ccc/common/core/database/di/JVMCommonCoreDatabaseModule.kt rename to common/core/database/src/jvmMain/kotlin/com/oztechan/ccc/common/core/database/di/CommonCoreDatabaseModule.jvm.kt diff --git a/common/core/infrastructure/common-core-infrastructure.gradle.kts b/common/core/infrastructure/common-core-infrastructure.gradle.kts index a7c3865bbc..e4a42582d4 100644 --- a/common/core/infrastructure/common-core-infrastructure.gradle.kts +++ b/common/core/infrastructure/common-core-infrastructure.gradle.kts @@ -1,9 +1,11 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") id(libs.plugins.multiplatform.get().pluginId) } kotlin { + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + iosX64() iosArm64() iosSimulatorArm64() @@ -12,7 +14,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { libs.common.apply { @@ -21,28 +22,5 @@ kotlin { } } } - val commonTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } - - val jvmMain by getting - val jvmTest by getting } } diff --git a/common/core/infrastructure/src/iosMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/IOSCommonCoreInfrastructureModule.kt b/common/core/infrastructure/src/iosMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/CommonCoreInfrastructureModule.ios.kt similarity index 95% rename from common/core/infrastructure/src/iosMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/IOSCommonCoreInfrastructureModule.kt rename to common/core/infrastructure/src/iosMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/CommonCoreInfrastructureModule.ios.kt index f005af5125..82b3100f68 100644 --- a/common/core/infrastructure/src/iosMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/IOSCommonCoreInfrastructureModule.kt +++ b/common/core/infrastructure/src/iosMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/CommonCoreInfrastructureModule.ios.kt @@ -8,8 +8,8 @@ import org.koin.core.module.Module import org.koin.core.qualifier.named import org.koin.dsl.module -@Suppress("OPT_IN_USAGE") actual val commonCoreInfrastructureModule: Module = module { + @Suppress("OPT_IN_USAGE") single { GlobalScope } single(named(DISPATCHER_MAIN)) { Dispatchers.Main } single(named(DISPATCHER_IO)) { Dispatchers.Default } diff --git a/common/core/infrastructure/src/jvmMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/JVMCommonCoreInfrastructureModule.kt b/common/core/infrastructure/src/jvmMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/CommonCoreInfrastructureModule.jvm.kt similarity index 100% rename from common/core/infrastructure/src/jvmMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/JVMCommonCoreInfrastructureModule.kt rename to common/core/infrastructure/src/jvmMain/kotlin/com/oztechan/ccc/common/core/infrastructure/di/CommonCoreInfrastructureModule.jvm.kt diff --git a/common/core/model/common-core-model.gradle.kts b/common/core/model/common-core-model.gradle.kts index 16bf1a1c2e..9906ec6887 100644 --- a/common/core/model/common-core-model.gradle.kts +++ b/common/core/model/common-core-model.gradle.kts @@ -1,9 +1,11 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") id(libs.plugins.multiplatform.get().pluginId) } kotlin { + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + iosX64() iosArm64() iosSimulatorArm64() @@ -12,8 +14,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - - val commonMain by getting val commonTest by getting { dependencies { libs.common.apply { @@ -21,27 +21,5 @@ kotlin { } } } - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } - - val jvmMain by getting - val jvmTest by getting } } diff --git a/common/core/model/src/commonMain/kotlin/com/oztechan/ccc/common/core/model/Conversion.kt b/common/core/model/src/commonMain/kotlin/com/oztechan/ccc/common/core/model/Conversion.kt index b2b3f03d3a..7e14fecc4b 100755 --- a/common/core/model/src/commonMain/kotlin/com/oztechan/ccc/common/core/model/Conversion.kt +++ b/common/core/model/src/commonMain/kotlin/com/oztechan/ccc/common/core/model/Conversion.kt @@ -3,7 +3,6 @@ */ package com.oztechan.ccc.common.core.model -@Suppress("ConstructorParameterNaming") data class Conversion( var base: String = "", var date: String? = null, diff --git a/common/core/network/common-core-network.gradle.kts b/common/core/network/common-core-network.gradle.kts index 81c0bc4c6d..2316b7cd08 100644 --- a/common/core/network/common-core-network.gradle.kts +++ b/common/core/network/common-core-network.gradle.kts @@ -4,7 +4,6 @@ import config.key.Key import config.key.secret plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(kotlinXSerialization.get().pluginId) @@ -14,8 +13,10 @@ plugins { } kotlin { + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() - android() + androidTarget() iosX64() iosArm64() @@ -25,10 +26,10 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { libs.common.apply { + implementation(coroutines) implementation(koinCore) implementation(ktorLogging) implementation(ktorClientContentNegotiation) @@ -45,42 +46,21 @@ kotlin { } } } - val androidMain by getting { dependencies { implementation(libs.android.ktor) } } - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { + val iosMain by getting { dependencies { implementation(libs.ios.ktor) } - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } - val jvmMain by getting { dependencies { implementation(libs.jvm.ktor) } } - val jvmTest by getting } } diff --git a/common/core/network/src/commonMain/kotlin/com/oztechan/ccc/common/core/network/model/Conversion.kt b/common/core/network/src/commonMain/kotlin/com/oztechan/ccc/common/core/network/model/Conversion.kt index f0df3aa99f..f562d7cb5a 100755 --- a/common/core/network/src/commonMain/kotlin/com/oztechan/ccc/common/core/network/model/Conversion.kt +++ b/common/core/network/src/commonMain/kotlin/com/oztechan/ccc/common/core/network/model/Conversion.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames -@Suppress("ConstructorParameterNaming", "OPT_IN_USAGE") +@Suppress("OPT_IN_USAGE") @Serializable data class Conversion( @SerialName("base") var base: String = "", diff --git a/common/core/network/src/commonTest/kotlin/com/oztechan/ccc/common/core/network/base/BaseNetworkServiceTest.kt b/common/core/network/src/commonTest/kotlin/com/oztechan/ccc/common/core/network/base/BaseNetworkServiceTest.kt index 9da5a890a3..910250035a 100644 --- a/common/core/network/src/commonTest/kotlin/com/oztechan/ccc/common/core/network/base/BaseNetworkServiceTest.kt +++ b/common/core/network/src/commonTest/kotlin/com/oztechan/ccc/common/core/network/base/BaseNetworkServiceTest.kt @@ -13,13 +13,11 @@ import kotlinx.coroutines.test.runTest import kotlinx.serialization.SerializationException import kotlin.coroutines.cancellation.CancellationException import kotlin.test.Test -import kotlin.test.assertEquals import kotlin.test.assertFailsWith -import kotlin.test.assertNotNull -@Suppress("OPT_IN_USAGE") class BaseNetworkServiceTest { + @Suppress("OPT_IN_USAGE") private val subject = object : BaseNetworkService(UnconfinedTestDispatcher()) { fun parameterCheck(parameter: String) = withEmptyParameterCheck(parameter) suspend fun request( @@ -33,11 +31,6 @@ class BaseNetworkServiceTest { fun `CancellationException should return exception itself`() = runTest { assertFailsWith(TerminationException::class) { subject.request { throw CancellationException(exception) } - }.let { - assertNotNull(it.cause) - assertNotNull(it.cause.cause) - assertEquals(exception, it.cause.cause) - assertEquals(exception.message, it.cause.cause!!.message) } } @@ -45,9 +38,6 @@ class BaseNetworkServiceTest { fun `IOException should return NetworkException`() = runTest { assertFailsWith(NetworkException::class) { subject.request { throw IOException(exception.message.toString()) } - }.let { - assertNotNull(it.cause) - assertEquals(exception.message, it.cause.message) } } @@ -55,9 +45,6 @@ class BaseNetworkServiceTest { fun `ConnectTimeoutException should return TimeoutException`() = runTest { assertFailsWith(TimeoutException::class) { subject.request { throw ConnectTimeoutException(exception.message.toString()) } - }.let { - assertNotNull(it.cause) - assertEquals(exception.message, it.cause.message) } } @@ -65,34 +52,20 @@ class BaseNetworkServiceTest { fun `SerializationException should return ModelMappingException`() = runTest { assertFailsWith(ModelMappingException::class) { subject.request { throw SerializationException(exception) } - }.let { - assertNotNull(it.cause) - assertNotNull(it.cause.cause) - assertEquals(exception, it.cause.cause) - assertEquals(exception.message, it.cause.cause!!.message) } } - @Suppress("TooGenericExceptionThrown") @Test fun `Any other exception should return UnknownNetworkException`() = runTest { assertFailsWith(UnknownNetworkException::class) { - subject.request { throw Exception(exception) } - }.let { - assertNotNull(it.cause) - assertNotNull(it.cause.cause) - assertEquals(exception, it.cause.cause) - assertEquals(exception.message, it.cause.cause!!.message) + subject.request { throw exception } } } @Test - fun `Empty string should return EmptyParameterException`() = runTest { + fun `Empty string should return EmptyParameterException`() { assertFailsWith(EmptyParameterException::class) { subject.parameterCheck("") - }.let { - assertNotNull(it) - assertEquals(EmptyParameterException().message, it.message) } } } diff --git a/common/datasource/conversion/common-datasource-conversion.gradle.kts b/common/datasource/conversion/common-datasource-conversion.gradle.kts index b09b7fdfa0..4a53c96bf4 100644 --- a/common/datasource/conversion/common-datasource-conversion.gradle.kts +++ b/common/datasource/conversion/common-datasource-conversion.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(androidLib.get().pluginId) @@ -8,7 +7,10 @@ plugins { } kotlin { - android() + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + + androidTarget() iosX64() iosArm64() @@ -18,7 +20,6 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { libs.common.apply { @@ -42,31 +43,6 @@ kotlin { } } } - - val androidMain by getting - val androidUnitTest by getting - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } - - val jvmMain by getting - val jvmTest by getting } } diff --git a/common/datasource/conversion/src/commonTest/kotlin/com/oztechan/ccc/common/datasource/conversion/ConversionDataSourceTest.kt b/common/datasource/conversion/src/commonTest/kotlin/com/oztechan/ccc/common/datasource/conversion/ConversionDataSourceTest.kt index 1cfe2157cc..b0b719b491 100644 --- a/common/datasource/conversion/src/commonTest/kotlin/com/oztechan/ccc/common/datasource/conversion/ConversionDataSourceTest.kt +++ b/common/datasource/conversion/src/commonTest/kotlin/com/oztechan/ccc/common/datasource/conversion/ConversionDataSourceTest.kt @@ -11,7 +11,7 @@ import com.squareup.sqldelight.db.SqlDriver import io.mockative.Mock import io.mockative.classOf import io.mockative.configure -import io.mockative.given +import io.mockative.every import io.mockative.mock import io.mockative.verify import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -19,15 +19,16 @@ import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test -@Suppress("OPT_IN_USAGE") internal class ConversionDataSourceTest { private val subject: ConversionDataSource by lazy { + @Suppress("OPT_IN_USAGE") ConversionDataSourceImpl(conversionQueries, UnconfinedTestDispatcher()) } @Mock - private val conversionQueries = configure(mock(classOf())) { stubsUnitByDefault = true } + private val conversionQueries = + configure(mock(classOf())) { stubsUnitByDefault = true } @Mock private val sqlDriver = mock(classOf()) @@ -43,13 +44,11 @@ internal class ConversionDataSourceTest { fun setup() { Logger.setLogWriters(CommonWriter()) - given(sqlDriver) - .invocation { executeQuery(-1, "", 0, null) } - .thenReturn(sqlCursor) + every { sqlDriver.executeQuery(-1, "", 0, null) } + .returns(sqlCursor) - given(sqlCursor) - .invocation { next() } - .thenReturn(false) + every { sqlCursor.next() } + .returns(false) } @Test @@ -58,23 +57,20 @@ internal class ConversionDataSourceTest { subject.insertConversion(Fakes.conversionModel) } - verify(conversionQueries) - .invocation { insertConversion(Fakes.conversionModel.toConversionDBModel()) } + verify { conversionQueries.insertConversion(Fakes.conversionModel.toConversionDBModel()) } .wasInvoked() } @Test fun getConversionByBase() { - given(conversionQueries) - .invocation { getConversionByBase(Fakes.conversionModel.base) } - .then { query } + every { conversionQueries.getConversionByBase(Fakes.conversionModel.base) } + .returns(query) runTest { subject.getConversionByBase(Fakes.conversionModel.base) } - verify(conversionQueries) - .invocation { getConversionByBase(Fakes.conversionModel.base) } + verify { conversionQueries.getConversionByBase(Fakes.conversionModel.base) } .wasInvoked() } } diff --git a/gradle.properties b/gradle.properties index 42ed5702a7..7eba3ab183 100755 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,5 @@ # Android android.useAndroidX=true -android.databinding.incremental=true # Gradle org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=2g -XX:+HeapDumpOnOutOfMemoryError -XX:+UseParallelGC -Dfile.encoding=UTF-8 org.gradle.parallel=true @@ -14,9 +13,6 @@ kotlin.incremental=true # KMP kotlin.mpp.stability.nowarn=true xcodeproj=./ios -# OPT-IN & Templorary settings -# Can be removed with Gradle 8 -kotlin.mpp.androidSourceSetLayoutVersion=2 # todo Need to get rid of having 2 static frameworks fails the build with Duplicate symbols kotlin.native.cacheKind=none # todo this is only needed for res module but now way found for setting only 1 module diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2b21cd01c..40123d555f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,47 +1,47 @@ [versions] -kotlin = "1.8.22" -ksp = "1.8.22-1.0.11" -detekt = "1.23.0" -androidGradlePlugin = "8.0.2" -composeCompiler = "1.4.8" -compose = "1.4.3" -glance = "1.0.0-beta01" +kotlin = "1.9.10" +ksp = "1.9.10-1.0.13" +detekt = "1.23.1" +androidGradlePlugin = "8.1.1" +composeCompiler = "1.5.3" +compose = "1.5.1" +glance = "1.0.0" material3 = "1.1.1" androidDesugaring = "2.0.3" androidMaterial = "1.9.0" composeActivity = "1.7.2" constraintLayout = "2.1.4" -koinCore = "3.4.2" -koinCompose = "3.4.5" -koinAndroid = "3.4.2" -koinKtor = "3.4.1" -ktor = "2.3.2" +koinCore = "3.4.3" +koinCompose = "3.4.6" +koinAndroid = "3.4.3" +koinKtor = "3.4.3" +ktor = "2.3.4" multiplatformSettings = "1.0.0" firebaseAnalytics = "21.3.0" -firebaseRemoteConfig = "21.4.0" +firebaseRemoteConfig = "21.4.1" gsm = "4.3.15" -firebasePer = "20.4.0" +firebasePer = "20.4.1" firebasePerPlugin = "1.4.2" -crashlytics = "2.9.6" -googleAds = "22.2.0" -huaweiAds = "3.4.64.302" +crashlytics = "2.9.9" +googleAds = "22.3.0" +huaweiAds = "3.4.65.303" huaweiOsm="1.3.35" -navigation = "2.6.0" +navigation = "2.7.2" playCore = "1.10.3" -kotlinXDateTime = "0.4.0" -coroutines = "1.7.2" -billing = "5.2.1" +kotlinXDateTime = "0.4.1" +coroutines = "1.7.3" +billing = "6.0.1" leakCanary = "2.12" sqlDelight = "1.5.5" -lifecycle = "2.6.1" +lifecycle = "2.6.2" mokoResources = "0.23.0" -buildKonfig = "0.13.3" +buildKonfig = "0.14.0" splashScreen = "1.0.1" kover = "0.6.1" rootBeer = "0.1.0" -mockative = "1.4.1" -firebaseCrashlytics = "18.4.0" +mockative = "2.0.1" +firebaseCrashlytics = "18.4.1" anrWatchDog = "1.4.0" kermit = "1.2.2" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9b0a13f0fb..03bc515044 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/ios/CCC.xcodeproj/project.pbxproj b/ios/CCC.xcodeproj/project.pbxproj index 89d9844fc5..54cd2e481d 100644 --- a/ios/CCC.xcodeproj/project.pbxproj +++ b/ios/CCC.xcodeproj/project.pbxproj @@ -9,11 +9,18 @@ /* Begin PBXBuildFile section */ 15A796074B10DEA83142F40F /* Pods_CCC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 341CA58575E08166F616E331 /* Pods_CCC.framework */; }; 5C039FD625C1B705008350A3 /* FormProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C039FD525C1B705008350A3 /* FormProgressView.swift */; }; - 5C17581A25BC74BD00D16BD9 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C17581925BC74BD00D16BD9 /* SettingsView.swift */; }; + 5C0ABFDE2A9369A1002904AC /* CalculatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0ABFDD2A9369A1002904AC /* CalculatorView.swift */; }; + 5C0ABFE02A9390D6002904AC /* CurrenciesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0ABFDF2A9390D6002904AC /* CurrenciesView.swift */; }; + 5C0ABFE22A93A88C002904AC /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0ABFE12A93A88C002904AC /* SettingsView.swift */; }; + 5C0ABFE42A93B1E4002904AC /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0ABFE32A93B1E4002904AC /* PremiumView.swift */; }; + 5C0ABFE82A96A2EF002904AC /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0ABFE72A96A2EF002904AC /* MainView.swift */; }; + 5C0ABFEA2A974C76002904AC /* SelectCurrencyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0ABFE92A974C76002904AC /* SelectCurrencyView.swift */; }; + 5C0ABFEC2A975532002904AC /* WatchersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C0ABFEB2A975532002904AC /* WatchersView.swift */; }; + 5C17581A25BC74BD00D16BD9 /* SettingsRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C17581925BC74BD00D16BD9 /* SettingsRootView.swift */; }; 5C23124529ABF754002D6892 /* WindowUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C23124429ABF754002D6892 /* WindowUtil.swift */; }; - 5C3037B929AB874E00AFFAAF /* PremiumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3037B829AB874E00AFFAAF /* PremiumView.swift */; }; + 5C3037B929AB874E00AFFAAF /* PremiumRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3037B829AB874E00AFFAAF /* PremiumRootView.swift */; }; 5C3037BB29AB9B5D00AFFAAF /* PremiumItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3037BA29AB9B5D00AFFAAF /* PremiumItemView.swift */; }; - 5C314CBE25BA0AC0007B22D8 /* CurrenciesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C314CBD25BA0AC0007B22D8 /* CurrenciesView.swift */; }; + 5C314CBE25BA0AC0007B22D8 /* CurrenciesRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C314CBD25BA0AC0007B22D8 /* CurrenciesRootView.swift */; }; 5C31E41E28141C7B008C42B9 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C31E41D28141C7B008C42B9 /* InputView.swift */; }; 5C31E42028141CA4008C42B9 /* OutputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C31E41F28141CA4008C42B9 /* OutputView.swift */; }; 5C31E42228141CC9008C42B9 /* KeyboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C31E42128141CC9008C42B9 /* KeyboardView.swift */; }; @@ -32,7 +39,7 @@ 5C34B8CE29590DC1009C84AB /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C34B8CD29590DC1009C84AB /* AlertView.swift */; }; 5C34B8D02959A363009C84AB /* ActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C34B8CF2959A363009C84AB /* ActionButton.swift */; }; 5C3EB6D828775AFF001E822A /* GoogleMobileAds in Frameworks */ = {isa = PBXBuildFile; productRef = 5C3EB6D728775AFF001E822A /* GoogleMobileAds */; }; - 5C4B53692818057F00D10185 /* WatchersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B53682818057F00D10185 /* WatchersView.swift */; }; + 5C4B53692818057F00D10185 /* WatchersRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B53682818057F00D10185 /* WatchersRootView.swift */; }; 5C4B536B2818066000D10185 /* WatchersToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4B536A2818066000D10185 /* WatchersToolbarView.swift */; }; 5C5B0E7C28A4084200FACFDE /* AnalyticsManagerImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5B0E7B28A4084200FACFDE /* AnalyticsManagerImpl.swift */; }; 5C5C0BA42874B8450061AEF9 /* NavigationStack in Frameworks */ = {isa = PBXBuildFile; productRef = 5C5C0BA32874B8450061AEF9 /* NavigationStack */; }; @@ -42,17 +49,19 @@ 5C5C71EF2922C06F00733C49 /* SecretUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5C71EE2922C06F00733C49 /* SecretUtil.swift */; }; 5C5D09332562EB9E00DA9C4A /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5D09322562EB9E00DA9C4A /* Application.swift */; }; 5C5D09362562EBDE00DA9C4A /* Koin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5D09352562EBDE00DA9C4A /* Koin.swift */; }; - 5C5D093C2562EC2D00DA9C4A /* CalculatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5D093B2562EC2D00DA9C4A /* CalculatorView.swift */; }; + 5C5D093C2562EC2D00DA9C4A /* CalculatorRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5D093B2562EC2D00DA9C4A /* CalculatorRootView.swift */; }; 5C693EBA25C4AFF800C9373E /* SelectCurrenciesBottomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C693EB925C4AFF800C9373E /* SelectCurrenciesBottomView.swift */; }; - 5C6E674025C5A711001CC0D6 /* SliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6E673F25C5A711001CC0D6 /* SliderView.swift */; }; 5C8FDBDD25BF3FBE00F280FF /* ObservableSEEDViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C8FDBDC25BF3FBE00F280FF /* ObservableSEEDViewModel.swift */; }; 5C94AC32282FA4B2004C9B3D /* CurrencyImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C94AC31282FA4B2004C9B3D /* CurrencyImageView.swift */; }; 5C9645D628A137FE001DC24E /* EnvironmentUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9645D528A137FE001DC24E /* EnvironmentUtil.swift */; }; - 5C9A59BB25C350DE006745B0 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A59BA25C350DE006745B0 /* MainView.swift */; }; + 5C9A59BB25C350DE006745B0 /* MainRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9A59BA25C350DE006745B0 /* MainRootView.swift */; }; 5C9C75C82603A36A00D66FDD /* ToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9C75C72603A36A00D66FDD /* ToolbarButton.swift */; }; 5CB954BF26932408007632DC /* AdaptiveBannerAdView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB954BE26932408007632DC /* AdaptiveBannerAdView.swift */; }; + 5CCFB5882A9B9F13002DF46B /* BugReportSlideRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCFB5872A9B9F13002DF46B /* BugReportSlideRootView.swift */; }; + 5CCFB58A2A9B9F8F002DF46B /* IntroSlideRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCFB5892A9B9F8F002DF46B /* IntroSlideRootView.swift */; }; + 5CCFB58C2A9B9FFA002DF46B /* PremiumSlideRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCFB58B2A9B9FFA002DF46B /* PremiumSlideRootView.swift */; }; 5CD8EB7328A6AA6100E9C434 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5CD8EB7228A6AA6100E9C434 /* GoogleService-Info.plist */; }; - 5CDE468425BC3B2000CA0FB1 /* SelectCurrencyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDE468325BC3B2000CA0FB1 /* SelectCurrencyView.swift */; }; + 5CDE468425BC3B2000CA0FB1 /* SelectCurrencyRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDE468325BC3B2000CA0FB1 /* SelectCurrencyRootView.swift */; }; 5CEA86F52840CF65001386FB /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEA86F42840CF65001386FB /* NotificationManager.swift */; }; 5CEAF774295F2ECC0018C7FA /* DeviceUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEAF773295F2ECC0018C7FA /* DeviceUtil.swift */; }; 5CF0622E28AD93CF00C579F6 /* FirebaseAnalyticsSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 5CF0622D28AD93CF00C579F6 /* FirebaseAnalyticsSwift */; }; @@ -83,11 +92,18 @@ /* Begin PBXFileReference section */ 341CA58575E08166F616E331 /* Pods_CCC.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_CCC.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5C039FD525C1B705008350A3 /* FormProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormProgressView.swift; sourceTree = ""; }; - 5C17581925BC74BD00D16BD9 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 5C0ABFDD2A9369A1002904AC /* CalculatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorView.swift; sourceTree = ""; }; + 5C0ABFDF2A9390D6002904AC /* CurrenciesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrenciesView.swift; sourceTree = ""; }; + 5C0ABFE12A93A88C002904AC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 5C0ABFE32A93B1E4002904AC /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = ""; }; + 5C0ABFE72A96A2EF002904AC /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + 5C0ABFE92A974C76002904AC /* SelectCurrencyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectCurrencyView.swift; sourceTree = ""; }; + 5C0ABFEB2A975532002904AC /* WatchersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchersView.swift; sourceTree = ""; }; + 5C17581925BC74BD00D16BD9 /* SettingsRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRootView.swift; sourceTree = ""; }; 5C23124429ABF754002D6892 /* WindowUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowUtil.swift; sourceTree = ""; }; - 5C3037B829AB874E00AFFAAF /* PremiumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumView.swift; sourceTree = ""; }; + 5C3037B829AB874E00AFFAAF /* PremiumRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumRootView.swift; sourceTree = ""; }; 5C3037BA29AB9B5D00AFFAAF /* PremiumItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumItemView.swift; sourceTree = ""; }; - 5C314CBD25BA0AC0007B22D8 /* CurrenciesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrenciesView.swift; sourceTree = ""; }; + 5C314CBD25BA0AC0007B22D8 /* CurrenciesRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrenciesRootView.swift; sourceTree = ""; }; 5C31E41D28141C7B008C42B9 /* InputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputView.swift; sourceTree = ""; }; 5C31E41F28141CA4008C42B9 /* OutputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutputView.swift; sourceTree = ""; }; 5C31E42128141CC9008C42B9 /* KeyboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardView.swift; sourceTree = ""; }; @@ -104,7 +120,7 @@ 5C34B8CB29590500009C84AB /* SnackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackView.swift; sourceTree = ""; }; 5C34B8CD29590DC1009C84AB /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; 5C34B8CF2959A363009C84AB /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; - 5C4B53682818057F00D10185 /* WatchersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchersView.swift; sourceTree = ""; }; + 5C4B53682818057F00D10185 /* WatchersRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchersRootView.swift; sourceTree = ""; }; 5C4B536A2818066000D10185 /* WatchersToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchersToolbarView.swift; sourceTree = ""; }; 5C5B0E7B28A4084200FACFDE /* AnalyticsManagerImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsManagerImpl.swift; sourceTree = ""; }; 5C5C71E82922BBD200733C49 /* ViewExt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExt.swift; sourceTree = ""; }; @@ -113,18 +129,20 @@ 5C5C71EE2922C06F00733C49 /* SecretUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecretUtil.swift; sourceTree = ""; }; 5C5D09322562EB9E00DA9C4A /* Application.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Application.swift; sourceTree = ""; }; 5C5D09352562EBDE00DA9C4A /* Koin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Koin.swift; sourceTree = ""; }; - 5C5D093B2562EC2D00DA9C4A /* CalculatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorView.swift; sourceTree = ""; }; + 5C5D093B2562EC2D00DA9C4A /* CalculatorRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorRootView.swift; sourceTree = ""; }; 5C693EB925C4AFF800C9373E /* SelectCurrenciesBottomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectCurrenciesBottomView.swift; sourceTree = ""; }; - 5C6E673F25C5A711001CC0D6 /* SliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SliderView.swift; sourceTree = ""; }; 5C8FDBDC25BF3FBE00F280FF /* ObservableSEEDViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableSEEDViewModel.swift; sourceTree = ""; }; 5C94AC31282FA4B2004C9B3D /* CurrencyImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyImageView.swift; sourceTree = ""; }; 5C9645D528A137FE001DC24E /* EnvironmentUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentUtil.swift; sourceTree = ""; }; - 5C9A59BA25C350DE006745B0 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + 5C9A59BA25C350DE006745B0 /* MainRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainRootView.swift; sourceTree = ""; }; 5C9C75C72603A36A00D66FDD /* ToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarButton.swift; sourceTree = ""; }; 5CACB69328A7EF1800A2D13C /* Config.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config.xcconfig; sourceTree = ""; }; 5CB954BE26932408007632DC /* AdaptiveBannerAdView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveBannerAdView.swift; sourceTree = ""; }; + 5CCFB5872A9B9F13002DF46B /* BugReportSlideRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportSlideRootView.swift; sourceTree = ""; }; + 5CCFB5892A9B9F8F002DF46B /* IntroSlideRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntroSlideRootView.swift; sourceTree = ""; }; + 5CCFB58B2A9B9FFA002DF46B /* PremiumSlideRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PremiumSlideRootView.swift; sourceTree = ""; }; 5CD8EB7228A6AA6100E9C434 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; - 5CDE468325BC3B2000CA0FB1 /* SelectCurrencyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectCurrencyView.swift; sourceTree = ""; }; + 5CDE468325BC3B2000CA0FB1 /* SelectCurrencyRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectCurrencyRootView.swift; sourceTree = ""; }; 5CEA86F42840CF65001386FB /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; 5CEAF773295F2ECC0018C7FA /* DeviceUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceUtil.swift; sourceTree = ""; }; 5CF57E39269588060081E4BB /* RewardedAd.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RewardedAd.swift; sourceTree = ""; }; @@ -178,7 +196,8 @@ 5C31E41C28141C61008C42B9 /* Calculator */ = { isa = PBXGroup; children = ( - 5C5D093B2562EC2D00DA9C4A /* CalculatorView.swift */, + 5C5D093B2562EC2D00DA9C4A /* CalculatorRootView.swift */, + 5C0ABFDD2A9369A1002904AC /* CalculatorView.swift */, 5C31E41D28141C7B008C42B9 /* InputView.swift */, 5C31E41F28141CA4008C42B9 /* OutputView.swift */, 5C31E42128141CC9008C42B9 /* KeyboardView.swift */, @@ -191,24 +210,28 @@ 5C31E42728141EDF008C42B9 /* Main */ = { isa = PBXGroup; children = ( - 5C9A59BA25C350DE006745B0 /* MainView.swift */, + 5C9A59BA25C350DE006745B0 /* MainRootView.swift */, + 5C0ABFE72A96A2EF002904AC /* MainView.swift */, ); path = Main; sourceTree = ""; }; - 5C31E42828141EFA008C42B9 /* Slider */ = { + 5C31E42828141EFA008C42B9 /* Slides */ = { isa = PBXGroup; children = ( - 5C6E673F25C5A711001CC0D6 /* SliderView.swift */, + 5CCFB5892A9B9F8F002DF46B /* IntroSlideRootView.swift */, + 5CCFB58B2A9B9FFA002DF46B /* PremiumSlideRootView.swift */, + 5CCFB5872A9B9F13002DF46B /* BugReportSlideRootView.swift */, 5C31E42928141F1B008C42B9 /* SlideView.swift */, ); - path = Slider; + path = Slides; sourceTree = ""; }; 5C31E42B28142033008C42B9 /* Currencies */ = { isa = PBXGroup; children = ( - 5C314CBD25BA0AC0007B22D8 /* CurrenciesView.swift */, + 5C314CBD25BA0AC0007B22D8 /* CurrenciesRootView.swift */, + 5C0ABFDF2A9390D6002904AC /* CurrenciesView.swift */, 5C31E42C28142058008C42B9 /* SelectionView.swift */, 5C31E42E281420BC008C42B9 /* CurrenciesToolbarView.swift */, 5C31E43028142110008C42B9 /* CurrenciesItemView.swift */, @@ -219,7 +242,8 @@ 5C31E4322814304F008C42B9 /* Settings */ = { isa = PBXGroup; children = ( - 5C17581925BC74BD00D16BD9 /* SettingsView.swift */, + 5C17581925BC74BD00D16BD9 /* SettingsRootView.swift */, + 5C0ABFE12A93A88C002904AC /* SettingsView.swift */, 5C31E4332814306D008C42B9 /* SettingsToolbarView.swift */, 5C31E4352814308B008C42B9 /* SettingsItemView.swift */, ); @@ -229,7 +253,8 @@ 5C4B53652818053E00D10185 /* Watchers */ = { isa = PBXGroup; children = ( - 5C4B53682818057F00D10185 /* WatchersView.swift */, + 5C4B53682818057F00D10185 /* WatchersRootView.swift */, + 5C0ABFEB2A975532002904AC /* WatchersView.swift */, 5C4B536A2818066000D10185 /* WatchersToolbarView.swift */, 5CF898D32823C1F900712580 /* WatcherItem.swift */, ); @@ -239,7 +264,8 @@ 5C4B536E28184AEA00D10185 /* SelectCurrency */ = { isa = PBXGroup; children = ( - 5CDE468325BC3B2000CA0FB1 /* SelectCurrencyView.swift */, + 5CDE468325BC3B2000CA0FB1 /* SelectCurrencyRootView.swift */, + 5C0ABFE92A974C76002904AC /* SelectCurrencyView.swift */, 5C31E438281431A3008C42B9 /* SelectCurrencyItemView.swift */, ); path = SelectCurrency; @@ -273,7 +299,7 @@ isa = PBXGroup; children = ( 5C31E42728141EDF008C42B9 /* Main */, - 5C31E42828141EFA008C42B9 /* Slider */, + 5C31E42828141EFA008C42B9 /* Slides */, 5C31E41C28141C61008C42B9 /* Calculator */, 5C31E42B28142033008C42B9 /* Currencies */, 5C31E4322814304F008C42B9 /* Settings */, @@ -333,7 +359,8 @@ 5CC0025429AA094600A09D64 /* Premium */ = { isa = PBXGroup; children = ( - 5C3037B829AB874E00AFFAAF /* PremiumView.swift */, + 5C3037B829AB874E00AFFAAF /* PremiumRootView.swift */, + 5C0ABFE32A93B1E4002904AC /* PremiumView.swift */, 5C3037BA29AB9B5D00AFFAAF /* PremiumItemView.swift */, ); path = Premium; @@ -550,38 +577,47 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 5C314CBE25BA0AC0007B22D8 /* CurrenciesView.swift in Sources */, + 5C314CBE25BA0AC0007B22D8 /* CurrenciesRootView.swift in Sources */, + 5C0ABFE82A96A2EF002904AC /* MainView.swift in Sources */, 5C94AC32282FA4B2004C9B3D /* CurrencyImageView.swift in Sources */, 5C4B536B2818066000D10185 /* WatchersToolbarView.swift in Sources */, 5CF898D42823C1F900712580 /* WatcherItem.swift in Sources */, 5CB954BF26932408007632DC /* AdaptiveBannerAdView.swift in Sources */, + 5C0ABFE42A93B1E4002904AC /* PremiumView.swift in Sources */, 5C31E42428141D1B008C42B9 /* ConversionStateView.swift in Sources */, 5CF57E3A269588060081E4BB /* RewardedAd.swift in Sources */, 5C9C75C82603A36A00D66FDD /* ToolbarButton.swift in Sources */, 5CF8BE4227DE205B00E441F5 /* MailView.swift in Sources */, 5C31E4362814308B008C42B9 /* SettingsItemView.swift in Sources */, - 5C4B53692818057F00D10185 /* WatchersView.swift in Sources */, - 5C3037B929AB874E00AFFAAF /* PremiumView.swift in Sources */, - 5C6E674025C5A711001CC0D6 /* SliderView.swift in Sources */, + 5C4B53692818057F00D10185 /* WatchersRootView.swift in Sources */, + 5C3037B929AB874E00AFFAAF /* PremiumRootView.swift in Sources */, + 5C0ABFDE2A9369A1002904AC /* CalculatorView.swift in Sources */, + 5C0ABFEC2A975532002904AC /* WatchersView.swift in Sources */, 5C5C71E92922BBD200733C49 /* ViewExt.swift in Sources */, + 5C0ABFE22A93A88C002904AC /* SettingsView.swift in Sources */, 5C5C71EF2922C06F00733C49 /* SecretUtil.swift in Sources */, 5C34B8D02959A363009C84AB /* ActionButton.swift in Sources */, 5C31E42628141D3E008C42B9 /* CalculatorItemView.swift in Sources */, 5C039FD625C1B705008350A3 /* FormProgressView.swift in Sources */, 5C8FDBDD25BF3FBE00F280FF /* ObservableSEEDViewModel.swift in Sources */, - 5CDE468425BC3B2000CA0FB1 /* SelectCurrencyView.swift in Sources */, + 5CDE468425BC3B2000CA0FB1 /* SelectCurrencyRootView.swift in Sources */, 5C31E43128142110008C42B9 /* CurrenciesItemView.swift in Sources */, 5C31E42028141CA4008C42B9 /* OutputView.swift in Sources */, 5CEAF774295F2ECC0018C7FA /* DeviceUtil.swift in Sources */, - 5C9A59BB25C350DE006745B0 /* MainView.swift in Sources */, + 5C9A59BB25C350DE006745B0 /* MainRootView.swift in Sources */, 5C34B8CC29590500009C84AB /* SnackView.swift in Sources */, 5C5D09362562EBDE00DA9C4A /* Koin.swift in Sources */, 5C23124529ABF754002D6892 /* WindowUtil.swift in Sources */, + 5CCFB5882A9B9F13002DF46B /* BugReportSlideRootView.swift in Sources */, 5C3037BB29AB9B5D00AFFAAF /* PremiumItemView.swift in Sources */, 5C34B8CE29590DC1009C84AB /* AlertView.swift in Sources */, 5C31E42D28142058008C42B9 /* SelectionView.swift in Sources */, + 5C0ABFEA2A974C76002904AC /* SelectCurrencyView.swift in Sources */, + 5CCFB58C2A9B9FFA002DF46B /* PremiumSlideRootView.swift in Sources */, 5C9645D628A137FE001DC24E /* EnvironmentUtil.swift in Sources */, - 5C5D093C2562EC2D00DA9C4A /* CalculatorView.swift in Sources */, + 5C5D093C2562EC2D00DA9C4A /* CalculatorRootView.swift in Sources */, + 5CCFB58A2A9B9F8F002DF46B /* IntroSlideRootView.swift in Sources */, + 5C0ABFE02A9390D6002904AC /* CurrenciesView.swift in Sources */, 5C5C71EB2922BC0300733C49 /* ResourceExt.swift in Sources */, 5C5B0E7C28A4084200FACFDE /* AnalyticsManagerImpl.swift in Sources */, 5CF8BE4627DE334100E441F5 /* WebView.swift in Sources */, @@ -589,7 +625,7 @@ 5C31E42228141CC9008C42B9 /* KeyboardView.swift in Sources */, 5C31E4342814306D008C42B9 /* SettingsToolbarView.swift in Sources */, 5CF57E3C2695A3B20081E4BB /* InterstitialAd.swift in Sources */, - 5C17581A25BC74BD00D16BD9 /* SettingsView.swift in Sources */, + 5C17581A25BC74BD00D16BD9 /* SettingsRootView.swift in Sources */, 5C5D09332562EB9E00DA9C4A /* Application.swift in Sources */, 5CEA86F52840CF65001386FB /* NotificationManager.swift in Sources */, 5C693EBA25C4AFF800C9373E /* SelectCurrenciesBottomView.swift in Sources */, diff --git a/ios/CCC/Application.swift b/ios/CCC/Application.swift index 339ddbebcf..e8bbb33b52 100644 --- a/ios/CCC/Application.swift +++ b/ios/CCC/Application.swift @@ -9,13 +9,12 @@ import BackgroundTasks import FirebaseCore import GoogleMobileAds -import PopupView import Provider import Res import SwiftUI var logger: KermitLogger = { - return IOSLoggerKt.doInitLogger(isCrashlyticsEnabled: EnvironmentUtil.isRelease) + return LoggerKt.doInitLogger(isCrashlyticsEnabled: EnvironmentUtil.isRelease) }() @main @@ -55,8 +54,8 @@ struct Application: App { var body: some Scene { WindowGroup { - MainView() - .popup(isPresented: $isWatcherAlertShown) { + MainRootView() + .alert(isPresented: $isWatcherAlertShown) { AlertView( title: Res.strings().txt_watcher_alert_title.get(), message: Res.strings().txt_watcher_alert_sub_title.get(), diff --git a/ios/CCC/Notification/NotificationManager.swift b/ios/CCC/Notification/NotificationManager.swift index 5779aa6567..4cc31d00a9 100644 --- a/ios/CCC/Notification/NotificationManager.swift +++ b/ios/CCC/Notification/NotificationManager.swift @@ -14,11 +14,11 @@ final class NotificationManager: ObservableObject { @Published var authorizationStatus: UNAuthorizationStatus? init() { - logger.i(message: { "NotificationManager init" }) + logger.v(message: { "NotificationManager init" }) } func reloadAuthorisationStatus() { - logger.i(message: { "NotificationManager reloadAuthorisationStatus" }) + logger.v(message: { "NotificationManager reloadAuthorisationStatus" }) UNUserNotificationCenter.current().getNotificationSettings { settings in DispatchQueue.main.async { @@ -28,11 +28,11 @@ final class NotificationManager: ObservableObject { } func requestAuthorisation() { - logger.i(message: { "NotificationManager requestAuthorisation" }) + logger.v(message: { "NotificationManager requestAuthorisation" }) UNUserNotificationCenter.current().requestAuthorization( options: [.badge, .alert, .sound] ) { isGranted, error in - logger.i(message: { + logger.v(message: { "NotificationManager requestAuthorisation error: \(String(describing: error)) isGradted: \(isGranted)" }) DispatchQueue.main.async { @@ -42,7 +42,7 @@ final class NotificationManager: ObservableObject { } func sendNotification(title: String, body: String) { - logger.i(message: { "NotificationManager sendNotification title:\(title) body:\(body)" }) + logger.v(message: { "NotificationManager sendNotification title:\(title) body:\(body)" }) let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 3, repeats: false) @@ -58,7 +58,7 @@ final class NotificationManager: ObservableObject { ) UNUserNotificationCenter.current().add(request) { error in - logger.i(message: { + logger.v(message: { "NotificationManager sendNotification error: \(String(describing: error))" }) } diff --git a/ios/CCC/UI/Calculator/CalculatorRootView.swift b/ios/CCC/UI/Calculator/CalculatorRootView.swift new file mode 100644 index 0000000000..7dcc034855 --- /dev/null +++ b/ios/CCC/UI/Calculator/CalculatorRootView.swift @@ -0,0 +1,127 @@ +// +// CalculatorRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 16/11/2020. +// Copyright © 2020 orgName. All rights reserved. +// + +import NavigationStack +import Provider +import Res +import SwiftUI + +struct CalculatorRootView: View { + @StateObject var observable = ObservableSEEDViewModel< + CalculatorState, + CalculatorEffect, + CalculatorEvent, + CalculatorData, + CalculatorViewModel + >() + @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var navigationStack: NavigationStackCompat + @State var isBarShown = false + @State var isTooBigInputSnackShown = false + @State var isTooBigOutputSnackShown = false + @State var isGenericErrorSnackShown = false + @State var isFewCurrencySnackShown = false + @State var isCopyClipboardSnackShown = false + @State var isPasteRequestSnackShown = false + + @State var isConversionSnackShown = false + static var conversionText: String? + static var conversionCode: String? + + private let analyticsManager: AnalyticsManager = koin.get() + + var body: some View { + CalculatorView( + event: observable.event, + state: observable.state + ) + .snack(isPresented: $isTooBigInputSnackShown) { + SnackView(text: Res.strings().text_too_big_input.get()) + } + .snack(isPresented: $isTooBigOutputSnackShown) { + SnackView(text: Res.strings().text_too_big_output.get()) + } + .snack(isPresented: $isPasteRequestSnackShown) { + SnackView( + text: Res.strings().text_paste_request.get(), + buttonText: Res.strings().text_paste.get(), + buttonAction: { + observable.event.onPasteToInput(text: UIPasteboard.general.string ?? "") + } + ) + } + .snack(isPresented: $isGenericErrorSnackShown) { + SnackView(text: Res.strings().error_text_unknown.get()) + } + .snack(isPresented: $isFewCurrencySnackShown) { + SnackView( + text: Res.strings().choose_at_least_two_currency.get(), + buttonText: Res.strings().select.get(), + buttonAction: { + navigationStack.push(CurrenciesRootView(onBaseChange: { observable.event.onBaseChange(base: $0) })) + } + ) + } + .snack(isPresented: $isCopyClipboardSnackShown) { + SnackView(text: Res.strings().copied_to_clipboard.get()) + } + .snack(isPresented: $isConversionSnackShown) { + if CalculatorRootView.conversionText != nil && CalculatorRootView.conversionCode != nil { + SnackView( + text: CalculatorRootView.conversionText!, + iconName: CalculatorRootView.conversionCode! + ) + } + } + .sheet( + isPresented: $isBarShown, + content: { + SelectCurrencyRootView( + isBarShown: $isBarShown, + onCurrencySelected: { observable.event.onBaseChange(base: $0) } + ).environmentObject(navigationStack) + } + ) + .onAppear { + observable.startObserving() + analyticsManager.trackScreen(screenName: ScreenName.Calculator()) + } + .onDisappear { observable.stopObserving() } + .onReceive(observable.effect) { onEffect(effect: $0) } + } + + private func onEffect(effect: CalculatorEffect) { + logger.i(message: { "CalculatorRootView onEffect \(effect.description)" }) + switch effect { + case is CalculatorEffect.Error: + isGenericErrorSnackShown.toggle() + case is CalculatorEffect.FewCurrency: + isFewCurrencySnackShown.toggle() + case is CalculatorEffect.TooBigInput: + isTooBigInputSnackShown.toggle() + case is CalculatorEffect.TooBigOutput: + isTooBigOutputSnackShown.toggle() + case is CalculatorEffect.OpenBar: + isBarShown = true + case is CalculatorEffect.OpenSettings: + navigationStack.push(SettingsRootView(onBaseChange: { observable.event.onBaseChange(base: $0) })) + case is CalculatorEffect.ShowPasteRequest: + isPasteRequestSnackShown.toggle() + case let copyToClipboardEffect as CalculatorEffect.CopyToClipboard: + let pasteBoard = UIPasteboard.general + pasteBoard.string = copyToClipboardEffect.amount + isCopyClipboardSnackShown.toggle() + case let showConversionEffect as CalculatorEffect.ShowConversion: + CalculatorRootView.conversionText = showConversionEffect.text + CalculatorRootView.conversionCode = showConversionEffect.code + isConversionSnackShown.toggle() + default: + logger.i(message: { "CalculatorRootView unknown effect" }) + } + } +} diff --git a/ios/CCC/UI/Calculator/CalculatorView.swift b/ios/CCC/UI/Calculator/CalculatorView.swift index db95fe458d..b929185dd4 100644 --- a/ios/CCC/UI/Calculator/CalculatorView.swift +++ b/ios/CCC/UI/Calculator/CalculatorView.swift @@ -1,215 +1,82 @@ // -// MainView.swift +// CalculatorView.swift // CCC // -// Created by Mustafa Ozhan on 16/11/2020. -// Copyright © 2020 orgName. All rights reserved. +// Created by Mustafa Ozhan on 21.08.23. +// Copyright © 2023 orgName. All rights reserved. // -import NavigationStack -import PopupView import Provider import Res import SwiftUI struct CalculatorView: View { - @StateObject var observable = ObservableSEEDViewModel< - CalculatorState, - CalculatorEffect, - CalculatorEvent, - CalculatorData, - CalculatorViewModel - >() - @Environment(\.colorScheme) var colorScheme - @EnvironmentObject private var navigationStack: NavigationStackCompat - @State var isBarShown = false - @State var isTooBigInputSnackShown = false - @State var isTooBigOutputSnackShown = false - @State var isGenericErrorSnackShown = false - @State var isFewCurrencySnackShown = false - @State var isCopyClipboardSnackShown = false - @State var isPasteRequestSnackShown = false + @Environment(\.colorScheme) private var colorScheme - @State var isConversionSnackShown = false - static var conversionText: String? - static var conversionCode: String? - - private let analyticsManager: AnalyticsManager = koin.get() + var event: CalculatorEvent + var state: CalculatorState var body: some View { - NavigationView { - ZStack { - Color(Res.colors().background_strong.get()).edgesIgnoringSafeArea(.all) + ZStack { + Color(Res.colors().background_strong.get()).edgesIgnoringSafeArea(.all) - VStack { - InputView( - input: observable.state.input, - onSettingsClick: observable.event.onSettingsClicked, - onInputLongClick: observable.event.onInputLongClick - ) + VStack { + InputView( + input: state.input, + onSettingsClick: event.onSettingsClicked, + onInputLongClick: event.onInputLongClick + ) - OutputView( - baseCurrency: observable.state.base, - output: observable.state.output, - symbol: observable.state.symbol, - onBarClick: observable.event.onBarClick, - onOutputLongClick: observable.event.onOutputLongClick - ) + OutputView( + baseCurrency: state.base, + output: state.output, + symbol: state.symbol, + onBarClick: event.onBarClick, + onOutputLongClick: event.onOutputLongClick + ) - if observable.state.loading { - FormProgressView() - .padding(bottom: 4.cp()) - } else { - Form { - List( - CalculatorUtilKt.toValidList( - observable.state.currencyList, - currentBase: observable.state.base - ), - id: \.code - ) { - CalculatorItemView( - item: $0, - onItemClick: { observable.event.onItemClick(currency: $0) }, - onItemImageLongClick: { observable.event.onItemImageLongClick(currency: $0) }, - onItemAmountLongClick: { observable.event.onItemAmountLongClick(amount: $0) } - ) - } - .listRowInsets(.init()) - .listRowBackground(Res.colors().background.get()) - .animation(.default) - } - .withClearBackground(color: Res.colors().background.get()) + if state.loading { + FormProgressView() .padding(bottom: 4.cp()) + } else { + Form { + List( + CalculatorUtilKt.toValidList( + state.currencyList, + currentBase: state.base + ), + id: \.code + ) { + CalculatorItemView( + item: $0, + onItemClick: { event.onItemClick(currency: $0) }, + onItemImageLongClick: { event.onItemImageLongClick(currency: $0) }, + onItemAmountLongClick: { event.onItemAmountLongClick(amount: $0) } + ) + } + .listRowInsets(.init()) + .listRowBackground(Res.colors().background.get()) + .animation(.default) } + .withClearBackground(color: Res.colors().background.get()) + .padding(bottom: 4.cp()) + } - KeyboardView(onKeyPress: { observable.event.onKeyPress(key: $0) }) - - if !(observable.state.conversionState is ConversionState.None) { - ConversionStateView( - color: observable.state.conversionState.getColor(), - text: observable.state.conversionState.getText() - ) - } + KeyboardView(onKeyPress: { event.onKeyPress(key: $0) }) - if observable.viewModel.shouldShowBannerAd() { - AdaptiveBannerAdView(unitID: "BANNER_AD_UNIT_ID_CALCULATOR").adapt() - } - } - } - .background(Res.colors().background_strong.get()) - .navigationBarHidden(true) - } - .navigationViewStyle(StackNavigationViewStyle()) - .popup( - isPresented: $isTooBigInputSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().text_too_big_input.get()) - } - .popup( - isPresented: $isTooBigOutputSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().text_too_big_output.get()) - } - .popup(isPresented: $isPasteRequestSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView( - text: Res.strings().text_paste_request.get(), - buttonText: Res.strings().text_paste.get(), - buttonAction: { - observable.event.pasteToInput(text: UIPasteboard.general.string ?? "") + if !(state.conversionState is ConversionState.None) { + ConversionStateView( + color: state.conversionState.getColor(), + text: state.conversionState.getText() + ) } - ) - } - .popup( - isPresented: $isGenericErrorSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().error_text_unknown.get()) - } - .popup( - isPresented: $isFewCurrencySnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView( - text: Res.strings().choose_at_least_two_currency.get(), - buttonText: Res.strings().select.get(), - buttonAction: { - navigationStack.push(CurrenciesView(onBaseChange: { observable.event.onBaseChange(base: $0) })) + + if state.isBannerAdVisible { + AdaptiveBannerAdView(unitID: "BANNER_AD_UNIT_ID_CALCULATOR").adapt() } - ) - } - .popup( - isPresented: $isCopyClipboardSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().copied_to_clipboard.get()) - } - .popup( - isPresented: $isConversionSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - if CalculatorView.conversionText != nil && CalculatorView.conversionCode != nil { - SnackView( - text: CalculatorView.conversionText!, - iconName: CalculatorView.conversionCode! - ) - } - } - .sheet( - isPresented: $isBarShown, - content: { - SelectCurrencyView( - isBarShown: $isBarShown, - onCurrencySelected: { observable.event.onBaseChange(base: $0) } - ).environmentObject(navigationStack) } - ) - .onAppear { - observable.startObserving() - analyticsManager.trackScreen(screenName: ScreenName.Calculator()) - } - .onDisappear { observable.stopObserving() } - .onReceive(observable.effect) { onEffect(effect: $0) } - } - - private func onEffect(effect: CalculatorEffect) { - logger.i(message: { "CalculatorView onEffect \(effect.description)" }) - switch effect { - case is CalculatorEffect.Error: - isGenericErrorSnackShown.toggle() - case is CalculatorEffect.FewCurrency: - isFewCurrencySnackShown.toggle() - case is CalculatorEffect.TooBigInput: - isTooBigInputSnackShown.toggle() - case is CalculatorEffect.TooBigOutput: - isTooBigOutputSnackShown.toggle() - case is CalculatorEffect.OpenBar: - isBarShown = true - case is CalculatorEffect.OpenSettings: - navigationStack.push(SettingsView(onBaseChange: { observable.event.onBaseChange(base: $0) })) - case is CalculatorEffect.ShowPasteRequest: - isPasteRequestSnackShown.toggle() - case let copyToClipboardEffect as CalculatorEffect.CopyToClipboard: - let pasteBoard = UIPasteboard.general - pasteBoard.string = copyToClipboardEffect.amount - isCopyClipboardSnackShown.toggle() - case let showConversionEffect as CalculatorEffect.ShowConversion: - CalculatorView.conversionText = showConversionEffect.text - CalculatorView.conversionCode = showConversionEffect.code - isConversionSnackShown.toggle() - default: - logger.i(message: { "CalculatorView unknown effect" }) } + .navigationBarHidden(true) + .background(Res.colors().background_strong.get()) } } diff --git a/ios/CCC/UI/Calculator/InputView.swift b/ios/CCC/UI/Calculator/InputView.swift index 4eed21a42c..a17f9f4199 100644 --- a/ios/CCC/UI/Calculator/InputView.swift +++ b/ios/CCC/UI/Calculator/InputView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct InputView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme var input: String var onSettingsClick: () -> Void diff --git a/ios/CCC/UI/Calculator/KeyboardView.swift b/ios/CCC/UI/Calculator/KeyboardView.swift index ebb2b6f7f9..35f470c802 100644 --- a/ios/CCC/UI/Calculator/KeyboardView.swift +++ b/ios/CCC/UI/Calculator/KeyboardView.swift @@ -12,7 +12,7 @@ import SwiftUI struct KeyboardView: View { var onKeyPress: (String) -> Void - let keys = [ + private let keys = [ [Res.strings().seven.get(), Res.strings().eight.get(), Res.strings().nine.get(), Res.strings().multiply.get()], [Res.strings().four.get(), Res.strings().five.get(), Res.strings().six.get(), Res.strings().divide.get()], [Res.strings().one.get(), Res.strings().two.get(), Res.strings().three.get(), Res.strings().minus.get()], diff --git a/ios/CCC/UI/Components/ActionButton.swift b/ios/CCC/UI/Components/ActionButton.swift index 8ad0108f16..8dff254137 100644 --- a/ios/CCC/UI/Components/ActionButton.swift +++ b/ios/CCC/UI/Components/ActionButton.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct ActionButton: View { - @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.colorScheme) private var colorScheme: ColorScheme let buttonText: String let buttonAction: () -> Void diff --git a/ios/CCC/UI/Components/AdaptiveBannerAdView.swift b/ios/CCC/UI/Components/AdaptiveBannerAdView.swift index 436eb47abd..3f78f292c8 100644 --- a/ios/CCC/UI/Components/AdaptiveBannerAdView.swift +++ b/ios/CCC/UI/Components/AdaptiveBannerAdView.swift @@ -21,7 +21,7 @@ struct AdaptiveBannerAdView: UIViewControllerRepresentable { self.unitID = SecretUtil.getSecret(key: unitID) } - let bannerView = GADBannerView(adSize: GADAdSizeBanner) + private let bannerView = GADBannerView(adSize: GADAdSizeBanner) func makeUIViewController(context: Context) -> UIViewController { let viewController = UIViewController() diff --git a/ios/CCC/UI/Components/AlertView.swift b/ios/CCC/UI/Components/AlertView.swift index 59e5fdfd4e..524f27e522 100644 --- a/ios/CCC/UI/Components/AlertView.swift +++ b/ios/CCC/UI/Components/AlertView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct AlertView: View { - @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.colorScheme) private var colorScheme: ColorScheme let title: String let message: String let buttonText: String diff --git a/ios/CCC/UI/Components/FormProgressView.swift b/ios/CCC/UI/Components/FormProgressView.swift index c0a063850e..aee3159c1d 100644 --- a/ios/CCC/UI/Components/FormProgressView.swift +++ b/ios/CCC/UI/Components/FormProgressView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct FormProgressView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme var body: some View { VStack { diff --git a/ios/CCC/UI/Components/SelectCurrenciesBottomView.swift b/ios/CCC/UI/Components/SelectCurrenciesBottomView.swift index 6888a2163f..ddd195493c 100644 --- a/ios/CCC/UI/Components/SelectCurrenciesBottomView.swift +++ b/ios/CCC/UI/Components/SelectCurrenciesBottomView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct SelectCurrenciesBottomView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme var text: String var buttonText: String diff --git a/ios/CCC/UI/Components/SnackView.swift b/ios/CCC/UI/Components/SnackView.swift index d57b2efa1b..957eea1a6a 100644 --- a/ios/CCC/UI/Components/SnackView.swift +++ b/ios/CCC/UI/Components/SnackView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct SnackView: View { - @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.colorScheme) private var colorScheme: ColorScheme var text: String var iconName: String? diff --git a/ios/CCC/UI/Components/ToolbarButton.swift b/ios/CCC/UI/Components/ToolbarButton.swift index 71ce66401c..96f4ac598f 100644 --- a/ios/CCC/UI/Components/ToolbarButton.swift +++ b/ios/CCC/UI/Components/ToolbarButton.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct ToolbarButton: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme var clickEvent: () -> Void var imgName: String diff --git a/ios/CCC/UI/Currencies/CurrenciesItemView.swift b/ios/CCC/UI/Currencies/CurrenciesItemView.swift index f309fda02f..37176911ec 100644 --- a/ios/CCC/UI/Currencies/CurrenciesItemView.swift +++ b/ios/CCC/UI/Currencies/CurrenciesItemView.swift @@ -11,7 +11,7 @@ import Res import SwiftUI struct CurrenciesItemView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme @State var item: Currency var onItemClick: () -> Void diff --git a/ios/CCC/UI/Currencies/CurrenciesRootView.swift b/ios/CCC/UI/Currencies/CurrenciesRootView.swift new file mode 100644 index 0000000000..5d52f79d80 --- /dev/null +++ b/ios/CCC/UI/Currencies/CurrenciesRootView.swift @@ -0,0 +1,60 @@ +// +// CurrenciesRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 21/01/2021. +// Copyright © 2021 orgName. All rights reserved. +// + +import NavigationStack +import Provider +import Res +import SwiftUI + +struct CurrenciesRootView: View { + @StateObject var observable = ObservableSEEDViewModel< + CurrenciesState, + CurrenciesEffect, + CurrenciesEvent, + CurrenciesData, + CurrenciesViewModel + >() + @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var navigationStack: NavigationStackCompat + @State var isFewCurrencySnackShown = false + + private let analyticsManager: AnalyticsManager = koin.get() + + var onBaseChange: (String) -> Void + + var body: some View { + CurrenciesView( + event: observable.event, + state: observable.state + ).snack(isPresented: $isFewCurrencySnackShown) { + SnackView(text: Res.strings().choose_at_least_two_currency.get()) + } + .onAppear { + observable.startObserving() + analyticsManager.trackScreen(screenName: ScreenName.Currencies()) + } + .onDisappear { observable.stopObserving() } + .onReceive(observable.effect) { onEffect(effect: $0) } + } + + private func onEffect(effect: CurrenciesEffect) { + logger.i(message: { "CurrenciesRootView onEffect \(effect.description)" }) + switch effect { + case is CurrenciesEffect.FewCurrency: + isFewCurrencySnackShown.toggle() + case is CurrenciesEffect.OpenCalculator: + navigationStack.push(CalculatorRootView()) + case is CurrenciesEffect.Back: + navigationStack.pop() + case let changeBaseEffect as CurrenciesEffect.ChangeBase: + onBaseChange(changeBaseEffect.newBase) + default: + logger.i(message: { "CurrenciesRootView unknown effect" }) + } + } +} diff --git a/ios/CCC/UI/Currencies/CurrenciesToolbarView.swift b/ios/CCC/UI/Currencies/CurrenciesToolbarView.swift index 65b04de1b6..05e606c296 100644 --- a/ios/CCC/UI/Currencies/CurrenciesToolbarView.swift +++ b/ios/CCC/UI/Currencies/CurrenciesToolbarView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct CurrenciesToolbarView: View { - var firstRun: Bool + var isOnboardingVisible: Bool var onBackClick: () -> Void var onQueryChange: (String) -> Void @@ -19,7 +19,7 @@ struct CurrenciesToolbarView: View { var body: some View { HStack { - if firstRun { + if isOnboardingVisible { Text("").padding(trailing: 8.cp()) } else { ToolbarButton(clickEvent: onBackClick, imgName: "chevron.left") diff --git a/ios/CCC/UI/Currencies/CurrenciesView.swift b/ios/CCC/UI/Currencies/CurrenciesView.swift index ed7f237d15..2fb2a555c2 100644 --- a/ios/CCC/UI/Currencies/CurrenciesView.swift +++ b/ios/CCC/UI/Currencies/CurrenciesView.swift @@ -2,58 +2,47 @@ // CurrenciesView.swift // CCC // -// Created by Mustafa Ozhan on 21/01/2021. -// Copyright © 2021 orgName. All rights reserved. +// Created by Mustafa Ozhan on 21.08.23. +// Copyright © 2023 orgName. All rights reserved. // -import NavigationStack import Provider import Res import SwiftUI struct CurrenciesView: View { - @StateObject var observable = ObservableSEEDViewModel< - CurrenciesState, - CurrenciesEffect, - CurrenciesEvent, - CurrenciesData, - CurrenciesViewModel - >() - @Environment(\.colorScheme) var colorScheme - @EnvironmentObject private var navigationStack: NavigationStackCompat - @State var isFewCurrencySnackShown = false + @Environment(\.colorScheme) private var colorScheme - private let analyticsManager: AnalyticsManager = koin.get() - - var onBaseChange: (String) -> Void + var event: CurrenciesEvent + var state: CurrenciesState var body: some View { ZStack { Res.colors().background_strong.get().edgesIgnoringSafeArea(.all) VStack { - if observable.state.selectionVisibility { + if state.selectionVisibility { SelectionView( - onCloseClick: observable.event.onCloseClick, - updateAllCurrenciesState: { observable.event.updateAllCurrenciesState(state: $0) } + onCloseClick: event.onCloseClick, + updateAllCurrenciesState: { event.updateAllCurrenciesState(state: $0) } ) } else { CurrenciesToolbarView( - firstRun: observable.viewModel.isFirstRun(), - onBackClick: observable.event.onCloseClick, - onQueryChange: { observable.event.onQueryChange(query: $0) } + isOnboardingVisible: state.isOnboardingVisible, + onBackClick: event.onCloseClick, + onQueryChange: { event.onQueryChange(query: $0) } ) } - if observable.state.loading { + if state.loading { FormProgressView() } else { Form { - List(observable.state.currencyList, id: \.code) { currency in + List(state.currencyList, id: \.code) { currency in CurrenciesItemView( item: currency, - onItemClick: { observable.event.onItemClick(currency: currency) }, - onItemLongClick: observable.event.onItemLongClick + onItemClick: { event.onItemClick(currency: currency) }, + onItemLongClick: event.onItemLongClick ) } .listRowInsets(.init()) @@ -63,49 +52,20 @@ struct CurrenciesView: View { .withClearBackground(color: Res.colors().background.get()) } - if observable.viewModel.isFirstRun() { + if state.isOnboardingVisible { SelectCurrenciesBottomView( text: Res.strings().txt_select_currencies.get(), buttonText: Res.strings().btn_done.get(), - onButtonClick: observable.event.onDoneClick + onButtonClick: event.onDoneClick ) } - if observable.viewModel.shouldShowBannerAd() { + if state.isBannerAdVisible { AdaptiveBannerAdView(unitID: "BANNER_AD_UNIT_ID_CURRENCIES").adapt() } } .animation(.default) .navigationBarHidden(true) } - .popup( - isPresented: $isFewCurrencySnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().choose_at_least_two_currency.get()) - } - .onAppear { - observable.startObserving() - analyticsManager.trackScreen(screenName: ScreenName.Currencies()) - } - .onDisappear { observable.stopObserving() } - .onReceive(observable.effect) { onEffect(effect: $0) } - } - - private func onEffect(effect: CurrenciesEffect) { - logger.i(message: { "CurrenciesView onEffect \(effect.description)" }) - switch effect { - case is CurrenciesEffect.FewCurrency: - isFewCurrencySnackShown.toggle() - case is CurrenciesEffect.OpenCalculator: - navigationStack.push(CalculatorView()) - case is CurrenciesEffect.Back: - navigationStack.pop() - case let changeBaseEffect as CurrenciesEffect.ChangeBase: - onBaseChange(changeBaseEffect.newBase) - default: - logger.i(message: { "CurrenciesView unknown effect" }) - } } } diff --git a/ios/CCC/UI/Main/MainRootView.swift b/ios/CCC/UI/Main/MainRootView.swift new file mode 100644 index 0000000000..79526c2afe --- /dev/null +++ b/ios/CCC/UI/Main/MainRootView.swift @@ -0,0 +1,48 @@ +// +// MainView.swift +// CCC +// +// Created by Mustafa Ozhan on 28/01/2021. +// Copyright © 2021 orgName. All rights reserved. +// + +import GoogleMobileAds +import NavigationStack +import Provider +import Res +import SwiftUI + +struct MainRootView: View { + @StateObject var observable = ObservableSEEDViewModel< + MainState, + MainEffect, + MainEvent, + MainData, + MainViewModel + >() + + var body: some View { + MainView( + state: observable.state + ) + .onAppear { + observable.startObserving() + observable.event.onResume() + } + .onDisappear { + observable.stopObserving() + observable.event.onPause() + } + .onReceive(observable.effect) { onEffect(effect: $0) } + } + + private func onEffect(effect: MainEffect) { + logger.i(message: { "MainRootView onEffect \(effect.description)" }) + switch effect { + case is MainEffect.ShowInterstitialAd: + InterstitialAd().show() + default: + logger.i(message: { "MainRootView unknown effect" }) + } + } +} diff --git a/ios/CCC/UI/Main/MainView.swift b/ios/CCC/UI/Main/MainView.swift index adccad1544..dbac5a7b80 100644 --- a/ios/CCC/UI/Main/MainView.swift +++ b/ios/CCC/UI/Main/MainView.swift @@ -2,54 +2,27 @@ // MainView.swift // CCC // -// Created by Mustafa Ozhan on 28/01/2021. -// Copyright © 2021 orgName. All rights reserved. +// Created by Mustafa Ozhan on 23.08.23. +// Copyright © 2023 orgName. All rights reserved. // -import GoogleMobileAds -import NavigationStack -import Provider -import Res import SwiftUI +import Provider +import NavigationStack struct MainView: View { - @StateObject var observable = ObservableSEEDViewModel< - BaseState, - MainEffect, - MainEvent, - MainData, - MainViewModel - >() + let state: MainState var body: some View { NavigationStackView( transitionType: .default, easing: Animation.easeInOut ) { - if observable.viewModel.isFistRun() { - SliderView() + if state.shouldOnboardUser { + IntroSlideRootView() } else { - CalculatorView() + CalculatorRootView() } } - .onAppear { - observable.startObserving() - observable.event.onResume() - } - .onDisappear { - observable.stopObserving() - observable.event.onPause() - } - .onReceive(observable.effect) { onEffect(effect: $0) } - } - - private func onEffect(effect: MainEffect) { - logger.i(message: { "MainView onEffect \(effect.description)" }) - switch effect { - case is MainEffect.ShowInterstitialAd: - InterstitialAd().show() - default: - logger.i(message: { "MainView unknown effect" }) - } } } diff --git a/ios/CCC/UI/Premium/PremiumItemView.swift b/ios/CCC/UI/Premium/PremiumItemView.swift index ade6cb5565..c2c932651c 100644 --- a/ios/CCC/UI/Premium/PremiumItemView.swift +++ b/ios/CCC/UI/Premium/PremiumItemView.swift @@ -11,7 +11,7 @@ import Provider import Res struct PremiumItemView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme let item: PremiumType? diff --git a/ios/CCC/UI/Premium/PremiumRootView.swift b/ios/CCC/UI/Premium/PremiumRootView.swift new file mode 100644 index 0000000000..1430130e64 --- /dev/null +++ b/ios/CCC/UI/Premium/PremiumRootView.swift @@ -0,0 +1,73 @@ +// +// PremiumRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 26.02.23. +// Copyright © 2023 orgName. All rights reserved. +// + +import Res +import Provider +import SwiftUI + +struct PremiumRootView: View { + @StateObject var observable = ObservableSEEDViewModel< + PremiumState, + PremiumEffect, + PremiumEvent, + BaseData, + PremiumViewModel + >() + @Environment(\.colorScheme) private var colorScheme + @Binding var premiumViewVisibility: Bool + @State var isPremiumDialogShown = false + + private let analyticsManager: AnalyticsManager = koin.get() + + var body: some View { + PremiumView( + event: observable.event, + state: observable.state + ) + .alert(isPresented: $isPremiumDialogShown) { + AlertView( + title: Res.strings().txt_premium.get(), + message: Res.strings().txt_premium_text.get(), + buttonText: Res.strings().txt_watch.get(), + buttonAction: { + RewardedAd( + onReward: { + observable.event.onPremiumActivated( + adType: PremiumType.video, + startDate: DateUtilKt.nowAsLong(), + isRestorePurchase: false + ) + }, + onError: { + observable.event.onPremiumActivationFailed() + } + ).show() + } + ) + } + .onAppear { + observable.startObserving() + observable.event.onPremiumActivationFailed() // no billing implementation on iOS yet + analyticsManager.trackScreen(screenName: ScreenName.Premium()) + } + .onDisappear { observable.stopObserving() } + .onReceive(observable.effect) { onEffect(effect: $0) } + } + + private func onEffect(effect: PremiumEffect) { + logger.i(message: { "PremiumRootView onEffect \(effect.description)" }) + switch effect { + case let launchActivatePremiumFlowEffect as PremiumEffect.LaunchActivatePremiumFlow: + if launchActivatePremiumFlowEffect.premiumType == PremiumType.video { + isPremiumDialogShown.toggle() + } + default: + logger.i(message: { "PremiumRootView unknown effect" }) + } + } +} diff --git a/ios/CCC/UI/Premium/PremiumView.swift b/ios/CCC/UI/Premium/PremiumView.swift index b3cec5a417..27e25d9898 100644 --- a/ios/CCC/UI/Premium/PremiumView.swift +++ b/ios/CCC/UI/Premium/PremiumView.swift @@ -2,7 +2,7 @@ // PremiumView.swift // CCC // -// Created by Mustafa Ozhan on 26.02.23. +// Created by Mustafa Ozhan on 21.08.23. // Copyright © 2023 orgName. All rights reserved. // @@ -11,96 +11,44 @@ import Provider import SwiftUI struct PremiumView: View { - @StateObject var observable = ObservableSEEDViewModel< - PremiumState, - PremiumEffect, - PremiumEvent, - BaseData, - PremiumViewModel - >() - @Environment(\.colorScheme) var colorScheme - @Binding var premiumViewVisibility: Bool - @State var isPremiumDialogShown = false + @Environment(\.colorScheme) private var colorScheme - private let analyticsManager: AnalyticsManager = koin.get() + var event: PremiumEvent + var state: PremiumState var body: some View { - NavigationView { - ZStack { - Color(Res.colors().background_strong.get()).edgesIgnoringSafeArea(.all) - - VStack { - Text(Res.strings().txt_premium.get()) - .font(relative: .title2) - .padding(4.cp()) - .padding(.top, 10.cp()) + ZStack { + Color(Res.colors().background_strong.get()).edgesIgnoringSafeArea(.all) + + VStack { + Text(Res.strings().txt_premium.get()) + .font(relative: .title2) + .padding(4.cp()) + .padding(.top, 10.cp()) + + if state.loading { + FormProgressView() + } else { + Form { + List(state.premiumTypes, id: \.data) { premiumType in + PremiumItemView(item: premiumType) + .onTapGesture { + event.onPremiumItemClick(type: premiumType) + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) + } + .listRowInsets(.init()) + .listRowBackground(Res.colors().background.get()) - if observable.state.loading { - FormProgressView() - } else { - Form { - List(observable.state.premiumTypes, id: \.data) { premiumType in - PremiumItemView(item: premiumType) - .onTapGesture { - observable.event.onPremiumItemClick(type: premiumType) - } - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - } + PremiumItemView(item: nil) .listRowInsets(.init()) .listRowBackground(Res.colors().background.get()) - - PremiumItemView(item: nil) - .listRowInsets(.init()) - .listRowBackground(Res.colors().background.get()) - } - .withClearBackground(color: Res.colors().background.get()) } - - Spacer() - }.navigationBarHidden(true) - } - } - .popup(isPresented: $isPremiumDialogShown) { - AlertView( - title: Res.strings().txt_premium.get(), - message: Res.strings().txt_premium_text.get(), - buttonText: Res.strings().txt_watch.get(), - buttonAction: { - observable.viewModel.showLoadingView(shouldShow: true) - - RewardedAd( - onReward: { - observable.viewModel.updatePremiumEndDate( - adType: PremiumType.video, - startDate: DateUtilKt.nowAsLong(), - isRestorePurchase: false - ) - }, - onError: { - observable.viewModel.showLoadingView(shouldShow: false) - } - ).show() + .withClearBackground(color: Res.colors().background.get()) } - ) - } - .onAppear { - observable.startObserving() - observable.viewModel.showLoadingView(shouldShow: false) - analyticsManager.trackScreen(screenName: ScreenName.Premium()) - } - .onDisappear { observable.stopObserving() } - .onReceive(observable.effect) { onEffect(effect: $0) } - } - private func onEffect(effect: PremiumEffect) { - logger.i(message: { "PremiumView onEffect \(effect.description)" }) - switch effect { - case let launchActivatePremiumFlowEffect as PremiumEffect.LaunchActivatePremiumFlow: - if launchActivatePremiumFlowEffect.premiumType == PremiumType.video { - isPremiumDialogShown.toggle() - } - default: - logger.i(message: { "PremiumView unknown effect" }) + Spacer() + }.navigationBarHidden(true) } } } diff --git a/ios/CCC/UI/SelectCurrency/SelectCurrencyItemView.swift b/ios/CCC/UI/SelectCurrency/SelectCurrencyItemView.swift index ede4bad2f6..f4b0640f07 100644 --- a/ios/CCC/UI/SelectCurrency/SelectCurrencyItemView.swift +++ b/ios/CCC/UI/SelectCurrency/SelectCurrencyItemView.swift @@ -11,7 +11,7 @@ import Res import SwiftUI struct SelectCurrencyItemView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme var item: Currency var body: some View { diff --git a/ios/CCC/UI/SelectCurrency/SelectCurrencyRootView.swift b/ios/CCC/UI/SelectCurrency/SelectCurrencyRootView.swift new file mode 100644 index 0000000000..ff10086610 --- /dev/null +++ b/ios/CCC/UI/SelectCurrency/SelectCurrencyRootView.swift @@ -0,0 +1,55 @@ +// +// SelectCurrencyRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 23/01/2021. +// Copyright © 2021 orgName. All rights reserved. +// + +import NavigationStack +import Provider +import Res +import SwiftUI + +struct SelectCurrencyRootView: View { + @StateObject var observable = ObservableSEEDViewModel< + SelectCurrencyState, + SelectCurrencyEffect, + SelectCurrencyEvent, + BaseData, + SelectCurrencyViewModel + >() + @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var navigationStack: NavigationStackCompat + @Binding var isBarShown: Bool + + private let analyticsManager: AnalyticsManager = koin.get() + + var onCurrencySelected: (String) -> Void + + var body: some View { + SelectCurrencyView( + event: observable.event, + state: observable.state + ) + .onAppear { + observable.startObserving() + analyticsManager.trackScreen(screenName: ScreenName.SelectCurrency()) + } + .onDisappear { observable.stopObserving() } + .onReceive(observable.effect) { onEffect(effect: $0) } + } + + private func onEffect(effect: SelectCurrencyEffect) { + logger.i(message: { "SelectCurrencyRootView onEffect \(effect.description)" }) + switch effect { + case let currencyChangeEffect as SelectCurrencyEffect.CurrencyChange: + onCurrencySelected(currencyChangeEffect.newBase) + isBarShown = false + case is SelectCurrencyEffect.OpenCurrencies: + navigationStack.push(CurrenciesRootView(onBaseChange: onCurrencySelected)) + default: + logger.i(message: { "SelectCurrencyRootView unknown effect" }) + } + } +} diff --git a/ios/CCC/UI/SelectCurrency/SelectCurrencyView.swift b/ios/CCC/UI/SelectCurrency/SelectCurrencyView.swift index 4df34d2dcc..f2369c3e30 100644 --- a/ios/CCC/UI/SelectCurrency/SelectCurrencyView.swift +++ b/ios/CCC/UI/SelectCurrency/SelectCurrencyView.swift @@ -1,90 +1,58 @@ // -// SelectCurrencyObservable.swift +// SelectCurrencyView.swift // CCC // -// Created by Mustafa Ozhan on 23/01/2021. -// Copyright © 2021 orgName. All rights reserved. +// Created by Mustafa Ozhan on 24.08.23. +// Copyright © 2023 orgName. All rights reserved. // -import NavigationStack import Provider import Res import SwiftUI struct SelectCurrencyView: View { - @StateObject var observable = ObservableSEEDViewModel< - SelectCurrencyState, - SelectCurrencyEffect, - SelectCurrencyEvent, - BaseData, - SelectCurrencyViewModel - >() - @Environment(\.colorScheme) var colorScheme - @EnvironmentObject private var navigationStack: NavigationStackCompat - @Binding var isBarShown: Bool + @Environment(\.colorScheme) private var colorScheme - private let analyticsManager: AnalyticsManager = koin.get() - - var onCurrencySelected: (String) -> Void + var event: SelectCurrencyEvent + var state: SelectCurrencyState var body: some View { - NavigationView { - ZStack { - Color(Res.colors().background_strong.get()).edgesIgnoringSafeArea(.all) - - VStack { - Text(Res.strings().txt_select_base_currency.get()) - .font(relative: .title2) - .padding(4.cp()) - .padding(.top, 10.cp()) - - if observable.state.loading { - FormProgressView() - } else { - Form { - List(observable.state.currencyList, id: \.code) { currency in - SelectCurrencyItemView(item: currency) - .onTapGesture { observable.event.onItemClick(currency: currency) } - .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) - } - .listRowInsets(.init()) - .listRowBackground(Res.colors().background.get()) + ZStack { + Color(Res.colors().background_strong.get()).edgesIgnoringSafeArea(.all) + + VStack { + Text(Res.strings().txt_select_base_currency.get()) + .font(relative: .title2) + .padding(4.cp()) + .padding(.top, 10.cp()) + + if state.loading { + FormProgressView() + } else { + Form { + List(state.currencyList, id: \.code) { currency in + SelectCurrencyItemView(item: currency) + .onTapGesture { event.onItemClick(currency: currency) } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .center) } - .withClearBackground(color: Res.colors().background.get()) + .listRowInsets(.init()) + .listRowBackground(Res.colors().background.get()) } - - Spacer() - - SelectCurrenciesBottomView( - text: observable.state.enoughCurrency ? - Res.strings().txt_update_favorite_currencies.get() : - Res.strings().choose_at_least_two_currency.get(), - buttonText: observable.state.enoughCurrency ? - Res.strings().update.get() : - Res.strings().select.get(), - onButtonClick: observable.event.onSelectClick - ).listRowBackground(Res.colors().background.get()) - }.navigationBarHidden(true) - } - } - .onAppear { - observable.startObserving() - analyticsManager.trackScreen(screenName: ScreenName.SelectCurrency()) - } - .onDisappear { observable.stopObserving() } - .onReceive(observable.effect) { onEffect(effect: $0) } - } - - private func onEffect(effect: SelectCurrencyEffect) { - logger.i(message: { "SelectCurrencyView onEffect \(effect.description)" }) - switch effect { - case let currencyChangeEffect as SelectCurrencyEffect.CurrencyChange: - onCurrencySelected(currencyChangeEffect.newBase) - isBarShown = false - case is SelectCurrencyEffect.OpenCurrencies: - navigationStack.push(CurrenciesView(onBaseChange: onCurrencySelected)) - default: - logger.i(message: { "BarView unknown effect" }) + .withClearBackground(color: Res.colors().background.get()) + } + + Spacer() + + SelectCurrenciesBottomView( + text: state.enoughCurrency ? + Res.strings().txt_update_favorite_currencies.get() : + Res.strings().choose_at_least_two_currency.get(), + buttonText: state.enoughCurrency ? + Res.strings().update.get() : + Res.strings().select.get(), + onButtonClick: event.onSelectClick + ).listRowBackground(Res.colors().background.get()) + }.navigationBarHidden(true) } } } diff --git a/ios/CCC/UI/Settings/SettingsItemView.swift b/ios/CCC/UI/Settings/SettingsItemView.swift index e4599cc12e..8860568cf0 100644 --- a/ios/CCC/UI/Settings/SettingsItemView.swift +++ b/ios/CCC/UI/Settings/SettingsItemView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct SettingsItemView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme let imgName: String let title: String let subTitle: String diff --git a/ios/CCC/UI/Settings/SettingsRootView.swift b/ios/CCC/UI/Settings/SettingsRootView.swift new file mode 100644 index 0000000000..3a4d7ffd0c --- /dev/null +++ b/ios/CCC/UI/Settings/SettingsRootView.swift @@ -0,0 +1,99 @@ +// +// SettingsRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 23/01/2021. +// Copyright © 2021 orgName. All rights reserved. +// + +import GoogleMobileAds +import NavigationStack +import Provider +import Res +import SwiftUI + +struct SettingsRootView: View { + @StateObject var observable = ObservableSEEDViewModel< + SettingsState, + SettingsEffect, + SettingsEvent, + SettingsData, + SettingsViewModel + >() + @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var navigationStack: NavigationStackCompat + @State var premiumViewVisibility = false + @State var emailViewVisibility = false + @State var webViewVisibility = false + @State var isAdsAlreadyDisabledSnackShown = false + @State var isAlreadySyncedSnackShown = false + @State var isSynchronisingShown = false + @State var isSyncedSnackShown = false + + private let analyticsManager: AnalyticsManager = koin.get() + + var onBaseChange: ((String) -> Void) + + var body: some View { + SettingsView( + event: observable.event, + state: observable.state + ) + .snack(isPresented: $isAdsAlreadyDisabledSnackShown) { + SnackView(text: Res.strings().txt_you_already_have_premium.get()) + } + .snack(isPresented: $isAlreadySyncedSnackShown) { + SnackView(text: Res.strings().txt_already_synced.get()) + } + .snack(isPresented: $isSynchronisingShown) { + SnackView(text: Res.strings().txt_synchronising.get()) + } + .snack(isPresented: $isSyncedSnackShown) { + SnackView(text: Res.strings().txt_synced.get()) + } + .sheet(isPresented: $premiumViewVisibility) { + PremiumRootView(premiumViewVisibility: $premiumViewVisibility) + } + .sheet(isPresented: $emailViewVisibility) { + MailView(isShowing: $emailViewVisibility) + } + .sheet(isPresented: $webViewVisibility) { + WebView(url: NSURL(string: Res.strings().github_url.get())! as URL) + } + .onAppear { + observable.startObserving() + analyticsManager.trackScreen(screenName: ScreenName.Settings()) + } + .onDisappear { observable.stopObserving() } + .onReceive(observable.effect) { onEffect(effect: $0) } + } + + // swiftlint:disable:next cyclomatic_complexity + private func onEffect(effect: SettingsEffect) { + logger.i(message: { "SettingsRootView onEffect \(effect.description)" }) + switch effect { + case is SettingsEffect.Back: + navigationStack.pop() + case is SettingsEffect.OpenCurrencies: + navigationStack.push(CurrenciesRootView(onBaseChange: onBaseChange)) + case is SettingsEffect.OpenWatchers: + navigationStack.push(WatchersRootView()) + case is SettingsEffect.FeedBack: + emailViewVisibility.toggle() + case is SettingsEffect.OnGitHub: + webViewVisibility.toggle() + case is SettingsEffect.Synchronising: + isSynchronisingShown.toggle() + case is SettingsEffect.Synchronised: + isSyncedSnackShown.toggle() + case is SettingsEffect.OnlyOneTimeSync: + isAlreadySyncedSnackShown.toggle() + case is SettingsEffect.AlreadyPremium: + isAdsAlreadyDisabledSnackShown.toggle() + case is SettingsEffect.Premium: + premiumViewVisibility.toggle() + default: + logger.i(message: { "SettingsRootView unknown effect" }) + } + } +} diff --git a/ios/CCC/UI/Settings/SettingsView.swift b/ios/CCC/UI/Settings/SettingsView.swift index 4499e18d98..0e034a4212 100644 --- a/ios/CCC/UI/Settings/SettingsView.swift +++ b/ios/CCC/UI/Settings/SettingsView.swift @@ -6,41 +6,22 @@ // Copyright © 2021 orgName. All rights reserved. // -import GoogleMobileAds -import NavigationStack -import PopupView import Provider import Res import SwiftUI struct SettingsView: View { - @StateObject var observable = ObservableSEEDViewModel< - SettingsState, - SettingsEffect, - SettingsEvent, - SettingsData, - SettingsViewModel - >() - @Environment(\.colorScheme) var colorScheme - @EnvironmentObject private var navigationStack: NavigationStackCompat - @State var premiumViewVisibility = false - @State var emailViewVisibility = false - @State var webViewVisibility = false - @State var isAdsAlreadyDisabledSnackShown = false - @State var isAlreadySyncedSnackShown = false - @State var isSynchronisingShown = false - @State var isSyncedSnackShown = false + @Environment(\.colorScheme) private var colorScheme - private let analyticsManager: AnalyticsManager = koin.get() - - var onBaseChange: ((String) -> Void) + var event: SettingsEvent + var state: SettingsState var body: some View { ZStack { Res.colors().background_strong.get().edgesIgnoringSafeArea(.all) VStack { - SettingsToolbarView(backEvent: observable.event.onBackClick) + SettingsToolbarView(backEvent: event.onBackClick) Form { SettingsItemView( @@ -48,9 +29,9 @@ struct SettingsView: View { title: Res.strings().settings_item_currencies_title.get(), subTitle: Res.strings().settings_item_currencies_sub_title.get(), value: Res.strings().settings_active_item_value.get( - parameter: observable.state.activeCurrencyCount + parameter: state.activeCurrencyCount ), - onClick: observable.event.onCurrenciesClick + onClick: event.onCurrenciesClick ) SettingsItemView( @@ -58,17 +39,17 @@ struct SettingsView: View { title: Res.strings().settings_item_watchers_title.get(), subTitle: Res.strings().settings_item_watchers_sub_title.get(), value: Res.strings().settings_active_item_value.get( - parameter: observable.state.activeWatcherCount + parameter: state.activeWatcherCount ), - onClick: observable.event.onWatchersClick + onClick: event.onWatchersClick ) SettingsItemView( imgName: "crown.fill", title: Res.strings().settings_item_premium_title.get(), subTitle: Res.strings().settings_item_premium_sub_title_no_ads.get(), - value: getPremiumText(premiumStatus: observable.state.premiumStatus), - onClick: observable.event.onPremiumClick + value: getPremiumText(premiumStatus: state.premiumStatus), + onClick: event.onPremiumClick ) SettingsItemView( @@ -76,7 +57,7 @@ struct SettingsView: View { title: Res.strings().settings_item_sync_title.get(), subTitle: Res.strings().settings_item_sync_sub_title.get(), value: "", - onClick: observable.event.onSyncClick + onClick: event.onSyncClick ) if MailView.canSendEmail() { @@ -85,7 +66,7 @@ struct SettingsView: View { title: Res.strings().settings_item_feedback_title.get(), subTitle: Res.strings().settings_item_feedback_sub_title.get(), value: "", - onClick: observable.event.onFeedBackClick + onClick: event.onFeedBackClick ) } @@ -94,97 +75,25 @@ struct SettingsView: View { title: Res.strings().settings_item_on_github_title.get(), subTitle: Res.strings().settings_item_on_github_sub_title.get(), value: "", - onClick: observable.event.onOnGitHubClick + onClick: event.onOnGitHubClick ) SettingsItemView( imgName: "textformat.123", title: Res.strings().settings_item_version_title.get(), subTitle: Res.strings().settings_item_version_sub_title.get(), - value: observable.state.version, + value: state.version, onClick: {} ) }.edgesIgnoringSafeArea(.bottom) .withClearBackground(color: Res.colors().background.get()) - if observable.viewModel.shouldShowBannerAd() { + if state.isBannerAdVisible { AdaptiveBannerAdView(unitID: "BANNER_AD_UNIT_ID_SETTINGS").adapt() } } .navigationBarHidden(true) } - .popup( - isPresented: $isAdsAlreadyDisabledSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().txt_you_already_have_premium.get()) - } - .popup( - isPresented: $isAlreadySyncedSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().txt_already_synced.get()) - } - .popup( - isPresented: $isSynchronisingShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().txt_synchronising.get()) - } - .popup( - isPresented: $isSyncedSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().txt_synced.get()) - } - .sheet(isPresented: $premiumViewVisibility) { - PremiumView(premiumViewVisibility: $premiumViewVisibility) - } - .sheet(isPresented: $emailViewVisibility) { - MailView(isShowing: $emailViewVisibility) - } - .sheet(isPresented: $webViewVisibility) { - WebView(url: NSURL(string: Res.strings().github_url.get())! as URL) - } - .onAppear { - observable.startObserving() - analyticsManager.trackScreen(screenName: ScreenName.Settings()) - } - .onDisappear { observable.stopObserving() } - .onReceive(observable.effect) { onEffect(effect: $0) } - } - - // swiftlint:disable:next cyclomatic_complexity - private func onEffect(effect: SettingsEffect) { - logger.i(message: { "SettingsView onEffect \(effect.description)" }) - switch effect { - case is SettingsEffect.Back: - navigationStack.pop() - case is SettingsEffect.OpenCurrencies: - navigationStack.push(CurrenciesView(onBaseChange: onBaseChange)) - case is SettingsEffect.OpenWatchers: - navigationStack.push(WatchersView()) - case is SettingsEffect.FeedBack: - emailViewVisibility.toggle() - case is SettingsEffect.OnGitHub: - webViewVisibility.toggle() - case is SettingsEffect.Synchronising: - isSynchronisingShown.toggle() - case is SettingsEffect.Synchronised: - isSyncedSnackShown.toggle() - case is SettingsEffect.OnlyOneTimeSync: - isAlreadySyncedSnackShown.toggle() - case is SettingsEffect.AlreadyPremium: - isAdsAlreadyDisabledSnackShown.toggle() - case is SettingsEffect.Premium: - premiumViewVisibility.toggle() - default: - logger.i(message: { "SettingsView unknown effect" }) - } } private func getPremiumText(premiumStatus: PremiumStatus) -> String { diff --git a/ios/CCC/UI/Slider/SliderView.swift b/ios/CCC/UI/Slider/SliderView.swift deleted file mode 100644 index 4408975c35..0000000000 --- a/ios/CCC/UI/Slider/SliderView.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// SliderView.swift -// CCC -// -// Created by Mustafa Ozhan on 30/01/2021. -// Copyright © 2021 orgName. All rights reserved. -// -import NavigationStack -import Provider -import Res -import SwiftUI - -struct SliderView: View { - @EnvironmentObject private var navigationStack: NavigationStackCompat - - private let analyticsManager: AnalyticsManager = koin.get() - - var body: some View { - VStack { - SlideView( - title: Res.strings().slide_intro_title.get(), - image: Image(uiImage: Res.images().ic_app_logo.get()), - subTitle1: Res.strings().slide_intro_text.get(), - subTitle2: "", - buttonText: Res.strings().next.get(), - buttonAction: { - navigationStack.push( - SlideView( - title: Res.strings().slide_premium_title.get(), - image: Image(systemName: "crown.fill"), - subTitle1: Res.strings().slide_premium_text_1_no_ads.get(), - subTitle2: Res.strings().slide_premium_text_2.get(), - buttonText: Res.strings().next.get(), - buttonAction: { - navigationStack.push( - SlideView( - title: Res.strings().slide_bug_report_title.get(), - image: Image(systemName: "ant.fill"), - subTitle1: Res.strings().slide_bug_report_text_1.get(), - subTitle2: Res.strings().slide_bug_report_text_2.get(), - buttonText: Res.strings().got_it.get(), - buttonAction: { - navigationStack.push( - CurrenciesView(onBaseChange: { _ in }) - ) - } - ).onAppear { - analyticsManager.trackScreen(screenName: ScreenName.Slider(position: 2)) - } - ) - } - ).onAppear { - analyticsManager.trackScreen(screenName: ScreenName.Slider(position: 1)) - } - ) - } - ).onAppear { - analyticsManager.trackScreen(screenName: ScreenName.Slider(position: 0)) - } - } - } -} diff --git a/ios/CCC/UI/Slides/BugReportSlideRootView.swift b/ios/CCC/UI/Slides/BugReportSlideRootView.swift new file mode 100644 index 0000000000..0f2263c05a --- /dev/null +++ b/ios/CCC/UI/Slides/BugReportSlideRootView.swift @@ -0,0 +1,34 @@ +// +// BugReportSlideRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 27.08.23. +// Copyright © 2023 orgName. All rights reserved. +// + +import Res +import SwiftUI +import NavigationStack +import Provider + +struct BugReportSlideRootView: View { + @Environment(\.colorScheme) private var colorScheme + + @EnvironmentObject private var navigationStack: NavigationStackCompat + + private let analyticsManager: AnalyticsManager = koin.get() + + var body: some View { + SlideView( + title: Res.strings().slide_bug_report_title.get(), + image: Image(systemName: "ant.fill"), + subTitle1: Res.strings().slide_bug_report_text_1.get(), + subTitle2: Res.strings().slide_bug_report_text_2.get(), + buttonText: Res.strings().got_it.get(), + buttonAction: { navigationStack.push(CurrenciesRootView(onBaseChange: { _ in })) } + ) + .onAppear { + analyticsManager.trackScreen(screenName: ScreenName.Slider(position: 2)) + } + } +} diff --git a/ios/CCC/UI/Slides/IntroSlideRootView.swift b/ios/CCC/UI/Slides/IntroSlideRootView.swift new file mode 100644 index 0000000000..931c0f96b8 --- /dev/null +++ b/ios/CCC/UI/Slides/IntroSlideRootView.swift @@ -0,0 +1,33 @@ +// +// IntroSlideRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 27.08.23. +// Copyright © 2023 orgName. All rights reserved. +// + +import Res +import SwiftUI +import NavigationStack +import Provider + +struct IntroSlideRootView: View { + @Environment(\.colorScheme) private var colorScheme + + @EnvironmentObject private var navigationStack: NavigationStackCompat + + private let analyticsManager: AnalyticsManager = koin.get() + + var body: some View { + SlideView( + title: Res.strings().slide_intro_title.get(), + image: Image(uiImage: Res.images().ic_app_logo.get()), + subTitle1: Res.strings().slide_intro_text.get(), + subTitle2: "", + buttonText: Res.strings().next.get(), + buttonAction: { navigationStack.push(PremiumSlideRootView()) } + ).onAppear { + analyticsManager.trackScreen(screenName: ScreenName.Slider(position: 0)) + } + } +} diff --git a/ios/CCC/UI/Slides/PremiumSlideRootView.swift b/ios/CCC/UI/Slides/PremiumSlideRootView.swift new file mode 100644 index 0000000000..9b6d882295 --- /dev/null +++ b/ios/CCC/UI/Slides/PremiumSlideRootView.swift @@ -0,0 +1,33 @@ +// +// PremiumSlideRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 27.08.23. +// Copyright © 2023 orgName. All rights reserved. +// + +import Res +import SwiftUI +import NavigationStack +import Provider + +struct PremiumSlideRootView: View { + @Environment(\.colorScheme) private var colorScheme + + @EnvironmentObject private var navigationStack: NavigationStackCompat + + private let analyticsManager: AnalyticsManager = koin.get() + + var body: some View { + SlideView( + title: Res.strings().slide_premium_title.get(), + image: Image(systemName: "crown.fill"), + subTitle1: Res.strings().slide_premium_text_1_no_ads.get(), + subTitle2: Res.strings().slide_premium_text_2.get(), + buttonText: Res.strings().next.get(), + buttonAction: { navigationStack.push(BugReportSlideRootView()) } + ).onAppear { + analyticsManager.trackScreen(screenName: ScreenName.Slider(position: 1)) + } + } +} diff --git a/ios/CCC/UI/Slider/SlideView.swift b/ios/CCC/UI/Slides/SlideView.swift similarity index 97% rename from ios/CCC/UI/Slider/SlideView.swift rename to ios/CCC/UI/Slides/SlideView.swift index 2d07a16f6f..1552f09578 100644 --- a/ios/CCC/UI/Slider/SlideView.swift +++ b/ios/CCC/UI/Slides/SlideView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct SlideView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme var title: String var image: Image diff --git a/ios/CCC/UI/Watchers/WatcherItem.swift b/ios/CCC/UI/Watchers/WatcherItem.swift index 98ff10f670..ff6f8dbeef 100644 --- a/ios/CCC/UI/Watchers/WatcherItem.swift +++ b/ios/CCC/UI/Watchers/WatcherItem.swift @@ -13,7 +13,7 @@ import Res import SwiftUI struct WatcherItem: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme @State private var relationSelection = 0 @State private var amount = "" diff --git a/ios/CCC/UI/Watchers/WatchersRootView.swift b/ios/CCC/UI/Watchers/WatchersRootView.swift new file mode 100644 index 0000000000..0b34d67ee2 --- /dev/null +++ b/ios/CCC/UI/Watchers/WatchersRootView.swift @@ -0,0 +1,136 @@ +// +// WatchersRootView.swift +// CCC +// +// Created by Mustafa Ozhan on 26.04.22. +// Copyright © 2022 orgName. All rights reserved. +// + +import NavigationStack +import Provider +import Res +import SwiftUI + +struct WatchersRootView: View { + @Environment(\.colorScheme) private var colorScheme + @EnvironmentObject private var navigationStack: NavigationStackCompat + @StateObject var observable = ObservableSEEDViewModel< + WatchersState, + WatchersEffect, + WatchersEvent, + WatchersData, + WatchersViewModel + >() + @StateObject var notificationManager = NotificationManager() + @State var baseBarInfo = BarInfo(isShown: false, watcher: nil) + @State var targetBarInfo = BarInfo(isShown: false, watcher: nil) + @State var isInvalidInputSnackShown = false + @State var isMaxWatchersSnackShown = false + @State var isTooBigInputSnackShown = false + + private let analyticsManager: AnalyticsManager = koin.get() + + var body: some View { + WatchersView( + event: observable.event, + state: observable.state, + authorizationStatus: notificationManager.authorizationStatus, + baseBarInfo: $baseBarInfo, + targetBarInfo: $targetBarInfo + ) + .snack(isPresented: $isInvalidInputSnackShown) { + SnackView(text: Res.strings().text_invalid_input.get()) + } + .snack(isPresented: $isMaxWatchersSnackShown) { + SnackView(text: Res.strings().text_maximum_number_of_watchers.get()) + } + .snack(isPresented: $isTooBigInputSnackShown) { + SnackView(text: Res.strings().text_too_big_input.get()) + } + .sheet( + isPresented: $baseBarInfo.isShown, + content: { + SelectCurrencyRootView( + isBarShown: $baseBarInfo.isShown, + onCurrencySelected: { + observable.event.onBaseChanged( + watcher: baseBarInfo.watcher!, + newBase: $0 + ) + } + ).environmentObject(navigationStack) + } + ) + .sheet( + isPresented: $targetBarInfo.isShown, + content: { + SelectCurrencyRootView( + isBarShown: $targetBarInfo.isShown, + onCurrencySelected: { + observable.event.onTargetChanged( + watcher: targetBarInfo.watcher!, + newTarget: $0 + ) + } + ).environmentObject(navigationStack) + } + ) + .onAppear { + observable.startObserving() + notificationManager.reloadAuthorisationStatus() + analyticsManager.trackScreen(screenName: ScreenName.Watchers()) + } + .onDisappear { observable.stopObserving() } + .onReceive(observable.effect) { onEffect(effect: $0) } + .onReceive(NotificationCenter.default.publisher( + for: UIApplication.willEnterForegroundNotification + )) { _ in + notificationManager.reloadAuthorisationStatus() + } + .onChange(of: notificationManager.authorizationStatus) { + onAuthorisationChange(authorizationStatus: $0) + } + .animation(.default) + } + + private func onEffect(effect: WatchersEffect) { + logger.i(message: { "WatchersRootView onEffect \(effect.description)" }) + switch effect { + case is WatchersEffect.Back: + navigationStack.pop() + case let selectBaseEffect as WatchersEffect.SelectBase: + baseBarInfo.watcher = selectBaseEffect.watcher + baseBarInfo.isShown.toggle() + case let selectTargetEffect as WatchersEffect.SelectTarget: + targetBarInfo.watcher = selectTargetEffect.watcher + targetBarInfo.isShown.toggle() + case is WatchersEffect.TooBigInput: + isTooBigInputSnackShown.toggle() + case is WatchersEffect.InvalidInput: + isInvalidInputSnackShown.toggle() + case is WatchersEffect.MaximumNumberOfWatchers: + isMaxWatchersSnackShown.toggle() + default: + logger.i(message: { "WatchersRootView unknown effect" }) + } + } + + private func onAuthorisationChange(authorizationStatus: UNAuthorizationStatus?) { + logger.i( + message: { "WatchersRootView onAuthorisationChange \(String(describing: authorizationStatus?.rawValue))" } + ) + switch authorizationStatus { + case .notDetermined: + notificationManager.requestAuthorisation() + case .authorized: + notificationManager.reloadAuthorisationStatus() + default: + break + } + } + + public struct BarInfo { + var isShown: Bool + var watcher: Provider.Watcher? + } +} diff --git a/ios/CCC/UI/Watchers/WatchersToolbarView.swift b/ios/CCC/UI/Watchers/WatchersToolbarView.swift index ea242b7c46..95edbb6062 100644 --- a/ios/CCC/UI/Watchers/WatchersToolbarView.swift +++ b/ios/CCC/UI/Watchers/WatchersToolbarView.swift @@ -10,7 +10,7 @@ import Res import SwiftUI struct WatchersToolbarView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) private var colorScheme var backEvent: () -> Void var body: some View { diff --git a/ios/CCC/UI/Watchers/WatchersView.swift b/ios/CCC/UI/Watchers/WatchersView.swift index 658e6b3734..d7e84b9552 100644 --- a/ios/CCC/UI/Watchers/WatchersView.swift +++ b/ios/CCC/UI/Watchers/WatchersView.swift @@ -2,53 +2,42 @@ // WatchersView.swift // CCC // -// Created by Mustafa Ozhan on 26.04.22. -// Copyright © 2022 orgName. All rights reserved. +// Created by Mustafa Ozhan on 24.08.23. +// Copyright © 2023 orgName. All rights reserved. // -import NavigationStack -import PopupView import Provider import Res import SwiftUI struct WatchersView: View { - @Environment(\.colorScheme) var colorScheme - @EnvironmentObject private var navigationStack: NavigationStackCompat - @StateObject var observable = ObservableSEEDViewModel< - WatchersState, - WatchersEffect, - WatchersEvent, - WatchersData, - WatchersViewModel - >() - @StateObject var notificationManager = NotificationManager() - @State var baseBarInfo = BarInfo(isShown: false, watcher: nil) - @State var targetBarInfo = BarInfo(isShown: false, watcher: nil) - @State var isInvalidInputSnackShown = false - @State var isMaxWatchersSnackShown = false - @State var isTooBigInputSnackShown = false + @Environment(\.colorScheme) private var colorScheme - private let analyticsManager: AnalyticsManager = koin.get() + var event: WatchersEvent + var state: WatchersState + var authorizationStatus: UNAuthorizationStatus? + + @Binding var baseBarInfo: WatchersRootView.BarInfo + @Binding var targetBarInfo: WatchersRootView.BarInfo var body: some View { ZStack { Res.colors().background_strong.get().edgesIgnoringSafeArea(.all) VStack { - WatchersToolbarView(backEvent: observable.event.onBackClick) + WatchersToolbarView(backEvent: event.onBackClick) - switch notificationManager.authorizationStatus { + switch authorizationStatus { case nil: Spacer() case .authorized: Form { - List(observable.state.watcherList, id: \.id) { watcher in + List(state.watcherList, id: \.id) { watcher in WatcherItem( isBaseBarShown: $baseBarInfo.isShown, isTargetBarShown: $targetBarInfo.isShown, watcher: watcher, - event: observable.event + event: event ) } .listRowInsets(.init()) @@ -63,7 +52,7 @@ struct WatchersView: View { Spacer() Button { - observable.event.onAddClick() + event.onAddClick() } label: { Label(Res.strings().txt_add.get(), systemImage: "plus") .imageScale(.large) @@ -106,115 +95,11 @@ struct WatchersView: View { .background(Res.colors().background.get()) } - if observable.viewModel.shouldShowBannerAd() { + if state.isBannerAdVisible { AdaptiveBannerAdView(unitID: "BANNER_AD_UNIT_ID_WATCHERS").adapt() } } .background(Res.colors().background_strong.get()) } - .popup( - isPresented: $isInvalidInputSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().text_invalid_input.get()) - } - .popup( - isPresented: $isMaxWatchersSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().text_maximum_number_of_watchers.get()) - } - .popup( - isPresented: $isTooBigInputSnackShown, - type: .toast, - autohideIn: 2.0 - ) { - SnackView(text: Res.strings().text_too_big_input.get()) - } - .sheet( - isPresented: $baseBarInfo.isShown, - content: { - SelectCurrencyView( - isBarShown: $baseBarInfo.isShown, - onCurrencySelected: { - observable.event.onBaseChanged( - watcher: baseBarInfo.watcher!, - newBase: $0 - ) - } - ).environmentObject(navigationStack) - } - ) - .sheet( - isPresented: $targetBarInfo.isShown, - content: { - SelectCurrencyView( - isBarShown: $targetBarInfo.isShown, - onCurrencySelected: { - observable.event.onTargetChanged( - watcher: targetBarInfo.watcher!, - newTarget: $0 - ) - } - ).environmentObject(navigationStack) - } - ) - .onAppear { - observable.startObserving() - notificationManager.reloadAuthorisationStatus() - analyticsManager.trackScreen(screenName: ScreenName.Watchers()) - } - .onDisappear { observable.stopObserving() } - .onReceive(observable.effect) { onEffect(effect: $0) } - .onReceive(NotificationCenter.default.publisher( - for: UIApplication.willEnterForegroundNotification - )) { _ in - notificationManager.reloadAuthorisationStatus() - } - .onChange(of: notificationManager.authorizationStatus) { - onAuthorisationChange(authorizationStatus: $0) - } - .animation(.default) - } - - private func onEffect(effect: WatchersEffect) { - logger.i(message: { "WatchersView onEffect \(effect.description)" }) - switch effect { - case is WatchersEffect.Back: - navigationStack.pop() - case let selectBaseEffect as WatchersEffect.SelectBase: - baseBarInfo.watcher = selectBaseEffect.watcher - baseBarInfo.isShown.toggle() - case let selectTargetEffect as WatchersEffect.SelectTarget: - targetBarInfo.watcher = selectTargetEffect.watcher - targetBarInfo.isShown.toggle() - case is WatchersEffect.TooBigInput: - isTooBigInputSnackShown.toggle() - case is WatchersEffect.InvalidInput: - isInvalidInputSnackShown.toggle() - case is WatchersEffect.MaximumNumberOfWatchers: - isMaxWatchersSnackShown.toggle() - default: - logger.i(message: { "WatchersView unknown effect" }) - } - } - - private func onAuthorisationChange(authorizationStatus: UNAuthorizationStatus?) { - logger.i(message: { "WatchersView onAuthorisationChange \(String(describing: authorizationStatus?.rawValue))" }) - switch authorizationStatus { - case .notDetermined: - notificationManager.requestAuthorisation() - case .authorized: - notificationManager.reloadAuthorisationStatus() - default: - break - } - } - - struct BarInfo { - var isShown: Bool - var watcher: Provider.Watcher? } } diff --git a/ios/CCC/Util/InterstitialAd.swift b/ios/CCC/Util/InterstitialAd.swift index 75fc427389..a9d6607a45 100644 --- a/ios/CCC/Util/InterstitialAd.swift +++ b/ios/CCC/Util/InterstitialAd.swift @@ -16,7 +16,7 @@ final class InterstitialAd: NSObject, GADFullScreenContentDelegate { request: GADRequest(), completionHandler: { interstitialAd, error in if let error = error { - logger.w(message: { "InterstitialAd show \(error.localizedDescription)" }) + logger.e(message: { "InterstitialAd show \(error.localizedDescription)" }) return } @@ -26,7 +26,7 @@ final class InterstitialAd: NSObject, GADFullScreenContentDelegate { fromRootViewController: WindowUtil.getCurrentController() ) } else { - logger.d(message: { "InterstitialAd not showed appState is not active" }) + logger.v(message: { "InterstitialAd not showed appState is not active" }) } } ) diff --git a/ios/CCC/Util/ObservableSEEDViewModel.swift b/ios/CCC/Util/ObservableSEEDViewModel.swift index c056b9c059..466350def3 100644 --- a/ios/CCC/Util/ObservableSEEDViewModel.swift +++ b/ios/CCC/Util/ObservableSEEDViewModel.swift @@ -23,17 +23,14 @@ final class ObservableSEEDViewModel< let effect = PassthroughSubject() let event: Event - let data: Data? - private var stateClosable: Closeable? private var effectClosable: Closeable? init() { - logger.i(message: { "ObservableSEED \(ViewModel.description()) init" }) + logger.d(message: { "ObservableSEED \(ViewModel.description()) init" }) - self.state = State() + self.state = (viewModel.state?.value as? State) ?? State() self.event = viewModel.event! - self.data = viewModel.data } deinit { @@ -41,16 +38,16 @@ final class ObservableSEEDViewModel< } func startObserving() { - logger.i(message: { "ObservableSEED \(ViewModel.description()) startObserving" }) + logger.d(message: { "ObservableSEED \(ViewModel.description()) startObserving" }) if viewModel.state != nil { - stateClosable = IOSCoroutineUtilKt.observeWithCloseable(viewModel.state!, onChange: { + stateClosable = CoroutineUtilKt.observeWithCloseable(viewModel.state!, onChange: { // swiftlint:disable:next force_cast self.state = $0 as! State }) } if viewModel.effect != nil { - effectClosable = IOSCoroutineUtilKt.observeWithCloseable(viewModel.effect!, onChange: { + effectClosable = CoroutineUtilKt.observeWithCloseable(viewModel.effect!, onChange: { // swiftlint:disable:next force_cast self.effect.send($0 as! Effect) }) @@ -58,7 +55,7 @@ final class ObservableSEEDViewModel< } func stopObserving() { - logger.i(message: { "ObservableSEED \(ViewModel.description()) stopObserving" }) + logger.d(message: { "ObservableSEED \(ViewModel.description()) stopObserving" }) closeClosables() } diff --git a/ios/CCC/Util/ResourceExt.swift b/ios/CCC/Util/ResourceExt.swift index bc98537eab..acde1f7a84 100644 --- a/ios/CCC/Util/ResourceExt.swift +++ b/ios/CCC/Util/ResourceExt.swift @@ -11,10 +11,10 @@ import SwiftUI extension ResourcesStringResource { func get() -> String { - return IOSResourcesKt.getString(stringResource: self).localized() + return Resources_iosKt.getString(stringResource: self).localized() } func get(parameter: Any) -> String { - return IOSResourcesKt.getString(stringResource: self, parameter: parameter).localized() + return Resources_iosKt.getString(stringResource: self, parameter: parameter).localized() } } @@ -23,7 +23,7 @@ extension ResourcesColorResource { return Color(get()) } func get() -> UIColor { - return IOSResourcesKt.getColor(colorResource: self) + return Resources_iosKt.getColor(colorResource: self) } } diff --git a/ios/CCC/Util/RewardedAd.swift b/ios/CCC/Util/RewardedAd.swift index 2dd4b12cd8..c5bf06e3ca 100644 --- a/ios/CCC/Util/RewardedAd.swift +++ b/ios/CCC/Util/RewardedAd.swift @@ -14,7 +14,7 @@ final class RewardedAd: NSObject, GADFullScreenContentDelegate { // below variables have to be local otherwise userDidEarnRewardHandler is not called let onReward: () -> Void let onError: () -> Void - var rewardedAd: GADRewardedAd? + private var rewardedAd: GADRewardedAd? init( onReward: @escaping () -> Void, @@ -30,7 +30,7 @@ final class RewardedAd: NSObject, GADFullScreenContentDelegate { request: GADRequest(), completionHandler: {rewardedAd, error in if error != nil { - logger.w(message: { "RewardedAd show error: \(String(describing: error?.localizedDescription))" }) + logger.e(message: { "RewardedAd show error: \(String(describing: error?.localizedDescription))" }) self.onError() return } @@ -41,7 +41,7 @@ final class RewardedAd: NSObject, GADFullScreenContentDelegate { self.rewardedAd?.present( fromRootViewController: WindowUtil.getCurrentController(), userDidEarnRewardHandler: { - logger.i(message: { "RewardedAd userDidEarnReward" }) + logger.v(message: { "RewardedAd userDidEarnReward" }) self.onReward() } ) diff --git a/ios/CCC/Util/ViewExt.swift b/ios/CCC/Util/ViewExt.swift index 4c019a1ce8..7eed4fec52 100644 --- a/ios/CCC/Util/ViewExt.swift +++ b/ios/CCC/Util/ViewExt.swift @@ -7,6 +7,7 @@ // import SwiftUI +import PopupView extension View { func withClearBackground(color: Color) -> some View { @@ -80,6 +81,29 @@ extension View { fatalError("Expected to have a valid style") } } + + func snack( + isPresented: Binding, + @ViewBuilder view: @escaping () -> PopupContent + ) -> some View { + self.popup( + isPresented: isPresented, + type: .toast, + autohideIn: 2.0, + closeOnTapOutside: true, + view: view + ) + } + + func alert( + isPresented: Binding, + @ViewBuilder view: @escaping () -> PopupContent + ) -> some View { + self.popup( + isPresented: isPresented, + view: view + ) + } } extension Double { diff --git a/ios/Gemfile.lock b/ios/Gemfile.lock index db805e7171..e3d7c8bfcb 100644 --- a/ios/Gemfile.lock +++ b/ios/Gemfile.lock @@ -3,13 +3,13 @@ GEM specs: CFPropertyList (3.0.6) rexml - addressable (2.8.4) + addressable (2.8.5) public_suffix (>= 2.0.2, < 6.0) artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.2.0) - aws-partitions (1.786.0) - aws-sdk-core (3.178.0) + aws-partitions (1.816.0) + aws-sdk-core (3.181.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) @@ -17,8 +17,8 @@ GEM aws-sdk-kms (1.71.0) aws-sdk-core (~> 3, >= 3.177.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.130.0) - aws-sdk-core (~> 3, >= 3.177.0) + aws-sdk-s3 (1.134.0) + aws-sdk-core (~> 3, >= 3.181.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) aws-sigv4 (1.6.0) @@ -36,7 +36,7 @@ GEM unf (>= 0.0.5, < 1.0.0) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.100.0) + excon (0.102.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -105,11 +105,12 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) - fastlane-plugin-firebase_app_distribution (0.6.1) + fastlane-plugin-firebase_app_distribution (0.7.2) + google-apis-firebaseappdistribution_v1 (~> 0.3.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.45.0) + google-apis-androidpublisher_v3 (0.49.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.0) + google-apis-core (0.11.1) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -118,6 +119,8 @@ GEM retriable (>= 2.0, < 4.a) rexml webrick + google-apis-firebaseappdistribution_v1 (0.3.0) + google-apis-core (>= 0.11.0, < 2.a) google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) @@ -138,7 +141,7 @@ GEM google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.6.0) + googleauth (1.7.0) faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) memoist (~> 0.16) @@ -154,7 +157,7 @@ GEM jwt (2.7.1) memoist (0.16.2) mini_magick (4.12.0) - mini_mime (1.1.2) + mini_mime (1.1.5) multi_json (1.15.0) multipart-post (2.3.0) nanaimo (0.3.0) @@ -169,7 +172,7 @@ GEM trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.2.5) + rexml (3.2.6) rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d7c391874a..ab70191c40 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,6 +1,6 @@ PODS: - - provider (0.0.2940) - - res (0.0.2940) + - provider (0.0.3086) + - res (0.0.3086) DEPENDENCIES: - provider (from `../ios/provider/provider.podspec`) @@ -13,8 +13,8 @@ EXTERNAL SOURCES: :path: "../client/core/res/res.podspec" SPEC CHECKSUMS: - provider: 5ea64cfbc8f20998f174581dda69d474a3285786 - res: 5078fa8681e16fb4b8c8008d25f501f0297d3bf3 + provider: 4b3bae2b51315a63ccf943420182b6d617d4a38e + res: b7e081a218a15086ab0ea72e0ac6235d109b71d6 PODFILE CHECKSUM: 412cec7fc739afa9e2c84446671ea1c95b0a4e74 diff --git a/ios/provider/ios-provider.gradle.kts b/ios/provider/ios-provider.gradle.kts index ca0c7737a0..df4a479c2e 100644 --- a/ios/provider/ios-provider.gradle.kts +++ b/ios/provider/ios-provider.gradle.kts @@ -2,7 +2,6 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.Framework import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) id(cocoapods.get().pluginId) @@ -10,6 +9,9 @@ plugins { } kotlin { + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + iosX64() iosArm64() iosSimulatorArm64() @@ -43,7 +45,6 @@ kotlin { export(project(selectCurrency)) export(project(watchers)) export(project(premium)) - export(project(premium)) } } @@ -56,15 +57,8 @@ kotlin { @Suppress("UNUSED_VARIABLE") sourceSets { - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) + val iosMain by getting { dependencies { - libs.common.apply { implementation(koinCore) implementation(kermit) @@ -128,13 +122,5 @@ kotlin { implementation(Submodules.logmob) } } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/ios/provider/src/iosMain/kotlin/com/oztechan/ccc/ios/provider/IOSLogger.kt b/ios/provider/src/iosMain/kotlin/com/oztechan/ccc/ios/provider/Logger.kt similarity index 100% rename from ios/provider/src/iosMain/kotlin/com/oztechan/ccc/ios/provider/IOSLogger.kt rename to ios/provider/src/iosMain/kotlin/com/oztechan/ccc/ios/provider/Logger.kt diff --git a/ios/provider/src/iosMain/kotlin/com/oztechan/ccc/ios/provider/di/Koin.kt b/ios/provider/src/iosMain/kotlin/com/oztechan/ccc/ios/provider/di/Koin.kt index d97dd73ecd..2878d89ac8 100644 --- a/ios/provider/src/iosMain/kotlin/com/oztechan/ccc/ios/provider/di/Koin.kt +++ b/ios/provider/src/iosMain/kotlin/com/oztechan/ccc/ios/provider/di/Koin.kt @@ -32,6 +32,7 @@ import com.oztechan.ccc.common.core.infrastructure.di.commonCoreInfrastructureMo import com.oztechan.ccc.common.core.network.di.commonCoreNetworkModule import com.oztechan.ccc.common.datasource.conversion.di.commonDataSourceConversionModule import com.oztechan.ccc.ios.repository.background.di.iosRepositoryBackgroundModule +import kotlinx.cinterop.BetaInteropApi import kotlinx.cinterop.ObjCClass import kotlinx.cinterop.ObjCObject import kotlinx.cinterop.ObjCProtocol @@ -97,7 +98,7 @@ fun initKoin( // endregion ) }.also { - Logger.i { "Koin initialised" } + Logger.v { "Koin initialised" } } private fun getIOSPlatformModule(userDefaults: NSUserDefaults) = module { @@ -105,6 +106,7 @@ private fun getIOSPlatformModule(userDefaults: NSUserDefaults) = module { single { Device.IOS } } +@BetaInteropApi @Suppress("unused") fun Koin.getDependency(objCObject: ObjCObject): T = when (objCObject) { is ObjCClass -> getOriginalKotlinClass(objCObject) diff --git a/ios/repository/background/ios-repository-background.gradle.kts b/ios/repository/background/ios-repository-background.gradle.kts index 49785a7b74..d1aa153460 100644 --- a/ios/repository/background/ios-repository-background.gradle.kts +++ b/ios/repository/background/ios-repository-background.gradle.kts @@ -1,5 +1,4 @@ plugins { - @Suppress("DSL_SCOPE_VIOLATION") libs.plugins.apply { id(multiplatform.get().pluginId) alias(ksp) @@ -7,13 +6,15 @@ plugins { } kotlin { + @Suppress("OPT_IN_USAGE") + targetHierarchy.default() + iosX64() iosArm64() iosSimulatorArm64() @Suppress("UNUSED_VARIABLE") sourceSets { - val commonMain by getting { dependencies { libs.common.apply { @@ -36,25 +37,6 @@ kotlin { } } } - - val iosX64Main by getting - val iosArm64Main by getting - val iosSimulatorArm64Main by getting - val iosMain by creating { - dependsOn(commonMain) - iosX64Main.dependsOn(this) - iosArm64Main.dependsOn(this) - iosSimulatorArm64Main.dependsOn(this) - } - val iosX64Test by getting - val iosArm64Test by getting - val iosSimulatorArm64Test by getting - val iosTest by creating { - dependsOn(commonTest) - iosX64Test.dependsOn(this) - iosArm64Test.dependsOn(this) - iosSimulatorArm64Test.dependsOn(this) - } } } diff --git a/ios/repository/background/src/commonMain/kotlin/com/oztechan/ccc/ios/repository/background/BackgroundRepositoryImpl.kt b/ios/repository/background/src/commonMain/kotlin/com/oztechan/ccc/ios/repository/background/BackgroundRepositoryImpl.kt index e71d056d1d..9f22b9809c 100644 --- a/ios/repository/background/src/commonMain/kotlin/com/oztechan/ccc/ios/repository/background/BackgroundRepositoryImpl.kt +++ b/ios/repository/background/src/commonMain/kotlin/com/oztechan/ccc/ios/repository/background/BackgroundRepositoryImpl.kt @@ -12,12 +12,12 @@ internal class BackgroundRepositoryImpl( ) : BackgroundRepository { init { - Logger.d { "BackgroundRepositoryImpl init" } + Logger.v { "BackgroundRepositoryImpl init" } } - @Suppress("LabeledExpression", "TooGenericExceptionCaught") + @Suppress("TooGenericExceptionCaught") override fun shouldSendNotification() = try { - Logger.d { "BackgroundRepositoryImpl shouldSendNotification" } + Logger.v { "BackgroundRepositoryImpl shouldSendNotification" } runBlocking { watchersDataSource.getWatchers().forEach { watcher -> @@ -37,7 +37,7 @@ internal class BackgroundRepositoryImpl( return@runBlocking false } } catch (e: Exception) { - Logger.w(e) { "BackgroundRepositoryImpl shouldSendNotification error catch: $e" } + Logger.e(e) { "BackgroundRepositoryImpl shouldSendNotification error catch: $e" } false } } diff --git a/ios/repository/background/src/commonTest/kotlin/com/oztechan/ccc/ios/repository/background/BackgroundRepositoryTest.kt b/ios/repository/background/src/commonTest/kotlin/com/oztechan/ccc/ios/repository/background/BackgroundRepositoryTest.kt index 4d99cdb683..c55be6b271 100644 --- a/ios/repository/background/src/commonTest/kotlin/com/oztechan/ccc/ios/repository/background/BackgroundRepositoryTest.kt +++ b/ios/repository/background/src/commonTest/kotlin/com/oztechan/ccc/ios/repository/background/BackgroundRepositoryTest.kt @@ -8,16 +8,15 @@ import com.oztechan.ccc.common.core.model.Conversion import com.oztechan.ccc.common.core.model.Watcher import io.mockative.Mock import io.mockative.classOf -import io.mockative.given +import io.mockative.coEvery +import io.mockative.coVerify import io.mockative.mock -import io.mockative.verify import kotlinx.coroutines.test.runTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -@Suppress("OPT_IN_USAGE") internal class BackgroundRepositoryTest { private val subject: BackgroundRepository by lazy { @@ -37,14 +36,12 @@ internal class BackgroundRepositoryTest { @Test fun `if getWatchers throw an error should return false`() = runTest { - given(watcherDataSource) - .coroutine { getWatchers() } - .thenThrow(Exception()) + coEvery { watcherDataSource.getWatchers() } + .throws(Exception()) assertFalse { subject.shouldSendNotification() } - verify(watcherDataSource) - .coroutine { getWatchers() } + coVerify { watcherDataSource.getWatchers() } .wasInvoked() } @@ -52,22 +49,18 @@ internal class BackgroundRepositoryTest { fun `if getConversion throw an error should return false`() = runTest { val watcher = Watcher(1, "EUR", "USD", true, 1.1) - given(watcherDataSource) - .coroutine { getWatchers() } - .thenReturn(listOf(watcher)) + coEvery { watcherDataSource.getWatchers() } + .returns(listOf(watcher)) - given(backendApiService) - .coroutine { getConversion(watcher.base) } - .thenThrow(Exception()) + coEvery { backendApiService.getConversion(watcher.base) } + .throws(Exception()) assertFalse { subject.shouldSendNotification() } - verify(watcherDataSource) - .coroutine { getWatchers() } + coVerify { watcherDataSource.getWatchers() } .wasInvoked() - verify(backendApiService) - .coroutine { getConversion(watcher.base) } + coVerify { backendApiService.getConversion(watcher.base) } .wasInvoked() } @@ -75,45 +68,38 @@ internal class BackgroundRepositoryTest { fun `if watcher set greater and response rate is more than watcher return true`() = runTest { val watcher = Watcher(1, "EUR", "USD", true, 1.1) - given(watcherDataSource) - .coroutine { getWatchers() } - .thenReturn(listOf(watcher)) + coEvery { watcherDataSource.getWatchers() } + .returns(listOf(watcher)) - given(backendApiService) - .coroutine { getConversion(watcher.base) } - .thenReturn(Conversion(base = watcher.base, usd = watcher.rate + 1)) + coEvery { backendApiService.getConversion(watcher.base) } + .returns(Conversion(base = watcher.base, usd = watcher.rate + 1)) assertTrue { subject.shouldSendNotification() } - verify(watcherDataSource) - .coroutine { getWatchers() } + coVerify { watcherDataSource.getWatchers() } .wasInvoked() - verify(backendApiService) - .coroutine { getConversion(watcher.base) } + coVerify { backendApiService.getConversion(watcher.base) } .wasInvoked() } @Test - fun `if watcher set not greater and response rate is less than watcher return true`() = runTest { - val watcher = Watcher(1, "EUR", "USD", false, 1.1) + fun `if watcher set not greater and response rate is less than watcher return true`() = + runTest { + val watcher = Watcher(1, "EUR", "USD", false, 1.1) - given(watcherDataSource) - .coroutine { getWatchers() } - .thenReturn(listOf(watcher)) + coEvery { watcherDataSource.getWatchers() } + .returns(listOf(watcher)) - given(backendApiService) - .coroutine { getConversion(watcher.base) } - .thenReturn(Conversion(base = watcher.base, usd = watcher.rate - 1)) + coEvery { backendApiService.getConversion(watcher.base) } + .returns(Conversion(base = watcher.base, usd = watcher.rate - 1)) - assertTrue { subject.shouldSendNotification() } + assertTrue { subject.shouldSendNotification() } - verify(watcherDataSource) - .coroutine { getWatchers() } - .wasInvoked() + coVerify { watcherDataSource.getWatchers() } + .wasInvoked() - verify(backendApiService) - .coroutine { getConversion(watcher.base) } - .wasInvoked() - } + coVerify { backendApiService.getConversion(watcher.base) } + .wasInvoked() + } } diff --git a/renovate.json b/renovate.json index bfd6abeb1e..8d1b71d35f 100644 --- a/renovate.json +++ b/renovate.json @@ -7,8 +7,8 @@ "dependencies" ], "rebaseWhen": "conflicted", - "gitAuthor": "Mustafa Ozhan ", - "commitBody": "Signed-off-by: {{{gitAuthor}}}\nCo-authored-by: {{{gitAuthor}}}", + "gitAuthor": "Renovate ", + "commitBody": "Co-authored-by: Mustafa Ozhan ", "commitMessageAction": "[Oztechan/CCC#1457] Dependency update", "git-submodules": { "enabled": true, diff --git a/submodule/basemob b/submodule/basemob index 40349f3d00..d3089953a2 160000 --- a/submodule/basemob +++ b/submodule/basemob @@ -1 +1 @@ -Subproject commit 40349f3d0011f5a2bbb8931fd36b3f549339846e +Subproject commit d3089953a229a8c076d79c6ba3e674190707c14e diff --git a/submodule/logmob b/submodule/logmob index b40a399e60..c0d35d7a96 160000 --- a/submodule/logmob +++ b/submodule/logmob @@ -1 +1 @@ -Subproject commit b40a399e60fe90d31ccf4b4e540f4ccfa67f0f01 +Subproject commit c0d35d7a96b6ec060885e1dd4ebb08eaedea75ec diff --git a/submodule/parsermob b/submodule/parsermob index c1e6556d12..bf803ce9e9 160000 --- a/submodule/parsermob +++ b/submodule/parsermob @@ -1 +1 @@ -Subproject commit c1e6556d127183aa9133675e9aa23d228cd8023a +Subproject commit bf803ce9e9d43c31d26062781373a00014404878 diff --git a/submodule/scopemob b/submodule/scopemob index 49aff90593..80f3681729 160000 --- a/submodule/scopemob +++ b/submodule/scopemob @@ -1 +1 @@ -Subproject commit 49aff9059384aa597cbc52ab7aa3b461ce73cc49 +Subproject commit 80f3681729b7ecfcb2dfbe8261da274967a31ec3