From 3f5aa8bc6ff23b40485a91dc03ffbfda4e1a6e2c Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Thu, 27 Nov 2025 08:31:10 +0200 Subject: [PATCH 01/60] Implement the new card in the Profile screen for the subscription (#805) * Implement the new card in the Profile screen for the subscription * Forecast in tab in station details + in forecast details (#807) * WIP of forecast tab's state & prompt. Billing library missing to check state. * Integrate the billing library, create functionality for initializing it and fetching if we have an active sub or not and update the UI respectively * Handle UI in Forecast Details if premium is available or not --- app/build.gradle.kts | 3 + .../main/java/com/weatherxm/data/Modules.kt | 13 +- .../java/com/weatherxm/data/models/Failure.kt | 5 + .../com/weatherxm/service/BillingService.kt | 116 ++++++++++++++++++ .../main/java/com/weatherxm/ui/Navigator.kt | 6 +- .../java/com/weatherxm/ui/common/Contracts.kt | 1 + .../java/com/weatherxm/ui/common/UIModels.kt | 38 ++++++ .../ui/components/compose/MessageCardView.kt | 7 +- .../components/compose/MosaicPromotionCard.kt | 107 ++++++++++++++++ .../ui/devicedetails/DeviceDetailsActivity.kt | 51 +++++++- .../devicedetails/DeviceDetailsViewModel.kt | 13 ++ .../forecast/ForecastFragment.kt | 38 +++--- .../ForecastDetailsActivity.kt | 44 ++++--- .../ForecastDetailsViewModel.kt | 1 + .../ui/home/profile/ProfileFragment.kt | 45 +++++++ app/src/main/res/drawable/ic_crown.xml | 9 ++ app/src/main/res/drawable/ic_subscription.xml | 9 ++ app/src/main/res/drawable/ic_thunder.xml | 9 ++ .../res/layout/activity_forecast_details.xml | 63 +++++++++- .../fragment_device_details_forecast.xml | 22 ++-- app/src/main/res/layout/fragment_profile.xml | 39 ++++++ .../res/layout/view_forecast_premium_tab.xml | 30 +++++ app/src/main/res/values-night/colors.xml | 1 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 14 +++ .../DeviceDetailsViewModelTest.kt | 3 + gradle/libs.versions.toml | 2 + 27 files changed, 628 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/weatherxm/service/BillingService.kt create mode 100644 app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt create mode 100644 app/src/main/res/drawable/ic_crown.xml create mode 100644 app/src/main/res/drawable/ic_subscription.xml create mode 100644 app/src/main/res/drawable/ic_thunder.xml create mode 100644 app/src/main/res/layout/view_forecast_premium_tab.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c7700b144..b5fd9a689 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -624,4 +624,7 @@ dependencies { // Markdown Renderer implementation(libs.markdown.renderer) + + // Billing + implementation(libs.billing) } diff --git a/app/src/main/java/com/weatherxm/data/Modules.kt b/app/src/main/java/com/weatherxm/data/Modules.kt index 28f6b35b4..7beb57156 100644 --- a/app/src/main/java/com/weatherxm/data/Modules.kt +++ b/app/src/main/java/com/weatherxm/data/Modules.kt @@ -163,6 +163,7 @@ import com.weatherxm.data.repository.bluetooth.BluetoothScannerRepositoryImpl import com.weatherxm.data.repository.bluetooth.BluetoothUpdaterRepository import com.weatherxm.data.repository.bluetooth.BluetoothUpdaterRepositoryImpl import com.weatherxm.data.services.CacheService +import com.weatherxm.service.BillingService import com.weatherxm.service.GlobalUploadObserverService import com.weatherxm.ui.Navigator import com.weatherxm.ui.analytics.AnalyticsOptInViewModel @@ -280,6 +281,8 @@ import com.weatherxm.util.Resources import com.weatherxm.util.WidgetHelper import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor @@ -346,6 +349,7 @@ private val logging = module { private val dispatchers = module { single { Dispatchers.IO } + single { CoroutineScope(SupervisorJob() + Dispatchers.IO) } } private val preferences = module { @@ -795,6 +799,12 @@ private val viewmodels = module { viewModelOf(::UpdatePromptViewModel) } +private val billingService = module { + single(createdAtStart = true) { + BillingService(androidContext(), get(), get()) + } +} + val modules = listOf( analytics, apiServiceModule, @@ -810,5 +820,6 @@ val modules = listOf( repositories, usecases, utilities, - viewmodels + viewmodels, + billingService ) diff --git a/app/src/main/java/com/weatherxm/data/models/Failure.kt b/app/src/main/java/com/weatherxm/data/models/Failure.kt index e4a535a60..f87b61f06 100644 --- a/app/src/main/java/com/weatherxm/data/models/Failure.kt +++ b/app/src/main/java/com/weatherxm/data/models/Failure.kt @@ -198,3 +198,8 @@ sealed class MapBoxError(code: String) : Failure(code) { @Keep object CancellationError : Failure() + +@Keep +sealed class BillingClientError : Failure() { + object NotReady : BillingClientError() +} diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt new file mode 100644 index 000000000..2fd3d8b4f --- /dev/null +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -0,0 +1,116 @@ +package com.weatherxm.service + +import android.content.Context +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.ProductType.SUBS +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.PendingPurchasesParams +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.Purchase.PurchaseState +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryPurchasesParams +import com.android.billingclient.api.queryPurchasesAsync +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import timber.log.Timber + +class BillingService( + context: Context, + private val coroutineScope: CoroutineScope, + private val dispatcher: CoroutineDispatcher +) { + private var billingClient: BillingClient? = null + + private var activeSub: Purchase? = null + private var hasFetchedPurchases: Boolean = false + + @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) + private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> + Timber.d("[Purchase Update]: $billingResult --- $purchases") + + when (billingResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + if (purchases?.get(0)?.purchaseState == PurchaseState.PURCHASED) { + // TODO: STOPSHIP Handle new purchase (acknowledge it etc) + } + } + else -> { + Timber.e("[Purchase Update] Error: $billingResult") + // TODO: STOPSHIP Got an error. Propagate the result. + } + } + } + + fun hasActiveSub(): Boolean { + return if (billingClient?.isReady == false && activeSub == null) { + startConnection() + false + } else if (!hasFetchedPurchases) { + coroutineScope.launch(dispatcher) { + setupPurchases() + } + false + } else { + activeSub != null + } + } + + fun getActiveSub(): Purchase? = activeSub + + fun startConnection() { + billingClient?.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + coroutineScope.launch(dispatcher) { + setupPurchases() + } + // TODO: Get the available subs + } + } + + override fun onBillingServiceDisconnected() { + // Not used as we have enableAutoServiceReconnection() above. + } + }) + } + + suspend fun setupPurchases() { + val purchasesResult = billingClient?.queryPurchasesAsync( + QueryPurchasesParams.newBuilder().setProductType(SUBS).build() + ) + + /** + * Got an error in the process. Terminate it. + */ + if (purchasesResult?.billingResult?.responseCode != BillingClient.BillingResponseCode.OK) { + return + } + hasFetchedPurchases = true + + val latestPurchase = purchasesResult.purchasesList.firstOrNull() + if (latestPurchase != null) { + if (latestPurchase.isAcknowledged) { + activeSub = latestPurchase + } else { + // TODO: STOPSHIP: Handle the purchase again - not acknowledged (and needs to be!!) + } + } else { + activeSub = null + } + } + + init { + billingClient = BillingClient.newBuilder(context) + .setListener(purchasesUpdatedListener) + .enableAutoServiceReconnection() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder().enableOneTimeProducts().build() + ) + .build() + startConnection() + } +} diff --git a/app/src/main/java/com/weatherxm/ui/Navigator.kt b/app/src/main/java/com/weatherxm/ui/Navigator.kt index a689f10c2..41e4bca2b 100644 --- a/app/src/main/java/com/weatherxm/ui/Navigator.kt +++ b/app/src/main/java/com/weatherxm/ui/Navigator.kt @@ -45,6 +45,7 @@ import com.weatherxm.ui.common.Contracts.ARG_DEVICE_TYPE import com.weatherxm.ui.common.Contracts.ARG_EXPLORER_CELL import com.weatherxm.ui.common.Contracts.ARG_FORECAST_SELECTED_DAY import com.weatherxm.ui.common.Contracts.ARG_FROM_ONBOARDING +import com.weatherxm.ui.common.Contracts.ARG_HAS_FREE_TRIAL_AVAILABLE import com.weatherxm.ui.common.Contracts.ARG_INSTRUCTIONS_ONLY import com.weatherxm.ui.common.Contracts.ARG_LOCATION import com.weatherxm.ui.common.Contracts.ARG_NETWORK_STATS @@ -477,18 +478,21 @@ class Navigator(private val analytics: AnalyticsWrapper) { ) } + @Suppress("LongParameterList") fun showForecastDetails( activityResultLauncher: ActivityResultLauncher?, context: Context?, device: UIDevice, location: UILocation, - forecastSelectedISODate: String? = null + forecastSelectedISODate: String? = null, + hasFreeTrialAvailable: Boolean = false ) { val intent = Intent(context, ForecastDetailsActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) .putExtra(ARG_DEVICE, device) .putExtra(ARG_LOCATION, location) .putExtra(ARG_FORECAST_SELECTED_DAY, forecastSelectedISODate) + .putExtra(ARG_HAS_FREE_TRIAL_AVAILABLE, hasFreeTrialAvailable) activityResultLauncher?.launch(intent) ?: context?.startActivity(intent) } diff --git a/app/src/main/java/com/weatherxm/ui/common/Contracts.kt b/app/src/main/java/com/weatherxm/ui/common/Contracts.kt index 5e3d5f2af..8c846ded1 100644 --- a/app/src/main/java/com/weatherxm/ui/common/Contracts.kt +++ b/app/src/main/java/com/weatherxm/ui/common/Contracts.kt @@ -43,4 +43,5 @@ object Contracts { const val STATION_COUNT_LAYER = "station_count_layer" const val STATION_COUNT = "station_count" const val MAPBOX_CUSTOM_DATA_KEY = "custom_data" + const val ARG_HAS_FREE_TRIAL_AVAILABLE = "has_free_trial_available" } diff --git a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt index b313f22b3..5206ae9b9 100644 --- a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt +++ b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt @@ -22,7 +22,11 @@ import com.weatherxm.data.models.Reward import com.weatherxm.data.models.RewardSplit import com.weatherxm.data.models.SeverityLevel import com.weatherxm.data.repository.RewardsRepositoryImpl +import com.weatherxm.util.NumberUtils.formatTokens +import com.weatherxm.util.NumberUtils.toBigDecimalSafe +import com.weatherxm.util.NumberUtils.weiToETH import kotlinx.parcelize.Parcelize +import java.math.BigDecimal import java.time.LocalDate import java.time.ZonedDateTime @@ -419,6 +423,38 @@ data class UIWalletRewards( companion object { fun empty() = UIWalletRewards(0.0, 0.0, 0.0, String.empty()) } + + /** + * If unclaimed tokens / earned tokens >= 20% AND earned tokens >= 100 then return true + */ + @Suppress("MagicNumber") + fun hasUnclaimedTokensForFreeTrial(): Boolean { + val earnedETH = weiToETH(totalEarned.toBigDecimalSafe()) + val allocatedETH = weiToETH(allocated.toBigDecimalSafe()) + return if (earnedETH >= BigDecimal.valueOf(100)) { + allocatedETH / earnedETH >= BigDecimal.valueOf(0.20) + } else { + false + } + } + + /** + * If earned tokens < 100 then we should return 100 as remaining tokens otherwise we should + * return the difference to reach 20% (the threshold) of the allocated / earned tokens + */ + @Suppress("MagicNumber") + fun remainingTokensForFreeTrial(): String { + val earnedETH = weiToETH(totalEarned.toBigDecimalSafe()) + val allocatedETH = weiToETH(allocated.toBigDecimalSafe()) + + return if (earnedETH < BigDecimal.valueOf(100)) { + formatTokens(BigDecimal.valueOf(100.0) - allocatedETH) + } else { + val threshold = earnedETH * BigDecimal.valueOf(0.20) + formatTokens(threshold - allocatedETH) + } + } + } @Keep @@ -725,6 +761,7 @@ data class DataForMessageView( val title: Int? = null, val subtitle: SubtitleForMessageView? = null, val drawable: Int? = null, + val drawableTint: Int? = null, val action: ActionForMessageView? = null, val useStroke: Boolean = false, val severityLevel: SeverityLevel = SeverityLevel.INFO, @@ -735,6 +772,7 @@ data class DataForMessageView( @JsonClass(generateAdapter = true) data class SubtitleForMessageView( val message: Int? = null, + val messageAsString: String? = null, val htmlMessage: Int? = null, val htmlMessageAsString: String? = null, val onLinkClickedListener: (() -> Unit)? = null diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/MessageCardView.kt b/app/src/main/java/com/weatherxm/ui/components/compose/MessageCardView.kt index ef5defc54..52a56f941 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/MessageCardView.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/MessageCardView.kt @@ -40,7 +40,7 @@ import com.weatherxm.ui.common.SubtitleForMessageView @Suppress("FunctionNaming", "LongMethod", "MagicNumber", "CyclomaticComplexMethod") @Composable fun MessageCardView(data: DataForMessageView) { - val (backgroundResId, strokeAndIconColor) = when (data.severityLevel) { + var (backgroundResId, strokeAndIconColor) = when (data.severityLevel) { SeverityLevel.INFO -> Pair(R.color.blueTint, R.color.infoStrokeColor) SeverityLevel.WARNING -> Pair(R.color.warningTint, R.color.warning) SeverityLevel.ERROR -> Pair(R.color.errorTint, R.color.error) @@ -69,7 +69,7 @@ fun MessageCardView(data: DataForMessageView) { data.drawable?.let { Icon( painter = painterResource(it), - tint = colorResource(strokeAndIconColor), + tint = colorResource(data.drawableTint ?: strokeAndIconColor), modifier = Modifier.size(20.dp), contentDescription = null ) @@ -105,6 +105,9 @@ fun MessageCardView(data: DataForMessageView) { data.subtitle?.message?.let { MediumText(stringResource(it)) } + data.subtitle?.messageAsString?.let { + MediumText(it) + } data.subtitle?.htmlMessage?.let { Text( color = colorResource(R.color.colorOnSurface), diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt new file mode 100644 index 000000000..cb1d0e485 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt @@ -0,0 +1,107 @@ +package com.weatherxm.ui.components.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.weatherxm.R + +@Suppress("FunctionNaming") +@Composable +fun MosaicPromotionCard(hasFreeSubAvailable: Boolean, onClickListener: () -> Unit) { + Card( + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.blueTint) + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_large)), + elevation = CardDefaults.cardElevation( + defaultElevation = dimensionResource(R.dimen.elevation_normal) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.padding_normal_to_large)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Title( + text = stringResource(R.string.mosaic), + fontSize = 25.sp, + colorRes = R.color.colorPrimary + ) + Text( + modifier = Modifier.padding(top = dimensionResource(R.dimen.padding_small)), + text = stringResource(R.string.mosaic_prompt_tagline), + fontWeight = FontWeight.Bold, + color = colorResource(R.color.colorOnSurface), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyLarge + ) + Text( + modifier = Modifier.padding(top = dimensionResource(R.dimen.padding_large)), + text = stringResource(R.string.mosaic_prompt_explanation), + color = colorResource(R.color.chart_primary_line), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + Button( + modifier = Modifier + .fillMaxWidth() + .padding(top = dimensionResource(R.dimen.padding_large)), + onClick = { onClickListener() }, + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.colorPrimary), + contentColor = colorResource(R.color.colorBackground) + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), + contentPadding = PaddingValues( + horizontal = 40.dp, + vertical = dimensionResource(R.dimen.padding_normal) + ) + ) { + LargeText( + text = stringResource(R.string.see_the_plans), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + colorRes = R.color.colorBackground + ) + } + if (hasFreeSubAvailable) { + Text( + modifier = Modifier.padding(top = dimensionResource(R.dimen.padding_small)), + text = stringResource(R.string.free_subscription_claim), + color = colorResource(R.color.chart_primary_line), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodySmall + ) + } + } + } +} + +@Suppress("FunctionNaming") +@Preview +@Composable +fun PreviewMosaicPromotionCard() { + MosaicPromotionCard(true) { + // Do nothing + } +} diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt index 5afc9ea46..c50075a84 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt @@ -3,6 +3,7 @@ package com.weatherxm.ui.devicedetails import android.os.Bundle import android.view.Menu import android.view.MenuItem +import android.widget.ImageView import androidx.activity.addCallback import androidx.appcompat.app.AlertDialog import androidx.fragment.app.Fragment @@ -13,10 +14,12 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.viewpager2.adapter.FragmentStateAdapter import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.tabs.TabLayoutMediator +import com.google.android.material.textview.MaterialTextView import com.google.firebase.analytics.FirebaseAnalytics import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService import com.weatherxm.databinding.ActivityDeviceDetailsBinding +import com.weatherxm.service.BillingService import com.weatherxm.ui.common.Contracts import com.weatherxm.ui.common.Contracts.ARG_DEVICE_ID import com.weatherxm.ui.common.DeviceAlert @@ -32,6 +35,7 @@ import com.weatherxm.ui.common.lowBatteryChip import com.weatherxm.ui.common.lowGwBatteryChip import com.weatherxm.ui.common.makeTextSelectable import com.weatherxm.ui.common.offlineChip +import com.weatherxm.ui.common.onTabSelected import com.weatherxm.ui.common.parcelable import com.weatherxm.ui.common.setBundleChip import com.weatherxm.ui.common.setColor @@ -48,6 +52,7 @@ import com.weatherxm.ui.devicedetails.current.CurrentFragment import com.weatherxm.ui.devicedetails.forecast.ForecastFragment import com.weatherxm.ui.devicedetails.rewards.RewardsFragment import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import timber.log.Timber @@ -61,6 +66,7 @@ class DeviceDetailsActivity : BaseActivity() { ) } private lateinit var binding: ActivityDeviceDetailsBinding + private val billingService: BillingService by inject() companion object { private const val OBSERVATIONS = 0 @@ -157,17 +163,23 @@ class DeviceDetailsActivity : BaseActivity() { TabLayoutMediator(binding.navigatorGroup, binding.viewPager) { tab, position -> tab.text = when (position) { OBSERVATIONS -> getString(R.string.overview) - FORECAST_TAB_POSITION -> resources.getString(R.string.forecast) - REWARDS_TAB_POSITION -> resources.getString(R.string.rewards) + FORECAST_TAB_POSITION -> getString(R.string.forecast) + REWARDS_TAB_POSITION -> getString(R.string.rewards) else -> throw IllegalStateException("Oops! You forgot to add a tab here.") } }.attach() + setupTabChangedListener() + updateDeviceInfo() } override fun onResume() { super.onResume() + if (billingService.hasActiveSub()) { + binding.navigatorGroup.getTabAt(FORECAST_TAB_POSITION) + ?.setCustomView(R.layout.view_forecast_premium_tab) + } if (model.device.relation != DeviceRelation.OWNED) { analytics.trackScreen( AnalyticsService.Screen.EXPLORER_DEVICE, classSimpleName(), model.device.id @@ -175,6 +187,41 @@ class DeviceDetailsActivity : BaseActivity() { } } + private fun setupTabChangedListener() { + with(binding.navigatorGroup) { + onTabSelected { + if (billingService.hasActiveSub()) { + if (it.position == FORECAST_TAB_POSITION) { + /** + * Paint the tab properly. + */ + val premiumColor = getColor(R.color.forecast_premium) + setSelectedTabIndicatorColor(premiumColor) + it.customView?.apply { + findViewById( + R.id.forecastIcon + )?.setColor(R.color.forecast_premium) + findViewById(R.id.forecastTitle)?.setTextColor( + premiumColor + ) + } + } else { + /** + * Revert the tab's color to the default non-selected ones. + */ + setSelectedTabIndicatorColor(getColor(R.color.colorPrimary)) + getTabAt(FORECAST_TAB_POSITION)?.customView?.apply { + findViewById(R.id.forecastIcon)?.setColor(R.color.darkGrey) + findViewById( + R.id.forecastTitle + )?.setTextColor(getColor(R.color.darkGrey)) + } + } + } + } + } + } + private fun onFollowStatus(followStatus: Resource, dialogOverlay: AlertDialog) { model.onFollowStatus().observe(this) { binding.loadingAnimation.visible(followStatus.status == Status.LOADING) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModel.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModel.kt index 73c61c9b5..191f10311 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModel.kt @@ -17,10 +17,12 @@ import com.weatherxm.ui.common.UIDevice import com.weatherxm.usecases.AuthUseCase import com.weatherxm.usecases.DeviceDetailsUseCase import com.weatherxm.usecases.FollowUseCase +import com.weatherxm.usecases.UserUseCase import com.weatherxm.util.Failure.getDefaultMessage import com.weatherxm.util.RefreshHandler import com.weatherxm.util.Resources import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import timber.log.Timber @@ -33,6 +35,7 @@ class DeviceDetailsViewModel( private val useCase: DeviceDetailsUseCase, private val authUseCase: AuthUseCase, private val followUseCase: FollowUseCase, + private val userUseCase: UserUseCase, private val resources: Resources, private val analytics: AnalyticsWrapper, private val dispatcher: CoroutineDispatcher, @@ -52,6 +55,7 @@ class DeviceDetailsViewModel( private val onDeviceFirstFetch = MutableLiveData() private val _onHealthCheckData = SingleLiveEvent>() + private var hasFreePremiumTrialAvailable = false val shouldShowTerms = mutableStateOf(false) val showNotificationsPrompt = mutableStateOf(false) @@ -60,6 +64,7 @@ class DeviceDetailsViewModel( fun onUpdatedDevice(): LiveData = onUpdatedDevice fun onFollowStatus(): LiveData> = onFollowStatus fun onHealthCheckData(): LiveData> = _onHealthCheckData + fun hasFreePremiumTrialAvailable() = hasFreePremiumTrialAvailable fun isLoggedIn() = isLoggedIn @@ -182,5 +187,13 @@ class DeviceDetailsViewModel( } } } + + viewModelScope.launch(Dispatchers.IO) { + userUseCase.getUser().onRight { user -> + userUseCase.getWalletRewards(user.wallet?.address).onRight { + hasFreePremiumTrialAvailable = it.hasUnclaimedTokensForFreeTrial() + } + } + } } } diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index 1ca915537..bd2cb2f7b 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -8,6 +8,7 @@ import com.google.firebase.analytics.FirebaseAnalytics import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService import com.weatherxm.databinding.FragmentDeviceDetailsForecastBinding +import com.weatherxm.service.BillingService import com.weatherxm.ui.common.DeviceRelation.UNFOLLOWED import com.weatherxm.ui.common.HourlyForecastAdapter import com.weatherxm.ui.common.Status @@ -18,10 +19,10 @@ import com.weatherxm.ui.common.invisible import com.weatherxm.ui.common.setHtml import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseFragment -import com.weatherxm.ui.components.ProPromotionDialogFragment -import com.weatherxm.ui.components.compose.ProPromotionCard +import com.weatherxm.ui.components.compose.MosaicPromotionCard import com.weatherxm.ui.devicedetails.DeviceDetailsViewModel import com.weatherxm.util.toISODate +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -32,6 +33,7 @@ class ForecastFragment : BaseFragment() { private val model: ForecastViewModel by viewModel { parametersOf(parentModel.device) } + private val billingService: BillingService by inject() override fun onCreateView( inflater: LayoutInflater, @@ -59,7 +61,8 @@ class ForecastFragment : BaseFragment() { context = context, device = model.device, location = UILocation.empty(), - forecastSelectedISODate = it.date.toString() + forecastSelectedISODate = it.date.toString(), + hasFreeTrialAvailable = parentModel.hasFreePremiumTrialAvailable() ) } val hourlyForecastAdapter = HourlyForecastAdapter { @@ -75,7 +78,8 @@ class ForecastFragment : BaseFragment() { context = context, device = model.device, location = UILocation.empty(), - forecastSelectedISODate = it.timestamp.toISODate() + forecastSelectedISODate = it.timestamp.toISODate(), + hasFreeTrialAvailable = parentModel.hasFreePremiumTrialAvailable() ) } binding.dailyForecastRecycler.adapter = dailyForecastAdapter @@ -112,7 +116,7 @@ class ForecastFragment : BaseFragment() { model.onForecast().observe(viewLifecycleOwner) { hourlyForecastAdapter.submitList(it.next24Hours) dailyForecastAdapter.submitList(it.forecastDays) - binding.proPromotionCard.visible(true) + binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) binding.dailyForecastRecycler.visible(true) binding.dailyForecastTitle.visible(true) binding.temperatureBarsInfoButton.visible(true) @@ -128,26 +132,20 @@ class ForecastFragment : BaseFragment() { showSnackbarMessage(binding.root, it.errorMessage, it.retryFunction) } - initProPromotionCard() + initMosaicPromotionCard() fetchOrHideContent() } override fun onResume() { super.onResume() + binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) analytics.trackScreen(AnalyticsService.Screen.DEVICE_FORECAST, classSimpleName()) } - private fun initProPromotionCard() { - binding.proPromotionCard.setContent { - ProPromotionCard(R.string.fine_tune_forecast) { - analytics.trackEventSelectContent( - AnalyticsService.ParamValue.PRO_PROMOTION_CTA.paramValue, - Pair( - FirebaseAnalytics.Param.SOURCE, - AnalyticsService.ParamValue.LOCAL_FORECAST.paramValue - ) - ) - ProPromotionDialogFragment().show(this) + private fun initMosaicPromotionCard() { + binding.mosaicPromotionCard.setContent { + MosaicPromotionCard(parentModel.hasFreePremiumTrialAvailable()) { + // TODO: STOPSHIP: Open Plans page } } } @@ -156,7 +154,7 @@ class ForecastFragment : BaseFragment() { if (isLoading && binding.swiperefresh.isRefreshing) { binding.progress.invisible() } else if (isLoading) { - binding.proPromotionCard.visible(false) + binding.mosaicPromotionCard.visible(false) binding.dailyForecastTitle.visible(false) binding.temperatureBarsInfoButton.visible(false) binding.hourlyForecastTitle.visible(false) @@ -170,10 +168,10 @@ class ForecastFragment : BaseFragment() { private fun fetchOrHideContent() { if (model.device.relation != UNFOLLOWED) { binding.hiddenContentContainer.visible(false) - binding.proPromotionCard.visible(true) + binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) model.fetchForecast() } else if (model.device.relation == UNFOLLOWED) { - binding.proPromotionCard.visible(false) + binding.mosaicPromotionCard.visible(false) binding.hourlyForecastTitle.visible(false) binding.hourlyForecastRecycler.visible(false) binding.dailyForecastRecycler.visible(false) diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 4c2fc5a4e..c1732797d 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -5,6 +5,7 @@ import com.google.firebase.analytics.FirebaseAnalytics import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService import com.weatherxm.databinding.ActivityForecastDetailsBinding +import com.weatherxm.service.BillingService import com.weatherxm.ui.common.Contracts import com.weatherxm.ui.common.Contracts.ARG_FORECAST_SELECTED_DAY import com.weatherxm.ui.common.Contracts.EMPTY_VALUE @@ -26,10 +27,9 @@ import com.weatherxm.ui.common.toast import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseActivity import com.weatherxm.ui.components.LineChartView -import com.weatherxm.ui.components.ProPromotionDialogFragment import com.weatherxm.ui.components.compose.HeaderView import com.weatherxm.ui.components.compose.JoinNetworkPromoCard -import com.weatherxm.ui.components.compose.ProPromotionCard +import com.weatherxm.ui.components.compose.MosaicPromotionCard import com.weatherxm.util.DateTimeHelper.getRelativeDayAndShort import com.weatherxm.util.Weather.getFormattedHumidity import com.weatherxm.util.Weather.getFormattedPrecipitation @@ -39,6 +39,7 @@ import com.weatherxm.util.Weather.getFormattedTemperature import com.weatherxm.util.Weather.getFormattedUV import com.weatherxm.util.Weather.getFormattedWind import com.weatherxm.util.Weather.getWindDirectionDrawable +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import timber.log.Timber @@ -49,11 +50,13 @@ class ForecastDetailsActivity : BaseActivity() { } private lateinit var binding: ActivityForecastDetailsBinding + private val billingService: BillingService by inject() private val model: ForecastDetailsViewModel by viewModel { parametersOf( intent.parcelable(Contracts.ARG_DEVICE), - intent.parcelable(Contracts.ARG_LOCATION) + intent.parcelable(Contracts.ARG_LOCATION), + intent.getBooleanExtra(Contracts.ARG_HAS_FREE_TRIAL_AVAILABLE, false) ) } @@ -123,11 +126,20 @@ class ForecastDetailsActivity : BaseActivity() { if (!model.device.isEmpty()) { model.fetchDeviceForecast() + initMosaicPromotionCard() } else if (!model.location.isEmpty()) { model.fetchLocationForecast() } } + private fun initMosaicPromotionCard() { + binding.mosaicPromotionCard.setContent { + MosaicPromotionCard(model.hasFreeTrialAvailable) { + // TODO: STOPSHIP: Open Plans page + } + } + } + private fun updateUI(forecast: UIForecastDay) { // Update the header now that model.address has valid data and we are in a location if (!model.location.isEmpty()) { @@ -337,6 +349,10 @@ class ForecastDetailsActivity : BaseActivity() { override fun onResume() { super.onResume() if (!model.device.isEmpty()) { + billingService.hasActiveSub().apply { + binding.poweredByMosaic.visible(this) + binding.mosaicPromotionCard.visible(!this) + } analytics.trackScreen( AnalyticsService.Screen.DEVICE_FORECAST_DETAILS, classSimpleName() @@ -353,25 +369,13 @@ class ForecastDetailsActivity : BaseActivity() { ) } - model.isLoggedIn().also { - binding.promoCard.setContent { - if (it) { - ProPromotionCard(R.string.want_more_accurate_forecasts) { - analytics.trackEventSelectContent( - AnalyticsService.ParamValue.PRO_PROMOTION_CTA.paramValue, - Pair( - FirebaseAnalytics.Param.SOURCE, - AnalyticsService.ParamValue.LOCAL_FORECAST_DETAILS.paramValue - ) - ) - ProPromotionDialogFragment().show(this) - } - } else { - JoinNetworkPromoCard { - navigator.openWebsite(this, getString(R.string.shop_url)) - } + if (!model.isLoggedIn()) { + binding.joinNetworkCard.setContent { + JoinNetworkPromoCard { + navigator.openWebsite(this, getString(R.string.shop_url)) } } } + binding.joinNetworkCard.visible(model.isLoggedIn()) } } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt index cde3af628..ed83b46c7 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt @@ -31,6 +31,7 @@ import java.time.LocalDate class ForecastDetailsViewModel( val device: UIDevice, val location: UILocation, + val hasFreeTrialAvailable: Boolean, private val resources: Resources, private val analytics: AnalyticsWrapper, private val authUseCase: AuthUseCase, diff --git a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt index cb82e246f..c6d1bb811 100644 --- a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt @@ -14,6 +14,7 @@ import com.weatherxm.analytics.AnalyticsService import com.weatherxm.data.models.SeverityLevel import com.weatherxm.data.models.User import com.weatherxm.databinding.FragmentProfileBinding +import com.weatherxm.service.BillingService import com.weatherxm.ui.common.ActionForMessageView import com.weatherxm.ui.common.Contracts.ARG_TOKEN_CLAIMED_AMOUNT import com.weatherxm.ui.common.Contracts.NOT_AVAILABLE_VALUE @@ -37,6 +38,7 @@ import com.weatherxm.util.NumberUtils.formatTokens import com.weatherxm.util.NumberUtils.toBigDecimalSafe import com.weatherxm.util.NumberUtils.weiToETH import dev.chrisbanes.insetter.applyInsetter +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.activityViewModel import timber.log.Timber @@ -44,6 +46,7 @@ class ProfileFragment : BaseFragment() { private lateinit var binding: FragmentProfileBinding private val model: ProfileViewModel by activityViewModel() private val parentModel: HomeViewModel by activityViewModel() + private val billingService: BillingService by inject() // Register the launcher for the connect wallet activity and wait for a possible result private val connectWalletLauncher = @@ -148,6 +151,7 @@ class ProfileFragment : BaseFragment() { Status.SUCCESS -> { resource.data?.let { updateRewardsUI(it) + updateSubscriptionUI(it) } toggleLoading(false) } @@ -224,6 +228,7 @@ class ProfileFragment : BaseFragment() { binding.rewardsContainerCard.visible(false) binding.walletContainerCard.visible(false) binding.proPromotionCard.visible(false) + binding.subscriptionCard.visible(false) binding.progress.invisible() binding.swiperefresh.isRefreshing = false } @@ -303,6 +308,46 @@ class ProfileFragment : BaseFragment() { binding.rewardsContainerCard.visible(true) } + private fun updateSubscriptionUI(it: UIWalletRewards) { + if (billingService.hasActiveSub()) { + binding.subscriptionSecondaryCard.visible(false) + } else if (it.hasUnclaimedTokensForFreeTrial()) { + binding.subscriptionSecondaryCard.setContent { + MessageCardView( + data = DataForMessageView( + extraTopPadding = 24.dp, + drawable = R.drawable.ic_crown, + drawableTint = R.color.colorPrimary, + title = R.string.claim_free_trial, + subtitle = SubtitleForMessageView( + message = R.string.claim_free_trial_subtitle + ) + ) + ) + } + binding.subscriptionSecondaryCard.visible(true) + } else { + binding.subscriptionSecondaryCard.setContent { + MessageCardView( + data = DataForMessageView( + extraTopPadding = 24.dp, + drawable = R.drawable.ic_crown, + drawableTint = R.color.colorPrimary, + title = R.string.free_trial_locked, + subtitle = SubtitleForMessageView( + messageAsString = getString( + R.string.free_trial_locked_subtitle, + it.remainingTokensForFreeTrial() + ) + ) + ) + ) + } + binding.subscriptionSecondaryCard.visible(true) + } + binding.subscriptionCard.visible(true) + } + private fun updateUserUI(user: User?) { binding.wallet.clear() binding.toolbar.subtitle = user?.email diff --git a/app/src/main/res/drawable/ic_crown.xml b/app/src/main/res/drawable/ic_crown.xml new file mode 100644 index 000000000..f4d3e2e0b --- /dev/null +++ b/app/src/main/res/drawable/ic_crown.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_subscription.xml b/app/src/main/res/drawable/ic_subscription.xml new file mode 100644 index 000000000..0065d40da --- /dev/null +++ b/app/src/main/res/drawable/ic_subscription.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thunder.xml b/app/src/main/res/drawable/ic_thunder.xml new file mode 100644 index 000000000..b5cff1ada --- /dev/null +++ b/app/src/main/res/drawable/ic_thunder.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index 6768923e3..20c44ffb7 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -42,7 +42,6 @@ android:id="@+id/header" android:layout_width="0dp" android:layout_height="wrap_content" - android:layout_marginHorizontal="@dimen/margin_normal" android:layout_marginTop="@dimen/margin_small" app:layout_constraintEnd_toStartOf="@id/statusIcon" @@ -81,6 +80,44 @@ + + + + + + + + + + + + tools:composableName="com.weatherxm.ui.components.compose.JoinNetworkPromoCardKt.PreviewJoinNetworkPromoCard" + tools:visibility="visible" /> + app:layout_constraintTop_toBottomOf="@id/joinNetworkCard" /> + + diff --git a/app/src/main/res/layout/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml index 811c143df..4c6e504f9 100644 --- a/app/src/main/res/layout/fragment_device_details_forecast.xml +++ b/app/src/main/res/layout/fragment_device_details_forecast.xml @@ -103,15 +103,6 @@ app:layout_constraintTop_toBottomOf="@id/hourlyForecastTitle" tools:listitem="@layout/list_item_hourly_forecast" /> - - + app:layout_constraintTop_toBottomOf="@id/hourlyForecastRecycler" /> + diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml index e66214369..dc474a30f 100644 --- a/app/src/main/res/layout/fragment_profile.xml +++ b/app/src/main/res/layout/fragment_profile.xml @@ -241,6 +241,45 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 99ae70f75..2a5da14ad 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -50,6 +50,7 @@ @color/dark_top @color/layer1 + #CD9EFC @color/dark_crypto diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 548683e4c..5ccdda815 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -50,6 +50,7 @@ @color/light_layer1 @color/light_top + #800162 @color/light_crypto diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f48ee549b..d3c85bf80 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -899,4 +899,18 @@ This cell has already reached its station limit based on its geospatial capacity. Only the top-ranked stations in this cell, determined by reward score and seniority, are eligible for rewards.\nIf you deploy here and your station ranks below the capacity threshold, it won’t receive any rewards.\nFor example, in a cell with a capacity of 5 stations, only the top 5 ranked devices are rewarded.\nTo maximize your earnings, consider deploying in a cell with available capacity. Relocate Proceed anyway + + + Premium subscription + See and manage your subscription + Claim your free trial! + You’ve earned over 100 $WXM and kept at least 20% unclaimed. + Free trial locked + You are %s $WXM away to unlock a free trial. Keep up! + MOSAIC + Smarter. Sharper. Sunnier. + We’ve picked the top performed models in your area to give you the most accurate forecast possible. + See the plans + You have a free subscription. Claim now! + powered by MOSAIC diff --git a/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt index 0557ac70d..85184e032 100644 --- a/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt @@ -21,6 +21,7 @@ import com.weatherxm.ui.common.empty import com.weatherxm.usecases.AuthUseCase import com.weatherxm.usecases.DeviceDetailsUseCase import com.weatherxm.usecases.FollowUseCase +import com.weatherxm.usecases.UserUseCase import com.weatherxm.util.Resources import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe @@ -44,6 +45,7 @@ class DeviceDetailsViewModelTest : BehaviorSpec({ val deviceDetailsUseCase = mockk() val authUseCase = mockk() val followUseCase = mockk() + val userUseCase = mockk() val analytics = mockk() lateinit var viewModel: DeviceDetailsViewModel @@ -123,6 +125,7 @@ class DeviceDetailsViewModelTest : BehaviorSpec({ deviceDetailsUseCase, authUseCase, followUseCase, + userUseCase, resources, analytics, dispatcher diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c8ae722d5..97895e525 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ androidx-work-runtime-ktx = "2.11.0" arrow = "2.2.0" barcode-scanner = "4.3.0" better-link-movement-method = "2.2.0" +billing = "8.0.0" chucker = "4.2.0" coil = "3.3.0" desugar_jdk_libs = "2.1.5" @@ -107,6 +108,7 @@ arrow-stack-bom = { group = "io.arrow-kt", name = "arrow-stack", version.ref = " arrow-core = { module = "io.arrow-kt:arrow-core" } barcode-scanner = { module = "com.journeyapps:zxing-android-embedded", version.ref = "barcode-scanner" } better-link-movement-method = { module = "me.saket:better-link-movement-method", version.ref = "better-link-movement-method" } +billing = { module = "com.android.billingclient:billing-ktx", version.ref = "billing" } chucker-no-op = { module = "com.github.chuckerteam.chucker:library-no-op", version.ref = "chucker" } chucker = { module = "com.github.chuckerteam.chucker:library", version.ref = "chucker" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } From f2ee0cf5ca946ec222600f0285954b519e9d9943 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Mon, 1 Dec 2025 17:30:07 +0200 Subject: [PATCH 02/60] Feature/fe 2044 plans (#823) * Implement the new card in the Profile screen for the subscription (#805) * Implement the new card in the Profile screen for the subscription * Forecast in tab in station details + in forecast details (#807) * WIP of forecast tab's state & prompt. Billing library missing to check state. * Integrate the billing library, create functionality for initializing it and fetching if we have an active sub or not and update the UI respectively * Handle UI in Forecast Details if premium is available or not * Create the initial manage subscription screen * Manage subscription + plans screen * Launch billing flow, handle purchases * Update UI of the purchase update state + support of remote configurable banner * Finalize the UI * Fixes * Fix detekt + unit tests * Fixes on when clearing the shared flow * Add some view content events * UI fixes + add "powered by Meteoblue banner" * Update gradle version * Fix the actions * Fixed the check of free premium trial available * Fixes on logged out state in forecast tab * Handle login state in Manage Subscription screen * Handle tabs coloring in device details * Fix text on success button in the billing flow * Fixes --- .../on-main-firebase-distribution.yml | 2 + .github/workflows/on-pr.yml | 2 + .../workflows/on-qa-firebase-distribution.yml | 2 + .github/workflows/production-distribution.yml | 2 + app/build.gradle.kts | 7 +- app/src/main/AndroidManifest.xml | 4 + .../weatherxm/analytics/AnalyticsService.kt | 4 +- .../java/com/weatherxm/data/Extensions.kt | 10 + .../main/java/com/weatherxm/data/Modules.kt | 2 + .../datasource/RemoteBannersDataSource.kt | 1 + .../com/weatherxm/data/models/DataModels.kt | 8 + .../com/weatherxm/service/BillingService.kt | 297 ++++++++++++++++-- .../main/java/com/weatherxm/ui/Navigator.kt | 32 ++ .../ClaimPulsePrepareGatewayFragment.kt | 3 +- .../java/com/weatherxm/ui/common/Contracts.kt | 1 + .../java/com/weatherxm/ui/common/UIModels.kt | 34 +- .../components/compose/MosaicPromotionCard.kt | 2 +- .../ui/devicedetails/DeviceDetailsActivity.kt | 50 +-- .../forecast/ForecastFragment.kt | 30 +- .../ForecastDetailsActivity.kt | 16 +- .../com/weatherxm/ui/home/HomeActivity.kt | 9 + .../com/weatherxm/ui/home/HomeViewModel.kt | 13 + .../ui/home/locations/LocationsFragment.kt | 13 +- .../ui/home/profile/ProfileFragment.kt | 8 + .../ui/managesubscription/CurrentPlanView.kt | 198 ++++++++++++ .../ManageSubscriptionActivity.kt | 188 +++++++++++ .../ManageSubscriptionViewModel.kt | 13 + .../ui/managesubscription/PlansView.kt | 202 ++++++++++++ .../managesubscription/PremiumFeaturesView.kt | 130 ++++++++ .../main/res/drawable/meteoblue_logo_2024.xml | 57 ++++ .../res/layout/activity_forecast_details.xml | 29 +- .../layout/activity_manage_subscription.xml | 122 +++++++ .../fragment_device_details_forecast.xml | 50 ++- app/src/main/res/values/strings.xml | 38 ++- .../DeviceDetailsViewModelTest.kt | 17 + .../ForecastDetailsViewModelTest.kt | 1 + .../weatherxm/ui/home/HomeViewModelTest.kt | 17 + .../ManageSubscriptionViewModelTest.kt | 29 ++ gradle/libs.versions.toml | 2 +- production.env.template | 3 + 40 files changed, 1565 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/CurrentPlanView.kt create mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt create mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionViewModel.kt create mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt create mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/PremiumFeaturesView.kt create mode 100644 app/src/main/res/drawable/meteoblue_logo_2024.xml create mode 100644 app/src/main/res/layout/activity_manage_subscription.xml create mode 100644 app/src/test/java/com/weatherxm/ui/managesubscription/ManageSubscriptionViewModelTest.kt diff --git a/.github/workflows/on-main-firebase-distribution.yml b/.github/workflows/on-main-firebase-distribution.yml index 401a43bfe..c97a271cb 100644 --- a/.github/workflows/on-main-firebase-distribution.yml +++ b/.github/workflows/on-main-firebase-distribution.yml @@ -19,6 +19,8 @@ env: ORG_GRADLE_PROJECT_DEBUG_KEY_PASSWORD: ${{ secrets.DEBUG_KEY_PASSWORD }} # Firebase distribution on tech team group ORG_GRADLE_PROJECT_FIREBASE_TEST_GROUP: ${{ secrets.FIREBASE_TECH_TEAM_TEST_GROUP }} + # The base 64 encoded RSA public key found in Google Play Console + ORG_GRADLE_PROJECT_BASE64_ENCODED_RSA_PUBLIC_KEY: ${{ secrets.BASE64_ENCODED_RSA_PUBLIC_KEY }} # Gradle config GRADLE_USER_HOME: ${GITHUB_WORKSPACE}/.gradle GLOBAL_GRADLE_CACHE: gradle-cache-${GITHUB_REPOSITORY} diff --git a/.github/workflows/on-pr.yml b/.github/workflows/on-pr.yml index 889be6f59..dc6a70320 100644 --- a/.github/workflows/on-pr.yml +++ b/.github/workflows/on-pr.yml @@ -17,6 +17,8 @@ env: ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} # Firebase distribution on tech team group ORG_GRADLE_PROJECT_FIREBASE_TEST_GROUP: ${{ secrets.FIREBASE_TECH_TEAM_TEST_GROUP }} + # The base 64 encoded RSA public key found in Google Play Console + ORG_GRADLE_PROJECT_BASE64_ENCODED_RSA_PUBLIC_KEY: ${{ secrets.BASE64_ENCODED_RSA_PUBLIC_KEY }} # Gradle config GRADLE_USER_HOME: ${GITHUB_WORKSPACE}/.gradle GLOBAL_GRADLE_CACHE: gradle-cache-${GITHUB_REPOSITORY} diff --git a/.github/workflows/on-qa-firebase-distribution.yml b/.github/workflows/on-qa-firebase-distribution.yml index dc2cb69f9..7ff4df646 100644 --- a/.github/workflows/on-qa-firebase-distribution.yml +++ b/.github/workflows/on-qa-firebase-distribution.yml @@ -32,6 +32,8 @@ env: ORG_GRADLE_PROJECT_DEBUG_KEY_PASSWORD: ${{ secrets.DEBUG_KEY_PASSWORD }} # Firebase distribution only on QA Group ORG_GRADLE_PROJECT_FIREBASE_TEST_GROUP: ${{ secrets.FIREBASE_QA_TEST_GROUP }} + # The base 64 encoded RSA public key found in Google Play Console + ORG_GRADLE_PROJECT_BASE64_ENCODED_RSA_PUBLIC_KEY: ${{ secrets.BASE64_ENCODED_RSA_PUBLIC_KEY }} # Gradle config GRADLE_USER_HOME: ${GITHUB_WORKSPACE}/.gradle GLOBAL_GRADLE_CACHE: gradle-cache-${GITHUB_REPOSITORY} diff --git a/.github/workflows/production-distribution.yml b/.github/workflows/production-distribution.yml index 270d89f69..199b8f6ce 100644 --- a/.github/workflows/production-distribution.yml +++ b/.github/workflows/production-distribution.yml @@ -15,6 +15,8 @@ env: ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} # Firebase distribution on tech team group ORG_GRADLE_PROJECT_FIREBASE_TEST_GROUP: ${{ secrets.FIREBASE_TECH_TEAM_TEST_GROUP }} + # The base 64 encoded RSA public key found in Google Play Console + ORG_GRADLE_PROJECT_BASE64_ENCODED_RSA_PUBLIC_KEY: ${{ secrets.BASE64_ENCODED_RSA_PUBLIC_KEY }} # Gradle config GRADLE_USER_HOME: ${GITHUB_WORKSPACE}/.gradle GLOBAL_GRADLE_CACHE: gradle-cache-${GITHUB_REPOSITORY} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b5fd9a689..530cc3ae1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 10 + getVersionGitTags(isSolana = false).size + versionCode = 29 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { @@ -99,6 +99,11 @@ android { // Resource value fields resValue("string", "mapbox_access_token", getStringProperty("MAPBOX_ACCESS_TOKEN")) resValue("string", "mapbox_style", getStringProperty("MAPBOX_STYLE")) + resValue( + "string", + "base64_encoded_pub_key", + getStringProperty("BASE64_ENCODED_RSA_PUBLIC_KEY") + ) // Instrumented Tests testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d56c48b8c..5cbad394c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -358,6 +358,10 @@ android:name="com.weatherxm.ui.onboarding.OnboardingActivity" android:screenOrientation="portrait" android:theme="@style/Theme.WeatherXM" /> + () + private var acknowledgeJob: Job? = null + + private val purchaseUpdate = MutableSharedFlow( + replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + private val activeSubFlow = MutableStateFlow(null) + + fun getPurchaseUpdates(): SharedFlow = purchaseUpdate + + @OptIn(ExperimentalCoroutinesApi::class) + fun clearPurchaseUpdates() = purchaseUpdate.resetReplayCache() @OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> - Timber.d("[Purchase Update]: $billingResult --- $purchases") - - when (billingResult.responseCode) { - BillingClient.BillingResponseCode.OK -> { - if (purchases?.get(0)?.purchaseState == PurchaseState.PURCHASED) { - // TODO: STOPSHIP Handle new purchase (acknowledge it etc) + if (billingResult.responseCode == BillingResponseCode.OK) { + if (purchases?.get(0)?.purchaseState == PurchaseState.PURCHASED) { + Timber.d("[Purchase Update]: Purchase was successful") + coroutineScope.launch { + handlePurchase(purchases[0], false) } } - else -> { - Timber.e("[Purchase Update] Error: $billingResult") - // TODO: STOPSHIP Got an error. Propagate the result. - } + } else if (billingResult.responseCode == BillingResponseCode.USER_CANCELED) { + Timber.w("[Purchase Update]: Purchase was canceled") + purchaseUpdate.tryEmit( + PurchaseUpdateState( + success = false, + isLoading = false, + responseCode = billingResult.responseCode, + debugMessage = billingResult.debugMessage + ) + ) + } else { + Timber.e("[Purchase Update]: Purchase failed $billingResult") + purchaseUpdate.tryEmit( + PurchaseUpdateState( + success = false, + isLoading = false, + responseCode = billingResult.responseCode, + debugMessage = billingResult.debugMessage + ) + ) } } fun hasActiveSub(): Boolean { + val activeSub = activeSubFlow.value return if (billingClient?.isReady == false && activeSub == null) { startConnection() false @@ -59,16 +118,28 @@ class BillingService( } } - fun getActiveSub(): Purchase? = activeSub + fun getActiveSubFlow(): StateFlow = activeSubFlow + + fun getAvailableSubs(hasFreeTrialAvailable: Boolean): List { + return if (hasFreeTrialAvailable) { + subs.filter { + it.offerId == OFFER_FREE_TRIAL || it.offerId == null + }.distinctBy { it.id } + } else { + subs.filter { it.offerId == null }.distinctBy { it.id } + } + } - fun startConnection() { + private fun startConnection() { billingClient?.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { coroutineScope.launch(dispatcher) { setupPurchases() } - // TODO: Get the available subs + coroutineScope.launch(dispatcher) { + setupProducts() + } } } @@ -78,7 +149,7 @@ class BillingService( }) } - suspend fun setupPurchases() { + suspend fun setupPurchases(inBackground: Boolean = true) { val purchasesResult = billingClient?.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(SUBS).build() ) @@ -86,7 +157,7 @@ class BillingService( /** * Got an error in the process. Terminate it. */ - if (purchasesResult?.billingResult?.responseCode != BillingClient.BillingResponseCode.OK) { + if (purchasesResult?.billingResult?.responseCode != BillingResponseCode.OK) { return } hasFetchedPurchases = true @@ -94,12 +165,198 @@ class BillingService( val latestPurchase = purchasesResult.purchasesList.firstOrNull() if (latestPurchase != null) { if (latestPurchase.isAcknowledged) { - activeSub = latestPurchase + activeSubFlow.tryEmit(latestPurchase) } else { - // TODO: STOPSHIP: Handle the purchase again - not acknowledged (and needs to be!!) + handlePurchase(latestPurchase, inBackground) } } else { - activeSub = null + activeSubFlow.tryEmit(null) + } + } + + private suspend fun getSubscriptionProduct(): ProductDetails? { + val params = QueryProductDetailsParams.newBuilder() + .setProductList( + listOf( + QueryProductDetailsParams.Product.newBuilder() + .setProductId(PREMIUM_FORECAST_PRODUCT_ID) + .setProductType(SUBS) + .build() + ) + ) + .build() + + val productDetailsResult = withContext(Dispatchers.IO) { + billingClient?.queryProductDetails(params) + } + + return productDetailsResult?.productDetailsList?.getOrNull(0) + } + + private suspend fun setupProducts() { + val productDetails = getSubscriptionProduct() + subs = mutableListOf() + + productDetails?.subscriptionOfferDetails?.forEach { details -> + details.pricingPhases.pricingPhaseList.forEach { + /** + * The below might produce duplicates (e.g. a plan with and without the free trial), + * the UI will be responsible to show each one by calling the getAvailableSubs() + * function. + * + * Also due to the Billing Service returning the formatted price as "3.99 $" + * we format it so that it becomes "3.99$". + */ + val offerSupported = details.offerId == OFFER_FREE_TRIAL || details.offerId == null + if (it.priceAmountMicros > 0 && offerSupported) { + subs.add( + SubscriptionOffer( + details.basePlanId, + it.formattedPrice.replaceLast(" ", ""), + details.offerToken, + details.offerId + ) + ) + } + } + } + } + + fun startBillingFlow(activity: Activity, offerToken: String?) { + purchaseUpdate.tryEmit( + PurchaseUpdateState( + success = false, + isLoading = true, + responseCode = null, + debugMessage = null + ) + ) + + coroutineScope.launch(dispatcher) { + val productDetails = getSubscriptionProduct() + + if (offerToken.isNullOrEmpty() || productDetails == null) { + return@launch + } + + val productDetailsParamsList = listOf( + BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + .setOfferToken(offerToken) + .build() + ) + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(productDetailsParamsList) + .build() + + val billingResult = billingClient?.launchBillingFlow(activity, billingFlowParams) + Timber.d("[Purchase Update]: Purchase Flow Launch: $billingResult") + + when (billingResult?.responseCode) { + BillingResponseCode.OK -> { + // All good, billing flow started, do nothing. + } + BillingResponseCode.USER_CANCELED -> { + purchaseUpdate.tryEmit( + PurchaseUpdateState( + success = false, + isLoading = false, + responseCode = billingResult.responseCode, + debugMessage = billingResult.debugMessage + ) + ) + } + else -> { + Timber.w("[Purchase Update]: Purchase failed $billingResult") + purchaseUpdate.tryEmit( + PurchaseUpdateState( + success = false, + isLoading = false, + responseCode = billingResult?.responseCode, + debugMessage = billingResult?.debugMessage + ) + ) + } + } + } + } + + private fun handlePurchase(purchase: Purchase, inBackground: Boolean) { + if (!verifyPurchase(purchase.originalJson, purchase.signature)) { + if (inBackground) return + purchaseUpdate.tryEmit( + PurchaseUpdateState( + success = false, + isLoading = false, + responseCode = null, + debugMessage = "Verification Failed" + ) + ) + return + } + if (!purchase.isAcknowledged) { + acknowledgePurchase(purchase, inBackground) + } + } + + private fun acknowledgePurchase(purchase: Purchase, inBackground: Boolean = false) { + if (acknowledgeJob?.isActive == true) { + return + } + acknowledgeJob = coroutineScope.launch(Dispatchers.IO) { + val params = + AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchase.purchaseToken) + val result = billingClient?.acknowledgePurchase(params.build()) + Timber.d("[Acknowledge Purchase Update]: $result") + + if (inBackground) return@launch + + if (result?.responseCode == BillingResponseCode.OK) { + activeSubFlow.tryEmit(purchase) + purchaseUpdate.tryEmit( + PurchaseUpdateState( + success = true, + isLoading = false, + responseCode = result.responseCode, + debugMessage = result.debugMessage + ) + ) + } else { + activeSubFlow.tryEmit(null) + purchaseUpdate.tryEmit( + PurchaseUpdateState( + success = false, + isLoading = false, + responseCode = result?.responseCode, + debugMessage = result?.debugMessage + ) + ) + } + } + } + + private fun verifyPurchase(json: String, sig: String): Boolean { + return try { + val key = + Base64.getDecoder().decode(context.getString(R.string.base64_encoded_pub_key)) + val pubKey = KeyFactory.getInstance("RSA").generatePublic( + X509EncodedKeySpec(key) + ) + val signatureBytes = Base64.getDecoder().decode(sig) + val signature = Signature.getInstance("SHA1withRSA") + signature.initVerify(pubKey) + signature.update(json.toByteArray()) + signature.verify(signatureBytes) + } catch (e: IllegalArgumentException) { + Timber.e(e) + false + } catch (e: NoSuchAlgorithmException) { + Timber.e(e) + false + } catch (e: InvalidKeySpecException) { + Timber.e(e) + false } } diff --git a/app/src/main/java/com/weatherxm/ui/Navigator.kt b/app/src/main/java/com/weatherxm/ui/Navigator.kt index 41e4bca2b..dbdac4656 100644 --- a/app/src/main/java/com/weatherxm/ui/Navigator.kt +++ b/app/src/main/java/com/weatherxm/ui/Navigator.kt @@ -28,6 +28,7 @@ import com.weatherxm.data.models.Location import com.weatherxm.data.models.Reward import com.weatherxm.data.models.RewardDetails import com.weatherxm.data.models.WXMRemoteMessage +import com.weatherxm.service.PREMIUM_FORECAST_PRODUCT_ID import com.weatherxm.ui.analytics.AnalyticsOptInActivity import com.weatherxm.ui.cellinfo.CellInfoActivity import com.weatherxm.ui.claimdevice.helium.ClaimHeliumActivity @@ -47,6 +48,7 @@ import com.weatherxm.ui.common.Contracts.ARG_FORECAST_SELECTED_DAY import com.weatherxm.ui.common.Contracts.ARG_FROM_ONBOARDING import com.weatherxm.ui.common.Contracts.ARG_HAS_FREE_TRIAL_AVAILABLE import com.weatherxm.ui.common.Contracts.ARG_INSTRUCTIONS_ONLY +import com.weatherxm.ui.common.Contracts.ARG_IS_LOGGED_IN import com.weatherxm.ui.common.Contracts.ARG_LOCATION import com.weatherxm.ui.common.Contracts.ARG_NETWORK_STATS import com.weatherxm.ui.common.Contracts.ARG_OPEN_EXPLORER_ON_BACK @@ -87,6 +89,7 @@ import com.weatherxm.ui.forecastdetails.ForecastDetailsActivity import com.weatherxm.ui.home.HomeActivity import com.weatherxm.ui.home.explorer.UICell import com.weatherxm.ui.login.LoginActivity +import com.weatherxm.ui.managesubscription.ManageSubscriptionActivity import com.weatherxm.ui.networkstats.NetworkStats import com.weatherxm.ui.networkstats.NetworkStatsActivity import com.weatherxm.ui.networkstats.growth.NetworkGrowthActivity @@ -572,6 +575,19 @@ class Navigator(private val analytics: AnalyticsWrapper) { ) } + fun showManageSubscription( + context: Context?, + hasFreeTrialAvailable: Boolean, + isLoggedIn: Boolean + ) { + context?.startActivity( + Intent(context, ManageSubscriptionActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + .putExtra(ARG_HAS_FREE_TRIAL_AVAILABLE, hasFreeTrialAvailable) + .putExtra(ARG_IS_LOGGED_IN, isLoggedIn) + ) + } + @Suppress("LongParameterList") fun showMessageDialog( fragmentManager: FragmentManager, @@ -713,6 +729,22 @@ class Navigator(private val analytics: AnalyticsWrapper) { } } + fun openSubscriptionInStore(context: Context) { + try { + val subscriptionId = PREMIUM_FORECAST_PRODUCT_ID + val packageName = context.packageName + + val intent = Intent(Intent.ACTION_VIEW).apply { + data = ("https://play.google.com/store/account/subscriptions?sku=" + + "$subscriptionId&package=$packageName").toUri() + } + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Timber.d(e, "Could not open the store.") + context.toast(R.string.error_cannot_open_store) + } + } + fun openAppSettings(context: Context?) { context?.let { try { diff --git a/app/src/main/java/com/weatherxm/ui/claimdevice/pulse/preparegateway/ClaimPulsePrepareGatewayFragment.kt b/app/src/main/java/com/weatherxm/ui/claimdevice/pulse/preparegateway/ClaimPulsePrepareGatewayFragment.kt index 2cdc594d2..72c5d07b8 100644 --- a/app/src/main/java/com/weatherxm/ui/claimdevice/pulse/preparegateway/ClaimPulsePrepareGatewayFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/claimdevice/pulse/preparegateway/ClaimPulsePrepareGatewayFragment.kt @@ -14,6 +14,7 @@ import com.weatherxm.ui.common.setHtml import com.weatherxm.ui.components.BaseFragment import com.weatherxm.util.checkPermissionsAndThen import org.koin.androidx.viewmodel.ext.android.activityViewModel +import timber.log.Timber class ClaimPulsePrepareGatewayFragment : BaseFragment() { private val model: ClaimPulseViewModel by activityViewModel() @@ -22,7 +23,7 @@ class ClaimPulsePrepareGatewayFragment : BaseFragment() { // Register the launcher and result handler for QR code scanner private val barcodeLauncher = registerForActivityResult(ScanContract()) { if (!it.contents.isNullOrEmpty()) { - println("[BARCODE SCAN RESULT]: $it") + Timber.d("[BARCODE SCAN RESULT]: $it") val scannedInfo = it.contents.removePrefix("P") if (model.validateSerial(scannedInfo)) { dismissSnackbar() diff --git a/app/src/main/java/com/weatherxm/ui/common/Contracts.kt b/app/src/main/java/com/weatherxm/ui/common/Contracts.kt index 8c846ded1..bfa4a42f8 100644 --- a/app/src/main/java/com/weatherxm/ui/common/Contracts.kt +++ b/app/src/main/java/com/weatherxm/ui/common/Contracts.kt @@ -44,4 +44,5 @@ object Contracts { const val STATION_COUNT = "station_count" const val MAPBOX_CUSTOM_DATA_KEY = "custom_data" const val ARG_HAS_FREE_TRIAL_AVAILABLE = "has_free_trial_available" + const val ARG_IS_LOGGED_IN = "is_logged_in" } diff --git a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt index 5206ae9b9..6ccf703eb 100644 --- a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt +++ b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt @@ -425,34 +425,17 @@ data class UIWalletRewards( } /** - * If unclaimed tokens / earned tokens >= 20% AND earned tokens >= 100 then return true + * If unclaimed tokens >= 80 then return true */ @Suppress("MagicNumber") fun hasUnclaimedTokensForFreeTrial(): Boolean { - val earnedETH = weiToETH(totalEarned.toBigDecimalSafe()) - val allocatedETH = weiToETH(allocated.toBigDecimalSafe()) - return if (earnedETH >= BigDecimal.valueOf(100)) { - allocatedETH / earnedETH >= BigDecimal.valueOf(0.20) - } else { - false - } + return weiToETH(allocated.toBigDecimalSafe()) >= BigDecimal.valueOf(80.0) } - /** - * If earned tokens < 100 then we should return 100 as remaining tokens otherwise we should - * return the difference to reach 20% (the threshold) of the allocated / earned tokens - */ @Suppress("MagicNumber") fun remainingTokensForFreeTrial(): String { - val earnedETH = weiToETH(totalEarned.toBigDecimalSafe()) - val allocatedETH = weiToETH(allocated.toBigDecimalSafe()) - - return if (earnedETH < BigDecimal.valueOf(100)) { - formatTokens(BigDecimal.valueOf(100.0) - allocatedETH) - } else { - val threshold = earnedETH * BigDecimal.valueOf(0.20) - formatTokens(threshold - allocatedETH) - } + val tokensDifference = BigDecimal.valueOf(80.0) - weiToETH(allocated.toBigDecimalSafe()) + return formatTokens(tokensDifference) } } @@ -789,6 +772,15 @@ data class ActionForMessageView( val onClickListener: () -> Unit ) +@Keep +@JsonClass(generateAdapter = true) +data class PurchaseUpdateState( + val success: Boolean, + val isLoading: Boolean, + val responseCode: Int?, + val debugMessage: String? = null +) + enum class RewardTimelineType { DATA, END_OF_LIST diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt index cb1d0e485..20798eafd 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt @@ -43,7 +43,7 @@ fun MosaicPromotionCard(hasFreeSubAvailable: Boolean, onClickListener: () -> Uni horizontalAlignment = Alignment.CenterHorizontally ) { Title( - text = stringResource(R.string.mosaic), + text = stringResource(R.string.hyper_local), fontSize = 25.sp, colorRes = R.color.colorPrimary ) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt index c50075a84..c04cf8b62 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt @@ -159,17 +159,7 @@ class DeviceDetailsActivity : BaseActivity() { binding.viewPager.adapter = adapter binding.viewPager.offscreenPageLimit = adapter.itemCount - 1 - @Suppress("UseCheckOrError") - TabLayoutMediator(binding.navigatorGroup, binding.viewPager) { tab, position -> - tab.text = when (position) { - OBSERVATIONS -> getString(R.string.overview) - FORECAST_TAB_POSITION -> getString(R.string.forecast) - REWARDS_TAB_POSITION -> getString(R.string.rewards) - else -> throw IllegalStateException("Oops! You forgot to add a tab here.") - } - }.attach() - - setupTabChangedListener() + setupTabs() updateDeviceInfo() } @@ -187,16 +177,42 @@ class DeviceDetailsActivity : BaseActivity() { } } - private fun setupTabChangedListener() { + private fun setupTabs() { with(binding.navigatorGroup) { + @Suppress("UseCheckOrError") + TabLayoutMediator(this, binding.viewPager) { tab, position -> + tab.text = when (position) { + OBSERVATIONS -> getString(R.string.overview) + FORECAST_TAB_POSITION -> getString(R.string.forecast) + REWARDS_TAB_POSITION -> getString(R.string.rewards) + else -> throw IllegalStateException("Oops! You forgot to add a tab here.") + } + }.attach() + + val premiumColor = getColor(R.color.forecast_premium) + if (billingService.hasActiveSub()) { + setSelectedTabIndicatorColor(premiumColor) + setTabTextColors( + context.getColor(R.color.darkGrey), + context.getColor(R.color.forecast_premium) + ) + } else { + /** + * Revert the tab's color to the default non-selected ones. + */ + setSelectedTabIndicatorColor(getColor(R.color.colorPrimary)) + setTabTextColors( + context.getColor(R.color.darkGrey), + context.getColor(R.color.colorPrimary) + ) + } + onTabSelected { if (billingService.hasActiveSub()) { if (it.position == FORECAST_TAB_POSITION) { /** - * Paint the tab properly. + * Paint the custom tab properly. */ - val premiumColor = getColor(R.color.forecast_premium) - setSelectedTabIndicatorColor(premiumColor) it.customView?.apply { findViewById( R.id.forecastIcon @@ -206,10 +222,6 @@ class DeviceDetailsActivity : BaseActivity() { ) } } else { - /** - * Revert the tab's color to the default non-selected ones. - */ - setSelectedTabIndicatorColor(getColor(R.color.colorPrimary)) getTabAt(FORECAST_TAB_POSITION)?.customView?.apply { findViewById(R.id.forecastIcon)?.setColor(R.color.darkGrey) findViewById( diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index bd2cb2f7b..6219f2b67 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -138,14 +138,23 @@ class ForecastFragment : BaseFragment() { override fun onResume() { super.onResume() - binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) + if (model.device.relation != UNFOLLOWED) { + handleForecastPremiumComponents() + } else { + binding.mosaicPromotionCard.visible(false) + binding.poweredByCard.visible(false) + } analytics.trackScreen(AnalyticsService.Screen.DEVICE_FORECAST, classSimpleName()) } private fun initMosaicPromotionCard() { binding.mosaicPromotionCard.setContent { MosaicPromotionCard(parentModel.hasFreePremiumTrialAvailable()) { - // TODO: STOPSHIP: Open Plans page + navigator.showManageSubscription( + context, + parentModel.hasFreePremiumTrialAvailable(), + true + ) } } } @@ -168,10 +177,11 @@ class ForecastFragment : BaseFragment() { private fun fetchOrHideContent() { if (model.device.relation != UNFOLLOWED) { binding.hiddenContentContainer.visible(false) - binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) + handleForecastPremiumComponents() model.fetchForecast() } else if (model.device.relation == UNFOLLOWED) { binding.mosaicPromotionCard.visible(false) + binding.poweredByCard.visible(false) binding.hourlyForecastTitle.visible(false) binding.hourlyForecastRecycler.visible(false) binding.dailyForecastRecycler.visible(false) @@ -201,4 +211,18 @@ class ForecastFragment : BaseFragment() { } } } + + private fun handleForecastPremiumComponents() { + billingService.hasActiveSub().apply { + if (this) { + binding.poweredByText.text = getString(R.string.powered_by_weatherxm) + } else { + binding.poweredByText.text = getString(R.string.powered_by_meteoblue) + } + binding.poweredByWeatherXMIcon.visible(this) + binding.poweredByMeteoblueIcon.visible(!this) + binding.mosaicPromotionCard.visible(!this) + } + binding.poweredByCard.visible(true) + } } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index c1732797d..93e4af8a4 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -135,7 +135,11 @@ class ForecastDetailsActivity : BaseActivity() { private fun initMosaicPromotionCard() { binding.mosaicPromotionCard.setContent { MosaicPromotionCard(model.hasFreeTrialAvailable) { - // TODO: STOPSHIP: Open Plans page + navigator.showManageSubscription( + this, + model.hasFreeTrialAvailable, + model.isLoggedIn() + ) } } } @@ -350,7 +354,13 @@ class ForecastDetailsActivity : BaseActivity() { super.onResume() if (!model.device.isEmpty()) { billingService.hasActiveSub().apply { - binding.poweredByMosaic.visible(this) + if (this) { + binding.poweredByText.text = getString(R.string.powered_by_weatherxm) + } else { + binding.poweredByText.text = getString(R.string.powered_by_meteoblue) + } + binding.poweredByWeatherXMIcon.visible(this) + binding.poweredByMeteoblueIcon.visible(!this) binding.mosaicPromotionCard.visible(!this) } analytics.trackScreen( @@ -358,6 +368,8 @@ class ForecastDetailsActivity : BaseActivity() { classSimpleName() ) } else { + binding.poweredByText.text = getString(R.string.powered_by_meteoblue) + binding.poweredByMeteoblueIcon.visible(true) analytics.trackScreen( screen = AnalyticsService.Screen.LOCATION_FORECAST_DETAILS, screenClass = classSimpleName(), diff --git a/app/src/main/java/com/weatherxm/ui/home/HomeActivity.kt b/app/src/main/java/com/weatherxm/ui/home/HomeActivity.kt index f8ecb8572..cd1e91362 100644 --- a/app/src/main/java/com/weatherxm/ui/home/HomeActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/home/HomeActivity.kt @@ -1,7 +1,9 @@ package com.weatherxm.ui.home import android.os.Bundle +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.withCreated import androidx.navigation.NavController import androidx.navigation.NavDestination @@ -13,6 +15,7 @@ import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService import com.weatherxm.data.models.Location import com.weatherxm.databinding.ActivityHomeBinding +import com.weatherxm.service.BillingService import com.weatherxm.service.workers.DevicesNotificationsWorker import com.weatherxm.ui.common.Contracts import com.weatherxm.ui.common.Resource @@ -31,6 +34,7 @@ import com.weatherxm.ui.home.explorer.MapLayerPickerDialogFragment import com.weatherxm.ui.home.locations.LocationsViewModel import com.weatherxm.ui.home.profile.ProfileViewModel import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import timber.log.Timber @@ -40,6 +44,7 @@ class HomeActivity : BaseActivity(), BaseMapFragment.OnMapDebugInfoListener { private val devicesViewModel: DevicesViewModel by viewModel() private val profileViewModel: ProfileViewModel by viewModel() private val locationsViewModel: LocationsViewModel by viewModel() + private val billingService: BillingService by inject() private lateinit var binding: ActivityHomeBinding private lateinit var navController: NavController @@ -49,6 +54,10 @@ class HomeActivity : BaseActivity(), BaseMapFragment.OnMapDebugInfoListener { withCreated { requestNotificationsPermissions() } + + repeatOnLifecycle(Lifecycle.State.RESUMED) { + billingService.setupPurchases() + } } } diff --git a/app/src/main/java/com/weatherxm/ui/home/HomeViewModel.kt b/app/src/main/java/com/weatherxm/ui/home/HomeViewModel.kt index d823fa283..9b9dfede1 100644 --- a/app/src/main/java/com/weatherxm/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/home/HomeViewModel.kt @@ -17,6 +17,7 @@ import com.weatherxm.usecases.DevicePhotoUseCase import com.weatherxm.usecases.RemoteBannersUseCase import com.weatherxm.usecases.UserUseCase import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @Suppress("TooManyFunctions") @@ -37,6 +38,7 @@ class HomeViewModel( private val onSurvey = SingleLiveEvent() private val onInfoBanner = SingleLiveEvent() private val onAnnouncementBanner = SingleLiveEvent() + private var hasFreePremiumTrialAvailable = false // Needed for passing info to the activity to show/hide elements when scrolling on the list private val showOverlayViews = MutableLiveData(true) @@ -47,6 +49,7 @@ class HomeViewModel( fun onInfoBanner(): LiveData = onInfoBanner fun onAnnouncementBanner(): LiveData = onAnnouncementBanner fun showOverlayViews() = showOverlayViews + fun hasFreePremiumTrialAvailable() = hasFreePremiumTrialAvailable fun hasDevices() = hasDevices fun isLoggedIn() = isLoggedIn ?: false @@ -141,4 +144,14 @@ class HomeViewModel( fun setClaimingBadgeShouldShow(shouldShow: Boolean) { userUseCase.setClaimingBadgeShouldShow(shouldShow) } + + init { + viewModelScope.launch(Dispatchers.IO) { + userUseCase.getUser().onRight { user -> + userUseCase.getWalletRewards(user.wallet?.address).onRight { + hasFreePremiumTrialAvailable = it.hasUnclaimedTokensForFreeTrial() + } + } + } + } } diff --git a/app/src/main/java/com/weatherxm/ui/home/locations/LocationsFragment.kt b/app/src/main/java/com/weatherxm/ui/home/locations/LocationsFragment.kt index 300699a79..0568cc7c2 100644 --- a/app/src/main/java/com/weatherxm/ui/home/locations/LocationsFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/home/locations/LocationsFragment.kt @@ -15,6 +15,7 @@ import com.google.android.material.search.SearchView.TransitionState import com.google.firebase.analytics.FirebaseAnalytics import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService +import com.weatherxm.data.datasource.RemoteBannersDataSourceImpl.Companion.ANNOUNCEMENT_LOCAL_PREMIUM import com.weatherxm.data.datasource.RemoteBannersDataSourceImpl.Companion.ANNOUNCEMENT_LOCAL_PRO_ACTION_URL import com.weatherxm.data.models.Location import com.weatherxm.data.models.RemoteBanner @@ -342,8 +343,18 @@ class LocationsFragment : BaseFragment() { ) ) ProPromotionDialogFragment().show(this) + } else if (it.url == ANNOUNCEMENT_LOCAL_PREMIUM) { + navigator.showManageSubscription( + context, + parentModel.hasFreePremiumTrialAvailable(), + parentModel.isLoggedIn() + ) } else { - navigator.openWebsite(context, it.url) + navigator.showManageSubscription( + context, + parentModel.hasFreePremiumTrialAvailable(), + parentModel.isLoggedIn() + ) } }, onClose = { diff --git a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt index c6d1bb811..f253b8d8d 100644 --- a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt @@ -104,6 +104,14 @@ class ProfileFragment : BaseFragment() { navigator.showPreferences(this) } + binding.subscriptionCard.setOnClickListener { + navigator.showManageSubscription( + context, + model.onWalletRewards().value?.data?.hasUnclaimedTokensForFreeTrial() == true, + true + ) + } + return binding.root } diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/CurrentPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/CurrentPlanView.kt new file mode 100644 index 000000000..6eb861099 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/CurrentPlanView.kt @@ -0,0 +1,198 @@ +package com.weatherxm.ui.managesubscription + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.android.billingclient.api.Purchase +import com.weatherxm.R +import com.weatherxm.ui.components.compose.LargeText +import com.weatherxm.ui.components.compose.MediumText + +@Suppress("FunctionNaming", "LongMethod") +@Composable +fun CurrentPlanView(currentPurchase: Purchase?, onManageSubscription: () -> Unit) { + Column( + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) + ) { + LargeText( + text = stringResource(R.string.current_plan), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Card( + colors = CardDefaults.cardColors( + containerColor = if (currentPurchase?.isAutoRenewing == false) { + colorResource(R.color.errorTint) + } else { + colorResource(R.color.colorSurface) + } + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small)), + elevation = CardDefaults.cardElevation( + dimensionResource(R.dimen.elevation_small) + ) + ) { + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.padding_normal)), + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_large)) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + LargeText( + text = if (currentPurchase == null) { + stringResource(R.string.standard) + } else { + stringResource(R.string.premium_forecast) + }, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Card( + colors = CardDefaults.cardColors( + containerColor = if (currentPurchase?.isAutoRenewing == false) { + colorResource(R.color.error) + } else { + colorResource(R.color.colorPrimary) + } + ) + ) { + Text( + text = if (currentPurchase?.isAutoRenewing == false) { + stringResource(R.string.canceled) + } else { + stringResource(R.string.active) + }, + color = if (currentPurchase?.isAutoRenewing == false) { + colorResource(R.color.colorOnSurface) + } else { + colorResource(R.color.colorOnPrimary) + }, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding( + horizontal = dimensionResource(R.dimen.padding_small_to_normal), + vertical = dimensionResource(R.dimen.padding_extra_small) + ) + ) + } + } + + if (currentPurchase == null) { + MediumText( + text = stringResource(R.string.just_the_basics), + colorRes = R.color.darkGrey + ) + } else if (!currentPurchase.isAutoRenewing) { + MediumText( + text = stringResource(R.string.canceled_subtitle), + colorRes = R.color.darkGrey + ) + } else { + Column( + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) + ) { + Row( + horizontalArrangement = spacedBy( + dimensionResource(R.dimen.margin_small_to_normal) + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + tint = colorResource(R.color.colorOnSurface), + modifier = Modifier + .padding(top = dimensionResource(R.dimen.padding_extra_small)) + .size(16.dp), + contentDescription = null + ) + Column( + verticalArrangement = spacedBy( + dimensionResource(R.dimen.margin_extra_small) + ) + ) { + LargeText( + text = stringResource(R.string.mosaic_forecast), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + MediumText( + text = stringResource(R.string.mosaic_forecast_explanation), + colorRes = R.color.darkGrey + ) + } + } + Row( + horizontalArrangement = spacedBy( + dimensionResource(R.dimen.margin_small_to_normal) + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + tint = colorResource(R.color.colorOnSurface), + modifier = Modifier + .padding(top = dimensionResource(R.dimen.padding_extra_small)) + .size(16.dp), + contentDescription = null + ) + Column( + verticalArrangement = spacedBy( + dimensionResource(R.dimen.margin_extra_small) + ) + ) { + LargeText( + text = stringResource(R.string.hourly_forecast), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + MediumText( + text = stringResource(R.string.just_the_basics), + colorRes = R.color.darkGrey + ) + } + } + } + } + if (currentPurchase != null) { + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onManageSubscription, + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.layer1), + contentColor = colorResource(R.color.colorPrimary) + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)), + ) { + MediumText( + stringResource(R.string.manage_subscription), + fontWeight = FontWeight.Bold, + colorRes = R.color.colorPrimary + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt new file mode 100644 index 000000000..ff6768181 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -0,0 +1,188 @@ +package com.weatherxm.ui.managesubscription + +import android.os.Bundle +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.billingclient.api.BillingClient.BillingResponseCode +import com.weatherxm.R +import com.weatherxm.analytics.AnalyticsService +import com.weatherxm.databinding.ActivityManageSubscriptionBinding +import com.weatherxm.service.BillingService +import com.weatherxm.ui.common.Contracts.ARG_HAS_FREE_TRIAL_AVAILABLE +import com.weatherxm.ui.common.Contracts.ARG_IS_LOGGED_IN +import com.weatherxm.ui.common.PurchaseUpdateState +import com.weatherxm.ui.common.classSimpleName +import com.weatherxm.ui.common.visible +import com.weatherxm.ui.components.BaseActivity +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel + +class ManageSubscriptionActivity : BaseActivity() { + private lateinit var binding: ActivityManageSubscriptionBinding + private val model: ManageSubscriptionViewModel by viewModel() + private val billingService: BillingService by inject() + + private var hasFreeTrialAvailable = false + private var isLoggedIn = false + + init { + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { + billingService.getPurchaseUpdates().collect { state -> + state?.let { + onPurchaseUpdate(it) + } + } + } + + launch { + billingService.getActiveSubFlow().collect { + binding.currentPlanComposable.setContent { + CurrentPlanView(it) { + navigator.openSubscriptionInStore(this@ManageSubscriptionActivity) + } + } + + if (it == null || !it.isAutoRenewing) { + binding.premiumFeaturesComposable.visible(true) + } else { + binding.premiumFeaturesComposable.visible(false) + } + } + } + + launch { + billingService.setupPurchases(false) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityManageSubscriptionBinding.inflate(layoutInflater) + setContentView(binding.root) + + hasFreeTrialAvailable = intent?.extras?.getBoolean(ARG_HAS_FREE_TRIAL_AVAILABLE) == true + isLoggedIn = intent?.extras?.getBoolean(ARG_IS_LOGGED_IN) == true + + with(binding.toolbar) { + setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } + } + + binding.premiumFeaturesComposable.setContent { + PremiumFeaturesView { + if (isLoggedIn) { + binding.selectPlanComposable.visible(true) + binding.premiumFeaturesComposable.visible(false) + binding.currentPlanComposable.visible(false) + } else { + navigator.showLoginDialog( + fragmentActivity = this, + title = getString(R.string.get_premium), + message = getString(R.string.get_premium_login_prompt) + ) + } + } + } + + binding.selectPlanComposable.setContent { + PlansView(billingService.getAvailableSubs(hasFreeTrialAvailable)) { offer -> + offer?.let { + model.setOfferToken(it.offerToken) + billingService.startBillingFlow(this, it.offerToken) + } + } + } + + binding.successBtn.setOnClickListener { + finish() + } + + binding.backBtn.setOnClickListener { + binding.currentPlanComposable.visible(true) + binding.appBar.visible(true) + binding.mainContainer.visible(true) + binding.selectPlanComposable.visible(false) + binding.statusView.visible(false) + binding.successBtn.visible(false) + binding.errorButtonsContainer.visible(false) + } + + binding.retryBtn.setOnClickListener { + model.getOfferToken()?.let { + billingService.startBillingFlow(this, it) + } + } + + billingService.clearPurchaseUpdates() + } + + override fun onResume() { + super.onResume() + analytics.trackScreen(AnalyticsService.Screen.MANAGE_SUBSCRIPTION, classSimpleName()) + } + + private fun onPurchaseUpdate(state: PurchaseUpdateState) { + if (state.isLoading) { + binding.appBar.visible(false) + binding.mainContainer.visible(false) + binding.selectPlanComposable.visible(false) + binding.successBtn.visible(false) + binding.errorButtonsContainer.visible(false) + binding.statusView.clear().animation(R.raw.anim_loading).visible(true) + } else if (state.responseCode == BillingResponseCode.USER_CANCELED) { + binding.statusView.visible(false) + binding.successBtn.visible(false) + binding.errorButtonsContainer.visible(false) + binding.appBar.visible(true) + binding.currentPlanComposable.visible(true) + binding.mainContainer.visible(true) + billingService.clearPurchaseUpdates() + analytics.trackEventViewContent( + AnalyticsService.ParamValue.BILLING_FLOW_RESULT.paramValue, + success = 0L + ) + } else if (state.success) { + binding.appBar.visible(false) + binding.mainContainer.visible(false) + binding.selectPlanComposable.visible(false) + binding.errorButtonsContainer.visible(false) + binding.statusView.clear() + .animation(R.raw.anim_success) + .title(R.string.premium_subscription_unlocked) + .subtitle(R.string.premium_subscription_unlocked_subtitle) + .visible(true) + binding.successBtn.visible(true) + billingService.clearPurchaseUpdates() + analytics.trackEventViewContent( + AnalyticsService.ParamValue.BILLING_FLOW_RESULT.paramValue, + success = 1L + ) + } else { + binding.appBar.visible(false) + binding.mainContainer.visible(false) + binding.selectPlanComposable.visible(false) + binding.statusView.clear() + .animation(R.raw.anim_error) + .title(R.string.purchase_failed) + .htmlSubtitle( + R.string.purchase_failed_message, + state.responseCode?.toString() ?: state.debugMessage + ) + .action(resources.getString(R.string.contact_support_title)) + .listener { navigator.openSupportCenter(this) } + .visible(true) + binding.successBtn.visible(false) + binding.errorButtonsContainer.visible(true) + billingService.clearPurchaseUpdates() + analytics.trackEventViewContent( + AnalyticsService.ParamValue.BILLING_FLOW_RESULT.paramValue, + success = -1L + ) + } + } +} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionViewModel.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionViewModel.kt new file mode 100644 index 000000000..10ee7c7c2 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionViewModel.kt @@ -0,0 +1,13 @@ +package com.weatherxm.ui.managesubscription + +import androidx.lifecycle.ViewModel + +class ManageSubscriptionViewModel : ViewModel() { + + private var offerToken: String? = null + fun getOfferToken(): String? = offerToken + + fun setOfferToken(token: String) { + offerToken = token + } +} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt new file mode 100644 index 000000000..11dcb768e --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt @@ -0,0 +1,202 @@ +package com.weatherxm.ui.managesubscription + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.weatherxm.R +import com.weatherxm.data.models.SubscriptionOffer +import com.weatherxm.service.OFFER_FREE_TRIAL +import com.weatherxm.service.PLAN_MONTHLY +import com.weatherxm.service.PLAN_YEARLY +import com.weatherxm.ui.components.compose.LargeText +import com.weatherxm.ui.components.compose.MediumText +import com.weatherxm.ui.components.compose.SmallText + +@Suppress("FunctionNaming", "LongMethod") +@Composable +fun PlansView(plans: List, onContinue: (SubscriptionOffer?) -> Unit) { + var selectedPlan by remember { mutableStateOf(plans.firstOrNull()) } + + Column(verticalArrangement = Arrangement.SpaceBetween) { + Column( + modifier = Modifier.weight(1F), + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) + ) { + LargeText( + text = stringResource(R.string.select_a_plan), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + LazyColumn { + items(plans) { + Plan(it, it == selectedPlan) { + selectedPlan = it + } + } + } + } + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { onContinue(selectedPlan) }, + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.colorPrimary), + contentColor = colorResource(R.color.colorBackground) + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)), + ) { + MediumText( + stringResource(R.string.action_continue), + fontWeight = FontWeight.Bold, + colorRes = R.color.colorBackground + ) + } + } +} + +@Suppress("FunctionNaming", "LongMethod") +@Composable +private fun Plan( + sub: SubscriptionOffer, + isSelected: Boolean, + onClick: () -> Unit +) { + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = dimensionResource(R.dimen.padding_large)), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small)), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + colorResource(R.color.colorSurface) + } else { + colorResource(R.color.layer1) + } + ), + elevation = CardDefaults.cardElevation( + defaultElevation = dimensionResource(R.dimen.elevation_normal) + ), + border = if (isSelected) { + BorderStroke(2.dp, colorResource(R.color.colorPrimary)) + } else { + null + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + top = dimensionResource(R.dimen.margin_normal), + end = dimensionResource(R.dimen.margin_normal), + bottom = dimensionResource(R.dimen.margin_normal) + ), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = onClick, + colors = RadioButtonDefaults.colors( + selectedColor = colorResource(R.color.colorPrimary), + unselectedColor = colorResource(R.color.colorPrimary) + ) + ) + Column( + modifier = Modifier.weight(1F), + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) + ) { + SmallText( + text = when (sub.id) { + PLAN_MONTHLY -> stringResource(R.string.monthly) + PLAN_YEARLY -> stringResource(R.string.annually) + else -> sub.id + }, + colorRes = R.color.darkGrey + ) + LargeText( + text = when (sub.id) { + PLAN_MONTHLY -> "${sub.price}${stringResource(R.string.per_month)}" + PLAN_YEARLY -> "${sub.price}${stringResource(R.string.per_year)}" + else -> "${sub.price}/${sub.id}" + }, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + if (sub.offerId == OFFER_FREE_TRIAL) { + Card( + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_small)), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.successTint) + ) + ) { + SmallText( + text = stringResource(R.string.one_month_free_trial), + colorRes = R.color.success, + paddingValues = PaddingValues( + horizontal = dimensionResource(R.dimen.margin_small_to_normal), + vertical = 6.dp + ) + ) + } + } + val subtitle = if (sub.offerId != null) { + buildString { + if (sub.id == PLAN_MONTHLY) { + append(stringResource(R.string.then_per_month, sub.price)) + append(" ") + } else if (sub.id == PLAN_YEARLY) { + append(stringResource(R.string.then_per_year, sub.price)) + append(" ") + } + append(stringResource(R.string.cancel_anytime)) + } + } else { + stringResource(R.string.cancel_anytime) + } + MediumText( + text = subtitle, + colorRes = R.color.darkGrey + ) + } + } + } +} + +@Suppress("UnusedPrivateMember", "FunctionNaming") +@Preview +@Composable +private fun PreviewPlans() { + PlansView( + plans = listOf( + SubscriptionOffer("monthly", "3.99$", "offerToken", "free-trial"), + SubscriptionOffer("yearly", "39.99$", "offerToken", null), + ) + ) { } +} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumFeaturesView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumFeaturesView.kt new file mode 100644 index 000000000..ea3c18fd0 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumFeaturesView.kt @@ -0,0 +1,130 @@ +package com.weatherxm.ui.managesubscription + +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.weatherxm.R +import com.weatherxm.ui.components.compose.LargeText +import com.weatherxm.ui.components.compose.MediumText + +@Suppress("FunctionNaming", "LongMethod") +@Preview +@Composable +fun PremiumFeaturesView(onGetPremium: () -> Unit = {}) { + Column( + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) + ) { + LargeText( + text = stringResource(R.string.premium_features), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + Card( + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.layer1) + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small)), + elevation = CardDefaults.cardElevation( + dimensionResource(R.dimen.elevation_small) + ) + ) { + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.padding_normal)), + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) + ) { + Row( + horizontalArrangement = spacedBy( + dimensionResource(R.dimen.margin_small_to_normal) + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + tint = colorResource(R.color.colorOnSurface), + modifier = Modifier + .padding(top = dimensionResource(R.dimen.padding_extra_small)) + .size(16.dp), + contentDescription = null + ) + Column( + verticalArrangement = spacedBy( + dimensionResource(R.dimen.margin_extra_small) + ) + ) { + LargeText( + text = stringResource(R.string.mosaic_forecast), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + MediumText( + text = stringResource(R.string.mosaic_forecast_explanation), + colorRes = R.color.darkGrey + ) + } + } + Row( + horizontalArrangement = spacedBy( + dimensionResource(R.dimen.margin_small_to_normal) + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + tint = colorResource(R.color.colorOnSurface), + modifier = Modifier + .padding(top = dimensionResource(R.dimen.padding_extra_small)) + .size(16.dp), + contentDescription = null + ) + Column( + verticalArrangement = spacedBy( + dimensionResource(R.dimen.margin_extra_small) + ) + ) { + LargeText( + text = stringResource(R.string.hourly_forecast), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + MediumText( + text = stringResource(R.string.just_the_basics), + colorRes = R.color.darkGrey + ) + } + } + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onGetPremium, + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.colorPrimary), + contentColor = colorResource(R.color.colorBackground) + ), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)), + ) { + MediumText( + stringResource(R.string.get_premium), + fontWeight = FontWeight.Bold, + colorRes = R.color.colorBackground + ) + } + } + } + } +} diff --git a/app/src/main/res/drawable/meteoblue_logo_2024.xml b/app/src/main/res/drawable/meteoblue_logo_2024.xml new file mode 100644 index 000000000..4b24bd785 --- /dev/null +++ b/app/src/main/res/drawable/meteoblue_logo_2024.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index 20c44ffb7..fb7060499 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -81,18 +81,16 @@ + app:layout_constraintTop_toBottomOf="@id/header"> + tools:ignore="ContentDescription" + tools:visibility="visible" /> + + + app:layout_constraintTop_toBottomOf="@id/dailyTilesRecycler" + tools:text="@string/powered_by_weatherxm" /> @@ -130,7 +143,7 @@ android:paddingHorizontal="@dimen/padding_normal" android:scrollbars="vertical" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" - app:layout_constraintTop_toBottomOf="@id/poweredByMosaic" + app:layout_constraintTop_toBottomOf="@id/poweredByCard" tools:listitem="@layout/list_item_daily_tile_forecast" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml index 4c6e504f9..3dc317526 100644 --- a/app/src/main/res/layout/fragment_device_details_forecast.xml +++ b/app/src/main/res/layout/fragment_device_details_forecast.xml @@ -79,6 +79,54 @@ app:icon="@drawable/ic_favorite_outline" /> + + + + + + + + + + + + + + app:layout_constraintTop_toBottomOf="@id/poweredByCard" /> Premium subscription See and manage your subscription Claim your free trial! - You’ve earned over 100 $WXM and kept at least 20% unclaimed. + You’ve kept at least 80 $WXM unclaimed. Free trial locked You are %s $WXM away to unlock a free trial. Keep up! - MOSAIC + HYPER LOCAL Smarter. Sharper. Sunnier. We’ve picked the top performed models in your area to give you the most accurate forecast possible. See the plans You have a free subscription. Claim now! - powered by MOSAIC + powered by WeatherXM + powered by Meteoblue + Current plan + Premium features + HYPER LOCAL forecast + We tested 30 forecast models to see which ones match real weather the best. The best model today might not be the best for the next few days! We pick the top models in your area each day to give you the most reliable forecast. + Standard + Just the basics. Premium features are locked.\nUpgrade your subscription to unlock them! + Hourly forecast + Get premium + Manage subscription + Select a plan + 1 Month Free Trial + Premium subscription unlocked + Premium features unlocked!\nHead to a station and explore them. + Go to Station + Purchase Failed +
Please make sure to mention that you’re facing an Error %s for faster resolution.]]>
+ MONTHLY + then %s per month. + then %s per year. + Cancel anytime. + ANNUALLY + /month + /year + Annual premium + Monthly premium + Next billing date + Cancel subscription + Canceled + Premium features are available until your subscription expires.\nSelect a plan to extend your subscription. + Premium Forecast + You need to login to get premium forecast. diff --git a/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt index 85184e032..13b089e3b 100644 --- a/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt @@ -14,9 +14,12 @@ import com.weatherxm.TestUtils.testHandleFailureViewModel import com.weatherxm.analytics.AnalyticsService import com.weatherxm.analytics.AnalyticsWrapper import com.weatherxm.data.models.ApiError +import com.weatherxm.data.models.User +import com.weatherxm.data.models.Wallet import com.weatherxm.ui.InstantExecutorListener import com.weatherxm.ui.common.DeviceRelation import com.weatherxm.ui.common.UIDevice +import com.weatherxm.ui.common.UIWalletRewards import com.weatherxm.ui.common.empty import com.weatherxm.usecases.AuthUseCase import com.weatherxm.usecases.DeviceDetailsUseCase @@ -49,6 +52,8 @@ class DeviceDetailsViewModelTest : BehaviorSpec({ val analytics = mockk() lateinit var viewModel: DeviceDetailsViewModel + val user = User("id", "email", null, null, null, Wallet("address", null)) + val testWalletRewards = UIWalletRewards(8E19, 0.0, 8E19, "0x00") val emptyDevice = UIDevice.empty() val device = UIDevice( "deviceId", @@ -118,6 +123,8 @@ class DeviceDetailsViewModelTest : BehaviorSpec({ justRun { deviceDetailsUseCase.setAcceptTerms() } every { deviceDetailsUseCase.shouldShowTermsPrompt() } returns true every { deviceDetailsUseCase.showDeviceNotificationsPrompt() } returns true + coMockEitherRight({ userUseCase.getUser() }, user) + coMockEitherRight({ userUseCase.getWalletRewards(user.wallet?.address) }, testWalletRewards) viewModel = DeviceDetailsViewModel( emptyDevice, @@ -149,6 +156,16 @@ class DeviceDetailsViewModelTest : BehaviorSpec({ } } + context("Get if the user has a free premium trial available or not") { + given("A use case returning the user and the rewards") { + When("it's a success") { + then("Depending on the rewards it should return a boolean") { + viewModel.hasFreePremiumTrialAvailable() shouldBe true + } + } + } + } + context("GET / SET the device") { When("GET the device") { then("return the default empty device") { diff --git a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt index b25f3b832..0b6d33f9f 100644 --- a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt @@ -172,6 +172,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ viewModel = ForecastDetailsViewModel( device, location, + false, resources, analytics, authUseCase, diff --git a/app/src/test/java/com/weatherxm/ui/home/HomeViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/home/HomeViewModelTest.kt index faa39bea2..00da5cc89 100644 --- a/app/src/test/java/com/weatherxm/ui/home/HomeViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/home/HomeViewModelTest.kt @@ -8,8 +8,11 @@ import com.weatherxm.analytics.AnalyticsWrapper import com.weatherxm.data.models.RemoteBanner import com.weatherxm.data.models.RemoteBannerType import com.weatherxm.data.models.Survey +import com.weatherxm.data.models.User +import com.weatherxm.data.models.Wallet import com.weatherxm.ui.InstantExecutorListener import com.weatherxm.ui.common.UIDevice +import com.weatherxm.ui.common.UIWalletRewards import com.weatherxm.ui.common.WalletWarnings import com.weatherxm.usecases.AuthUseCase import com.weatherxm.usecases.DevicePhotoUseCase @@ -41,6 +44,8 @@ class HomeViewModelTest : BehaviorSpec({ val bannerId = "bannerId" val deviceId = "deviceId" val devices = listOf(UIDevice.empty()) + val user = User("id", "email", null, null, null, Wallet("address", null)) + val testWalletRewards = UIWalletRewards(0.0, 0.0, 0.01, "0x00") listener(InstantExecutorListener()) @@ -65,6 +70,8 @@ class HomeViewModelTest : BehaviorSpec({ remoteBannersUseCase.dismissRemoteBanner(RemoteBannerType.ANNOUNCEMENT, bannerId) } justRun { photosUseCase.retryUpload(deviceId) } + coMockEitherRight({ userUseCase.getUser() }, user) + coMockEitherRight({ userUseCase.getWalletRewards(user.wallet?.address) }, testWalletRewards) viewModel = HomeViewModel( @@ -77,6 +84,16 @@ class HomeViewModelTest : BehaviorSpec({ ) } + context("Get if the user has a free premium trial available or not") { + given("A use case returning the user and the rewards") { + When("it's a success") { + then("Depending on the rewards it should return a boolean") { + viewModel.hasFreePremiumTrialAvailable() shouldBe false + } + } + } + } + context("GET if the user is logged in") { When("the check hasn't been performed yet") { then("the user is not logged in") { diff --git a/app/src/test/java/com/weatherxm/ui/managesubscription/ManageSubscriptionViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/managesubscription/ManageSubscriptionViewModelTest.kt new file mode 100644 index 000000000..a70b1343f --- /dev/null +++ b/app/src/test/java/com/weatherxm/ui/managesubscription/ManageSubscriptionViewModelTest.kt @@ -0,0 +1,29 @@ +package com.weatherxm.ui.managesubscription + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.FlowPreview + +@OptIn(FlowPreview::class) +class ManageSubscriptionViewModelTest : BehaviorSpec({ + val viewModel = ManageSubscriptionViewModel() + + val offerToken = "offerToken" + + context("Get the initial value of the offer token") { + given("The ViewModel's GET function") { + then("return it") { + viewModel.getOfferToken() shouldBe null + } + } + } + + context("Set a new value of the offer token") { + given("The ViewModel's SET function") { + then("ensure that it's SET correctly") { + viewModel.setOfferToken(offerToken) + viewModel.getOfferToken() shouldBe offerToken + } + } + } +}) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 97895e525..75aac446c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ androidx-work-runtime-ktx = "2.11.0" arrow = "2.2.0" barcode-scanner = "4.3.0" better-link-movement-method = "2.2.0" -billing = "8.0.0" +billing = "8.1.0" chucker = "4.2.0" coil = "3.3.0" desugar_jdk_libs = "2.1.5" diff --git a/production.env.template b/production.env.template index f27df1267..c0aa1fcbc 100644 --- a/production.env.template +++ b/production.env.template @@ -45,3 +45,6 @@ API_URL=https://api.weatherxm.com # The Claim DApp URL used for this flavor/environment CLAIM_APP_URL=https://claim.weatherxm.com + +# The base 64 encoded RSA public key found in Google Play Console +BASE64_ENCODED_RSA_PUBLIC_KEY= From 0d23eb8363120bb82699d48724218263e2e352e7 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 3 Dec 2025 14:48:34 +0200 Subject: [PATCH 03/60] Fixed the placement of meteoblue logo --- .../forecast/ForecastFragment.kt | 2 +- .../ForecastDetailsActivity.kt | 4 ++-- .../res/layout/activity_forecast_details.xml | 21 ++++++++--------- .../fragment_device_details_forecast.xml | 23 ++++++++++--------- app/src/main/res/values/strings.xml | 2 +- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index 6219f2b67..24a571d77 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -217,7 +217,7 @@ class ForecastFragment : BaseFragment() { if (this) { binding.poweredByText.text = getString(R.string.powered_by_weatherxm) } else { - binding.poweredByText.text = getString(R.string.powered_by_meteoblue) + binding.poweredByText.text = getString(R.string.powered_by) } binding.poweredByWeatherXMIcon.visible(this) binding.poweredByMeteoblueIcon.visible(!this) diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 93e4af8a4..3abedda63 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -357,7 +357,7 @@ class ForecastDetailsActivity : BaseActivity() { if (this) { binding.poweredByText.text = getString(R.string.powered_by_weatherxm) } else { - binding.poweredByText.text = getString(R.string.powered_by_meteoblue) + binding.poweredByText.text = getString(R.string.powered_by) } binding.poweredByWeatherXMIcon.visible(this) binding.poweredByMeteoblueIcon.visible(!this) @@ -368,7 +368,7 @@ class ForecastDetailsActivity : BaseActivity() { classSimpleName() ) } else { - binding.poweredByText.text = getString(R.string.powered_by_meteoblue) + binding.poweredByText.text = getString(R.string.powered_by) binding.poweredByMeteoblueIcon.visible(true) analytics.trackScreen( screen = AnalyticsService.Screen.LOCATION_FORECAST_DETAILS, diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index fb7060499..3bca138e2 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -109,17 +109,6 @@ tools:ignore="ContentDescription" tools:visibility="visible" /> - - + +
diff --git a/app/src/main/res/layout/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml index 3dc317526..b3e3bdc07 100644 --- a/app/src/main/res/layout/fragment_device_details_forecast.xml +++ b/app/src/main/res/layout/fragment_device_details_forecast.xml @@ -104,16 +104,6 @@ android:src="@drawable/ic_thunder" android:visibility="gone" app:tint="@color/forecast_premium" - tools:ignore="ContentDescription" - tools:visibility="visible" /> - - + tools:text="@string/powered_by" /> + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8d1278c15..362be8492 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -913,7 +913,7 @@ See the plans You have a free subscription. Claim now! powered by WeatherXM - powered by Meteoblue + powered by Current plan Premium features HYPER LOCAL forecast From e944e278c1336cb5863079c03f94c9ee5bc9cb16 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Tue, 9 Dec 2025 17:16:43 +0200 Subject: [PATCH 04/60] Show powered by weatherxm logo instead of the text we had before --- .../devicedetails/forecast/ForecastFragment.kt | 8 ++------ .../forecastdetails/ForecastDetailsActivity.kt | 8 ++------ .../res/layout/activity_forecast_details.xml | 17 ++++++++++++++--- .../layout/fragment_device_details_forecast.xml | 16 +++++++++++++--- app/src/main/res/values/strings.xml | 1 - 5 files changed, 31 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index 24a571d77..09ea965ec 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -214,12 +214,8 @@ class ForecastFragment : BaseFragment() { private fun handleForecastPremiumComponents() { billingService.hasActiveSub().apply { - if (this) { - binding.poweredByText.text = getString(R.string.powered_by_weatherxm) - } else { - binding.poweredByText.text = getString(R.string.powered_by) - } - binding.poweredByWeatherXMIcon.visible(this) + binding.poweredByWXMLogo.visible(this) + binding.poweredByPremiumThunder.visible(this) binding.poweredByMeteoblueIcon.visible(!this) binding.mosaicPromotionCard.visible(!this) } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 3abedda63..73179c9da 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -354,12 +354,8 @@ class ForecastDetailsActivity : BaseActivity() { super.onResume() if (!model.device.isEmpty()) { billingService.hasActiveSub().apply { - if (this) { - binding.poweredByText.text = getString(R.string.powered_by_weatherxm) - } else { - binding.poweredByText.text = getString(R.string.powered_by) - } - binding.poweredByWeatherXMIcon.visible(this) + binding.poweredByWXMLogo.visible(this) + binding.poweredByPremiumThunder.visible(this) binding.poweredByMeteoblueIcon.visible(!this) binding.mosaicPromotionCard.visible(!this) } diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index 3bca138e2..f3c5f69c9 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -99,7 +99,7 @@ android:orientation="horizontal"> + app:layout_constraintTop_toBottomOf="@id/dailyTilesRecycler" /> + + diff --git a/app/src/main/res/layout/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml index b3e3bdc07..f915be342 100644 --- a/app/src/main/res/layout/fragment_device_details_forecast.xml +++ b/app/src/main/res/layout/fragment_device_details_forecast.xml @@ -97,7 +97,7 @@ android:orientation="horizontal"> + app:layout_constraintTop_toBottomOf="@id/dailyTilesRecycler" /> + + We’ve picked the top performed models in your area to give you the most accurate forecast possible. See the plans You have a free subscription. Claim now! - powered by WeatherXM powered by Current plan Premium features From 4276c9ebae92908d7927dd04389932012e7ed3ae Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 10 Dec 2025 09:42:50 +0200 Subject: [PATCH 05/60] WIP - Integrate the purchaseToken in the API request --- .../CacheWeatherForecastDataSource.kt | 3 +- .../NetworkWeatherForecastDataSource.kt | 6 ++- .../datasource/WeatherForecastDataSource.kt | 3 +- .../com/weatherxm/data/network/ApiService.kt | 1 + .../repository/WeatherForecastRepository.kt | 42 +++++++++++++++---- .../WeatherForecastRepositoryTest.kt | 40 +++++++++++++++++- 6 files changed, 82 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/weatherxm/data/datasource/CacheWeatherForecastDataSource.kt b/app/src/main/java/com/weatherxm/data/datasource/CacheWeatherForecastDataSource.kt index da8d634c2..8da6f4e5e 100644 --- a/app/src/main/java/com/weatherxm/data/datasource/CacheWeatherForecastDataSource.kt +++ b/app/src/main/java/com/weatherxm/data/datasource/CacheWeatherForecastDataSource.kt @@ -16,7 +16,8 @@ class CacheWeatherForecastDataSource( deviceId: String, fromDate: LocalDate, toDate: LocalDate, - exclude: String? + exclude: String?, + token: String? ): Either> { return cacheService.getDeviceForecast(deviceId) } diff --git a/app/src/main/java/com/weatherxm/data/datasource/NetworkWeatherForecastDataSource.kt b/app/src/main/java/com/weatherxm/data/datasource/NetworkWeatherForecastDataSource.kt index da55d9fb6..034d697c5 100644 --- a/app/src/main/java/com/weatherxm/data/datasource/NetworkWeatherForecastDataSource.kt +++ b/app/src/main/java/com/weatherxm/data/datasource/NetworkWeatherForecastDataSource.kt @@ -16,13 +16,15 @@ class NetworkWeatherForecastDataSource( deviceId: String, fromDate: LocalDate, toDate: LocalDate, - exclude: String? + exclude: String?, + token: String? ): Either> { return apiService.getForecast( deviceId, fromDate.toString(), toDate.toString(), - exclude + exclude, + token ).mapResponse() } diff --git a/app/src/main/java/com/weatherxm/data/datasource/WeatherForecastDataSource.kt b/app/src/main/java/com/weatherxm/data/datasource/WeatherForecastDataSource.kt index ce590ad5e..fda8efe27 100644 --- a/app/src/main/java/com/weatherxm/data/datasource/WeatherForecastDataSource.kt +++ b/app/src/main/java/com/weatherxm/data/datasource/WeatherForecastDataSource.kt @@ -23,7 +23,8 @@ interface WeatherForecastDataSource { deviceId: String, fromDate: LocalDate, toDate: LocalDate, - exclude: @Exclude String? = null + exclude: @Exclude String? = null, + token: String? = null ): Either> suspend fun setDeviceForecast(deviceId: String, forecast: List) diff --git a/app/src/main/java/com/weatherxm/data/network/ApiService.kt b/app/src/main/java/com/weatherxm/data/network/ApiService.kt index 22d541902..7bc8d7cd0 100644 --- a/app/src/main/java/com/weatherxm/data/network/ApiService.kt +++ b/app/src/main/java/com/weatherxm/data/network/ApiService.kt @@ -100,6 +100,7 @@ interface ApiService { @Query("fromDate") fromDate: String, @Query("toDate") toDate: String, @Query("exclude") exclude: String? = null, + @Query("token") token: String? = null, ): NetworkResponse, ErrorResponse> @Mock diff --git a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt index 6f7686b5f..7bcba6c39 100644 --- a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt +++ b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt @@ -6,6 +6,7 @@ import com.weatherxm.data.datasource.NetworkWeatherForecastDataSource import com.weatherxm.data.models.Failure import com.weatherxm.data.models.Location import com.weatherxm.data.models.WeatherData +import com.weatherxm.service.BillingService import timber.log.Timber import java.time.LocalDate import java.time.temporal.ChronoUnit @@ -23,6 +24,7 @@ interface WeatherForecastRepository { } class WeatherForecastRepositoryImpl( + private val billingService: BillingService, private val networkSource: NetworkWeatherForecastDataSource, private val cacheSource: CacheWeatherForecastDataSource, ) : WeatherForecastRepository { @@ -37,28 +39,52 @@ class WeatherForecastRepositoryImpl( toDate: LocalDate, forceRefresh: Boolean ): Either> { - if (forceRefresh) { - clearDeviceForecastFromCache() - } - val to = if (ChronoUnit.DAYS.between(fromDate, toDate) < PREFETCH_DAYS) { fromDate.plusDays(PREFETCH_DAYS) } else { toDate } - return cacheSource.getDeviceForecast(deviceId, fromDate, to) + return if (billingService.hasActiveSub()) { + getDevicePremiumForecast(deviceId, fromDate, to) + } else { + getDeviceDefaultForecast(deviceId, fromDate, to, forceRefresh) + } + } + + private suspend fun getDeviceDefaultForecast( + deviceId: String, + fromDate: LocalDate, + toDate: LocalDate, + forceRefresh: Boolean + ): Either> { + if (forceRefresh) { + clearDeviceForecastFromCache() + } + + return cacheSource.getDeviceForecast(deviceId, fromDate, toDate) .onRight { - Timber.d("Got forecast from cache [$fromDate to $to].") + Timber.d("Got forecast from cache [$fromDate to $toDate].") } .mapLeft { - return networkSource.getDeviceForecast(deviceId, fromDate, to).onRight { - Timber.d("Got forecast from network [$fromDate to $to].") + return networkSource.getDeviceForecast(deviceId, fromDate, toDate).onRight { + Timber.d("Got forecast from network [$fromDate to $toDate].") cacheSource.setDeviceForecast(deviceId, it) } } } + private suspend fun getDevicePremiumForecast( + deviceId: String, + fromDate: LocalDate, + toDate: LocalDate + ): Either> { + val token = billingService.getActiveSubFlow().value?.purchaseToken + return networkSource.getDeviceForecast(deviceId, fromDate, toDate, token = token).onRight { + Timber.d("Got premium forecast from network [$fromDate to $toDate].") + } + } + override fun clearLocationForecastFromCache() { cacheSource.clearLocationForecast() } diff --git a/app/src/test/java/com/weatherxm/data/repository/WeatherForecastRepositoryTest.kt b/app/src/test/java/com/weatherxm/data/repository/WeatherForecastRepositoryTest.kt index 589c3ff02..946884775 100644 --- a/app/src/test/java/com/weatherxm/data/repository/WeatherForecastRepositoryTest.kt +++ b/app/src/test/java/com/weatherxm/data/repository/WeatherForecastRepositoryTest.kt @@ -3,16 +3,19 @@ package com.weatherxm.data.repository import com.weatherxm.TestConfig.failure import com.weatherxm.TestUtils.coMockEitherLeft import com.weatherxm.TestUtils.coMockEitherRight +import com.weatherxm.TestUtils.isError import com.weatherxm.TestUtils.isSuccess import com.weatherxm.data.datasource.CacheWeatherForecastDataSource import com.weatherxm.data.datasource.NetworkWeatherForecastDataSource import com.weatherxm.data.models.Location import com.weatherxm.data.models.WeatherData import com.weatherxm.data.repository.WeatherForecastRepositoryImpl.Companion.PREFETCH_DAYS +import com.weatherxm.service.BillingService import io.kotest.core.spec.style.BehaviorSpec import io.kotest.core.test.isRootTest import io.mockk.coJustRun import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import java.time.ZonedDateTime @@ -20,6 +23,7 @@ class WeatherForecastRepositoryTest : BehaviorSpec({ lateinit var networkSource: NetworkWeatherForecastDataSource lateinit var cacheSource: CacheWeatherForecastDataSource lateinit var repo: WeatherForecastRepositoryImpl + lateinit var billingService: BillingService val location = Location.empty() val deviceId = "deviceId" @@ -27,12 +31,14 @@ class WeatherForecastRepositoryTest : BehaviorSpec({ val fromDate = now.minusDays(PREFETCH_DAYS) val toDateLessThanPrefetched = fromDate.plusDays(PREFETCH_DAYS - 1) val forecastData = mockk>() + val purchaseToken = "purchaseToken" beforeInvocation { testCase, _ -> if (testCase.isRootTest()) { networkSource = mockk() cacheSource = mockk() - repo = WeatherForecastRepositoryImpl(networkSource, cacheSource) + billingService = mockk() + repo = WeatherForecastRepositoryImpl(billingService, networkSource, cacheSource) coJustRun { cacheSource.clearDeviceForecast() } coJustRun { cacheSource.clearLocationForecast() } coJustRun { cacheSource.setDeviceForecast(deviceId, forecastData) } @@ -45,8 +51,14 @@ class WeatherForecastRepositoryTest : BehaviorSpec({ { cacheSource.getDeviceForecast(deviceId, fromDate, now) }, forecastData ) + coMockEitherRight( + { networkSource.getDeviceForecast(deviceId, fromDate, now, token = purchaseToken) }, + forecastData + ) coMockEitherRight({ networkSource.getLocationForecast(location) }, forecastData) coMockEitherRight({ cacheSource.getLocationForecast(location) }, forecastData) + every { billingService.hasActiveSub() } returns false + every { billingService.getActiveSubFlow().value?.purchaseToken } returns purchaseToken } } @@ -159,4 +171,30 @@ class WeatherForecastRepositoryTest : BehaviorSpec({ } } + context("Handle fetching premium forecast") { + given("the datasource that we use to perform the API call") { + every { billingService.hasActiveSub() } returns true + When("the API returns the correct data") { + then("forecast should be fetched from network") { + repo.getDeviceForecast(deviceId, fromDate, now, false).isSuccess(forecastData) + } + } + When("the API returns a failure") { + coMockEitherLeft( + { + networkSource.getDeviceForecast( + deviceId, + fromDate, + now, + token = purchaseToken + ) + }, + failure + ) + then("forecast should return the failure") { + repo.getDeviceForecast(deviceId, fromDate, now, false).isError() + } + } + } + } }) From 0ff61e822dbe3a6933d167e02c51e1c2ecc75234 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 10 Dec 2025 18:03:21 +0200 Subject: [PATCH 06/60] Finalize the integration of the purchaseToken in the API and the isPremium in the response --- .../get_location_weather_forecast.json | 3 + .../get_user_device_weather_forecast.json | 7 + ..._user_device_weather_forecast_premium.json | 866 ++++++++++++++++++ .../com/weatherxm/data/models/ApiModels.kt | 1 + .../com/weatherxm/data/network/ApiService.kt | 2 +- .../repository/WeatherForecastRepository.kt | 3 +- .../java/com/weatherxm/ui/common/UIModels.kt | 3 +- .../forecast/ForecastFragment.kt | 22 +- .../ForecastDetailsViewModel.kt | 12 +- .../com/weatherxm/usecases/ChartsUseCase.kt | 4 +- .../weatherxm/usecases/ChartsUseCaseImpl.kt | 11 +- .../weatherxm/usecases/ForecastUseCaseImpl.kt | 1 + .../fragment_device_details_forecast.xml | 4 +- .../WeatherHistoryDataSourceTest.kt | 1 + .../ForecastDetailsViewModelTest.kt | 2 +- .../weatherxm/usecases/ForecastUseCaseTest.kt | 2 + .../usecases/LocationsUseCaseTest.kt | 1 + 17 files changed, 919 insertions(+), 26 deletions(-) create mode 100644 app/src/local/assets/mock_files/get_user_device_weather_forecast_premium.json diff --git a/app/src/local/assets/mock_files/get_location_weather_forecast.json b/app/src/local/assets/mock_files/get_location_weather_forecast.json index 99b253288..ee35ded22 100644 --- a/app/src/local/assets/mock_files/get_location_weather_forecast.json +++ b/app/src/local/assets/mock_files/get_location_weather_forecast.json @@ -3,6 +3,7 @@ "address": "Athens, GR", "tz": "Europe/Athens", "date": "2025-07-24", + "isPremium": false, "hourly": [ { "precipitation": 0, @@ -410,6 +411,7 @@ "address": "Athens, GR", "tz": "Europe/Athens", "date": "2025-07-25", + "isPremium": false, "hourly": [ { "precipitation": 0, @@ -792,6 +794,7 @@ { "tz": "Europe/Athens", "date": "2025-07-26", + "isPremium": false, "hourly": [ { "precipitation": 0, diff --git a/app/src/local/assets/mock_files/get_user_device_weather_forecast.json b/app/src/local/assets/mock_files/get_user_device_weather_forecast.json index 1dbf1fa63..e71237ba8 100644 --- a/app/src/local/assets/mock_files/get_user_device_weather_forecast.json +++ b/app/src/local/assets/mock_files/get_user_device_weather_forecast.json @@ -2,6 +2,7 @@ { "tz": "Europe/Athens", "date": "2024-04-11", + "isPremium": false, "hourly": [ { "precipitation": 0, @@ -408,6 +409,7 @@ { "tz": "Europe/Athens", "date": "2021-12-21", + "isPremium": false, "hourly": [ { "precipitation": 0, @@ -790,6 +792,7 @@ { "tz": "Europe/Athens", "date": "2021-12-20", + "isPremium": false, "hourly": [ { "precipitation": 0, @@ -1172,6 +1175,7 @@ { "tz": "Europe/Athens", "date": "2021-12-23", + "isPremium": false, "hourly": [ { "precipitation": 0, @@ -1554,6 +1558,7 @@ { "tz": "Europe/Athens", "date": "2021-12-24", + "isPremium": false, "hourly": [ { "precipitation": 0, @@ -1936,6 +1941,7 @@ { "tz": "Europe/Athens", "date": "2021-12-25", + "isPremium": false, "hourly": [ { "precipitation": 0, @@ -2318,6 +2324,7 @@ { "tz": "Europe/Athens", "date": "2021-12-26", + "isPremium": false, "hourly": [ { "precipitation": 0, diff --git a/app/src/local/assets/mock_files/get_user_device_weather_forecast_premium.json b/app/src/local/assets/mock_files/get_user_device_weather_forecast_premium.json new file mode 100644 index 000000000..025ec6751 --- /dev/null +++ b/app/src/local/assets/mock_files/get_user_device_weather_forecast_premium.json @@ -0,0 +1,866 @@ +[ + { + "tz": "Europe/Athens", + "date": "2025-12-10", + "isPremium": true, + "hourly": [ + { + "timestamp": "2025-12-10T16:00:00+02:00", + "temperature": 15.33, + "humidity": 67, + "precipitation": 0, + "wind_speed": 2.76, + "wind_direction": 324, + "feels_like": 15.33, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-10T17:00:00+02:00", + "temperature": 14.42, + "humidity": 71, + "precipitation": 0, + "wind_speed": 2.38, + "wind_direction": 312, + "feels_like": 14.42, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-10T18:00:00+02:00", + "temperature": 13.7, + "humidity": 73, + "precipitation": 0, + "wind_speed": 2.13, + "wind_direction": 239, + "feels_like": 13.7, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-10T19:00:00+02:00", + "temperature": 13.19, + "humidity": 74, + "precipitation": 0, + "wind_speed": 1.94, + "wind_direction": 211, + "feels_like": 13.19, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-10T20:00:00+02:00", + "temperature": 12.84, + "humidity": 72, + "precipitation": 0, + "wind_speed": 1.9, + "wind_direction": 196, + "feels_like": 12.84, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-10T21:00:00+02:00", + "temperature": 12.57, + "humidity": 69, + "precipitation": 0, + "wind_speed": 1.82, + "wind_direction": 193, + "feels_like": 12.57, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-10T22:00:00+02:00", + "temperature": 12.34, + "humidity": 66, + "precipitation": 0, + "wind_speed": 1.79, + "wind_direction": 197, + "feels_like": 12.34, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-10T23:00:00+02:00", + "temperature": 12.09, + "humidity": 65, + "precipitation": 0, + "wind_speed": 1.82, + "wind_direction": 203, + "feels_like": 12.09, + "icon": "clear-night" + } + ], + "daily": { + "temperature_max": 15.33, + "temperature_min": 12.09, + "timestamp": "2025-12-10T00:00:00+02:00", + "humidity": 70, + "wind_speed": 2.07, + "wind_direction": 234.76598366222618, + "icon": "partly-cloudy-day-drizzle" + } + }, + { + "tz": "Europe/Athens", + "date": "2025-12-11", + "isPremium": true, + "hourly": [ + { + "timestamp": "2025-12-11T00:00:00+02:00", + "temperature": 11.8, + "humidity": 66, + "precipitation": 0, + "wind_speed": 1.88, + "wind_direction": 207, + "feels_like": 11.8, + "icon": "clear-night" + }, + { + "timestamp": "2025-12-11T01:00:00+02:00", + "temperature": 11.66, + "humidity": 68, + "precipitation": 0, + "wind_speed": 1.98, + "wind_direction": 207, + "feels_like": 11.66, + "icon": "clear-night" + }, + { + "timestamp": "2025-12-11T02:00:00+02:00", + "temperature": 11.58, + "humidity": 70, + "precipitation": 0, + "wind_speed": 2.07, + "wind_direction": 205, + "feels_like": 11.58, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T03:00:00+02:00", + "temperature": 11.63, + "humidity": 70, + "precipitation": 0, + "wind_speed": 2.11, + "wind_direction": 205, + "feels_like": 11.63, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T04:00:00+02:00", + "temperature": 11.6, + "humidity": 69, + "precipitation": 0, + "wind_speed": 2.16, + "wind_direction": 201, + "feels_like": 11.6, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T05:00:00+02:00", + "temperature": 11.7, + "humidity": 69, + "precipitation": 0, + "wind_speed": 2.32, + "wind_direction": 199, + "feels_like": 11.7, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T06:00:00+02:00", + "temperature": 11.94, + "humidity": 73, + "precipitation": 0, + "wind_speed": 2.42, + "wind_direction": 192, + "feels_like": 11.94, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T07:00:00+02:00", + "temperature": 12.63, + "humidity": 75, + "precipitation": 0, + "wind_speed": 2.54, + "wind_direction": 183, + "feels_like": 12.63, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T08:00:00+02:00", + "temperature": 13.79, + "humidity": 74, + "precipitation": 0, + "wind_speed": 2.64, + "wind_direction": 124, + "feels_like": 13.79, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T09:00:00+02:00", + "temperature": 15.16, + "humidity": 68, + "precipitation": 0, + "wind_speed": 2.79, + "wind_direction": 63, + "feels_like": 15.16, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T10:00:00+02:00", + "temperature": 16.44, + "humidity": 66, + "precipitation": 0, + "wind_speed": 2.92, + "wind_direction": 45, + "feels_like": 16.44, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T11:00:00+02:00", + "temperature": 17.37, + "humidity": 63, + "precipitation": 0, + "wind_speed": 3.11, + "wind_direction": 50, + "feels_like": 17.37, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T12:00:00+02:00", + "temperature": 18.09, + "humidity": 61, + "precipitation": 0, + "wind_speed": 3.47, + "wind_direction": 45, + "feels_like": 18.09, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T13:00:00+02:00", + "temperature": 18.32, + "humidity": 60, + "precipitation": 0, + "wind_speed": 3.84, + "wind_direction": 33, + "feels_like": 18.32, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T14:00:00+02:00", + "temperature": 18.08, + "humidity": 62, + "precipitation": 0, + "wind_speed": 4.04, + "wind_direction": 26, + "feels_like": 18.08, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T15:00:00+02:00", + "temperature": 17.36, + "humidity": 64, + "precipitation": 0, + "wind_speed": 3.9, + "wind_direction": 27, + "feels_like": 17.36, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T16:00:00+02:00", + "temperature": 16.46, + "humidity": 68, + "precipitation": 0, + "wind_speed": 3.68, + "wind_direction": 28, + "feels_like": 16.46, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T17:00:00+02:00", + "temperature": 15.63, + "humidity": 72, + "precipitation": 0, + "wind_speed": 3.4, + "wind_direction": 29, + "feels_like": 15.63, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-11T18:00:00+02:00", + "temperature": 15.01, + "humidity": 76, + "precipitation": 0, + "wind_speed": 3.23, + "wind_direction": 36, + "feels_like": 15.01, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T19:00:00+02:00", + "temperature": 14.61, + "humidity": 79, + "precipitation": 0, + "wind_speed": 3.2, + "wind_direction": 40, + "feels_like": 14.61, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T20:00:00+02:00", + "temperature": 14.35, + "humidity": 80, + "precipitation": 0, + "wind_speed": 3.23, + "wind_direction": 46, + "feels_like": 14.35, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T21:00:00+02:00", + "temperature": 14.2, + "humidity": 80, + "precipitation": 0, + "wind_speed": 3.14, + "wind_direction": 58, + "feels_like": 14.2, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T22:00:00+02:00", + "temperature": 14.08, + "humidity": 80, + "precipitation": 0, + "wind_speed": 3.09, + "wind_direction": 60, + "feels_like": 14.08, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-11T23:00:00+02:00", + "temperature": 13.96, + "humidity": 79, + "precipitation": 0, + "wind_speed": 3.03, + "wind_direction": 59, + "feels_like": 13.96, + "icon": "partly-cloudy-night" + } + ], + "daily": { + "temperature_max": 18.32, + "temperature_min": 11.58, + "timestamp": "2025-12-11T00:00:00+02:00", + "humidity": 71, + "wind_speed": 2.92, + "wind_direction": 57.43656854300959, + "icon": "partly-cloudy-day" + } + }, + { + "tz": "Europe/Athens", + "date": "2025-12-12", + "isPremium": true, + "hourly": [ + { + "timestamp": "2025-12-12T00:00:00+02:00", + "temperature": 13.79, + "humidity": 79, + "precipitation": 0, + "wind_speed": 3.04, + "wind_direction": 56, + "feels_like": 13.79, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-12T01:00:00+02:00", + "temperature": 13.25, + "humidity": 77, + "precipitation": 0, + "wind_speed": 2.36, + "wind_direction": 71, + "feels_like": 13.25, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-12T02:00:00+02:00", + "temperature": 12.8, + "humidity": 78, + "precipitation": 0.04, + "wind_speed": 2.16, + "wind_direction": 97, + "feels_like": 12.8, + "icon": "drizzle" + }, + { + "timestamp": "2025-12-12T03:00:00+02:00", + "temperature": 12.45, + "humidity": 78, + "precipitation": 0.01, + "wind_speed": 2.22, + "wind_direction": 54, + "feels_like": 12.45, + "icon": "drizzle" + }, + { + "timestamp": "2025-12-12T04:00:00+02:00", + "temperature": 12.22, + "humidity": 77, + "precipitation": 0, + "wind_speed": 2.22, + "wind_direction": 144, + "feels_like": 12.22, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-12T05:00:00+02:00", + "temperature": 12.1, + "humidity": 75, + "precipitation": 0, + "wind_speed": 2.39, + "wind_direction": 146, + "feels_like": 12.1, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-12T06:00:00+02:00", + "temperature": 12.09, + "humidity": 75, + "precipitation": 0, + "wind_speed": 2.39, + "wind_direction": 146, + "feels_like": 12.09, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-12T07:00:00+02:00", + "temperature": 12.09, + "humidity": 74, + "precipitation": 0, + "wind_speed": 2.28, + "wind_direction": 151, + "feels_like": 12.09, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-12T08:00:00+02:00", + "temperature": 12.14, + "humidity": 74, + "precipitation": 0, + "wind_speed": 2.2, + "wind_direction": 149, + "feels_like": 12.14, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-12T09:00:00+02:00", + "temperature": 13.15, + "humidity": 74, + "precipitation": 0, + "wind_speed": 2.16, + "wind_direction": 146, + "feels_like": 13.15, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-12T10:00:00+02:00", + "temperature": 15.39, + "humidity": 73, + "precipitation": 0, + "wind_speed": 1.7, + "wind_direction": 130, + "feels_like": 15.39, + "icon": "clear-day" + }, + { + "timestamp": "2025-12-12T11:00:00+02:00", + "temperature": 17.08, + "humidity": 70, + "precipitation": 0, + "wind_speed": 1.73, + "wind_direction": 79, + "feels_like": 17.08, + "icon": "clear-day" + }, + { + "timestamp": "2025-12-12T12:00:00+02:00", + "temperature": 17.74, + "humidity": 69, + "precipitation": 0, + "wind_speed": 2.22, + "wind_direction": 54, + "feels_like": 17.74, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-12T13:00:00+02:00", + "temperature": 18.07, + "humidity": 67, + "precipitation": 0, + "wind_speed": 2.39, + "wind_direction": 33, + "feels_like": 18.07, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-12T14:00:00+02:00", + "temperature": 18.14, + "humidity": 66, + "precipitation": 0, + "wind_speed": 2.64, + "wind_direction": 24, + "feels_like": 18.14, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-12T15:00:00+02:00", + "temperature": 18.02, + "humidity": 67, + "precipitation": 0, + "wind_speed": 2.78, + "wind_direction": 30, + "feels_like": 18.02, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-12T16:00:00+02:00", + "temperature": 17.66, + "humidity": 69, + "precipitation": 0, + "wind_speed": 2.69, + "wind_direction": 44, + "feels_like": 17.66, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-12T17:00:00+02:00", + "temperature": 16.86, + "humidity": 73, + "precipitation": 0, + "wind_speed": 2.47, + "wind_direction": 58, + "feels_like": 16.86, + "icon": "clear-day" + }, + { + "timestamp": "2025-12-12T18:00:00+02:00", + "temperature": 15.68, + "humidity": 78, + "precipitation": 0, + "wind_speed": 1.96, + "wind_direction": 75, + "feels_like": 15.68, + "icon": "clear-night" + }, + { + "timestamp": "2025-12-12T19:00:00+02:00", + "temperature": 14.71, + "humidity": 84, + "precipitation": 0, + "wind_speed": 1.36, + "wind_direction": 107, + "feels_like": 14.71, + "icon": "haze-night" + }, + { + "timestamp": "2025-12-12T20:00:00+02:00", + "temperature": 13.86, + "humidity": 85, + "precipitation": 0, + "wind_speed": 1.53, + "wind_direction": 148, + "feels_like": 13.86, + "icon": "haze-night" + }, + { + "timestamp": "2025-12-12T21:00:00+02:00", + "temperature": 13.16, + "humidity": 81, + "precipitation": 0, + "wind_speed": 1.93, + "wind_direction": 158, + "feels_like": 13.16, + "icon": "haze-night" + }, + { + "timestamp": "2025-12-12T22:00:00+02:00", + "temperature": 12.85, + "humidity": 76, + "precipitation": 0, + "wind_speed": 2.06, + "wind_direction": 165, + "feels_like": 12.85, + "icon": "haze-night" + }, + { + "timestamp": "2025-12-12T23:00:00+02:00", + "temperature": 12.7, + "humidity": 74, + "precipitation": 0, + "wind_speed": 2.14, + "wind_direction": 169, + "feels_like": 12.7, + "icon": "haze-night" + } + ], + "daily": { + "temperature_max": 18.14, + "temperature_min": 12.09, + "timestamp": "2025-12-12T00:00:00+02:00", + "humidity": 75, + "wind_speed": 2.21, + "wind_direction": 97.27073457027066, + "icon": "partly-cloudy-day" + } + }, + { + "tz": "Europe/Athens", + "date": "2025-12-13", + "isPremium": true, + "hourly": [ + { + "timestamp": "2025-12-13T00:00:00+02:00", + "temperature": 12.57, + "humidity": 74, + "precipitation": 0, + "wind_speed": 2.09, + "wind_direction": 163, + "feels_like": 12.57, + "icon": "clear-night" + }, + { + "timestamp": "2025-12-13T01:00:00+02:00", + "temperature": 12.43, + "humidity": 77, + "precipitation": 0, + "wind_speed": 2, + "wind_direction": 171, + "feels_like": 12.43, + "icon": "clear-night" + }, + { + "timestamp": "2025-12-13T02:00:00+02:00", + "temperature": 12.36, + "humidity": 79, + "precipitation": 0, + "wind_speed": 2.15, + "wind_direction": 182, + "feels_like": 12.36, + "icon": "clear-night" + }, + { + "timestamp": "2025-12-13T03:00:00+02:00", + "temperature": 12.3, + "humidity": 81, + "precipitation": 0, + "wind_speed": 2.22, + "wind_direction": 184, + "feels_like": 12.3, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T04:00:00+02:00", + "temperature": 12.25, + "humidity": 83, + "precipitation": 0, + "wind_speed": 2.29, + "wind_direction": 185, + "feels_like": 12.25, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T05:00:00+02:00", + "temperature": 12.35, + "humidity": 82, + "precipitation": 0, + "wind_speed": 2.3, + "wind_direction": 180, + "feels_like": 12.35, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T06:00:00+02:00", + "temperature": 12.6, + "humidity": 80, + "precipitation": 0, + "wind_speed": 2.29, + "wind_direction": 178, + "feels_like": 12.6, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T07:00:00+02:00", + "temperature": 13.29, + "humidity": 76, + "precipitation": 0, + "wind_speed": 2.3, + "wind_direction": 176, + "feels_like": 13.29, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T08:00:00+02:00", + "temperature": 14.42, + "humidity": 72, + "precipitation": 0, + "wind_speed": 2.31, + "wind_direction": 170, + "feels_like": 14.42, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T09:00:00+02:00", + "temperature": 15.72, + "humidity": 68, + "precipitation": 0, + "wind_speed": 2.34, + "wind_direction": 135, + "feels_like": 15.72, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T10:00:00+02:00", + "temperature": 16.92, + "humidity": 66, + "precipitation": 0, + "wind_speed": 2.4, + "wind_direction": 73, + "feels_like": 16.92, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T11:00:00+02:00", + "temperature": 17.77, + "humidity": 65, + "precipitation": 0, + "wind_speed": 2.52, + "wind_direction": 51, + "feels_like": 17.77, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T12:00:00+02:00", + "temperature": 18.44, + "humidity": 63, + "precipitation": 0, + "wind_speed": 2.68, + "wind_direction": 41, + "feels_like": 18.44, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T13:00:00+02:00", + "temperature": 18.65, + "humidity": 62, + "precipitation": 0, + "wind_speed": 2.89, + "wind_direction": 35, + "feels_like": 18.65, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T14:00:00+02:00", + "temperature": 18.39, + "humidity": 63, + "precipitation": 0, + "wind_speed": 3.07, + "wind_direction": 32, + "feels_like": 18.39, + "icon": "overcast-day" + }, + { + "timestamp": "2025-12-13T15:00:00+02:00", + "temperature": 17.64, + "humidity": 67, + "precipitation": 0, + "wind_speed": 3.07, + "wind_direction": 32, + "feels_like": 17.64, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T16:00:00+02:00", + "temperature": 16.73, + "humidity": 72, + "precipitation": 0, + "wind_speed": 2.98, + "wind_direction": 36, + "feels_like": 16.73, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T17:00:00+02:00", + "temperature": 15.9, + "humidity": 77, + "precipitation": 0, + "wind_speed": 2.84, + "wind_direction": 40, + "feels_like": 15.9, + "icon": "partly-cloudy-day" + }, + { + "timestamp": "2025-12-13T18:00:00+02:00", + "temperature": 15.29, + "humidity": 81, + "precipitation": 0, + "wind_speed": 2.7, + "wind_direction": 43, + "feels_like": 15.29, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T19:00:00+02:00", + "temperature": 14.88, + "humidity": 83, + "precipitation": 0, + "wind_speed": 2.55, + "wind_direction": 45, + "feels_like": 14.88, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T20:00:00+02:00", + "temperature": 14.6, + "humidity": 84, + "precipitation": 0, + "wind_speed": 2.46, + "wind_direction": 47, + "feels_like": 14.6, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T21:00:00+02:00", + "temperature": 14.43, + "humidity": 85, + "precipitation": 0, + "wind_speed": 2.47, + "wind_direction": 48, + "feels_like": 14.43, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T22:00:00+02:00", + "temperature": 14.29, + "humidity": 85, + "precipitation": 0, + "wind_speed": 2.55, + "wind_direction": 53, + "feels_like": 14.29, + "icon": "partly-cloudy-night" + }, + { + "timestamp": "2025-12-13T23:00:00+02:00", + "temperature": 14.15, + "humidity": 86, + "precipitation": 0, + "wind_speed": 2.62, + "wind_direction": 62, + "feels_like": 14.15, + "icon": "partly-cloudy-night" + } + ], + "daily": { + "temperature_max": 18.65, + "temperature_min": 12.25, + "timestamp": "2025-12-13T00:00:00+02:00", + "humidity": 75, + "wind_speed": 2.5, + "wind_direction": 80.35896996755905, + "icon": "partly-cloudy-day-drizzle" + } + } +] diff --git a/app/src/main/java/com/weatherxm/data/models/ApiModels.kt b/app/src/main/java/com/weatherxm/data/models/ApiModels.kt index 7fe86c545..42bc38c70 100644 --- a/app/src/main/java/com/weatherxm/data/models/ApiModels.kt +++ b/app/src/main/java/com/weatherxm/data/models/ApiModels.kt @@ -288,6 +288,7 @@ data class WeatherData( val address: String?, var date: LocalDate, val tz: String?, + val isPremium: Boolean?, val hourly: List?, val daily: DailyData? ) : Parcelable diff --git a/app/src/main/java/com/weatherxm/data/network/ApiService.kt b/app/src/main/java/com/weatherxm/data/network/ApiService.kt index 7bc8d7cd0..be4306a62 100644 --- a/app/src/main/java/com/weatherxm/data/network/ApiService.kt +++ b/app/src/main/java/com/weatherxm/data/network/ApiService.kt @@ -93,7 +93,7 @@ interface ApiService { ): NetworkResponse @Mock - @MockResponse(body = "mock_files/get_user_device_weather_forecast.json") + @MockResponse(body = "mock_files/get_user_device_weather_forecast_premium.json") @GET("/api/v1/me/devices/{deviceId}/forecast") suspend fun getForecast( @Path("deviceId") deviceId: String, diff --git a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt index 7bcba6c39..c6aa1b511 100644 --- a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt +++ b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt @@ -45,7 +45,8 @@ class WeatherForecastRepositoryImpl( toDate } - return if (billingService.hasActiveSub()) { + // TODO: STOPSHIP: Revert the below with billingService.hasActiveSub() + return if (true) { getDevicePremiumForecast(deviceId, fromDate, to) } else { getDeviceDefaultForecast(deviceId, fromDate, to, forceRefresh) diff --git a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt index 6ccf703eb..eed30e4c3 100644 --- a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt +++ b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt @@ -263,11 +263,12 @@ enum class DeviceAlertType : Parcelable { @Parcelize data class UIForecast( val address: String?, + val isPremium: Boolean?, val next24Hours: List?, val forecastDays: List ) : Parcelable { companion object { - fun empty() = UIForecast(String.empty(), mutableListOf(), mutableListOf()) + fun empty() = UIForecast(String.empty(), null,mutableListOf(), mutableListOf()) } fun isEmpty(): Boolean = next24Hours.isNullOrEmpty() && forecastDays.isEmpty() diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index 09ea965ec..a9c053f36 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -122,6 +122,11 @@ class ForecastFragment : BaseFragment() { binding.temperatureBarsInfoButton.visible(true) binding.hourlyForecastRecycler.visible(true) binding.hourlyForecastTitle.visible(true) + binding.poweredByWXMLogo.visible(it.isPremium == true) + binding.poweredByPremiumThunder.visible(it.isPremium == true) + binding.poweredByMeteoblueIcon.visible(it.isPremium == false) + binding.mosaicPromotionCard.visible(it.isPremium == false) + binding.poweredByCard.visible(it.isPremium != null) } model.onLoading().observe(viewLifecycleOwner) { @@ -138,12 +143,6 @@ class ForecastFragment : BaseFragment() { override fun onResume() { super.onResume() - if (model.device.relation != UNFOLLOWED) { - handleForecastPremiumComponents() - } else { - binding.mosaicPromotionCard.visible(false) - binding.poweredByCard.visible(false) - } analytics.trackScreen(AnalyticsService.Screen.DEVICE_FORECAST, classSimpleName()) } @@ -177,7 +176,6 @@ class ForecastFragment : BaseFragment() { private fun fetchOrHideContent() { if (model.device.relation != UNFOLLOWED) { binding.hiddenContentContainer.visible(false) - handleForecastPremiumComponents() model.fetchForecast() } else if (model.device.relation == UNFOLLOWED) { binding.mosaicPromotionCard.visible(false) @@ -211,14 +209,4 @@ class ForecastFragment : BaseFragment() { } } } - - private fun handleForecastPremiumComponents() { - billingService.hasActiveSub().apply { - binding.poweredByWXMLogo.visible(this) - binding.poweredByPremiumThunder.visible(this) - binding.poweredByMeteoblueIcon.visible(!this) - binding.mosaicPromotionCard.visible(!this) - } - binding.poweredByCard.visible(true) - } } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt index ed83b46c7..9c40a3f51 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt @@ -18,6 +18,8 @@ import com.weatherxm.ui.common.UIForecastDay import com.weatherxm.ui.common.UILocation import com.weatherxm.usecases.AuthUseCase import com.weatherxm.usecases.ChartsUseCase +import com.weatherxm.usecases.ChartsUseCaseImpl.Companion.FORECAST_CHART_STEP_DEFAULT +import com.weatherxm.usecases.ChartsUseCaseImpl.Companion.FORECAST_CHART_STEP_PREMIUM import com.weatherxm.usecases.ForecastUseCase import com.weatherxm.usecases.LocationsUseCase import com.weatherxm.util.Failure.getDefaultMessage @@ -25,6 +27,7 @@ import com.weatherxm.util.Resources import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch import timber.log.Timber +import java.time.Duration import java.time.LocalDate @Suppress("LongParameterList") @@ -145,8 +148,15 @@ class ForecastDetailsViewModel( fun getCharts(forecastDay: UIForecastDay): Charts { Timber.d("Returning forecast charts for [${forecastDay.date}]") + val chartStep = if (forecast.isPremium == true) { + Duration.ofHours(FORECAST_CHART_STEP_PREMIUM) + } else { + Duration.ofHours(FORECAST_CHART_STEP_DEFAULT) + } return chartsUseCase.createHourlyCharts( - forecastDay.date, forecastDay.hourlyWeather ?: mutableListOf() + forecastDay.date, + forecastDay.hourlyWeather ?: mutableListOf(), + chartStep ) } diff --git a/app/src/main/java/com/weatherxm/usecases/ChartsUseCase.kt b/app/src/main/java/com/weatherxm/usecases/ChartsUseCase.kt index b28ec7409..732c10b7b 100644 --- a/app/src/main/java/com/weatherxm/usecases/ChartsUseCase.kt +++ b/app/src/main/java/com/weatherxm/usecases/ChartsUseCase.kt @@ -2,11 +2,13 @@ package com.weatherxm.usecases import com.weatherxm.data.models.HourlyWeather import com.weatherxm.ui.common.Charts +import java.time.Duration import java.time.LocalDate interface ChartsUseCase { fun createHourlyCharts( date: LocalDate, - hourlyWeatherData: List + hourlyWeatherData: List, + chartStep: Duration = Duration.ofHours(1) ): Charts } diff --git a/app/src/main/java/com/weatherxm/usecases/ChartsUseCaseImpl.kt b/app/src/main/java/com/weatherxm/usecases/ChartsUseCaseImpl.kt index fdfe7566f..1a40ef077 100644 --- a/app/src/main/java/com/weatherxm/usecases/ChartsUseCaseImpl.kt +++ b/app/src/main/java/com/weatherxm/usecases/ChartsUseCaseImpl.kt @@ -12,9 +12,14 @@ import com.weatherxm.util.NumberUtils import com.weatherxm.util.Weather import com.weatherxm.util.Weather.convertPrecipitation import com.weatherxm.util.Weather.convertWindSpeed +import java.time.Duration import java.time.LocalDate class ChartsUseCaseImpl(private val context: Context) : ChartsUseCase { + companion object { + const val FORECAST_CHART_STEP_DEFAULT = 3L + const val FORECAST_CHART_STEP_PREMIUM = 1L + } /** * Suppress long and Complex method warning by detekt because it is just a bunch of `.let` @@ -23,7 +28,8 @@ class ChartsUseCaseImpl(private val context: Context) : ChartsUseCase { @Suppress("LongMethod", "ComplexMethod") override fun createHourlyCharts( date: LocalDate, - hourlyWeatherData: List + hourlyWeatherData: List, + chartStep: Duration ): Charts { val temperatureEntries = mutableListOf() val feelsLikeEntries = mutableListOf() @@ -41,7 +47,8 @@ class ChartsUseCaseImpl(private val context: Context) : ChartsUseCase { LocalDateTimeRange( date.atStartOfDay(), - date.plusDays(1).atStartOfDay().minusHours(1) + date.plusDays(1).atStartOfDay().minusHours(1), + chartStep ).forEachIndexed { i, localDateTime -> val counter = i.toFloat() val emptyEntry = Entry(counter, Float.NaN) diff --git a/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt b/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt index 21c6f6378..921ddbb88 100644 --- a/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt +++ b/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt @@ -81,6 +81,7 @@ class ForecastUseCaseImpl( return UIForecast( address = data[0].address, + isPremium = data[0].isPremium, next24Hours = nextHourlyWeatherForecast, forecastDays = forecastDays ) diff --git a/app/src/main/res/layout/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml index f915be342..3a4cf0ec8 100644 --- a/app/src/main/res/layout/fragment_device_details_forecast.xml +++ b/app/src/main/res/layout/fragment_device_details_forecast.xml @@ -84,11 +84,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="@dimen/margin_large" + android:visibility="gone" app:cardBackgroundColor="@color/blueTint" app:cardCornerRadius="@dimen/radius_small" app:contentPaddingBottom="@dimen/padding_small" app:contentPaddingTop="@dimen/padding_small" - app:layout_constraintTop_toTopOf="parent"> + app:layout_constraintTop_toTopOf="parent" + tools:visibility="visible"> () val savedLocationsLessThanMax = mutableListOf().apply { repeat(MAX_AUTH_LOCATIONS - 1) { diff --git a/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt b/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt index 9d0daf01d..5ee1345b3 100644 --- a/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt +++ b/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt @@ -35,6 +35,7 @@ class ForecastUseCaseTest : BehaviorSpec({ device.address, tomorrowInUtc.toLocalDate(), utc, + false, listOf( HourlyWeather( tomorrowInUtc, @@ -94,6 +95,7 @@ class ForecastUseCaseTest : BehaviorSpec({ ) val uiForecast = UIForecast( address = device.address, + isPremium = false, next24Hours = listOf(hourlyWeather), forecastDays = listOf( UIForecastDay( diff --git a/app/src/test/java/com/weatherxm/usecases/LocationsUseCaseTest.kt b/app/src/test/java/com/weatherxm/usecases/LocationsUseCaseTest.kt index b7ddd548d..d3a4e717a 100644 --- a/app/src/test/java/com/weatherxm/usecases/LocationsUseCaseTest.kt +++ b/app/src/test/java/com/weatherxm/usecases/LocationsUseCaseTest.kt @@ -37,6 +37,7 @@ class LocationsUseCaseTest : BehaviorSpec({ "Address", tomorrowInUtc.toLocalDate(), utc, + false, listOf( HourlyWeather( tomorrowInUtc, From d877e5544879652653e6b9bc570239cbdf690df6 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 10 Dec 2025 23:30:06 +0200 Subject: [PATCH 07/60] Fix the discrepancies between the premium data and the default ones --- ..._user_device_weather_forecast_premium.json | 12 +- .../ui/common/HourlyForecastAdapter.kt | 6 +- .../com/weatherxm/ui/components/ChartsView.kt | 61 ++++-- .../forecast/DailyForecastAdapter.kt | 26 ++- .../ForecastDetailsActivity.kt | 97 +++++++--- .../res/layout/activity_forecast_details.xml | 37 +++- .../main/res/layout/list_item_forecast.xml | 183 +++++++++--------- .../res/layout/list_item_hourly_forecast.xml | 1 + 8 files changed, 269 insertions(+), 154 deletions(-) diff --git a/app/src/local/assets/mock_files/get_user_device_weather_forecast_premium.json b/app/src/local/assets/mock_files/get_user_device_weather_forecast_premium.json index 025ec6751..46f12b7b2 100644 --- a/app/src/local/assets/mock_files/get_user_device_weather_forecast_premium.json +++ b/app/src/local/assets/mock_files/get_user_device_weather_forecast_premium.json @@ -8,7 +8,7 @@ "timestamp": "2025-12-10T16:00:00+02:00", "temperature": 15.33, "humidity": 67, - "precipitation": 0, + "precipitation": 10, "wind_speed": 2.76, "wind_direction": 324, "feels_like": 15.33, @@ -18,7 +18,7 @@ "timestamp": "2025-12-10T17:00:00+02:00", "temperature": 14.42, "humidity": 71, - "precipitation": 0, + "precipitation": 5, "wind_speed": 2.38, "wind_direction": 312, "feels_like": 14.42, @@ -91,7 +91,7 @@ "timestamp": "2025-12-10T00:00:00+02:00", "humidity": 70, "wind_speed": 2.07, - "wind_direction": 234.76598366222618, + "wind_direction": 234, "icon": "partly-cloudy-day-drizzle" } }, @@ -347,7 +347,7 @@ "timestamp": "2025-12-11T00:00:00+02:00", "humidity": 71, "wind_speed": 2.92, - "wind_direction": 57.43656854300959, + "wind_direction": 57, "icon": "partly-cloudy-day" } }, @@ -603,7 +603,7 @@ "timestamp": "2025-12-12T00:00:00+02:00", "humidity": 75, "wind_speed": 2.21, - "wind_direction": 97.27073457027066, + "wind_direction": 97, "icon": "partly-cloudy-day" } }, @@ -859,7 +859,7 @@ "timestamp": "2025-12-13T00:00:00+02:00", "humidity": 75, "wind_speed": 2.5, - "wind_direction": 80.35896996755905, + "wind_direction": 80, "icon": "partly-cloudy-day-drizzle" } } diff --git a/app/src/main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt index 727264d23..6cd6a3f38 100644 --- a/app/src/main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt @@ -43,8 +43,10 @@ class HourlyForecastAdapter( binding.icon.setWeatherAnimation(item.icon) binding.temperaturePrimary.text = Weather.getFormattedTemperature(itemView.context, item.temperature, 1) - binding.precipProbability.text = - Weather.getFormattedPrecipitationProbability(item.precipProbability) + item.precipProbability?.let { + binding.precipProbability.text = + Weather.getFormattedPrecipitationProbability(item.precipProbability) + } ?: binding.precipProbabilityContainer.visible(false) } } diff --git a/app/src/main/java/com/weatherxm/ui/components/ChartsView.kt b/app/src/main/java/com/weatherxm/ui/components/ChartsView.kt index 51381392d..838600a3b 100644 --- a/app/src/main/java/com/weatherxm/ui/components/ChartsView.kt +++ b/app/src/main/java/com/weatherxm/ui/components/ChartsView.kt @@ -9,6 +9,8 @@ import com.github.mikephil.charting.highlight.Highlight import com.github.mikephil.charting.listener.OnChartValueSelectedListener import com.weatherxm.databinding.ViewChartsBinding import com.weatherxm.ui.common.LineChartData +import com.weatherxm.ui.common.empty +import com.weatherxm.ui.common.visible import com.weatherxm.util.NumberUtils.formatNumber import com.weatherxm.util.UnitSelector import com.weatherxm.util.Weather @@ -74,7 +76,19 @@ class ChartsView : LinearLayout { binding.chartSolar.clearChart() } - fun initTemperatureChart(temperatureData: LineChartData, feelsLikeData: LineChartData) { + private fun LineChartView.handleNoData(hideChartIfNoData: Boolean) { + if (hideChartIfNoData) { + visible(false) + } else { + showNoDataText() + } + } + + fun initTemperatureChart( + temperatureData: LineChartData, + feelsLikeData: LineChartData, + hideChartIfNoData: Boolean = false + ) { if (temperatureData.isDataValid() && feelsLikeData.isDataValid()) { temperatureDataSets = binding.chartTemperature .getChart() @@ -107,11 +121,11 @@ class ChartsView : LinearLayout { } }) } else { - binding.chartTemperature.showNoDataText() + binding.chartTemperature.handleNoData(hideChartIfNoData) } } - fun initHumidityChart(data: LineChartData) { + fun initHumidityChart(data: LineChartData, hideChartIfNoData: Boolean = false) { if (data.isDataValid()) { humidityDataSets = binding.chartHumidity.getChart().initHumidity24hChart(data) binding.chartHumidity.getChart().setOnChartValueSelectedListener( @@ -133,11 +147,11 @@ class ChartsView : LinearLayout { } }) } else { - binding.chartHumidity.showNoDataText() + binding.chartHumidity.handleNoData(hideChartIfNoData) } } - fun initPressureChart(data: LineChartData) { + fun initPressureChart(data: LineChartData, hideChartIfNoData: Boolean = false) { if (data.isDataValid()) { pressureDataSets = binding.chartPressure.getChart().initPressure24hChart(data) binding.chartPressure.getChart().setOnChartValueSelectedListener( @@ -163,11 +177,15 @@ class ChartsView : LinearLayout { } }) } else { - binding.chartPressure.showNoDataText() + binding.chartPressure.handleNoData(hideChartIfNoData) } } - fun initSolarChart(uvData: LineChartData, radiationData: LineChartData) { + fun initSolarChart( + uvData: LineChartData, + radiationData: LineChartData, + hideChartIfNoData: Boolean = false + ) { if (uvData.isDataValid() || radiationData.isDataValid()) { solarDataSets = binding.chartSolar.getChart().initSolarChart(uvData, radiationData) binding.chartSolar.getChart().setOnChartValueSelectedListener( @@ -181,16 +199,17 @@ class ChartsView : LinearLayout { } }) } else { - binding.chartSolar.showNoDataText() + binding.chartSolar.handleNoData(hideChartIfNoData) } } fun initPrecipitationChart( primaryData: LineChartData, secondaryData: LineChartData, - isHistoricalData: Boolean + isHistoricalData: Boolean, + hideChartIfNoData: Boolean = false ) { - if (primaryData.isDataValid() && secondaryData.isDataValid()) { + if (primaryData.isDataValid()) { precipDataSets = binding.chartPrecipitation .getChart() .initPrecipitation24hChart(primaryData, secondaryData, isHistoricalData) @@ -209,12 +228,15 @@ class ChartsView : LinearLayout { } }) } else { - binding.chartPrecipitation.showNoDataText() + binding.chartPrecipitation.handleNoData(hideChartIfNoData) } } fun initWindChart( - windSpeedData: LineChartData, windGustData: LineChartData, windDirectionData: LineChartData + windSpeedData: LineChartData, + windGustData: LineChartData, + windDirectionData: LineChartData, + hideChartIfNoData: Boolean = false ) { if (windSpeedData.isDataValid() && windDirectionData.isDataValid()) { windDataSets = binding.chartWind @@ -231,7 +253,7 @@ class ChartsView : LinearLayout { } }) } else { - binding.chartWind.showNoDataText() + binding.chartWind.handleNoData(hideChartIfNoData) } } @@ -300,13 +322,18 @@ class ChartsView : LinearLayout { primaryData.entries[e.x.toInt()].y, getDecimalsPrecipitation(precipUnit.type) ) - val percentage = Weather.getFormattedPrecipitationProbability( - secondaryData.entries[e.x.toInt()].y.toInt() - ) + + val secondaryDataText = if (secondaryData.isDataValid()) { + Weather.getFormattedPrecipitationProbability( + secondaryData.entries[e.x.toInt()].y.toInt() + ) + } else { + String.empty() + } binding.chartPrecipitation.onHighlightedData( time, "${precipitationValue}${precipUnit.unit}", - percentage + secondaryDataText ) autoHighlightCharts(e.x) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt index 2121c416d..19d2396c6 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt @@ -12,6 +12,7 @@ import com.weatherxm.databinding.ListItemForecastBinding import com.weatherxm.ui.common.UIForecastDay import com.weatherxm.ui.common.invisible import com.weatherxm.ui.common.setWeatherAnimation +import com.weatherxm.ui.common.visible import com.weatherxm.util.DateTimeHelper.getRelativeDayAndMonthDay import com.weatherxm.util.NumberUtils.roundToDecimals import com.weatherxm.util.Resources @@ -103,13 +104,24 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) binding.maxTemperature.text = Weather.getFormattedTemperature(itemView.context, item.maxTemp) - binding.precipProbability.text = - Weather.getFormattedPrecipitationProbability(item.precipProbability) - binding.precip.text = Weather.getFormattedPrecipitation( - context = itemView.context, - value = item.precip, - isRainRate = false - ) + if (item.precipProbability == null) { + binding.precipProbabilityIcon.visible(false) + binding.precipProbability.visible(false) + } else { + binding.precipProbability.text = + Weather.getFormattedPrecipitationProbability(item.precipProbability) + } + + if (item.precip == null) { + binding.precipIcon.visible(false) + binding.precip.visible(false) + } else { + binding.precip.text = Weather.getFormattedPrecipitation( + context = itemView.context, + value = item.precip, + isRainRate = false + ) + } binding.wind.text = Weather.getFormattedWind(itemView.context, item.windSpeed, item.windDirection) diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 73179c9da..2881bc15d 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -6,6 +6,7 @@ import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService import com.weatherxm.databinding.ActivityForecastDetailsBinding import com.weatherxm.service.BillingService +import com.weatherxm.ui.common.Charts import com.weatherxm.ui.common.Contracts import com.weatherxm.ui.common.Contracts.ARG_FORECAST_SELECTED_DAY import com.weatherxm.ui.common.Contracts.EMPTY_VALUE @@ -95,7 +96,6 @@ class ForecastDetailsActivity : BaseActivity() { initSavedLocationIcon() binding.displayTimeNotice.visible(false) } - setupChartsAndListeners() model.onForecastLoaded().observe(this) { when (it.status) { @@ -164,22 +164,55 @@ class ForecastDetailsActivity : BaseActivity() { } } + // Update the "Powered By" card + if (model.forecast().isPremium == true) { + binding.poweredByWXMLogo.visible(true) + binding.poweredByPremiumThunder.visible(true) + binding.poweredByMeteoblueIcon.visible(false) + binding.mosaicPromotionCard.visible(false) + } else { + binding.poweredByMeteoblueIcon.visible(true) + // Show the Mosaic promo only on devices' forecast + binding.mosaicPromotionCard.visible(!model.device.isEmpty()) + } + // Update Daily Weather binding.dailyDate.text = forecast.date.getRelativeDayAndShort(this) binding.dailyIcon.setWeatherAnimation(forecast.icon) binding.dailyMaxTemp.text = getFormattedTemperature(this, forecast.maxTemp) binding.dailyMinTemp.text = getFormattedTemperature(this, forecast.minTemp) - binding.precipProbabilityCard.setData( - getFormattedPrecipitationProbability(forecast.precipProbability) - ) - binding.windCard.setIcon(getWindDirectionDrawable(this, forecast.windDirection)) - binding.windCard.setData(getFormattedWind(this, forecast.windSpeed, forecast.windDirection)) - binding.dailyPrecipCard.setData( - getFormattedPrecipitation(context = this, value = forecast.precip, isRainRate = false) - ) - binding.uvCard.setData(getFormattedUV(this, forecast.uv)) - binding.humidityCard.setData(getFormattedHumidity(forecast.humidity)) - binding.pressureCard.setData(getFormattedPressure(this, forecast.pressure)) + if (model.forecast().isPremium == true) { + binding.dailyPremiumWind.setIcon(getWindDirectionDrawable(this, forecast.windDirection)) + binding.dailyPremiumWind.setData( + getFormattedWind(this, forecast.windSpeed, forecast.windDirection) + ) + binding.dailyPremiumHumidity.setData(getFormattedHumidity(forecast.humidity)) + binding.dailyDefaultFirstRow.visible(false) + binding.dailyDefaultSecondRow.visible(false) + binding.dailyPremiumRow.visible(true) + } else { + binding.precipProbabilityCard.setData( + getFormattedPrecipitationProbability(forecast.precipProbability) + ) + binding.windCard.setIcon(getWindDirectionDrawable(this, forecast.windDirection)) + binding.windCard.setData( + getFormattedWind( + this, + forecast.windSpeed, + forecast.windDirection + ) + ) + binding.dailyPrecipCard.setData( + getFormattedPrecipitation( + context = this, + value = forecast.precip, + isRainRate = false + ) + ) + binding.uvCard.setData(getFormattedUV(this, forecast.uv)) + binding.humidityCard.setData(getFormattedHumidity(forecast.humidity)) + binding.pressureCard.setData(getFormattedPressure(this, forecast.pressure)) + } // Update Hourly Tiles hourlyAdapter = HourlyForecastAdapter(null) @@ -195,26 +228,36 @@ class ForecastDetailsActivity : BaseActivity() { with(binding.charts) { val charts = model.getCharts(forecast) clearCharts() - initTemperatureChart(charts.temperature, charts.feelsLike) - initWindChart(charts.windSpeed, charts.windGust, charts.windDirection) - initPrecipitationChart(charts.precipitation, charts.precipProbability, false) - initHumidityChart(charts.humidity) - initPressureChart(charts.pressure) - initSolarChart(charts.uv, charts.solarRadiation) + initTemperatureChart(charts.temperature, charts.feelsLike, true) + initWindChart(charts.windSpeed, charts.windGust, charts.windDirection, true) + initPrecipitationChart( + charts.precipitation, + charts.precipProbability, + isHistoricalData = false, + hideChartIfNoData = true + ) + initHumidityChart(charts.humidity, true) + initPressureChart(charts.pressure, true) + initSolarChart(charts.uv, charts.solarRadiation, true) autoHighlightCharts(0F) + setupChartsAndListeners(charts) visible(!charts.isEmpty()) } } - private fun setupChartsAndListeners() { + private fun setupChartsAndListeners(charts: Charts) { with(binding.charts) { chartPrecipitation().primaryLine( getString(R.string.precipitation), getString(R.string.precipitation) ) - chartPrecipitation().secondaryLine( - getString(R.string.probability), - getString(R.string.precipitation_probability) - ) + if (charts.precipProbability.isDataValid()) { + chartPrecipitation().secondaryLine( + getString(R.string.probability), + getString(R.string.precipitation_probability) + ) + } else { + chartPrecipitation().secondaryLine(null, null) + } chartWind().primaryLine(null, getString(R.string.speed)) chartWind().secondaryLine(null, null) chartSolar().updateTitle(getString(R.string.uv_index)) @@ -353,19 +396,11 @@ class ForecastDetailsActivity : BaseActivity() { override fun onResume() { super.onResume() if (!model.device.isEmpty()) { - billingService.hasActiveSub().apply { - binding.poweredByWXMLogo.visible(this) - binding.poweredByPremiumThunder.visible(this) - binding.poweredByMeteoblueIcon.visible(!this) - binding.mosaicPromotionCard.visible(!this) - } analytics.trackScreen( AnalyticsService.Screen.DEVICE_FORECAST_DETAILS, classSimpleName() ) } else { - binding.poweredByText.text = getString(R.string.powered_by) - binding.poweredByMeteoblueIcon.visible(true) analytics.trackScreen( screen = AnalyticsService.Screen.LOCATION_FORECAST_DETAILS, screenClass = classSimpleName(), diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index f3c5f69c9..6029d7143 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -251,7 +251,38 @@ + + + + + + + + app:layout_constraintTop_toBottomOf="@id/dailyDefaultFirstRow"> - - - - - - - + android:gravity="center_vertical" + android:orientation="horizontal" + app:layout_constraintEnd_toStartOf="@id/icon" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/temperatureContainer"> + + - + + + - + + + - + + + - + + diff --git a/app/src/main/res/layout/list_item_hourly_forecast.xml b/app/src/main/res/layout/list_item_hourly_forecast.xml index eb1ad019c..12db1cfa8 100644 --- a/app/src/main/res/layout/list_item_hourly_forecast.xml +++ b/app/src/main/res/layout/list_item_hourly_forecast.xml @@ -48,6 +48,7 @@ tools:text="15.4°C" /> Date: Thu, 11 Dec 2025 18:55:30 +0200 Subject: [PATCH 08/60] Bump version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c123d1386..d8893fedb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 30 + getVersionGitTags(isSolana = false).size + versionCode = 31 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { From 4dabdebd6273923a45cf6cb52a6be160f9e8d193 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Thu, 11 Dec 2025 19:00:48 +0200 Subject: [PATCH 09/60] Fix the STOPSHIP comment --- .../com/weatherxm/data/repository/WeatherForecastRepository.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt index c6aa1b511..7bcba6c39 100644 --- a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt +++ b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt @@ -45,8 +45,7 @@ class WeatherForecastRepositoryImpl( toDate } - // TODO: STOPSHIP: Revert the below with billingService.hasActiveSub() - return if (true) { + return if (billingService.hasActiveSub()) { getDevicePremiumForecast(deviceId, fromDate, to) } else { getDeviceDefaultForecast(deviceId, fromDate, to, forceRefresh) From 7702e891bdf8c16264de4fd6d1ef53488a682203 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Thu, 11 Dec 2025 19:06:56 +0200 Subject: [PATCH 10/60] Minor detekt fixes --- .../forecast/ForecastFragment.kt | 35 ++++++++++++------- .../ForecastDetailsActivity.kt | 8 +++-- .../ForecastDetailsViewModelTest.kt | 8 +++-- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index a9c053f36..65c688c02 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -12,6 +12,7 @@ import com.weatherxm.service.BillingService import com.weatherxm.ui.common.DeviceRelation.UNFOLLOWED import com.weatherxm.ui.common.HourlyForecastAdapter import com.weatherxm.ui.common.Status +import com.weatherxm.ui.common.UIForecast import com.weatherxm.ui.common.UILocation import com.weatherxm.ui.common.blockParentViewPagerOnScroll import com.weatherxm.ui.common.classSimpleName @@ -114,19 +115,7 @@ class ForecastFragment : BaseFragment() { } model.onForecast().observe(viewLifecycleOwner) { - hourlyForecastAdapter.submitList(it.next24Hours) - dailyForecastAdapter.submitList(it.forecastDays) - binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) - binding.dailyForecastRecycler.visible(true) - binding.dailyForecastTitle.visible(true) - binding.temperatureBarsInfoButton.visible(true) - binding.hourlyForecastRecycler.visible(true) - binding.hourlyForecastTitle.visible(true) - binding.poweredByWXMLogo.visible(it.isPremium == true) - binding.poweredByPremiumThunder.visible(it.isPremium == true) - binding.poweredByMeteoblueIcon.visible(it.isPremium == false) - binding.mosaicPromotionCard.visible(it.isPremium == false) - binding.poweredByCard.visible(it.isPremium != null) + onForecast(hourlyForecastAdapter, dailyForecastAdapter, it) } model.onLoading().observe(viewLifecycleOwner) { @@ -209,4 +198,24 @@ class ForecastFragment : BaseFragment() { } } } + + private fun onForecast( + hourlyForecastAdapter: HourlyForecastAdapter, + dailyForecastAdapter: DailyForecastAdapter, + forecast: UIForecast + ) { + hourlyForecastAdapter.submitList(forecast.next24Hours) + dailyForecastAdapter.submitList(forecast.forecastDays) + binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) + binding.dailyForecastRecycler.visible(true) + binding.dailyForecastTitle.visible(true) + binding.temperatureBarsInfoButton.visible(true) + binding.hourlyForecastRecycler.visible(true) + binding.hourlyForecastTitle.visible(true) + binding.poweredByWXMLogo.visible(forecast.isPremium == true) + binding.poweredByPremiumThunder.visible(forecast.isPremium == true) + binding.poweredByMeteoblueIcon.visible(forecast.isPremium == false) + binding.mosaicPromotionCard.visible(forecast.isPremium == false) + binding.poweredByCard.visible(forecast.isPremium != null) + } } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 2881bc15d..f680c5565 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -5,7 +5,6 @@ import com.google.firebase.analytics.FirebaseAnalytics import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService import com.weatherxm.databinding.ActivityForecastDetailsBinding -import com.weatherxm.service.BillingService import com.weatherxm.ui.common.Charts import com.weatherxm.ui.common.Contracts import com.weatherxm.ui.common.Contracts.ARG_FORECAST_SELECTED_DAY @@ -40,7 +39,6 @@ import com.weatherxm.util.Weather.getFormattedTemperature import com.weatherxm.util.Weather.getFormattedUV import com.weatherxm.util.Weather.getFormattedWind import com.weatherxm.util.Weather.getWindDirectionDrawable -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf import timber.log.Timber @@ -51,7 +49,6 @@ class ForecastDetailsActivity : BaseActivity() { } private lateinit var binding: ActivityForecastDetailsBinding - private val billingService: BillingService by inject() private val model: ForecastDetailsViewModel by viewModel { parametersOf( @@ -224,6 +221,11 @@ class ForecastDetailsActivity : BaseActivity() { ) } + // Update Charts + updateCharts(forecast) + } + + private fun updateCharts(forecast: UIForecastDay) { // Update Charts with(binding.charts) { val charts = model.getCharts(forecast) diff --git a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt index 90a9f8213..b33d994b2 100644 --- a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt @@ -118,8 +118,12 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ null ) val emptyForecast: UIForecast = UIForecast.empty() - val forecast = - UIForecast(device.address, true, listOf(hourlyWeather), listOf(forecastDay, forecastDayTomorrow)) + val forecast = UIForecast( + device.address, + true, + listOf(hourlyWeather), + listOf(forecastDay, forecastDayTomorrow) + ) val charts = mockk() val savedLocationsLessThanMax = mutableListOf().apply { repeat(MAX_AUTH_LOCATIONS - 1) { From 782e5a68b10db5b7a138662e0c13d56192475fa9 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Fri, 12 Dec 2025 00:26:05 +0200 Subject: [PATCH 11/60] More fixes --- app/build.gradle.kts | 2 +- .../java/com/weatherxm/ui/home/profile/ProfileFragment.kt | 5 +++-- app/src/main/res/layout/fragment_profile.xml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d8893fedb..b8d1720a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 31 + getVersionGitTags(isSolana = false).size + versionCode = 34 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { diff --git a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt index f253b8d8d..37759ce7e 100644 --- a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt @@ -166,6 +166,7 @@ class ProfileFragment : BaseFragment() { Status.ERROR -> { Timber.d("Got error: $resource.message") onNotAvailableRewards() + updateSubscriptionUI(null) resource.message?.let { context.toast(it) } toggleLoading(false) } @@ -316,8 +317,8 @@ class ProfileFragment : BaseFragment() { binding.rewardsContainerCard.visible(true) } - private fun updateSubscriptionUI(it: UIWalletRewards) { - if (billingService.hasActiveSub()) { + private fun updateSubscriptionUI(it: UIWalletRewards?) { + if (billingService.hasActiveSub() || it == null) { binding.subscriptionSecondaryCard.visible(false) } else if (it.hasUnclaimedTokensForFreeTrial()) { binding.subscriptionSecondaryCard.setContent { diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml index dc474a30f..3764c3802 100644 --- a/app/src/main/res/layout/fragment_profile.xml +++ b/app/src/main/res/layout/fragment_profile.xml @@ -208,7 +208,7 @@ android:id="@+id/walletContainerCard" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginVertical="@dimen/margin_normal" + android:layout_marginTop="@dimen/margin_normal" app:cardElevation="@dimen/elevation_small" app:contentPadding="0dp" tools:strokeColor="@color/error" From 674b1bd8bed8b572caea60a6c5ba6640cdb33275 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Fri, 12 Dec 2025 12:07:59 +0200 Subject: [PATCH 12/60] Reverse the position of powered by and hyperlocal --- .../res/layout/activity_forecast_details.xml | 131 +++++++++--------- .../fragment_device_details_forecast.xml | 124 ++++++++--------- 2 files changed, 128 insertions(+), 127 deletions(-) diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index 6029d7143..92e7446a1 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -80,66 +80,19 @@ - - - - - - - - - - - - - - + android:clipChildren="false" + android:clipToPadding="false" + android:paddingBottom="@dimen/padding_small" + android:visibility="gone" + app:layout_constraintTop_toBottomOf="@id/header" + tools:composableName="com.weatherxm.ui.components.compose.MosaicPromotionCardKt.PreviewMosaicPromotionCard" + tools:visibility="visible" /> - + app:cardBackgroundColor="@color/blueTint" + app:cardCornerRadius="@dimen/radius_small" + app:contentPaddingBottom="@dimen/padding_small" + app:contentPaddingTop="@dimen/padding_small" + app:layout_constraintTop_toBottomOf="@id/displayTimeNotice"> + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml index 3a4cf0ec8..5ca730068 100644 --- a/app/src/main/res/layout/fragment_device_details_forecast.xml +++ b/app/src/main/res/layout/fragment_device_details_forecast.xml @@ -79,66 +79,16 @@ app:icon="@drawable/ic_favorite_outline" /> - - - - - - - - - - - - - - + tools:composableName="com.weatherxm.ui.components.compose.MosaicPromotionCardKt.PreviewMosaicPromotionCard" + tools:visibility="visible" /> + app:layout_constraintTop_toBottomOf="@id/mosaicPromotionCard" /> - + tools:visibility="visible"> + + + + + + + + + + + + + From 28c0173cafc5d93744f541a97084acb9d928679c Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Mon, 15 Dec 2025 00:24:10 +0200 Subject: [PATCH 13/60] Replace the RangeSlider with a custom compose component to better draw the corner radius and make it customizable --- .../ui/components/RewardsQualityCardView.kt | 20 ++++-- .../ui/components/compose/RoundedRangeView.kt | 64 +++++++++++++++++++ .../forecast/DailyForecastAdapter.kt | 19 ++++-- .../DeviceRewardsBoostAdapter.kt | 51 +++++++-------- .../NetworkStationStatsAdapter.kt | 2 +- .../tokenmetrics/TokenMetricsActivity.kt | 13 +++- .../res/layout/activity_token_metrics.xml | 15 ++--- .../layout/list_item_device_rewards_boost.xml | 13 +--- .../main/res/layout/list_item_forecast.xml | 20 ++---- .../list_item_network_station_stats.xml | 12 +++- .../res/layout/view_reward_quality_card.xml | 11 +--- app/src/main/res/values/styles_widget.xml | 21 ------ 12 files changed, 158 insertions(+), 103 deletions(-) create mode 100644 app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt diff --git a/app/src/main/java/com/weatherxm/ui/components/RewardsQualityCardView.kt b/app/src/main/java/com/weatherxm/ui/components/RewardsQualityCardView.kt index 515f598bf..1c81f96a8 100644 --- a/app/src/main/java/com/weatherxm/ui/components/RewardsQualityCardView.kt +++ b/app/src/main/java/com/weatherxm/ui/components/RewardsQualityCardView.kt @@ -1,14 +1,15 @@ package com.weatherxm.ui.components import android.content.Context -import android.content.res.ColorStateList import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout +import androidx.compose.ui.unit.dp import com.weatherxm.R import com.weatherxm.databinding.ViewRewardQualityCardBinding import com.weatherxm.ui.common.setCardStroke import com.weatherxm.ui.common.visible +import com.weatherxm.ui.components.compose.RoundedRangeView import com.weatherxm.util.Rewards.getRewardScoreColor open class RewardsQualityCardView : LinearLayout { @@ -85,13 +86,20 @@ open class RewardsQualityCardView : LinearLayout { @Suppress("MagicNumber") fun setSlider(score: Int): RewardsQualityCardView { // In case of zero score, show a very small number instead of an empty slider - binding.slider.values = if (score == 0) { - listOf(0.1F) + val rangeEnd = if (score == 0) { + 0.1F } else { - listOf(score.toFloat()) + score.toFloat() + } + binding.slider.setContent { + RoundedRangeView( + 20.dp, + 0F..rangeEnd, + 0F..100F, + R.color.blueTint, + getRewardScoreColor(score) + ) } - binding.slider.trackActiveTintList = - ColorStateList.valueOf(context.getColor(getRewardScoreColor(score))) binding.sliderContainer.visible(true) return this } diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt b/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt new file mode 100644 index 000000000..6ede96a50 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt @@ -0,0 +1,64 @@ +package com.weatherxm.ui.components.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.weatherxm.R + +@Suppress("FunctionNaming") +@Composable +fun RoundedRangeView( + height: Dp, + currentRange: ClosedFloatingPointRange, + totalRange: ClosedFloatingPointRange, + inactiveColorResId: Int, + activeColorResId: Int +) { + val cornerRadius = dimensionResource(R.dimen.radius_extra_extra_large).value + val inactiveColor = colorResource(inactiveColorResId) + val activeColor = colorResource(activeColorResId) + + Canvas(modifier = Modifier.height(height)) { + val width = size.width + val trackHeight = size.height + + // Draw inactive track + drawRoundRect( + color = inactiveColor, + size = Size(width, trackHeight), + cornerRadius = CornerRadius(cornerRadius, cornerRadius) + ) + + // Calculate active range position + val totalSpan = totalRange.endInclusive - totalRange.start + val startFraction = (currentRange.start - totalRange.start) / totalSpan + val endFraction = (currentRange.endInclusive - totalRange.start) / totalSpan + + val startX = width * startFraction + val activeWidth = width * (endFraction - startFraction) + + // Draw active track + drawRoundRect( + color = activeColor, + topLeft = Offset(startX, 0f), + size = Size(activeWidth, trackHeight), + cornerRadius = CornerRadius(cornerRadius, cornerRadius) + ) + } +} + +@Suppress("FunctionNaming", "MagicNumber") +@Preview +@Composable +fun PreviewRoundedRangeView() { + RoundedRangeView(16.dp, 0F..25F, 0F..100F, R.color.colorBackground, R.color.crypto) +} diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt index 19d2396c6..0eefb28ae 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt @@ -2,10 +2,12 @@ package com.weatherxm.ui.devicedetails.forecast import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.ui.unit.dp import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.google.firebase.analytics.FirebaseAnalytics +import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService import com.weatherxm.analytics.AnalyticsWrapper import com.weatherxm.databinding.ListItemForecastBinding @@ -13,6 +15,7 @@ import com.weatherxm.ui.common.UIForecastDay import com.weatherxm.ui.common.invisible import com.weatherxm.ui.common.setWeatherAnimation import com.weatherxm.ui.common.visible +import com.weatherxm.ui.components.compose.RoundedRangeView import com.weatherxm.util.DateTimeHelper.getRelativeDayAndMonthDay import com.weatherxm.util.NumberUtils.roundToDecimals import com.weatherxm.util.Resources @@ -91,12 +94,18 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) binding.date.text = item.date.getRelativeDayAndMonthDay(itemView.context) binding.icon.setWeatherAnimation(item.icon) if (minTemperature == Float.MAX_VALUE || maxTemperature == Float.MIN_VALUE) { - binding.temperature.invisible() + binding.temperatureView.invisible() } else { - binding.temperature.apply { - valueFrom = minTemperature - valueTo = maxTemperature - values = listOf(item.minTemp, item.maxTemp) + val rangeStart = item.minTemp ?: 0F + val rangeEnd = item.maxTemp ?: 0F + binding.temperatureView.setContent { + RoundedRangeView( + 16.dp, + rangeStart..rangeEnd, + minTemperature..maxTemperature, + R.color.colorBackground, + R.color.crypto + ) } } binding.minTemperature.text = diff --git a/app/src/main/java/com/weatherxm/ui/devicesrewards/DeviceRewardsBoostAdapter.kt b/app/src/main/java/com/weatherxm/ui/devicesrewards/DeviceRewardsBoostAdapter.kt index d79920e95..ebbc0fac7 100644 --- a/app/src/main/java/com/weatherxm/ui/devicesrewards/DeviceRewardsBoostAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/devicesrewards/DeviceRewardsBoostAdapter.kt @@ -3,6 +3,7 @@ package com.weatherxm.ui.devicesrewards import android.annotation.SuppressLint import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.ui.unit.dp import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView @@ -11,6 +12,7 @@ import com.weatherxm.data.models.BoostCode import com.weatherxm.databinding.ListItemDeviceRewardsBoostBinding import com.weatherxm.ui.common.DeviceTotalRewardsBoost import com.weatherxm.ui.common.visible +import com.weatherxm.ui.components.compose.RoundedRangeView import com.weatherxm.util.DateTimeHelper.getFormattedDate import com.weatherxm.util.NumberUtils.formatTokens import timber.log.Timber @@ -36,16 +38,20 @@ class DeviceRewardsBoostAdapter : holder.bind(getItem(position)) } - inner class DeviceRewardsBoostViewHolder( + class DeviceRewardsBoostViewHolder( private val binding: ListItemDeviceRewardsBoostBinding ) : RecyclerView.ViewHolder(binding.root) { + @Suppress("MagicNumber") @SuppressLint("SetTextI18n") fun bind(item: DeviceTotalRewardsBoost) { val boostCode = item.boostCode + var progressSliderActiveColor: Int = R.color.other_reward + var progressSliderInactiveColor: Int = R.color.other_reward_fill try { if (boostCode == null) { - onUnknownBoost() + binding.title.text = + itemView.context.getString(R.string.other_boost_reward_details) } else { val isBetaRewards = boostCode == BoostCode.beta_rewards.name val isCorrectionRewards = boostCode.startsWith(BoostCode.correction.name, true) @@ -56,39 +62,42 @@ class DeviceRewardsBoostAdapter : if (isBetaRewards) { binding.title.text = itemView.context.getString(R.string.beta_reward_details) - binding.boostProgressSlider.trackActiveTintList = - itemView.context.getColorStateList(R.color.beta_rewards_fill) - binding.boostProgressSlider.trackInactiveTintList = - itemView.context.getColorStateList(R.color.beta_rewards_color) + progressSliderActiveColor = R.color.beta_rewards_fill + progressSliderInactiveColor = R.color.beta_rewards_color } else if (isCorrectionRewards) { binding.title.text = itemView.context.getString(R.string.compensation_reward_details) - binding.boostProgressSlider.trackActiveTintList = - itemView.context.getColorStateList(R.color.correction_rewards_color) - binding.boostProgressSlider.trackInactiveTintList = - itemView.context.getColorStateList(R.color.correction_rewards_fill) + progressSliderActiveColor = R.color.correction_rewards_color + progressSliderInactiveColor = R.color.correction_rewards_fill } else if (isCellBountyReward) { binding.title.text = itemView.context.getString(R.string.cell_bounty_reward_details) - binding.boostProgressSlider.trackActiveTintList = - itemView.context.getColorStateList(R.color.cell_bounty_reward) - binding.boostProgressSlider.trackInactiveTintList = - itemView.context.getColorStateList(R.color.cell_bounty_reward_fill) + progressSliderActiveColor = R.color.cell_bounty_reward + progressSliderInactiveColor = R.color.cell_bounty_reward_fill } else if (isRolloutRewards) { binding.title.text = itemView.context.getString(R.string.rollouts_reward_details) } else { - onUnknownBoost() + binding.title.text = + itemView.context.getString(R.string.other_boost_reward_details) } } } catch (e: IllegalArgumentException) { Timber.e(e, "Unsupported Boost Code: $boostCode") - onUnknownBoost() + binding.title.text = itemView.context.getString(R.string.other_boost_reward_details) } item.completedPercentage?.let { binding.boostProgress.text = "$it%" - binding.boostProgressSlider.values = listOf(it.toFloat()) + binding.boostProgressSlider.setContent { + RoundedRangeView( + 23.dp, + 0F..it.toFloat(), + 0F..100F, + progressSliderInactiveColor, + progressSliderActiveColor + ) + } } ?: binding.boostProgressSlider.visible(false) if (item.currentRewards == null) { @@ -116,14 +125,6 @@ class DeviceRewardsBoostAdapter : val boostStopDate = item.boostPeriodEnd.getFormattedDate(true, includeComma = false) binding.boostPeriod.text = "$boostStartDate - $boostStopDate" } - - private fun onUnknownBoost() { - binding.title.text = itemView.context.getString(R.string.other_boost_reward_details) - binding.boostProgressSlider.trackActiveTintList = - itemView.context.getColorStateList(R.color.other_reward) - binding.boostProgressSlider.trackInactiveTintList = - itemView.context.getColorStateList(R.color.other_reward_fill) - } } } diff --git a/app/src/main/java/com/weatherxm/ui/networkstats/NetworkStationStatsAdapter.kt b/app/src/main/java/com/weatherxm/ui/networkstats/NetworkStationStatsAdapter.kt index 4bc34a613..d7cf13791 100644 --- a/app/src/main/java/com/weatherxm/ui/networkstats/NetworkStationStatsAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/networkstats/NetworkStationStatsAdapter.kt @@ -31,7 +31,7 @@ class NetworkStationStatsAdapter( holder.bind(getItem(position)) } - inner class StationInfoViewHolder( + class StationInfoViewHolder( private val binding: ListItemNetworkStationStatsBinding, private val listener: (NetworkStationStats) -> Unit ) : RecyclerView.ViewHolder(binding.root) { diff --git a/app/src/main/java/com/weatherxm/ui/networkstats/tokenmetrics/TokenMetricsActivity.kt b/app/src/main/java/com/weatherxm/ui/networkstats/tokenmetrics/TokenMetricsActivity.kt index 043b9e95a..0a6b88914 100644 --- a/app/src/main/java/com/weatherxm/ui/networkstats/tokenmetrics/TokenMetricsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/networkstats/tokenmetrics/TokenMetricsActivity.kt @@ -2,6 +2,7 @@ package com.weatherxm.ui.networkstats.tokenmetrics import android.annotation.SuppressLint import android.os.Bundle +import androidx.compose.ui.unit.dp import com.google.firebase.analytics.FirebaseAnalytics import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService @@ -14,6 +15,7 @@ import com.weatherxm.ui.common.setHtml import com.weatherxm.ui.common.toast import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseActivity +import com.weatherxm.ui.components.compose.RoundedRangeView import com.weatherxm.ui.networkstats.NetworkStats import com.weatherxm.util.NumberUtils.compactNumber import me.saket.bettermovementmethod.BetterLinkMovementMethod @@ -127,8 +129,15 @@ class TokenMetricsActivity : BaseActivity() { if (data.totalSupply != null && data.circulatingSupply != null && data.totalSupply >= data.circulatingSupply ) { - binding.circSupplyBar.valueTo = data.totalSupply.toFloat() - binding.circSupplyBar.values = listOf(data.circulatingSupply.toFloat()) + binding.circSupplyBar.setContent { + RoundedRangeView( + 4.dp, + 0F..data.circulatingSupply.toFloat(), + 0F..data.totalSupply.toFloat(), + R.color.colorSurface, + R.color.blue + ) + } } else { binding.circSupplyBar.visible(false) } diff --git a/app/src/main/res/layout/activity_token_metrics.xml b/app/src/main/res/layout/activity_token_metrics.xml index 16c039f1e..ac342ac53 100644 --- a/app/src/main/res/layout/activity_token_metrics.xml +++ b/app/src/main/res/layout/activity_token_metrics.xml @@ -419,19 +419,14 @@ app:layout_constraintTop_toBottomOf="@id/circSupplyTitle" tools:text="55.4M" /> - - + tools:composableName="com.weatherxm.ui.components.compose.RoundedRangeViewKt.PreviewRoundedRangeView" /> diff --git a/app/src/main/res/layout/list_item_device_rewards_boost.xml b/app/src/main/res/layout/list_item_device_rewards_boost.xml index ae507ad63..ecb8e1edb 100644 --- a/app/src/main/res/layout/list_item_device_rewards_boost.xml +++ b/app/src/main/res/layout/list_item_device_rewards_boost.xml @@ -29,22 +29,13 @@ app:layout_constraintTop_toTopOf="@id/boostProgressSlider" tools:text="70%" /> - + tools:composableName="com.weatherxm.ui.components.compose.RoundedRangeViewKt.PreviewRoundedRangeView" /> - + tools:composableName="com.weatherxm.ui.components.compose.RoundedRangeViewKt.PreviewRoundedRangeView" /> diff --git a/app/src/main/res/layout/view_reward_quality_card.xml b/app/src/main/res/layout/view_reward_quality_card.xml index f7b09c844..c42bb481b 100644 --- a/app/src/main/res/layout/view_reward_quality_card.xml +++ b/app/src/main/res/layout/view_reward_quality_card.xml @@ -86,17 +86,12 @@ app:layout_constraintBottom_toBottomOf="parent" tools:visibility="visible"> - + android:layout_marginHorizontal="@dimen/margin_small" + tools:composableName="com.weatherxm.ui.components.compose.RoundedRangeViewKt.PreviewRoundedRangeView" /> diff --git a/app/src/main/res/values/styles_widget.xml b/app/src/main/res/values/styles_widget.xml index 886b8a042..818f0d0b2 100644 --- a/app/src/main/res/values/styles_widget.xml +++ b/app/src/main/res/values/styles_widget.xml @@ -189,27 +189,6 @@ @color/transparent - - - - - From df185174ebed58e7247ebdff7cfa38f50c3b7c7e Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 17 Dec 2025 15:01:30 +0200 Subject: [PATCH 14/60] Chages in the Hyperlocal Prompt card and in the Powered by card --- .../components/compose/MosaicPromotionCard.kt | 27 ++++++++---- .../forecast/ForecastFragment.kt | 1 - .../ForecastDetailsActivity.kt | 1 - .../res/layout/activity_forecast_details.xml | 41 ++++++++----------- .../fragment_device_details_forecast.xml | 32 ++++++--------- app/src/main/res/values/palette.xml | 1 + app/src/main/res/values/strings.xml | 7 ++-- 7 files changed, 50 insertions(+), 60 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt index 20798eafd..129d07d2c 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt @@ -1,5 +1,6 @@ package com.weatherxm.ui.components.compose +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth @@ -34,7 +35,8 @@ fun MosaicPromotionCard(hasFreeSubAvailable: Boolean, onClickListener: () -> Uni shape = RoundedCornerShape(dimensionResource(R.dimen.radius_large)), elevation = CardDefaults.cardElevation( defaultElevation = dimensionResource(R.dimen.elevation_normal) - ) + ), + border = BorderStroke(2.dp, colorResource(R.color.dark_crypto_opacity_30)) ) { Column( modifier = Modifier @@ -42,10 +44,13 @@ fun MosaicPromotionCard(hasFreeSubAvailable: Boolean, onClickListener: () -> Uni .padding(dimensionResource(R.dimen.padding_normal_to_large)), horizontalAlignment = Alignment.CenterHorizontally ) { - Title( - text = stringResource(R.string.hyper_local), - fontSize = 25.sp, - colorRes = R.color.colorPrimary + Text( + text = stringResource(R.string.mosaic_forecast).uppercase(), + fontSize = 22.sp, + fontWeight = FontWeight.SemiBold, + color = colorResource(R.color.colorPrimary), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.headlineSmall ) Text( modifier = Modifier.padding(top = dimensionResource(R.dimen.padding_small)), @@ -56,7 +61,7 @@ fun MosaicPromotionCard(hasFreeSubAvailable: Boolean, onClickListener: () -> Uni style = MaterialTheme.typography.bodyLarge ) Text( - modifier = Modifier.padding(top = dimensionResource(R.dimen.padding_large)), + modifier = Modifier.padding(top = dimensionResource(R.dimen.padding_normal)), text = stringResource(R.string.mosaic_prompt_explanation), color = colorResource(R.color.chart_primary_line), textAlign = TextAlign.Center, @@ -65,11 +70,15 @@ fun MosaicPromotionCard(hasFreeSubAvailable: Boolean, onClickListener: () -> Uni Button( modifier = Modifier .fillMaxWidth() - .padding(top = dimensionResource(R.dimen.padding_large)), + .padding( + top = dimensionResource(R.dimen.padding_normal_to_large), + start = dimensionResource(R.dimen.padding_normal), + end = dimensionResource(R.dimen.padding_normal) + ), onClick = { onClickListener() }, colors = ButtonDefaults.buttonColors( containerColor = colorResource(R.color.colorPrimary), - contentColor = colorResource(R.color.colorBackground) + contentColor = colorResource(R.color.colorOnPrimary) ), shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), contentPadding = PaddingValues( @@ -81,7 +90,7 @@ fun MosaicPromotionCard(hasFreeSubAvailable: Boolean, onClickListener: () -> Uni text = stringResource(R.string.see_the_plans), fontWeight = FontWeight.Bold, fontSize = 18.sp, - colorRes = R.color.colorBackground + colorRes = R.color.colorOnPrimary ) } if (hasFreeSubAvailable) { diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index 65c688c02..8ef7977c4 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -213,7 +213,6 @@ class ForecastFragment : BaseFragment() { binding.hourlyForecastRecycler.visible(true) binding.hourlyForecastTitle.visible(true) binding.poweredByWXMLogo.visible(forecast.isPremium == true) - binding.poweredByPremiumThunder.visible(forecast.isPremium == true) binding.poweredByMeteoblueIcon.visible(forecast.isPremium == false) binding.mosaicPromotionCard.visible(forecast.isPremium == false) binding.poweredByCard.visible(forecast.isPremium != null) diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index f680c5565..7c0e9db0c 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -164,7 +164,6 @@ class ForecastDetailsActivity : BaseActivity() { // Update the "Powered By" card if (model.forecast().isPremium == true) { binding.poweredByWXMLogo.visible(true) - binding.poweredByPremiumThunder.visible(true) binding.poweredByMeteoblueIcon.visible(false) binding.mosaicPromotionCard.visible(false) } else { diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index 92e7446a1..5daf677ab 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -385,27 +385,18 @@ android:layout_marginHorizontal="@dimen/margin_normal" android:layout_marginTop="@dimen/margin_extra_large" app:cardBackgroundColor="@color/blueTint" - app:cardCornerRadius="@dimen/radius_small" - app:contentPaddingBottom="@dimen/padding_small" - app:contentPaddingTop="@dimen/padding_small" + app:cardCornerRadius="@dimen/radius_large" + app:contentPaddingLeft="@dimen/padding_normal_to_large" + app:contentPaddingRight="@dimen/padding_normal_to_large" + app:contentPaddingBottom="@dimen/padding_small_to_normal" + app:contentPaddingTop="@dimen/padding_small_to_normal" app:layout_constraintTop_toBottomOf="@id/displayTimeNotice"> - - + android:orientation="vertical"> @@ -171,17 +173,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" - android:orientation="horizontal"> - - + android:orientation="vertical"> #234170 #B33A3F6A #B38C97F5 + #4D8C97F5 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16e1c2fe5..21c5a2e14 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -907,15 +907,14 @@ You’ve kept at least 80 $WXM unclaimed. Free trial locked You are %s $WXM away to unlock a free trial. Keep up! - HYPER LOCAL - Smarter. Sharper. Sunnier. + Smarter. Sharper. More Accurate. We’ve picked the top performed models in your area to give you the most accurate forecast possible. See the plans You have a free subscription. Claim now! - powered by + Powered by Current plan Premium features - HYPER LOCAL forecast + ✨ HYPERLOCAL forecast We tested 30 forecast models to see which ones match real weather the best. The best model today might not be the best for the next few days! We pick the top models in your area each day to give you the most reliable forecast. Standard Just the basics. Premium features are locked.\nUpgrade your subscription to unlock them! From ef90a4a1868329360b062ed8d63ae5956a41e5d9 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Sat, 20 Dec 2025 15:19:40 +0200 Subject: [PATCH 15/60] Support of two different endpoints in Forecast (no UI yet) --- .../CacheWeatherForecastDataSource.kt | 12 +- .../NetworkWeatherForecastDataSource.kt | 18 +- .../datasource/WeatherForecastDataSource.kt | 10 +- .../com/weatherxm/data/network/ApiService.kt | 13 +- .../repository/WeatherForecastRepository.kt | 57 ++- .../forecast/ForecastFragment.kt | 96 ++--- .../forecast/ForecastViewModel.kt | 82 ++-- .../DailyTileForecastAdapter.kt | 4 +- .../ForecastDetailsActivity.kt | 134 ++++--- .../ForecastDetailsViewModel.kt | 80 ++-- .../com/weatherxm/usecases/ForecastUseCase.kt | 4 +- .../weatherxm/usecases/ForecastUseCaseImpl.kt | 35 +- .../res/layout/activity_forecast_details.xml | 9 +- .../fragment_device_details_forecast.xml | 376 +++++++++--------- app/src/main/res/values/strings.xml | 2 +- 15 files changed, 518 insertions(+), 414 deletions(-) diff --git a/app/src/main/java/com/weatherxm/data/datasource/CacheWeatherForecastDataSource.kt b/app/src/main/java/com/weatherxm/data/datasource/CacheWeatherForecastDataSource.kt index 8da6f4e5e..f143c2d3c 100644 --- a/app/src/main/java/com/weatherxm/data/datasource/CacheWeatherForecastDataSource.kt +++ b/app/src/main/java/com/weatherxm/data/datasource/CacheWeatherForecastDataSource.kt @@ -12,7 +12,7 @@ class CacheWeatherForecastDataSource( private val cacheService: CacheService ) : WeatherForecastDataSource { - override suspend fun getDeviceForecast( + override suspend fun getDeviceDefaultForecast( deviceId: String, fromDate: LocalDate, toDate: LocalDate, @@ -22,6 +22,16 @@ class CacheWeatherForecastDataSource( return cacheService.getDeviceForecast(deviceId) } + override suspend fun getDevicePremiumForecast( + deviceId: String, + fromDate: LocalDate, + toDate: LocalDate, + exclude: String?, + token: String + ): Either> { + throw NotImplementedError("Won't be implemented. Ignore this.") + } + override suspend fun setDeviceForecast(deviceId: String, forecast: List) { cacheService.setDeviceForecast(deviceId, forecast) } diff --git a/app/src/main/java/com/weatherxm/data/datasource/NetworkWeatherForecastDataSource.kt b/app/src/main/java/com/weatherxm/data/datasource/NetworkWeatherForecastDataSource.kt index 034d697c5..1ccbb4917 100644 --- a/app/src/main/java/com/weatherxm/data/datasource/NetworkWeatherForecastDataSource.kt +++ b/app/src/main/java/com/weatherxm/data/datasource/NetworkWeatherForecastDataSource.kt @@ -12,7 +12,7 @@ class NetworkWeatherForecastDataSource( private val apiService: ApiService ) : WeatherForecastDataSource { - override suspend fun getDeviceForecast( + override suspend fun getDeviceDefaultForecast( deviceId: String, fromDate: LocalDate, toDate: LocalDate, @@ -34,6 +34,22 @@ class NetworkWeatherForecastDataSource( return apiService.getLocationForecast(location.lat, location.lon).mapResponse() } + override suspend fun getDevicePremiumForecast( + deviceId: String, + fromDate: LocalDate, + toDate: LocalDate, + exclude: String?, + token: String + ): Either> { + return apiService.getPremiumForecast( + deviceId, + fromDate.toString(), + toDate.toString(), + exclude, + token + ).mapResponse() + } + override suspend fun setDeviceForecast(deviceId: String, forecast: List) { throw NotImplementedError("Won't be implemented. Ignore this.") } diff --git a/app/src/main/java/com/weatherxm/data/datasource/WeatherForecastDataSource.kt b/app/src/main/java/com/weatherxm/data/datasource/WeatherForecastDataSource.kt index fda8efe27..173ed7d57 100644 --- a/app/src/main/java/com/weatherxm/data/datasource/WeatherForecastDataSource.kt +++ b/app/src/main/java/com/weatherxm/data/datasource/WeatherForecastDataSource.kt @@ -19,7 +19,7 @@ interface WeatherForecastDataSource { @Retention(AnnotationRetention.SOURCE) private annotation class Exclude - suspend fun getDeviceForecast( + suspend fun getDeviceDefaultForecast( deviceId: String, fromDate: LocalDate, toDate: LocalDate, @@ -27,6 +27,14 @@ interface WeatherForecastDataSource { token: String? = null ): Either> + suspend fun getDevicePremiumForecast( + deviceId: String, + fromDate: LocalDate, + toDate: LocalDate, + exclude: @Exclude String? = null, + token: String + ): Either> + suspend fun setDeviceForecast(deviceId: String, forecast: List) suspend fun clearDeviceForecast() diff --git a/app/src/main/java/com/weatherxm/data/network/ApiService.kt b/app/src/main/java/com/weatherxm/data/network/ApiService.kt index be4306a62..36c9edcee 100644 --- a/app/src/main/java/com/weatherxm/data/network/ApiService.kt +++ b/app/src/main/java/com/weatherxm/data/network/ApiService.kt @@ -93,7 +93,7 @@ interface ApiService { ): NetworkResponse @Mock - @MockResponse(body = "mock_files/get_user_device_weather_forecast_premium.json") + @MockResponse(body = "mock_files/get_user_device_weather_forecast.json") @GET("/api/v1/me/devices/{deviceId}/forecast") suspend fun getForecast( @Path("deviceId") deviceId: String, @@ -103,6 +103,17 @@ interface ApiService { @Query("token") token: String? = null, ): NetworkResponse, ErrorResponse> + @Mock + @MockResponse(body = "mock_files/get_user_device_weather_forecast_premium.json") + @GET("/api/v1/me/devices/{deviceId}/forecast/premium") + suspend fun getPremiumForecast( + @Path("deviceId") deviceId: String, + @Query("fromDate") fromDate: String, + @Query("toDate") toDate: String, + @Query("exclude") exclude: String? = null, + @Query("token") token: String, + ): NetworkResponse, ErrorResponse> + @Mock @MockResponse(body = "mock_files/get_user_device_weather_history.json") @GET("/api/v1/me/devices/{deviceId}/history") diff --git a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt index 7bcba6c39..7e4614f49 100644 --- a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt +++ b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt @@ -7,20 +7,25 @@ import com.weatherxm.data.models.Failure import com.weatherxm.data.models.Location import com.weatherxm.data.models.WeatherData import com.weatherxm.service.BillingService +import com.weatherxm.ui.common.empty import timber.log.Timber import java.time.LocalDate -import java.time.temporal.ChronoUnit interface WeatherForecastRepository { - suspend fun getDeviceForecast( + fun clearLocationForecastFromCache() + suspend fun getLocationForecast(location: Location): Either> + suspend fun getDevicePremiumForecast( + deviceId: String, + fromDate: LocalDate, + toDate: LocalDate + ): Either> + + suspend fun getDeviceDefaultForecast( deviceId: String, fromDate: LocalDate, toDate: LocalDate, forceRefresh: Boolean ): Either> - - fun clearLocationForecastFromCache() - suspend fun getLocationForecast(location: Location): Either> } class WeatherForecastRepositoryImpl( @@ -33,26 +38,7 @@ class WeatherForecastRepositoryImpl( const val PREFETCH_DAYS = 7L } - override suspend fun getDeviceForecast( - deviceId: String, - fromDate: LocalDate, - toDate: LocalDate, - forceRefresh: Boolean - ): Either> { - val to = if (ChronoUnit.DAYS.between(fromDate, toDate) < PREFETCH_DAYS) { - fromDate.plusDays(PREFETCH_DAYS) - } else { - toDate - } - - return if (billingService.hasActiveSub()) { - getDevicePremiumForecast(deviceId, fromDate, to) - } else { - getDeviceDefaultForecast(deviceId, fromDate, to, forceRefresh) - } - } - - private suspend fun getDeviceDefaultForecast( + override suspend fun getDeviceDefaultForecast( deviceId: String, fromDate: LocalDate, toDate: LocalDate, @@ -62,27 +48,34 @@ class WeatherForecastRepositoryImpl( clearDeviceForecastFromCache() } - return cacheSource.getDeviceForecast(deviceId, fromDate, toDate) + return cacheSource.getDeviceDefaultForecast(deviceId, fromDate, toDate) .onRight { Timber.d("Got forecast from cache [$fromDate to $toDate].") } .mapLeft { - return networkSource.getDeviceForecast(deviceId, fromDate, toDate).onRight { + val token = billingService.getActiveSubFlow().value?.purchaseToken + return networkSource.getDeviceDefaultForecast( + deviceId, + fromDate, + toDate, + token = token + ).onRight { Timber.d("Got forecast from network [$fromDate to $toDate].") cacheSource.setDeviceForecast(deviceId, it) } } } - private suspend fun getDevicePremiumForecast( + override suspend fun getDevicePremiumForecast( deviceId: String, fromDate: LocalDate, toDate: LocalDate ): Either> { - val token = billingService.getActiveSubFlow().value?.purchaseToken - return networkSource.getDeviceForecast(deviceId, fromDate, toDate, token = token).onRight { - Timber.d("Got premium forecast from network [$fromDate to $toDate].") - } + val token = billingService.getActiveSubFlow().value?.purchaseToken ?: String.empty() + return networkSource.getDevicePremiumForecast(deviceId, fromDate, toDate, token = token) + .onRight { + Timber.d("Got premium forecast from network [$fromDate to $toDate].") + } } override fun clearLocationForecastFromCache() { diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index 8ef7977c4..d3322eba8 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -11,12 +11,12 @@ import com.weatherxm.databinding.FragmentDeviceDetailsForecastBinding import com.weatherxm.service.BillingService import com.weatherxm.ui.common.DeviceRelation.UNFOLLOWED import com.weatherxm.ui.common.HourlyForecastAdapter +import com.weatherxm.ui.common.Resource import com.weatherxm.ui.common.Status import com.weatherxm.ui.common.UIForecast import com.weatherxm.ui.common.UILocation import com.weatherxm.ui.common.blockParentViewPagerOnScroll import com.weatherxm.ui.common.classSimpleName -import com.weatherxm.ui.common.invisible import com.weatherxm.ui.common.setHtml import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseFragment @@ -36,6 +36,9 @@ class ForecastFragment : BaseFragment() { } private val billingService: BillingService by inject() + private lateinit var hourlyForecastAdapter: HourlyForecastAdapter + private lateinit var dailyForecastAdapter: DailyForecastAdapter + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -50,13 +53,13 @@ class ForecastFragment : BaseFragment() { super.onViewCreated(view, savedInstanceState) binding.swiperefresh.setOnRefreshListener { - model.fetchForecast(true) + model.fetchForecasts(true) } initHiddenContent() // Initialize the adapters with empty data - val dailyForecastAdapter = DailyForecastAdapter { + dailyForecastAdapter = DailyForecastAdapter { navigator.showForecastDetails( activityResultLauncher = null, context = context, @@ -66,7 +69,7 @@ class ForecastFragment : BaseFragment() { hasFreeTrialAvailable = parentModel.hasFreePremiumTrialAvailable() ) } - val hourlyForecastAdapter = HourlyForecastAdapter { + hourlyForecastAdapter = HourlyForecastAdapter { analytics.trackEventSelectContent( AnalyticsService.ParamValue.HOURLY_DETAILS_CARD.paramValue, Pair( @@ -111,19 +114,15 @@ class ForecastFragment : BaseFragment() { parentModel.onDeviceFirstFetch().observe(viewLifecycleOwner) { model.device = it - model.fetchForecast(true) - } - - model.onForecast().observe(viewLifecycleOwner) { - onForecast(hourlyForecastAdapter, dailyForecastAdapter, it) + model.fetchForecasts(true) } - model.onLoading().observe(viewLifecycleOwner) { - onLoading(it) + model.onDefaultForecast().observe(viewLifecycleOwner) { + onForecast(it) { model.fetchForecasts(true) } } - model.onError().observe(viewLifecycleOwner) { - showSnackbarMessage(binding.root, it.errorMessage, it.retryFunction) + model.onPremiumForecast().observe(viewLifecycleOwner) { + onForecast(it) { model.fetchForecasts() } } initMosaicPromotionCard() @@ -147,25 +146,10 @@ class ForecastFragment : BaseFragment() { } } - private fun onLoading(isLoading: Boolean) { - if (isLoading && binding.swiperefresh.isRefreshing) { - binding.progress.invisible() - } else if (isLoading) { - binding.mosaicPromotionCard.visible(false) - binding.dailyForecastTitle.visible(false) - binding.temperatureBarsInfoButton.visible(false) - binding.hourlyForecastTitle.visible(false) - binding.progress.visible(true) - } else { - binding.swiperefresh.isRefreshing = false - binding.progress.invisible() - } - } - private fun fetchOrHideContent() { if (model.device.relation != UNFOLLOWED) { binding.hiddenContentContainer.visible(false) - model.fetchForecast() + model.fetchForecasts() } else if (model.device.relation == UNFOLLOWED) { binding.mosaicPromotionCard.visible(false) binding.poweredByCard.visible(false) @@ -199,22 +183,42 @@ class ForecastFragment : BaseFragment() { } } - private fun onForecast( - hourlyForecastAdapter: HourlyForecastAdapter, - dailyForecastAdapter: DailyForecastAdapter, - forecast: UIForecast - ) { - hourlyForecastAdapter.submitList(forecast.next24Hours) - dailyForecastAdapter.submitList(forecast.forecastDays) - binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) - binding.dailyForecastRecycler.visible(true) - binding.dailyForecastTitle.visible(true) - binding.temperatureBarsInfoButton.visible(true) - binding.hourlyForecastRecycler.visible(true) - binding.hourlyForecastTitle.visible(true) - binding.poweredByWXMLogo.visible(forecast.isPremium == true) - binding.poweredByMeteoblueIcon.visible(forecast.isPremium == false) - binding.mosaicPromotionCard.visible(forecast.isPremium == false) - binding.poweredByCard.visible(forecast.isPremium != null) + private fun onForecast(resource: Resource, onErrorRetry: (() -> Unit)? = null) { + when (resource.status) { + Status.SUCCESS -> { + val forecast = resource.data + hourlyForecastAdapter.submitList(forecast?.next24Hours) + dailyForecastAdapter.submitList(forecast?.forecastDays) + binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) + binding.dailyForecastRecycler.visible(true) + binding.dailyForecastTitle.visible(true) + binding.temperatureBarsInfoButton.visible(true) + binding.hourlyForecastRecycler.visible(true) + binding.hourlyForecastTitle.visible(true) + binding.poweredByWXMLogo.visible(forecast?.isPremium == true) + binding.poweredByMeteoblueIcon.visible(forecast?.isPremium == false) + binding.mosaicPromotionCard.visible(forecast?.isPremium == false) + binding.poweredByCard.visible(forecast?.isPremium != null) + binding.swiperefresh.isRefreshing = false + binding.statusView.visible(false) + binding.swiperefresh.visible(true) + } + Status.ERROR -> { + binding.statusView.animation(R.raw.anim_error, false) + .title(R.string.error_generic_message) + .action(getString(R.string.action_retry)) + .subtitle(resource.message) + .listener { onErrorRetry?.invoke() } + .visible(true) + } + Status.LOADING -> { + if (binding.swiperefresh.isRefreshing) { + binding.statusView.visible(false) + } else { + binding.swiperefresh.visible(false) + binding.statusView.clear().animation(R.raw.anim_loading).visible(true) + } + } + } } } diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt index c0b1bf1a7..fd21ef2b0 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt @@ -4,21 +4,21 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import arrow.core.Either import com.weatherxm.R import com.weatherxm.analytics.AnalyticsWrapper import com.weatherxm.data.models.ApiError import com.weatherxm.data.models.Failure import com.weatherxm.data.models.NetworkError.ConnectionTimeoutError import com.weatherxm.data.models.NetworkError.NoConnectionError +import com.weatherxm.ui.common.Resource import com.weatherxm.ui.common.UIDevice -import com.weatherxm.ui.common.UIError import com.weatherxm.ui.common.UIForecast import com.weatherxm.usecases.ForecastUseCase import com.weatherxm.util.Failure.getDefaultMessage import com.weatherxm.util.Resources import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.launch -import timber.log.Timber class ForecastViewModel( var device: UIDevice = UIDevice.empty(), @@ -27,19 +27,13 @@ class ForecastViewModel( private val analytics: AnalyticsWrapper, private val dispatcher: CoroutineDispatcher, ) : ViewModel() { - private val onLoading = MutableLiveData() + private val onDefaultForecast = MutableLiveData>() + private val onPremiumForecast = MutableLiveData>() - private val onError = MutableLiveData() + fun onDefaultForecast(): LiveData> = onDefaultForecast + fun onPremiumForecast(): LiveData> = onPremiumForecast - private val onForecast = MutableLiveData() - - fun onLoading(): LiveData = onLoading - - fun onError(): LiveData = onError - - fun onForecast(): LiveData = onForecast - - fun fetchForecast(forceRefresh: Boolean = false) { + fun fetchForecasts(forceRefresh: Boolean = false) { /** * If we got here directly from a search result or through a notification, * then we need to wait for the View Model to load the device from the network, @@ -50,40 +44,52 @@ class ForecastViewModel( if (device.isEmpty() || device.isDeviceFromSearchResult || device.isUnfollowed()) { return } - onLoading.postValue(true) + fetchDeviceForecast( + mutableLiveData = onDefaultForecast, + fetchOperation = { forecastUseCase.getDeviceDefaultForecast(device, forceRefresh) } + ) + // TODO: STOPSHIP: We need a check here to not fetch the below if not premium available. + fetchDeviceForecast( + mutableLiveData = onPremiumForecast, + fetchOperation = { forecastUseCase.getDevicePremiumForecast(device) } + ) + } + + private fun fetchDeviceForecast( + mutableLiveData: MutableLiveData>, + fetchOperation: suspend () -> Either + ) { viewModelScope.launch(dispatcher) { - forecastUseCase.getDeviceForecast(device, forceRefresh).onRight { - Timber.d("Got forecast for device") + mutableLiveData.postValue(Resource.loading()) + fetchOperation().onRight { if (it.isEmpty()) { - onError.postValue(UIError(resources.getString(R.string.forecast_empty))) + mutableLiveData.postValue( + Resource.error(resources.getString(R.string.forecast_empty)) + ) + } else { + mutableLiveData.postValue(Resource.success(it)) } - onForecast.postValue(it) }.onLeft { analytics.trackEventFailure(it.code) - handleForecastFailure(it) + mutableLiveData.postValue(Resource.error(getFailureMessage(it))) } - onLoading.postValue(false) } } - private fun handleForecastFailure(failure: Failure) { - onError.postValue( - when (failure) { - is ApiError.UserError.InvalidFromDate, is ApiError.UserError.InvalidToDate -> { - UIError(resources.getString(R.string.error_forecast_generic_message)) - } - is ApiError.UserError.InvalidTimezone -> { - UIError(resources.getString(R.string.error_forecast_invalid_timezone)) - } - is NoConnectionError, is ConnectionTimeoutError -> { - UIError(failure.getDefaultMessage(R.string.error_reach_out_short)) { - fetchForecast() - } - } - else -> { - UIError(resources.getString(R.string.error_reach_out_short)) - } + private fun getFailureMessage(failure: Failure): String { + return when (failure) { + is ApiError.UserError.InvalidFromDate, is ApiError.UserError.InvalidToDate -> { + resources.getString(R.string.error_forecast_generic_message) } - ) + is ApiError.UserError.InvalidTimezone -> { + resources.getString(R.string.error_forecast_invalid_timezone) + } + is NoConnectionError, is ConnectionTimeoutError -> { + failure.getDefaultMessage(R.string.error_reach_out_short) + } + else -> { + resources.getString(R.string.error_reach_out_short) + } + } } } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt index f9500b911..718b660f1 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt @@ -17,7 +17,7 @@ import java.time.LocalDate class DailyTileForecastAdapter( private var selectedDate: LocalDate, private val onNewSelectedPosition: (Int, Int) -> Unit, - private val onClickListener: (UIForecastDay) -> Unit + private val onClickListener: (Int) -> Unit ) : ListAdapter( UIForecastDayDiffCallback() ) { @@ -44,7 +44,7 @@ class DailyTileForecastAdapter( fun bind(item: UIForecastDay, position: Int) { binding.root.setOnClickListener { - onClickListener.invoke(item) + onClickListener.invoke(position) selectedDate = item.date checkSelectionStatus(item, position) } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 7c0e9db0c..f415220d4 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -11,8 +11,10 @@ import com.weatherxm.ui.common.Contracts.ARG_FORECAST_SELECTED_DAY import com.weatherxm.ui.common.Contracts.EMPTY_VALUE import com.weatherxm.ui.common.DeviceRelation import com.weatherxm.ui.common.HourlyForecastAdapter +import com.weatherxm.ui.common.Resource import com.weatherxm.ui.common.Status import com.weatherxm.ui.common.UIDevice +import com.weatherxm.ui.common.UIForecast import com.weatherxm.ui.common.UIForecastDay import com.weatherxm.ui.common.UILocation import com.weatherxm.ui.common.capitalizeWords @@ -94,35 +96,20 @@ class ForecastDetailsActivity : BaseActivity() { binding.displayTimeNotice.visible(false) } - model.onForecastLoaded().observe(this) { - when (it.status) { - Status.SUCCESS -> { - val selectedDayPosition = model.getSelectedDayPosition( - intent.getStringExtra(ARG_FORECAST_SELECTED_DAY) - ) - val forecastDay = model.forecast().forecastDays[selectedDayPosition] - setupDailyAdapter(forecastDay, selectedDayPosition) - updateUI(forecastDay) - binding.statusView.visible(false) - binding.mainContainer.visible(true) - } - Status.ERROR -> { - binding.statusView.clear() - .animation(R.raw.anim_error) - .title(getString(R.string.error_generic_message)) - .subtitle(it.message) - binding.mainContainer.visible(false) - } - Status.LOADING -> { - binding.statusView.clear().animation(R.raw.anim_loading) - binding.mainContainer.visible(false) - binding.statusView.visible(true) - } - } + model.onDeviceDefaultForecast().observe(this) { + onForecast(it) + } + + model.onDevicePremiumForecast().observe(this) { + onForecast(it) + } + + model.onLocationForecast().observe(this) { + onForecast(it) } if (!model.device.isEmpty()) { - model.fetchDeviceForecast() + model.fetchDeviceForecasts() initMosaicPromotionCard() } else if (!model.location.isEmpty()) { model.fetchLocationForecast() @@ -141,19 +128,48 @@ class ForecastDetailsActivity : BaseActivity() { } } - private fun updateUI(forecast: UIForecastDay) { + private fun onForecast(resource: Resource) { + when (resource.status) { + Status.SUCCESS -> { + val forecast = resource.data ?: UIForecast.empty() + val selectedDayPosition = model.getSelectedDayPosition( + intent.getStringExtra(ARG_FORECAST_SELECTED_DAY), + forecast + ) + setupDailyAdapter(forecast, selectedDayPosition) + updateUI(forecast, selectedDayPosition) + binding.statusView.visible(false) + binding.mainContainer.visible(true) + } + Status.ERROR -> { + binding.statusView.clear() + .animation(R.raw.anim_error) + .title(getString(R.string.error_generic_message)) + .subtitle(resource.message) + binding.mainContainer.visible(false) + } + Status.LOADING -> { + binding.statusView.clear().animation(R.raw.anim_loading) + binding.mainContainer.visible(false) + binding.statusView.visible(true) + } + } + } + + private fun updateUI(forecast: UIForecast, selectedDayPosition: Int) { + val forecastDay = forecast.forecastDays[selectedDayPosition] // Update the header now that model.address has valid data and we are in a location if (!model.location.isEmpty()) { binding.header.setContent { if (model.location.isCurrentLocation) { HeaderView( title = getString(R.string.current_location).capitalizeWords(), - subtitle = model.forecast().address, + subtitle = forecast.address, onInfoButton = null ) } else { HeaderView( - title = model.forecast().address ?: EMPTY_VALUE, + title = forecast.address ?: EMPTY_VALUE, subtitle = null, onInfoButton = null ) @@ -162,7 +178,7 @@ class ForecastDetailsActivity : BaseActivity() { } // Update the "Powered By" card - if (model.forecast().isPremium == true) { + if (forecast.isPremium == true) { binding.poweredByWXMLogo.visible(true) binding.poweredByMeteoblueIcon.visible(false) binding.mosaicPromotionCard.visible(false) @@ -173,61 +189,66 @@ class ForecastDetailsActivity : BaseActivity() { } // Update Daily Weather - binding.dailyDate.text = forecast.date.getRelativeDayAndShort(this) - binding.dailyIcon.setWeatherAnimation(forecast.icon) - binding.dailyMaxTemp.text = getFormattedTemperature(this, forecast.maxTemp) - binding.dailyMinTemp.text = getFormattedTemperature(this, forecast.minTemp) - if (model.forecast().isPremium == true) { - binding.dailyPremiumWind.setIcon(getWindDirectionDrawable(this, forecast.windDirection)) + binding.dailyDate.text = forecastDay.date.getRelativeDayAndShort(this) + binding.dailyIcon.setWeatherAnimation(forecastDay.icon) + binding.dailyMaxTemp.text = getFormattedTemperature(this, forecastDay.maxTemp) + binding.dailyMinTemp.text = getFormattedTemperature(this, forecastDay.minTemp) + if (forecast.isPremium == true) { + binding.dailyPremiumWind.setIcon( + getWindDirectionDrawable( + this, + forecastDay.windDirection + ) + ) binding.dailyPremiumWind.setData( - getFormattedWind(this, forecast.windSpeed, forecast.windDirection) + getFormattedWind(this, forecastDay.windSpeed, forecastDay.windDirection) ) - binding.dailyPremiumHumidity.setData(getFormattedHumidity(forecast.humidity)) + binding.dailyPremiumHumidity.setData(getFormattedHumidity(forecastDay.humidity)) binding.dailyDefaultFirstRow.visible(false) binding.dailyDefaultSecondRow.visible(false) binding.dailyPremiumRow.visible(true) } else { binding.precipProbabilityCard.setData( - getFormattedPrecipitationProbability(forecast.precipProbability) + getFormattedPrecipitationProbability(forecastDay.precipProbability) ) - binding.windCard.setIcon(getWindDirectionDrawable(this, forecast.windDirection)) + binding.windCard.setIcon(getWindDirectionDrawable(this, forecastDay.windDirection)) binding.windCard.setData( getFormattedWind( this, - forecast.windSpeed, - forecast.windDirection + forecastDay.windSpeed, + forecastDay.windDirection ) ) binding.dailyPrecipCard.setData( getFormattedPrecipitation( context = this, - value = forecast.precip, + value = forecastDay.precip, isRainRate = false ) ) - binding.uvCard.setData(getFormattedUV(this, forecast.uv)) - binding.humidityCard.setData(getFormattedHumidity(forecast.humidity)) - binding.pressureCard.setData(getFormattedPressure(this, forecast.pressure)) + binding.uvCard.setData(getFormattedUV(this, forecastDay.uv)) + binding.humidityCard.setData(getFormattedHumidity(forecastDay.humidity)) + binding.pressureCard.setData(getFormattedPressure(this, forecastDay.pressure)) } // Update Hourly Tiles hourlyAdapter = HourlyForecastAdapter(null) binding.hourlyForecastRecycler.adapter = hourlyAdapter - hourlyAdapter.submitList(forecast.hourlyWeather) - if (!forecast.hourlyWeather.isNullOrEmpty()) { + hourlyAdapter.submitList(forecastDay.hourlyWeather) + if (!forecastDay.hourlyWeather.isNullOrEmpty()) { binding.hourlyForecastRecycler.scrollToPosition( - model.getDefaultHourPosition(forecast.hourlyWeather) + model.getDefaultHourPosition(forecastDay.hourlyWeather) ) } // Update Charts - updateCharts(forecast) + updateCharts(forecast, forecastDay) } - private fun updateCharts(forecast: UIForecastDay) { + private fun updateCharts(forecast: UIForecast, forecastDay: UIForecastDay) { // Update Charts with(binding.charts) { - val charts = model.getCharts(forecast) + val charts = model.getCharts(forecast, forecastDay) clearCharts() initTemperatureChart(charts.temperature, charts.feelsLike, true) initWindChart(charts.windSpeed, charts.windGust, charts.windDirection, true) @@ -289,13 +310,14 @@ class ForecastDetailsActivity : BaseActivity() { binding.mainContainer.smoothScrollTo(chartX, finalY, SCROLL_DURATION_MS) } - private fun setupDailyAdapter(forecastDay: UIForecastDay, selectedDayPosition: Int) { + private fun setupDailyAdapter(forecast: UIForecast, selectedDayPosition: Int) { + val forecastDay = forecast.forecastDays[selectedDayPosition] dailyAdapter = DailyTileForecastAdapter( forecastDay.date, onNewSelectedPosition = { position, width -> binding.dailyTilesRecycler.moveItemToCenter(position, binding.root.width, width) }, - onClickListener = { + onClickListener = { newSelectedDayPosition -> analytics.trackEventSelectContent( AnalyticsService.ParamValue.DAILY_CARD.paramValue, Pair( @@ -305,11 +327,11 @@ class ForecastDetailsActivity : BaseActivity() { ) // Get selected position before we update it in order to reset the stroke dailyAdapter.notifyItemChanged(dailyAdapter.getSelectedPosition()) - updateUI(it) + updateUI(forecast, newSelectedDayPosition) } ) binding.dailyTilesRecycler.adapter = dailyAdapter - dailyAdapter.submitList(model.forecast().forecastDays) + dailyAdapter.submitList(forecast.forecastDays) binding.dailyTilesRecycler.scrollToPosition(selectedDayPosition) } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt index 9c40a3f51..f59e7ab06 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import arrow.core.Either import com.weatherxm.R import com.weatherxm.analytics.AnalyticsWrapper import com.weatherxm.data.datasource.LocationsDataSource.Companion.MAX_AUTH_LOCATIONS @@ -43,75 +44,84 @@ class ForecastDetailsViewModel( private val locationsUseCase: LocationsUseCase, private val dispatcher: CoroutineDispatcher, ) : ViewModel() { - private val onForecastLoaded = MutableLiveData>() - - fun onForecastLoaded(): LiveData> = onForecastLoaded - - private var forecast: UIForecast = UIForecast.empty() - - fun forecast() = forecast + private val onDeviceDefaultForecast = MutableLiveData>() + private val onDevicePremiumForecast = MutableLiveData>() + private val onLocationForecast = MutableLiveData>() + + fun onDeviceDefaultForecast(): LiveData> = onDeviceDefaultForecast + fun onDevicePremiumForecast(): LiveData> = onDevicePremiumForecast + fun onLocationForecast(): LiveData> = onLocationForecast + + fun fetchDeviceForecasts() { + fetchDeviceForecast( + mutableLiveData = onDeviceDefaultForecast, + fetchOperation = { forecastUseCase.getDeviceDefaultForecast(device) } + ) + // TODO: STOPSHIP: We need a check here to not fetch the below if not premium available. + fetchDeviceForecast( + mutableLiveData = onDevicePremiumForecast, + fetchOperation = { forecastUseCase.getDevicePremiumForecast(device) } + ) + } - fun fetchDeviceForecast() { - onForecastLoaded.postValue(Resource.loading()) + private fun fetchDeviceForecast( + mutableLiveData: MutableLiveData>, + fetchOperation: suspend () -> Either + ) { viewModelScope.launch(dispatcher) { - forecastUseCase.getDeviceForecast(device).onRight { + mutableLiveData.postValue(Resource.loading()) + fetchOperation().onRight { Timber.d("Got forecast details for device forecast") - forecast = it if (it.isEmpty()) { - onForecastLoaded.postValue( + mutableLiveData.postValue( Resource.error(resources.getString(R.string.forecast_empty)) ) } else { - onForecastLoaded.postValue(Resource.success(Unit)) + mutableLiveData.postValue(Resource.success(it)) } }.onLeft { - forecast = UIForecast.empty() analytics.trackEventFailure(it.code) - handleForecastFailure(it) + mutableLiveData.postValue(getFailureResource(it)) } } } fun fetchLocationForecast() { - onForecastLoaded.postValue(Resource.loading()) + onLocationForecast.postValue(Resource.loading()) viewModelScope.launch(dispatcher) { forecastUseCase.getLocationForecast(location.coordinates) .onRight { Timber.d("Got forecast details for location forecast") - forecast = it if (it.isEmpty()) { - onForecastLoaded.postValue( + onLocationForecast.postValue( Resource.error(resources.getString(R.string.forecast_empty)) ) } else { - onForecastLoaded.postValue(Resource.success(Unit)) + onLocationForecast.postValue(Resource.success(it)) } } .onLeft { - forecast = UIForecast.empty() analytics.trackEventFailure(it.code) - handleForecastFailure(it) + onLocationForecast.postValue(getFailureResource(it)) } } } - private fun handleForecastFailure(failure: Failure) { - onForecastLoaded.postValue( - Resource.error( - when (failure) { - is ApiError.UserError.InvalidFromDate, is ApiError.UserError.InvalidToDate -> { - resources.getString(R.string.error_forecast_generic_message) - } - is ApiError.UserError.InvalidTimezone -> { - resources.getString(R.string.error_forecast_invalid_timezone) - } - else -> failure.getDefaultMessage(R.string.error_reach_out_short) + private fun getFailureResource(failure: Failure): Resource { + return Resource.error( + when (failure) { + is ApiError.UserError.InvalidFromDate, is ApiError.UserError.InvalidToDate -> { + resources.getString(R.string.error_forecast_generic_message) } - ) + is ApiError.UserError.InvalidTimezone -> { + resources.getString(R.string.error_forecast_invalid_timezone) + } + else -> failure.getDefaultMessage(R.string.error_reach_out_short) + } ) } - fun getSelectedDayPosition(selectedISODate: String?): Int { + fun getSelectedDayPosition(selectedISODate: String?, forecast: UIForecast): Int { if (selectedISODate == null) { return 0 } @@ -146,7 +156,7 @@ class ForecastDetailsViewModel( ) } - fun getCharts(forecastDay: UIForecastDay): Charts { + fun getCharts(forecast: UIForecast, forecastDay: UIForecastDay): Charts { Timber.d("Returning forecast charts for [${forecastDay.date}]") val chartStep = if (forecast.isPremium == true) { Duration.ofHours(FORECAST_CHART_STEP_PREMIUM) diff --git a/app/src/main/java/com/weatherxm/usecases/ForecastUseCase.kt b/app/src/main/java/com/weatherxm/usecases/ForecastUseCase.kt index f83aaa8ea..237c68f8f 100644 --- a/app/src/main/java/com/weatherxm/usecases/ForecastUseCase.kt +++ b/app/src/main/java/com/weatherxm/usecases/ForecastUseCase.kt @@ -7,10 +7,10 @@ import com.weatherxm.ui.common.UIDevice import com.weatherxm.ui.common.UIForecast interface ForecastUseCase { - suspend fun getDeviceForecast( + suspend fun getDeviceDefaultForecast( device: UIDevice, forceRefresh: Boolean = false ): Either - + suspend fun getDevicePremiumForecast(device: UIDevice): Either suspend fun getLocationForecast(location: Location): Either } diff --git a/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt b/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt index 921ddbb88..280142180 100644 --- a/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt +++ b/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt @@ -20,26 +20,43 @@ class ForecastUseCaseImpl( private val repo: WeatherForecastRepository ) : ForecastUseCase { - @Suppress("MagicNumber") - override suspend fun getDeviceForecast( + private suspend fun getDeviceForecast( + isPremium: Boolean, device: UIDevice, - forceRefresh: Boolean + forceRefresh: Boolean = false ): Either { if (device.timezone.isNullOrEmpty()) { return Either.Left(ApiError.UserError.InvalidTimezone(INVALID_TIMEZONE)) } val nowDeviceTz = ZonedDateTime.now(ZoneId.of(device.timezone)) val dateEndInDeviceTz = nowDeviceTz.plusDays(7).toLocalDate() - return repo.getDeviceForecast( - device.id, - nowDeviceTz.toLocalDate(), - dateEndInDeviceTz, - forceRefresh - ).map { + return if (isPremium) { + repo.getDevicePremiumForecast(device.id, nowDeviceTz.toLocalDate(), dateEndInDeviceTz) + } else { + repo.getDeviceDefaultForecast( + device.id, + nowDeviceTz.toLocalDate(), + dateEndInDeviceTz, + forceRefresh + ) + }.map { getUIForecastFromWeatherData(nowDeviceTz, it) } } + @Suppress("MagicNumber") + override suspend fun getDeviceDefaultForecast( + device: UIDevice, + forceRefresh: Boolean + ): Either { + return getDeviceForecast(false, device, forceRefresh) + } + + @Suppress("MagicNumber") + override suspend fun getDevicePremiumForecast(device: UIDevice): Either { + return getDeviceForecast(isPremium = true, device) + } + override suspend fun getLocationForecast(location: Location): Either { return repo.getLocationForecast(location).map { val timezone = it.first().tz diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index 5daf677ab..0ccca1543 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -85,10 +85,9 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_normal" - android:layout_marginTop="@dimen/margin_small" + android:layout_marginTop="@dimen/margin_large" android:clipChildren="false" android:clipToPadding="false" - android:paddingBottom="@dimen/padding_small" android:visibility="gone" app:layout_constraintTop_toBottomOf="@id/header" tools:composableName="com.weatherxm.ui.components.compose.MosaicPromotionCardKt.PreviewMosaicPromotionCard" @@ -98,7 +97,7 @@ android:id="@+id/dailyTilesRecycler" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/margin_normal" + android:layout_marginTop="@dimen/margin_large" android:clipToPadding="false" android:nestedScrollingEnabled="false" android:orientation="horizontal" @@ -383,12 +382,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/margin_normal" - android:layout_marginTop="@dimen/margin_extra_large" + android:layout_marginTop="@dimen/margin_large" app:cardBackgroundColor="@color/blueTint" app:cardCornerRadius="@dimen/radius_large" + app:contentPaddingBottom="@dimen/padding_small_to_normal" app:contentPaddingLeft="@dimen/padding_normal_to_large" app:contentPaddingRight="@dimen/padding_normal_to_large" - app:contentPaddingBottom="@dimen/padding_small_to_normal" app:contentPaddingTop="@dimen/padding_small_to_normal" app:layout_constraintTop_toBottomOf="@id/displayTimeNotice"> diff --git a/app/src/main/res/layout/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml index ef2aa464e..549c83f68 100644 --- a/app/src/main/res/layout/fragment_device_details_forecast.xml +++ b/app/src/main/res/layout/fragment_device_details_forecast.xml @@ -1,213 +1,221 @@ - - - - + + + android:clipToPadding="false" + android:fillViewport="true" + android:paddingBottom="@dimen/padding_small" + tools:targetApi="s"> - - - - - - - - - - - - - - - - - - - - - - - - - - + android:clipToOutline="false" + android:clipToPadding="false"> + android:orientation="vertical" + android:visibility="gone" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintTop_toTopOf="parent"> - - - - + + + + - - - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21c5a2e14..0d991fa6c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -504,7 +504,7 @@ Enter 6-digit key - Oops! No forecast weather data could not be found. + No forecast weather data could not be found. Next 24 hours Next 7 days Daily Conditions From 430514f09f54d9e8d09c607b23c8d11c1fd01ff5 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Sat, 20 Dec 2025 16:59:02 +0200 Subject: [PATCH 16/60] Add the two tabs in the forecast tab of the device details --- .../components/compose/ForecastTabSelector.kt | 161 +++++++++++ .../forecast/ForecastFragment.kt | 55 ++-- .../forecast/ForecastViewModel.kt | 13 +- .../weatherxm/usecases/ForecastUseCaseImpl.kt | 3 +- app/src/main/res/drawable/ic_sparkles.xml | 38 +++ .../fragment_device_details_forecast.xml | 259 ++++++++++-------- app/src/main/res/values/strings.xml | 2 + 7 files changed, 385 insertions(+), 146 deletions(-) create mode 100644 app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt create mode 100644 app/src/main/res/drawable/ic_sparkles.xml diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt b/app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt new file mode 100644 index 000000000..5fe21f890 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt @@ -0,0 +1,161 @@ +package com.weatherxm.ui.components.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.weatherxm.R + +/** + * A reusable tab selector component with stateful tab management. + * + * @param defaultSelectedIndex Index of the initially selected tab (default: 0) + * @param onTabSelected Callback invoked when a tab is selected, providing the index and label + */ +@Suppress("FunctionNaming", "MagicNumber") +@Composable +fun ForecastTabSelector( + defaultSelectedIndex: Int, + onTabSelected: (Int) -> Unit +) { + var selectedIndex by remember { mutableIntStateOf(defaultSelectedIndex) } + + Card( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.colorSurface) + ), + elevation = CardDefaults.cardElevation( + defaultElevation = dimensionResource(R.dimen.elevation_normal) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.padding_extra_small)), + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.padding_extra_small) + ) + ) { + TabItem( + includeIcon = false, + label = stringResource(R.string.basic_forecast), + isSelected = selectedIndex == 0, + onClick = { + selectedIndex = 0 + onTabSelected(0) + } + ) + TabItem( + includeIcon = true, + label = stringResource(R.string.hyperlocal), + isSelected = selectedIndex == 1, + onClick = { + selectedIndex = 1 + onTabSelected(1) + } + ) + } + } +} + +@Suppress("FunctionNaming", "MagicNumber") +@Composable +private fun RowScope.TabItem( + includeIcon: Boolean, + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Surface( + onClick = onClick, + modifier = Modifier + .fillMaxHeight() + .weight(1F), + color = Color.Transparent + ) { + Box( + modifier = Modifier + .background( + brush = if (isSelected) { + Brush.horizontalGradient( + colors = listOf( + Color(0xFF8C97F5), + Color(0xFF7985E5) + ) + ) + } else { + Brush.linearGradient(listOf(Color.Transparent, Color.Transparent)) + }, + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small)) + ), + contentAlignment = Alignment.Center + ) { + Row( + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.margin_small) + ), + verticalAlignment = Alignment.CenterVertically + ) { + if (includeIcon) { + Icon( + modifier = Modifier.size(16.dp), + painter = painterResource(R.drawable.ic_sparkles), + contentDescription = null, + tint = colorResource(R.color.dark_text) + ) + } + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = if (isSelected) { + colorResource(R.color.dark_text) + } else { + colorResource(R.color.darkGrey) + }, + ) + + } + } + } +} + +@Suppress("FunctionNaming") +@Preview(showBackground = true) +@Composable +fun PreviewForecastTabSelector() { + ForecastTabSelector(defaultSelectedIndex = 1) { } +} diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index d3322eba8..0aa1c49a3 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -8,7 +8,6 @@ import com.google.firebase.analytics.FirebaseAnalytics import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService import com.weatherxm.databinding.FragmentDeviceDetailsForecastBinding -import com.weatherxm.service.BillingService import com.weatherxm.ui.common.DeviceRelation.UNFOLLOWED import com.weatherxm.ui.common.HourlyForecastAdapter import com.weatherxm.ui.common.Resource @@ -20,10 +19,10 @@ import com.weatherxm.ui.common.classSimpleName import com.weatherxm.ui.common.setHtml import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseFragment +import com.weatherxm.ui.components.compose.ForecastTabSelector import com.weatherxm.ui.components.compose.MosaicPromotionCard import com.weatherxm.ui.devicedetails.DeviceDetailsViewModel import com.weatherxm.util.toISODate -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.viewModel import org.koin.core.parameter.parametersOf @@ -34,10 +33,10 @@ class ForecastFragment : BaseFragment() { private val model: ForecastViewModel by viewModel { parametersOf(parentModel.device) } - private val billingService: BillingService by inject() private lateinit var hourlyForecastAdapter: HourlyForecastAdapter private lateinit var dailyForecastAdapter: DailyForecastAdapter + private var currentSelectedTab = 0 override fun onCreateView( inflater: LayoutInflater, @@ -118,17 +117,39 @@ class ForecastFragment : BaseFragment() { } model.onDefaultForecast().observe(viewLifecycleOwner) { - onForecast(it) { model.fetchForecasts(true) } + if (currentSelectedTab == 0) { + onForecast(it) { model.fetchForecasts(true) } + } } model.onPremiumForecast().observe(viewLifecycleOwner) { - onForecast(it) { model.fetchForecasts() } + if (currentSelectedTab == 1) { + onForecast(it) { model.fetchForecasts() } + } } + initForecastTabsSelector() initMosaicPromotionCard() fetchOrHideContent() } + private fun initForecastTabsSelector() { + binding.forecastTabSelector.setContent { + ForecastTabSelector(0) { newSelectedTab -> + currentSelectedTab = newSelectedTab + if (newSelectedTab == 0) { + model.onDefaultForecast().value?.let { + onForecast(it) { model.fetchForecasts(true) } + } + } else { + model.onPremiumForecast().value?.let { + onForecast(it) { model.fetchForecasts() } + } + } + } + } + } + override fun onResume() { super.onResume() analytics.trackScreen(AnalyticsService.Screen.DEVICE_FORECAST, classSimpleName()) @@ -152,12 +173,8 @@ class ForecastFragment : BaseFragment() { model.fetchForecasts() } else if (model.device.relation == UNFOLLOWED) { binding.mosaicPromotionCard.visible(false) - binding.poweredByCard.visible(false) - binding.hourlyForecastTitle.visible(false) - binding.hourlyForecastRecycler.visible(false) - binding.dailyForecastRecycler.visible(false) - binding.dailyForecastTitle.visible(false) - binding.temperatureBarsInfoButton.visible(false) + binding.forecastTabSelector.visible(false) + binding.mainContainer.visible(false) binding.hiddenContentContainer.visible(true) } } @@ -189,21 +206,17 @@ class ForecastFragment : BaseFragment() { val forecast = resource.data hourlyForecastAdapter.submitList(forecast?.next24Hours) dailyForecastAdapter.submitList(forecast?.forecastDays) - binding.mosaicPromotionCard.visible(!billingService.hasActiveSub()) - binding.dailyForecastRecycler.visible(true) - binding.dailyForecastTitle.visible(true) - binding.temperatureBarsInfoButton.visible(true) - binding.hourlyForecastRecycler.visible(true) - binding.hourlyForecastTitle.visible(true) - binding.poweredByWXMLogo.visible(forecast?.isPremium == true) - binding.poweredByMeteoblueIcon.visible(forecast?.isPremium == false) + binding.poweredByMeteoblueIcon.visible(currentSelectedTab == 0) + binding.poweredByWXMLogo.visible(currentSelectedTab == 1) + binding.forecastTabSelector.visible(forecast?.isPremium == true) binding.mosaicPromotionCard.visible(forecast?.isPremium == false) binding.poweredByCard.visible(forecast?.isPremium != null) binding.swiperefresh.isRefreshing = false binding.statusView.visible(false) - binding.swiperefresh.visible(true) + binding.mainContainer.visible(true) } Status.ERROR -> { + binding.mainContainer.visible(false) binding.statusView.animation(R.raw.anim_error, false) .title(R.string.error_generic_message) .action(getString(R.string.action_retry)) @@ -215,7 +228,7 @@ class ForecastFragment : BaseFragment() { if (binding.swiperefresh.isRefreshing) { binding.statusView.visible(false) } else { - binding.swiperefresh.visible(false) + binding.mainContainer.visible(false) binding.statusView.clear().animation(R.raw.anim_loading).visible(true) } } diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt index fd21ef2b0..c47a21b68 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt @@ -11,6 +11,7 @@ import com.weatherxm.data.models.ApiError import com.weatherxm.data.models.Failure import com.weatherxm.data.models.NetworkError.ConnectionTimeoutError import com.weatherxm.data.models.NetworkError.NoConnectionError +import com.weatherxm.service.BillingService import com.weatherxm.ui.common.Resource import com.weatherxm.ui.common.UIDevice import com.weatherxm.ui.common.UIForecast @@ -22,6 +23,7 @@ import kotlinx.coroutines.launch class ForecastViewModel( var device: UIDevice = UIDevice.empty(), + private val billingService: BillingService, private val resources: Resources, private val forecastUseCase: ForecastUseCase, private val analytics: AnalyticsWrapper, @@ -48,11 +50,12 @@ class ForecastViewModel( mutableLiveData = onDefaultForecast, fetchOperation = { forecastUseCase.getDeviceDefaultForecast(device, forceRefresh) } ) - // TODO: STOPSHIP: We need a check here to not fetch the below if not premium available. - fetchDeviceForecast( - mutableLiveData = onPremiumForecast, - fetchOperation = { forecastUseCase.getDevicePremiumForecast(device) } - ) + if (billingService.hasActiveSub()) { + fetchDeviceForecast( + mutableLiveData = onPremiumForecast, + fetchOperation = { forecastUseCase.getDevicePremiumForecast(device) } + ) + } } private fun fetchDeviceForecast( diff --git a/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt b/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt index 280142180..2ca288245 100644 --- a/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt +++ b/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt @@ -20,6 +20,7 @@ class ForecastUseCaseImpl( private val repo: WeatherForecastRepository ) : ForecastUseCase { + @Suppress("MagicNumber") private suspend fun getDeviceForecast( isPremium: Boolean, device: UIDevice, @@ -44,7 +45,6 @@ class ForecastUseCaseImpl( } } - @Suppress("MagicNumber") override suspend fun getDeviceDefaultForecast( device: UIDevice, forceRefresh: Boolean @@ -52,7 +52,6 @@ class ForecastUseCaseImpl( return getDeviceForecast(false, device, forceRefresh) } - @Suppress("MagicNumber") override suspend fun getDevicePremiumForecast(device: UIDevice): Either { return getDeviceForecast(isPremium = true, device) } diff --git a/app/src/main/res/drawable/ic_sparkles.xml b/app/src/main/res/drawable/ic_sparkles.xml new file mode 100644 index 000000000..faad427e4 --- /dev/null +++ b/app/src/main/res/drawable/ic_sparkles.xml @@ -0,0 +1,38 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml index 549c83f68..f15f4cc65 100644 --- a/app/src/main/res/layout/fragment_device_details_forecast.xml +++ b/app/src/main/res/layout/fragment_device_details_forecast.xml @@ -76,133 +76,156 @@ app:icon="@drawable/ic_favorite_outline" /> - + app:layout_constraintTop_toTopOf="parent"> - - - + + + + + - - - - - - - - - - + + - - - - - - - - - + android:layout_height="wrap_content" + android:text="@string/next_24_hours" + android:textAppearance="@style/TextAppearance.WeatherXM.Default.BodyLarge" + android:textColor="@color/darkestBlue" + android:textStyle="bold" /> + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0d991fa6c..a18a307d5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -943,4 +943,6 @@ Premium features are available until your subscription expires.\nSelect a plan to extend your subscription. Premium Forecast You need to login to get premium forecast. + Basic Forecast + Hyperlocal From 1f1273f8a4c03ce9758332a1e48e4f770f66c4c3 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Mon, 22 Dec 2025 15:12:26 +0200 Subject: [PATCH 17/60] Fix powered by visibility in forecast fragment --- .../weatherxm/ui/devicedetails/forecast/ForecastFragment.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index 0aa1c49a3..daa0f8546 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -208,9 +208,9 @@ class ForecastFragment : BaseFragment() { dailyForecastAdapter.submitList(forecast?.forecastDays) binding.poweredByMeteoblueIcon.visible(currentSelectedTab == 0) binding.poweredByWXMLogo.visible(currentSelectedTab == 1) - binding.forecastTabSelector.visible(forecast?.isPremium == true) - binding.mosaicPromotionCard.visible(forecast?.isPremium == false) - binding.poweredByCard.visible(forecast?.isPremium != null) + binding.forecastTabSelector.visible(true) + binding.mosaicPromotionCard.visible(false) + binding.poweredByCard.visible(true) binding.swiperefresh.isRefreshing = false binding.statusView.visible(false) binding.mainContainer.visible(true) From d798bda89b7410a04b83ca6d311b4d373330fb39 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Mon, 22 Dec 2025 16:05:35 +0200 Subject: [PATCH 18/60] Fixes on forecast tab. Add the two tabs in forecast details. --- .../forecast/ForecastFragment.kt | 10 +- .../ForecastDetailsActivity.kt | 59 +- .../ForecastDetailsViewModel.kt | 19 +- .../res/layout/activity_forecast_details.xml | 658 +++++++++--------- 4 files changed, 402 insertions(+), 344 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index daa0f8546..eb96b7e37 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -200,7 +200,7 @@ class ForecastFragment : BaseFragment() { } } - private fun onForecast(resource: Resource, onErrorRetry: (() -> Unit)? = null) { + private fun onForecast(resource: Resource, onErrorRetry: () -> Unit) { when (resource.status) { Status.SUCCESS -> { val forecast = resource.data @@ -208,8 +208,10 @@ class ForecastFragment : BaseFragment() { dailyForecastAdapter.submitList(forecast?.forecastDays) binding.poweredByMeteoblueIcon.visible(currentSelectedTab == 0) binding.poweredByWXMLogo.visible(currentSelectedTab == 1) - binding.forecastTabSelector.visible(true) - binding.mosaicPromotionCard.visible(false) + binding.forecastTabSelector.visible( + forecast?.isPremium == true || currentSelectedTab == 1 + ) + binding.mosaicPromotionCard.visible(forecast?.isPremium == false) binding.poweredByCard.visible(true) binding.swiperefresh.isRefreshing = false binding.statusView.visible(false) @@ -221,7 +223,7 @@ class ForecastFragment : BaseFragment() { .title(R.string.error_generic_message) .action(getString(R.string.action_retry)) .subtitle(resource.message) - .listener { onErrorRetry?.invoke() } + .listener { onErrorRetry.invoke() } .visible(true) } Status.LOADING -> { diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index f415220d4..c288b2f0f 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -29,6 +29,7 @@ import com.weatherxm.ui.common.toast import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseActivity import com.weatherxm.ui.components.LineChartView +import com.weatherxm.ui.components.compose.ForecastTabSelector import com.weatherxm.ui.components.compose.HeaderView import com.weatherxm.ui.components.compose.JoinNetworkPromoCard import com.weatherxm.ui.components.compose.MosaicPromotionCard @@ -62,6 +63,7 @@ class ForecastDetailsActivity : BaseActivity() { private lateinit var dailyAdapter: DailyTileForecastAdapter private lateinit var hourlyAdapter: HourlyForecastAdapter + private var currentSelectedTab = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -97,25 +99,47 @@ class ForecastDetailsActivity : BaseActivity() { } model.onDeviceDefaultForecast().observe(this) { - onForecast(it) + if (currentSelectedTab == 0) { + onForecast(it) { model.fetchDeviceForecasts() } + } } model.onDevicePremiumForecast().observe(this) { - onForecast(it) + if (currentSelectedTab == 1) { + onForecast(it) { model.fetchDeviceForecasts() } + } } model.onLocationForecast().observe(this) { - onForecast(it) + onForecast(it) { model.fetchLocationForecast() } } if (!model.device.isEmpty()) { model.fetchDeviceForecasts() + initForecastTabsSelector() initMosaicPromotionCard() } else if (!model.location.isEmpty()) { model.fetchLocationForecast() } } + private fun initForecastTabsSelector() { + binding.forecastTabSelector.setContent { + ForecastTabSelector(0) { newSelectedTab -> + currentSelectedTab = newSelectedTab + if (newSelectedTab == 0) { + model.onDeviceDefaultForecast().value?.let { + onForecast(it) { model.fetchDeviceForecasts() } + } + } else { + model.onDevicePremiumForecast().value?.let { + onForecast(it) { model.fetchDeviceForecasts() } + } + } + } + } + } + private fun initMosaicPromotionCard() { binding.mosaicPromotionCard.setContent { MosaicPromotionCard(model.hasFreeTrialAvailable) { @@ -128,7 +152,7 @@ class ForecastDetailsActivity : BaseActivity() { } } - private fun onForecast(resource: Resource) { + private fun onForecast(resource: Resource, onErrorRetry: () -> Unit) { when (resource.status) { Status.SUCCESS -> { val forecast = resource.data ?: UIForecast.empty() @@ -146,6 +170,8 @@ class ForecastDetailsActivity : BaseActivity() { .animation(R.raw.anim_error) .title(getString(R.string.error_generic_message)) .subtitle(resource.message) + .action(getString(R.string.action_retry)) + .listener { onErrorRetry.invoke() } binding.mainContainer.visible(false) } Status.LOADING -> { @@ -178,14 +204,13 @@ class ForecastDetailsActivity : BaseActivity() { } // Update the "Powered By" card - if (forecast.isPremium == true) { - binding.poweredByWXMLogo.visible(true) - binding.poweredByMeteoblueIcon.visible(false) - binding.mosaicPromotionCard.visible(false) - } else { - binding.poweredByMeteoblueIcon.visible(true) - // Show the Mosaic promo only on devices' forecast - binding.mosaicPromotionCard.visible(!model.device.isEmpty()) + binding.poweredByMeteoblueIcon.visible(currentSelectedTab == 0) + binding.poweredByWXMLogo.visible(currentSelectedTab == 1) + + // Update the forecast tabs or the mosaic prompt + if (!model.device.isEmpty()) { + binding.forecastTabSelector.visible(forecast.isPremium == true || currentSelectedTab == 1) + binding.mosaicPromotionCard.visible(forecast.isPremium == false) } // Update Daily Weather @@ -193,7 +218,11 @@ class ForecastDetailsActivity : BaseActivity() { binding.dailyIcon.setWeatherAnimation(forecastDay.icon) binding.dailyMaxTemp.text = getFormattedTemperature(this, forecastDay.maxTemp) binding.dailyMinTemp.text = getFormattedTemperature(this, forecastDay.minTemp) - if (forecast.isPremium == true) { + + /** + * Some data are missing in the Hyper Local tab so we handle it differently below. + */ + if (currentSelectedTab == 1) { binding.dailyPremiumWind.setIcon( getWindDirectionDrawable( this, @@ -298,7 +327,7 @@ class ForecastDetailsActivity : BaseActivity() { @Suppress("MagicNumber") private fun scrollToChart(chart: LineChartView) { val (chartX, chartY) = chart.screenLocation() - val currentY = binding.mainContainer.scrollY + val currentY = binding.scrollView.scrollY /** * It didn't seem to scroll properly at the top of the chart's card, @@ -307,7 +336,7 @@ class ForecastDetailsActivity : BaseActivity() { * and scroll properly to the top of the card containing the chart */ val finalY = chartY - binding.appBar.height - binding.root.paddingTop + currentY - 110 - binding.mainContainer.smoothScrollTo(chartX, finalY, SCROLL_DURATION_MS) + binding.scrollView.smoothScrollTo(chartX, finalY, SCROLL_DURATION_MS) } private fun setupDailyAdapter(forecast: UIForecast, selectedDayPosition: Int) { diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt index f59e7ab06..228f80585 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt @@ -11,6 +11,7 @@ import com.weatherxm.data.datasource.LocationsDataSource.Companion.MAX_AUTH_LOCA import com.weatherxm.data.models.ApiError import com.weatherxm.data.models.Failure import com.weatherxm.data.models.HourlyWeather +import com.weatherxm.service.BillingService import com.weatherxm.ui.common.Charts import com.weatherxm.ui.common.Resource import com.weatherxm.ui.common.UIDevice @@ -36,6 +37,7 @@ class ForecastDetailsViewModel( val device: UIDevice, val location: UILocation, val hasFreeTrialAvailable: Boolean, + private val billingService: BillingService, private val resources: Resources, private val analytics: AnalyticsWrapper, private val authUseCase: AuthUseCase, @@ -57,11 +59,12 @@ class ForecastDetailsViewModel( mutableLiveData = onDeviceDefaultForecast, fetchOperation = { forecastUseCase.getDeviceDefaultForecast(device) } ) - // TODO: STOPSHIP: We need a check here to not fetch the below if not premium available. - fetchDeviceForecast( - mutableLiveData = onDevicePremiumForecast, - fetchOperation = { forecastUseCase.getDevicePremiumForecast(device) } - ) + if (billingService.hasActiveSub()) { + fetchDeviceForecast( + mutableLiveData = onDevicePremiumForecast, + fetchOperation = { forecastUseCase.getDevicePremiumForecast(device) } + ) + } } private fun fetchDeviceForecast( @@ -158,10 +161,10 @@ class ForecastDetailsViewModel( fun getCharts(forecast: UIForecast, forecastDay: UIForecastDay): Charts { Timber.d("Returning forecast charts for [${forecastDay.date}]") - val chartStep = if (forecast.isPremium == true) { - Duration.ofHours(FORECAST_CHART_STEP_PREMIUM) - } else { + val chartStep = if (forecast.isPremium == false) { Duration.ofHours(FORECAST_CHART_STEP_DEFAULT) + } else { + Duration.ofHours(FORECAST_CHART_STEP_PREMIUM) } return chartsUseCase.createHourlyCharts( forecastDay.date, diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index 0ccca1543..919c3777f 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -24,12 +24,10 @@ + app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> - + app:layout_constraintTop_toBottomOf="@id/header"> + + - + + + - - - - + android:clipChildren="false" + android:visibility="gone" + app:layout_constraintTop_toBottomOf="@id/tabsOrMosaicPromptContainer" + tools:visibility="visible"> - + + + + - - + android:layout_height="wrap_content" + android:layout_marginHorizontal="@dimen/margin_normal" + android:layout_marginTop="@dimen/margin_normal" + app:cardElevation="@dimen/elevation_normal" + app:contentPadding="@dimen/padding_normal" + app:layout_constraintTop_toBottomOf="@id/dailyTitle"> - - + + + app:lottie_autoPlay="false" + app:lottie_loop="true" + tools:lottie_rawRes="@raw/anim_weather_clear_day" /> - - - - - + + + + + + + + + + + - - - - - + + + + + + + - - + + + + + + + + + - + android:orientation="horizontal" + android:weightSum="3" + app:layout_constraintTop_toBottomOf="@id/dailyDefaultFirstRow"> + + + + + + + - + + - + - + + + + + - - + + + - - + android:orientation="vertical"> - + android:text="@string/powered_by" + android:textAppearance="@style/TextAppearance.WeatherXM.Default.BodySmall" + app:layout_constraintTop_toBottomOf="@id/dailyTilesRecycler" /> + + + + - + + - - - - - - - - - - - - - - - - - - - - - - - - - From 913210ef669d5e1ce1335f0cc6e285cacd5a7277 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Mon, 22 Dec 2025 16:49:05 +0200 Subject: [PATCH 19/60] Fix in forecast details --- .../weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index c288b2f0f..44613a829 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -258,6 +258,9 @@ class ForecastDetailsActivity : BaseActivity() { binding.uvCard.setData(getFormattedUV(this, forecastDay.uv)) binding.humidityCard.setData(getFormattedHumidity(forecastDay.humidity)) binding.pressureCard.setData(getFormattedPressure(this, forecastDay.pressure)) + binding.dailyPremiumRow.visible(false) + binding.dailyDefaultFirstRow.visible(true) + binding.dailyDefaultSecondRow.visible(true) } // Update Hourly Tiles From a583fcb31ad28978524154e002bdfb19f6985e7b Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Mon, 22 Dec 2025 17:02:34 +0200 Subject: [PATCH 20/60] Fixes in forecast tab in hourlies to set probability to visible when setting it --- app/src/main/java/com/weatherxm/service/BillingService.kt | 2 +- .../main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt | 1 + .../ui/devicedetails/forecast/DailyForecastAdapter.kt | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt index 8aaf5e2c6..67af7921a 100644 --- a/app/src/main/java/com/weatherxm/service/BillingService.kt +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -133,7 +133,7 @@ class BillingService( private fun startConnection() { billingClient?.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + if (billingResult.responseCode == BillingResponseCode.OK) { coroutineScope.launch(dispatcher) { setupPurchases() } diff --git a/app/src/main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt index 6cd6a3f38..843f26918 100644 --- a/app/src/main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/common/HourlyForecastAdapter.kt @@ -46,6 +46,7 @@ class HourlyForecastAdapter( item.precipProbability?.let { binding.precipProbability.text = Weather.getFormattedPrecipitationProbability(item.precipProbability) + binding.precipProbabilityContainer.visible(true) } ?: binding.precipProbabilityContainer.visible(false) } } diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt index 0eefb28ae..432d2ceab 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt @@ -153,9 +153,12 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) oldItem.maxTemp == newItem.maxTemp && oldItem.minTemp == newItem.minTemp && oldItem.precipProbability == newItem.precipProbability && + oldItem.precip == newItem.precip && oldItem.windSpeed == newItem.windSpeed && oldItem.windDirection == newItem.windDirection && oldItem.humidity == newItem.humidity && + oldItem.pressure == newItem.pressure && + oldItem.uv == newItem.uv && oldItem.hourlyWeather?.size == newItem.hourlyWeather?.size } } From df4c4e49f9469ffe81ebfbd3f572509948aac1a8 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Tue, 23 Dec 2025 20:49:14 +0200 Subject: [PATCH 21/60] WIP: Add premium gradient color in temperature bar in forecast tab --- .../components/compose/ForecastTabSelector.kt | 2 +- .../ui/components/compose/RoundedRangeView.kt | 25 +++++++++++++------ .../forecast/DailyForecastAdapter.kt | 20 ++++++++++++++- .../forecast/ForecastFragment.kt | 1 + 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt b/app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt index 5fe21f890..107594ded 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt @@ -117,7 +117,7 @@ private fun RowScope.TabItem( ) ) } else { - Brush.linearGradient(listOf(Color.Transparent, Color.Transparent)) + Brush.horizontalGradient(listOf(Color.Transparent, Color.Transparent)) }, shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small)) ), diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt b/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt index 6ede96a50..11952ebf4 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt @@ -7,6 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.tooling.preview.Preview @@ -21,7 +22,8 @@ fun RoundedRangeView( currentRange: ClosedFloatingPointRange, totalRange: ClosedFloatingPointRange, inactiveColorResId: Int, - activeColorResId: Int + activeColorResId: Int, + activeColorBrush: Brush? = null ) { val cornerRadius = dimensionResource(R.dimen.radius_extra_extra_large).value val inactiveColor = colorResource(inactiveColorResId) @@ -47,12 +49,21 @@ fun RoundedRangeView( val activeWidth = width * (endFraction - startFraction) // Draw active track - drawRoundRect( - color = activeColor, - topLeft = Offset(startX, 0f), - size = Size(activeWidth, trackHeight), - cornerRadius = CornerRadius(cornerRadius, cornerRadius) - ) + if (activeColorBrush != null) { + drawRoundRect( + brush = activeColorBrush, + topLeft = Offset(startX, 0f), + size = Size(activeWidth, trackHeight), + cornerRadius = CornerRadius(cornerRadius, cornerRadius) + ) + } else { + drawRoundRect( + color = activeColor, + topLeft = Offset(startX, 0f), + size = Size(activeWidth, trackHeight), + cornerRadius = CornerRadius(cornerRadius, cornerRadius) + ) + } } } diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt index 432d2ceab..290a2a476 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt @@ -2,6 +2,8 @@ package com.weatherxm.ui.devicedetails.forecast import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -31,10 +33,15 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) private var minTemperature: Float = Float.MAX_VALUE private var maxTemperature: Float = Float.MIN_VALUE + private var isOnPremiumTab: Boolean = false val resources: Resources by inject() val analytics: AnalyticsWrapper by inject() + fun setPremiumData(isOnPremiumTab: Boolean) { + this.isOnPremiumTab = isOnPremiumTab + } + override fun submitList(list: List?) { /* Consider the following case: @@ -99,12 +106,23 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) val rangeStart = item.minTemp ?: 0F val rangeEnd = item.maxTemp ?: 0F binding.temperatureView.setContent { + val brushColor = if (isOnPremiumTab) { + Brush.horizontalGradient( + colors = listOf( + Color(itemView.context.getColor(R.color.blue)), + Color(itemView.context.getColor(R.color.forecast_premium)) + ) + ) + } else { + null + } RoundedRangeView( 16.dp, rangeStart..rangeEnd, minTemperature..maxTemperature, R.color.colorBackground, - R.color.crypto + R.color.crypto, + brushColor ) } } diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index eb96b7e37..52c71b00d 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -205,6 +205,7 @@ class ForecastFragment : BaseFragment() { Status.SUCCESS -> { val forecast = resource.data hourlyForecastAdapter.submitList(forecast?.next24Hours) + dailyForecastAdapter.setPremiumData(currentSelectedTab == 1) dailyForecastAdapter.submitList(forecast?.forecastDays) binding.poweredByMeteoblueIcon.visible(currentSelectedTab == 0) binding.poweredByWXMLogo.visible(currentSelectedTab == 1) From 41f4457afd6d5e9989c5f0297b7ed7774847a961 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Tue, 23 Dec 2025 21:39:53 +0200 Subject: [PATCH 22/60] WIP paint the weather icons in the daily forecast in the forecast tab with gradient when premium --- .../ui/components/compose/GradientIcon.kt | 136 +++++++++++++ .../compose/GradientIconRotatable.kt | 184 ++++++++++++++++++ .../forecast/DailyForecastAdapter.kt | 60 +++++- .../main/res/layout/list_item_forecast.xml | 41 +--- 4 files changed, 384 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/weatherxm/ui/components/compose/GradientIcon.kt create mode 100644 app/src/main/java/com/weatherxm/ui/components/compose/GradientIconRotatable.kt diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/GradientIcon.kt b/app/src/main/java/com/weatherxm/ui/components/compose/GradientIcon.kt new file mode 100644 index 000000000..cd517bedf --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/components/compose/GradientIcon.kt @@ -0,0 +1,136 @@ +package com.weatherxm.ui.components.compose + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.weatherxm.R +import kotlin.math.min + +/** + * A reusable icon composable that can be tinted with either a gradient brush or a static color. + * + * @param iconRes The drawable resource ID for the icon + * @param modifier Modifier to be applied to the icon + * @param size The size of the icon (default 14.dp) + * @param brush Optional gradient brush to apply to the icon + * @param tint Optional static color to apply to the icon (used if brush is null) + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +fun GradientIcon( + @DrawableRes iconRes: Int, + modifier: Modifier = Modifier, + size: Dp = 14.dp, + brush: Brush? = null, + tint: Color? = null +) { + val painter = painterResource(iconRes) + + if (brush != null) { + // Use Canvas to apply gradient with proper masking using layer + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.matchParentSize()) { + // Calculate the size that fits within the canvas while maintaining aspect ratio + val intrinsicSize = painter.intrinsicSize + val canvasSize = this.size + + val scale = min( + canvasSize.width / intrinsicSize.width, + canvasSize.height / intrinsicSize.height + ) + + val scaledWidth = intrinsicSize.width * scale + val scaledHeight = intrinsicSize.height * scale + + // Center the icon + val left = (canvasSize.width - scaledWidth) / 2 + val top = (canvasSize.height - scaledHeight) / 2 + + // Use saveLayer to isolate the blend mode operations + drawContext.canvas.nativeCanvas.apply { + val checkPoint = saveLayer(null, null) + + // Translate to center position and draw + translate(left, top) { + // Step 1: Draw the icon (this becomes our mask) + with(painter) { + draw( + size = Size(scaledWidth, scaledHeight), + alpha = 1f + ) + } + + // Step 2: Draw gradient with SrcIn blend mode + // This makes gradient only visible where icon pixels exist + drawRect( + brush = brush, + size = Size(scaledWidth, scaledHeight), + blendMode = BlendMode.SrcIn + ) + } + + restoreToCount(checkPoint) + } + } + } + } else { + // Use standard Icon with solid color tint + Icon( + painter = painter, + contentDescription = null, + modifier = modifier.size(size), + tint = tint ?: Color.Unspecified + ) + } +} + +/** + * Preview for GradientIcon with gradient brush + */ +@Suppress("FunctionNaming", "MagicNumber") +@Preview(showBackground = true) +@Composable +fun PreviewGradientIconWithBrush() { + GradientIcon( + iconRes = R.drawable.ic_weather_precip_probability, + size = 24.dp, + brush = Brush.horizontalGradient( + colors = listOf( + Color(0xFF2196F3), + Color(0xFFE91E63) + ) + ) + ) +} + +/** + * Preview for GradientIcon with static color + */ +@Suppress("FunctionNaming") +@Preview(showBackground = true) +@Composable +fun PreviewGradientIconWithColor() { + GradientIcon( + iconRes = R.drawable.ic_weather_precip_probability, + size = 24.dp, + tint = Color.Gray + ) +} diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/GradientIconRotatable.kt b/app/src/main/java/com/weatherxm/ui/components/compose/GradientIconRotatable.kt new file mode 100644 index 000000000..96089077a --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/components/compose/GradientIconRotatable.kt @@ -0,0 +1,184 @@ +package com.weatherxm.ui.components.compose + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.weatherxm.R +import kotlin.math.min + +/** + * A reusable icon composable that can be rotated and tinted with either a gradient brush or a static color. + * Useful for icons that need to be oriented based on data (e.g., wind direction). + * + * @param iconRes The drawable resource ID for the icon + * @param rotation The rotation angle in degrees (clockwise) + * @param modifier Modifier to be applied to the icon + * @param size The size of the icon (default 14.dp) + * @param brush Optional gradient brush to apply to the icon + * @param tint Optional static color to apply to the icon (used if brush is null) + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +fun GradientIconRotatable( + @DrawableRes iconRes: Int, + rotation: Float, + modifier: Modifier = Modifier, + size: Dp = 14.dp, + brush: Brush? = null, + tint: Color? = null +) { + val painter = painterResource(iconRes) + + if (brush != null) { + // Use Canvas to apply gradient with rotation and proper masking using layer + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.matchParentSize()) { + // Calculate the size that fits within the canvas while maintaining aspect ratio + val intrinsicSize = painter.intrinsicSize + val canvasSize = this.size + + val scale = min( + canvasSize.width / intrinsicSize.width, + canvasSize.height / intrinsicSize.height + ) + + val scaledWidth = intrinsicSize.width * scale + val scaledHeight = intrinsicSize.height * scale + + // Center the icon + val left = (canvasSize.width - scaledWidth) / 2 + val top = (canvasSize.height - scaledHeight) / 2 + + // Use saveLayer to isolate the blend mode operations + drawContext.canvas.nativeCanvas.apply { + val checkPoint = saveLayer(null, null) + + // Translate to center position + translate(left, top) { + // Rotate around the center of the icon + rotate( + degrees = rotation, + pivot = androidx.compose.ui.geometry.Offset( + scaledWidth / 2, + scaledHeight / 2 + ) + ) { + // Step 1: Draw the icon (this becomes our mask) + with(painter) { + draw( + size = Size(scaledWidth, scaledHeight), + alpha = 1f + ) + } + + // Step 2: Draw gradient with SrcIn blend mode + // This makes gradient only visible where icon pixels exist + drawRect( + brush = brush, + size = Size(scaledWidth, scaledHeight), + blendMode = BlendMode.SrcIn + ) + } + } + + restoreToCount(checkPoint) + } + } + } + } else { + // Use standard Icon with solid color tint and rotation + Box( + modifier = modifier.size(size), + contentAlignment = Alignment.Center + ) { + Canvas(modifier = Modifier.matchParentSize()) { + val intrinsicSize = painter.intrinsicSize + val canvasSize = this.size + + val scale = min( + canvasSize.width / intrinsicSize.width, + canvasSize.height / intrinsicSize.height + ) + + val scaledWidth = intrinsicSize.width * scale + val scaledHeight = intrinsicSize.height * scale + + val left = (canvasSize.width - scaledWidth) / 2 + val top = (canvasSize.height - scaledHeight) / 2 + + translate(left, top) { + rotate( + degrees = rotation, + pivot = androidx.compose.ui.geometry.Offset( + scaledWidth / 2, + scaledHeight / 2 + ) + ) { + with(painter) { + draw( + size = Size(scaledWidth, scaledHeight), + alpha = 1f, + colorFilter = tint?.let { + androidx.compose.ui.graphics.ColorFilter.tint(it) + } + ) + } + } + } + } + } + } +} + +/** + * Preview for RotatableGradientIcon with gradient brush at 45 degrees + */ +@Suppress("FunctionNaming", "MagicNumber") +@Preview(showBackground = true) +@Composable +fun PreviewRotatableGradientIconWithBrush() { + GradientIconRotatable( + iconRes = R.drawable.ic_wind_direction, + rotation = 45f, + size = 24.dp, + brush = Brush.horizontalGradient( + colors = listOf( + Color(0xFF2196F3), + Color(0xFFE91E63) + ) + ) + ) +} + +/** + * Preview for RotatableGradientIcon with static color at 90 degrees + */ +@Suppress("FunctionNaming", "MagicNumber") +@Preview(showBackground = true) +@Composable +fun PreviewRotatableGradientIconWithColor() { + GradientIconRotatable( + iconRes = R.drawable.ic_wind_direction, + rotation = 90f, + size = 24.dp, + tint = Color.Gray + ) +} diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt index 290a2a476..c11649f8d 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt @@ -2,8 +2,10 @@ package com.weatherxm.ui.devicedetails.forecast import android.view.LayoutInflater import android.view.ViewGroup +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource import androidx.compose.ui.unit.dp import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter @@ -17,12 +19,13 @@ import com.weatherxm.ui.common.UIForecastDay import com.weatherxm.ui.common.invisible import com.weatherxm.ui.common.setWeatherAnimation import com.weatherxm.ui.common.visible +import com.weatherxm.ui.components.compose.GradientIcon +import com.weatherxm.ui.components.compose.GradientIconRotatable import com.weatherxm.ui.components.compose.RoundedRangeView import com.weatherxm.util.DateTimeHelper.getRelativeDayAndMonthDay import com.weatherxm.util.NumberUtils.roundToDecimals import com.weatherxm.util.Resources import com.weatherxm.util.Weather -import com.weatherxm.util.Weather.getWindDirectionDrawable import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -131,14 +134,19 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) binding.maxTemperature.text = Weather.getFormattedTemperature(itemView.context, item.maxTemp) + // Setup precipProbabilityIcon if (item.precipProbability == null) { binding.precipProbabilityIcon.visible(false) binding.precipProbability.visible(false) } else { binding.precipProbability.text = Weather.getFormattedPrecipitationProbability(item.precipProbability) + binding.precipProbabilityIcon.setContent { + SetWeatherIcon(R.drawable.ic_weather_precip_probability) + } } + // Setup precipIcon if (item.precip == null) { binding.precipIcon.visible(false) binding.precip.visible(false) @@ -148,17 +156,61 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) value = item.precip, isRainRate = false ) + binding.precipIcon.setContent { + SetWeatherIcon(R.drawable.ic_weather_precipitation) + } } + // Setup windIcon binding.wind.text = Weather.getFormattedWind(itemView.context, item.windSpeed, item.windDirection) - binding.windIcon.setImageDrawable( - getWindDirectionDrawable(itemView.context, item.windDirection) - ) + binding.windIcon.setContent { + SetWindDirectionIcon(item.windDirection) + } + + // Setup humidityIcon binding.humidity.text = Weather.getFormattedHumidity(item.humidity) + binding.humidityIcon.setContent { + SetWeatherIcon(R.drawable.ic_weather_humidity) + } } } + @Composable + private fun SetWeatherIcon(iconRes: Int) { + GradientIcon( + iconRes = iconRes, + size = 14.dp, + brush = if (isOnPremiumTab) { + Brush.horizontalGradient( + colors = listOf( + colorResource(R.color.blue), + colorResource(R.color.forecast_premium) + ) + ) + } else null, + tint = if (!isOnPremiumTab) colorResource(R.color.darkGrey) else null + ) + } + + @Composable + private fun SetWindDirectionIcon(windDirection: Int?) { + GradientIconRotatable( + iconRes = R.drawable.ic_wind_direction, + rotation = (windDirection?.toFloat() ?: 0f) + 180f, + size = 14.dp, + brush = if (isOnPremiumTab) { + Brush.horizontalGradient( + colors = listOf( + colorResource(R.color.blue), + colorResource(R.color.forecast_premium) + ) + ) + } else null, + tint = if (!isOnPremiumTab) colorResource(R.color.darkGrey) else null + ) + } + class UIForecastDiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: UIForecastDay, newItem: UIForecastDay): Boolean { diff --git a/app/src/main/res/layout/list_item_forecast.xml b/app/src/main/res/layout/list_item_forecast.xml index 1be2f1b88..ee9cb5021 100644 --- a/app/src/main/res/layout/list_item_forecast.xml +++ b/app/src/main/res/layout/list_item_forecast.xml @@ -22,7 +22,6 @@ tools:text="Today, Tuesday 31/1" /> - + android:layout_height="14dp" /> - + android:layout_marginStart="@dimen/margin_small" /> - + android:layout_height="14dp" /> - + android:layout_marginStart="@dimen/margin_small" /> Date: Tue, 23 Dec 2025 22:41:44 +0200 Subject: [PATCH 23/60] Add gradient in the charts in forecast details in premium tab --- .../weatherxm/ui/components/LineChartView.kt | 41 +++++++++++++++++-- .../components/WeatherMeasurementCardView.kt | 39 ++++++++++++++++++ .../ForecastDetailsActivity.kt | 27 +++++++++--- app/src/main/res/layout/view_line_chart.xml | 23 +++++++---- .../layout/view_weather_measurement_card.xml | 6 +++ app/src/main/res/values-night/colors.xml | 4 +- 6 files changed, 121 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/components/LineChartView.kt b/app/src/main/java/com/weatherxm/ui/components/LineChartView.kt index 5b4f6c5ac..38ececac5 100644 --- a/app/src/main/java/com/weatherxm/ui/components/LineChartView.kt +++ b/app/src/main/java/com/weatherxm/ui/components/LineChartView.kt @@ -6,11 +6,17 @@ import android.graphics.Typeface import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout +import androidx.compose.material3.Icon +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import com.github.mikephil.charting.charts.LineChart import com.weatherxm.R import com.weatherxm.databinding.ViewLineChartBinding import com.weatherxm.ui.common.empty import com.weatherxm.ui.common.visible +import com.weatherxm.ui.components.compose.GradientIcon class LineChartView : LinearLayout { @@ -40,9 +46,15 @@ class LineChartView : LinearLayout { this.context.theme.obtainStyledAttributes(attrs, R.styleable.LineChartView, 0, 0).apply { try { binding.chartTitle.text = getString(R.styleable.LineChartView_line_chart_title) - binding.chartTitle.setCompoundDrawablesRelativeWithIntrinsicBounds( - getResourceId(R.styleable.LineChartView_line_chart_title_icon, 0), 0, 0, 0 - ) + val iconResourceId = + getResourceId(R.styleable.LineChartView_line_chart_title_icon, 0) + binding.chartIcon.setContent { + Icon( + painter = painterResource(iconResourceId), + contentDescription = null, + tint = colorResource(R.color.colorOnSurface) + ) + } getString(R.styleable.LineChartView_line_chart_primary_line_name)?.let { binding.primaryLineName.text = it @@ -122,6 +134,29 @@ class LineChartView : LinearLayout { binding.chart.setNoDataTextTypeface(Typeface.DEFAULT_BOLD) } + fun updateIcon(iconResourceId: Int, isPremium: Boolean) { + binding.chartIcon.setContent { + if (isPremium) { + GradientIcon( + iconRes = iconResourceId, + size = 25.dp, + brush = Brush.horizontalGradient( + colors = listOf( + colorResource(R.color.blue), + colorResource(R.color.forecast_premium) + ) + ) + ) + } else { + Icon( + painter = painterResource(iconResourceId), + contentDescription = null, + tint = colorResource(R.color.colorOnSurface) + ) + } + } + } + fun updateTitle(text: String) { binding.chartTitle.text = text } diff --git a/app/src/main/java/com/weatherxm/ui/components/WeatherMeasurementCardView.kt b/app/src/main/java/com/weatherxm/ui/components/WeatherMeasurementCardView.kt index 123581dc1..52ad51d1f 100644 --- a/app/src/main/java/com/weatherxm/ui/components/WeatherMeasurementCardView.kt +++ b/app/src/main/java/com/weatherxm/ui/components/WeatherMeasurementCardView.kt @@ -5,8 +5,14 @@ import android.graphics.drawable.Drawable import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.unit.dp import com.weatherxm.R import com.weatherxm.databinding.ViewWeatherMeasurementCardBinding +import com.weatherxm.ui.common.visible +import com.weatherxm.ui.components.compose.GradientIcon +import com.weatherxm.ui.components.compose.GradientIconRotatable class WeatherMeasurementCardView : LinearLayout { @@ -43,9 +49,42 @@ class WeatherMeasurementCardView : LinearLayout { } } + fun setGradientIcon(iconRes: Int?, windDirection: Int?, isRotatableWindIcon: Boolean) { + binding.gradientIcon.setContent { + if (isRotatableWindIcon) { + GradientIconRotatable( + iconRes = R.drawable.ic_wind_direction, + rotation = (windDirection?.toFloat() ?: 0f) + 180f, + size = 25.dp, + brush = Brush.horizontalGradient( + colors = listOf( + colorResource(R.color.blue), + colorResource(R.color.forecast_premium) + ) + ) + ) + } else if(iconRes != null) { + GradientIcon( + iconRes = iconRes, + size = 25.dp, + brush = Brush.horizontalGradient( + colors = listOf( + colorResource(R.color.blue), + colorResource(R.color.forecast_premium) + ) + ) + ) + } + } + binding.icon.visible(false) + binding.gradientIcon.visible(true) + } + fun setIcon(drawable: Drawable?) { drawable?.let { binding.icon.setImageDrawable(it) + binding.gradientIcon.visible(false) + binding.icon.visible(true) } } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 44613a829..3db380d96 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -223,15 +223,19 @@ class ForecastDetailsActivity : BaseActivity() { * Some data are missing in the Hyper Local tab so we handle it differently below. */ if (currentSelectedTab == 1) { - binding.dailyPremiumWind.setIcon( - getWindDirectionDrawable( - this, - forecastDay.windDirection - ) + binding.dailyPremiumWind.setGradientIcon( + iconRes = null, + windDirection = forecastDay.windDirection, + isRotatableWindIcon = true ) binding.dailyPremiumWind.setData( getFormattedWind(this, forecastDay.windSpeed, forecastDay.windDirection) ) + binding.dailyPremiumHumidity.setGradientIcon( + iconRes = R.drawable.ic_weather_humidity, + windDirection = null, + isRotatableWindIcon = false + ) binding.dailyPremiumHumidity.setData(getFormattedHumidity(forecastDay.humidity)) binding.dailyDefaultFirstRow.visible(false) binding.dailyDefaultSecondRow.visible(false) @@ -241,6 +245,7 @@ class ForecastDetailsActivity : BaseActivity() { getFormattedPrecipitationProbability(forecastDay.precipProbability) ) binding.windCard.setIcon(getWindDirectionDrawable(this, forecastDay.windDirection)) + binding.humidityCard.setIcon(getDrawable(R.drawable.ic_weather_humidity)) binding.windCard.setData( getFormattedWind( this, @@ -317,6 +322,18 @@ class ForecastDetailsActivity : BaseActivity() { chartSolar().updateTitle(getString(R.string.uv_index)) chartSolar().primaryLine(null, getString(R.string.uv_index)) chartSolar().secondaryLine(null, null) + chartTemperature().updateIcon( + R.drawable.ic_weather_temperature, + currentSelectedTab == 1 + ) + chartPrecipitation().updateIcon( + R.drawable.ic_weather_precipitation, + currentSelectedTab == 1 + ) + chartWind().updateIcon(R.drawable.ic_weather_wind, currentSelectedTab == 1) + chartHumidity().updateIcon(R.drawable.ic_weather_humidity, currentSelectedTab == 1) + chartPressure().updateIcon(R.drawable.ic_weather_pressure, currentSelectedTab == 1) + chartSolar().updateIcon(R.drawable.ic_weather_solar, currentSelectedTab == 1) binding.dailyMainCard.setOnClickListener { scrollToChart(chartTemperature()) } binding.precipProbabilityCard.setOnClickListener { scrollToChart(chartPrecipitation()) } binding.dailyPrecipCard.setOnClickListener { scrollToChart(chartPrecipitation()) } diff --git a/app/src/main/res/layout/view_line_chart.xml b/app/src/main/res/layout/view_line_chart.xml index aa94de9c9..5a576c5a4 100644 --- a/app/src/main/res/layout/view_line_chart.xml +++ b/app/src/main/res/layout/view_line_chart.xml @@ -29,20 +29,25 @@ android:layout_height="wrap_content" app:layout_constraintTop_toTopOf="parent"> + + + + @color/dark_primary - @color/dark_top - @color/layer1 + @color/layer1 + @color/dark_top #CD9EFC From cf678479ffa674243160e1007ec1849a42ee5d20 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Tue, 23 Dec 2025 22:58:58 +0200 Subject: [PATCH 24/60] Fix the forecast tab color at onResume --- .../components/compose/GradientIconRotatable.kt | 4 ++-- .../ui/components/compose/RoundedRangeView.kt | 2 +- .../ui/devicedetails/DeviceDetailsActivity.kt | 16 ++++++++++++---- .../forecast/DailyForecastAdapter.kt | 2 ++ .../forecastdetails/ForecastDetailsActivity.kt | 5 ++++- 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/GradientIconRotatable.kt b/app/src/main/java/com/weatherxm/ui/components/compose/GradientIconRotatable.kt index 96089077a..7a472cd41 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/GradientIconRotatable.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/GradientIconRotatable.kt @@ -22,7 +22,7 @@ import com.weatherxm.R import kotlin.math.min /** - * A reusable icon composable that can be rotated and tinted with either a gradient brush or a static color. + * A reusable icon that can be rotated and tinted with either a gradient brush or a static color. * Useful for icons that need to be oriented based on data (e.g., wind direction). * * @param iconRes The drawable resource ID for the icon @@ -32,7 +32,7 @@ import kotlin.math.min * @param brush Optional gradient brush to apply to the icon * @param tint Optional static color to apply to the icon (used if brush is null) */ -@Suppress("FunctionNaming", "LongParameterList") +@Suppress("LongMethod", "FunctionNaming", "LongParameterList") @Composable fun GradientIconRotatable( @DrawableRes iconRes: Int, diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt b/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt index 11952ebf4..ced9779c4 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.weatherxm.R -@Suppress("FunctionNaming") +@Suppress("FunctionNaming", "LongParameterList") @Composable fun RoundedRangeView( height: Dp, diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt index c04cf8b62..aff771a1d 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt @@ -169,6 +169,17 @@ class DeviceDetailsActivity : BaseActivity() { if (billingService.hasActiveSub()) { binding.navigatorGroup.getTabAt(FORECAST_TAB_POSITION) ?.setCustomView(R.layout.view_forecast_premium_tab) + + if(binding.viewPager.currentItem == FORECAST_TAB_POSITION) { + binding.navigatorGroup.getTabAt(FORECAST_TAB_POSITION)?.customView?.apply { + findViewById( + R.id.forecastIcon + )?.setColor(R.color.forecast_premium) + findViewById(R.id.forecastTitle)?.setTextColor( + getColor(R.color.forecast_premium) + ) + } + } } if (model.device.relation != DeviceRelation.OWNED) { analytics.trackScreen( @@ -192,10 +203,7 @@ class DeviceDetailsActivity : BaseActivity() { val premiumColor = getColor(R.color.forecast_premium) if (billingService.hasActiveSub()) { setSelectedTabIndicatorColor(premiumColor) - setTabTextColors( - context.getColor(R.color.darkGrey), - context.getColor(R.color.forecast_premium) - ) + setTabTextColors(context.getColor(R.color.darkGrey), premiumColor) } else { /** * Revert the tab's color to the default non-selected ones. diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt index c11649f8d..712cccec1 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt @@ -176,6 +176,7 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) } } + @Suppress("FunctionNaming") @Composable private fun SetWeatherIcon(iconRes: Int) { GradientIcon( @@ -193,6 +194,7 @@ class DailyForecastAdapter(private val onClickListener: (UIForecastDay) -> Unit) ) } + @Suppress("FunctionNaming") @Composable private fun SetWindDirectionIcon(windDirection: Int?) { GradientIconRotatable( diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 3db380d96..5fa5bb2a4 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -182,6 +182,7 @@ class ForecastDetailsActivity : BaseActivity() { } } + @Suppress("LongMethod") private fun updateUI(forecast: UIForecast, selectedDayPosition: Int) { val forecastDay = forecast.forecastDays[selectedDayPosition] // Update the header now that model.address has valid data and we are in a location @@ -209,7 +210,9 @@ class ForecastDetailsActivity : BaseActivity() { // Update the forecast tabs or the mosaic prompt if (!model.device.isEmpty()) { - binding.forecastTabSelector.visible(forecast.isPremium == true || currentSelectedTab == 1) + binding.forecastTabSelector.visible( + forecast.isPremium == true || currentSelectedTab == 1 + ) binding.mosaicPromotionCard.visible(forecast.isPremium == false) } From a533ba20638e20bb637919b4d453cee7f2e6a8fc Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 24 Dec 2025 15:11:27 +0200 Subject: [PATCH 25/60] Replace the DailyTileForecastAdapter with a composable one, in order to better adjust the border when in hyperlocal tab --- app/build.gradle.kts | 1 + .../components/compose/DailyTileForecast.kt | 254 ++++++++++++++++++ .../DailyTileForecastAdapter.kt | 97 ------- .../ForecastDetailsActivity.kt | 45 ++-- .../res/layout/activity_forecast_details.xml | 19 +- .../fragment_device_details_forecast.xml | 3 +- .../layout/list_item_daily_tile_forecast.xml | 63 ----- gradle/libs.versions.toml | 1 + 8 files changed, 285 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt delete mode 100644 app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt delete mode 100644 app/src/main/res/layout/list_item_daily_tile_forecast.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8d1720a4..366d98049 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -564,6 +564,7 @@ dependencies { // Animations implementation(libs.lottie) + implementation(libs.lottie.compose) // Charts implementation(libs.mpAndroidCharts) diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt b/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt new file mode 100644 index 000000000..051324a07 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt @@ -0,0 +1,254 @@ +package com.weatherxm.ui.components.compose + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.airbnb.lottie.compose.LottieAnimation +import com.airbnb.lottie.compose.LottieCompositionSpec +import com.airbnb.lottie.compose.LottieConstants +import com.airbnb.lottie.compose.rememberLottieComposition +import com.weatherxm.R +import com.weatherxm.ui.common.UIForecastDay +import com.weatherxm.util.Weather +import com.weatherxm.util.getShortName +import java.time.LocalDate + +@Suppress("FunctionNaming", "LongMethod") +@Composable +fun DailyTileForecast( + forecastDays: List, + selectedDate: LocalDate, + isPremiumTabSelected: Boolean, + onDaySelected: (LocalDate) -> Unit +) { + var currentSelectedDate by rememberSaveable { mutableStateOf(selectedDate) } + var selectedPosition by rememberSaveable { mutableIntStateOf(0) } + val listState = rememberLazyListState() + + // Find initial selected position + LaunchedEffect(forecastDays, selectedDate) { + val position = forecastDays.indexOfFirst { it.date == selectedDate } + if (position != -1) { + selectedPosition = position + currentSelectedDate = selectedDate + } + } + + LazyRow( + state = listState, + contentPadding = PaddingValues(horizontal = dimensionResource(R.dimen.padding_normal)), + horizontalArrangement = Arrangement.Start + ) { + itemsIndexed( + items = forecastDays, + key = { _, item -> item.date.toString() } + ) { index, forecastDay -> + val isSelected = currentSelectedDate == forecastDay.date + + DailyTileItem( + forecastDay = forecastDay, + isSelected = isSelected, + isPremiumTabSelected = isPremiumTabSelected, + onClick = { + if (currentSelectedDate != forecastDay.date) { + currentSelectedDate = forecastDay.date + selectedPosition = index + + onDaySelected(forecastDay.date) + } + } + ) + + // Add spacing between items (except after last item) + if (index < forecastDays.size - 1) { + Spacer(modifier = Modifier.width(dimensionResource(R.dimen.margin_normal))) + } + } + } + + // Auto-scroll to selected item with centering + LaunchedEffect(selectedPosition) { + if (selectedPosition in forecastDays.indices) { + // Calculate offset to center the item + listState.animateScrollToItem( + index = selectedPosition, + scrollOffset = 0 + ) + } + } +} + +@Suppress("FunctionNaming") +@Composable +private fun DailyTileItem( + forecastDay: UIForecastDay, + isSelected: Boolean, + isPremiumTabSelected: Boolean, + onClick: () -> Unit +) { + val context = LocalContext.current + val shape = RoundedCornerShape(dimensionResource(R.dimen.radius_large)) + val borderModifier = if (isSelected) { + if (isPremiumTabSelected) { + Modifier.border( + width = 1.dp, + brush = Brush.horizontalGradient( + colors = listOf( + colorResource(R.color.blue), + colorResource(R.color.forecast_premium) + ) + ), + shape = shape + ) + } else { + Modifier.border( + width = 1.dp, + color = colorResource(R.color.colorPrimary), + shape = shape + ) + } + } else { + Modifier + } + + Surface( + modifier = borderModifier, + onClick = onClick, + shape = shape, + color = if (isSelected) { + colorResource(R.color.daily_selected_tile) + } else { + colorResource(R.color.daily_unselected_tile) + }, + shadowElevation = dimensionResource(R.dimen.elevation_normal) + ) { + Column( + modifier = Modifier + .padding( + horizontal = dimensionResource(R.dimen.padding_normal), + vertical = dimensionResource(R.dimen.padding_small) + ), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = context.getString(forecastDay.date.dayOfWeek.getShortName()), + modifier = Modifier.padding(bottom = dimensionResource(R.dimen.margin_small)), + style = MaterialTheme.typography.bodySmall, + color = colorResource(R.color.darkestBlue), + textAlign = TextAlign.Center + ) + + val animationComposition by rememberLottieComposition( + LottieCompositionSpec.RawRes(Weather.getWeatherAnimation(forecastDay.icon)) + ) + + LottieAnimation( + composition = animationComposition, + iterations = LottieConstants.IterateForever, + modifier = Modifier.size(40.dp) + ) + + Text( + text = Weather.getFormattedTemperature(context, forecastDay.maxTemp), + modifier = Modifier.padding(top = dimensionResource(R.dimen.margin_small)), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = colorResource(R.color.darkestBlue), + textAlign = TextAlign.Center + ) + + Text( + text = Weather.getFormattedTemperature(context, forecastDay.minTemp), + style = MaterialTheme.typography.bodySmall, + color = colorResource(R.color.darkestBlue), + textAlign = TextAlign.Center + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewDailyTileForecast() { + val forecastDays = listOf( + UIForecastDay( + date = LocalDate.now(), + icon = "clear-day", + minTemp = 15.4f, + maxTemp = 25.6f, + precipProbability = 20, + precip = 0.5f, + windSpeed = 10.5f, + windDirection = 180, + humidity = 65, + pressure = 1013.25f, + uv = 5, + hourlyWeather = null + ), + UIForecastDay( + date = LocalDate.now().plusDays(1), + icon = "partly-cloudy-day", + minTemp = 14.2f, + maxTemp = 23.8f, + precipProbability = 30, + precip = 1.2f, + windSpeed = 12.0f, + windDirection = 200, + humidity = 70, + pressure = 1012.5f, + uv = 4, + hourlyWeather = null + ), + UIForecastDay( + date = LocalDate.now().plusDays(2), + icon = "rain", + minTemp = 12.0f, + maxTemp = 18.5f, + precipProbability = 80, + precip = 5.5f, + windSpeed = 15.5f, + windDirection = 220, + humidity = 85, + pressure = 1010.0f, + uv = 2, + hourlyWeather = null + ) + ) + + DailyTileForecast( + forecastDays = forecastDays, + selectedDate = LocalDate.now(), + isPremiumTabSelected = true, + onDaySelected = { _ -> } + ) +} diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt deleted file mode 100644 index 718b660f1..000000000 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt +++ /dev/null @@ -1,97 +0,0 @@ -package com.weatherxm.ui.forecastdetails - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.weatherxm.R -import com.weatherxm.databinding.ListItemDailyTileForecastBinding -import com.weatherxm.ui.common.UIForecastDay -import com.weatherxm.ui.common.setCardStroke -import com.weatherxm.ui.common.setWeatherAnimation -import com.weatherxm.util.Weather.getFormattedTemperature -import com.weatherxm.util.getShortName -import java.time.LocalDate - -class DailyTileForecastAdapter( - private var selectedDate: LocalDate, - private val onNewSelectedPosition: (Int, Int) -> Unit, - private val onClickListener: (Int) -> Unit -) : ListAdapter( - UIForecastDayDiffCallback() -) { - - private var selectedPosition = 0 - - fun getSelectedPosition(): Int = selectedPosition - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DailyTileViewHolder { - val binding = ListItemDailyTileForecastBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return DailyTileViewHolder(binding) - } - - override fun onBindViewHolder(holder: DailyTileViewHolder, position: Int) { - holder.bind(getItem(position), position) - } - - inner class DailyTileViewHolder(private val binding: ListItemDailyTileForecastBinding) : - RecyclerView.ViewHolder(binding.root) { - - fun bind(item: UIForecastDay, position: Int) { - binding.root.setOnClickListener { - onClickListener.invoke(position) - selectedDate = item.date - checkSelectionStatus(item, position) - } - - checkSelectionStatus(item, position) - - binding.timestamp.text = itemView.context.getString(item.date.dayOfWeek.getShortName()) - binding.icon.setWeatherAnimation(item.icon) - binding.temperaturePrimary.text = - getFormattedTemperature(itemView.context, item.maxTemp) - binding.temperatureSecondary.text = - getFormattedTemperature(itemView.context, item.minTemp) - } - - private fun checkSelectionStatus(item: UIForecastDay, position: Int) { - if (selectedDate == item.date) { - selectedPosition = position - binding.root.setCardBackgroundColor( - itemView.context.getColor(R.color.daily_selected_tile) - ) - binding.root.setCardStroke(R.color.colorPrimary, 2) - onNewSelectedPosition.invoke(position, binding.root.width) - } else { - binding.root.setCardBackgroundColor( - itemView.context.getColor(R.color.daily_unselected_tile) - ) - binding.root.strokeWidth = 0 - } - } - } - - class UIForecastDayDiffCallback : DiffUtil.ItemCallback() { - - override fun areItemsTheSame(oldItem: UIForecastDay, newItem: UIForecastDay): Boolean { - return oldItem == newItem - } - - override fun areContentsTheSame(oldItem: UIForecastDay, newItem: UIForecastDay): Boolean { - return oldItem.date == newItem.date && - oldItem.icon == newItem.icon && - oldItem.minTemp == newItem.minTemp && - oldItem.maxTemp == newItem.maxTemp && - oldItem.precip == newItem.precip && - oldItem.precipProbability == newItem.precipProbability && - oldItem.windSpeed == newItem.windSpeed && - oldItem.windDirection == newItem.windDirection && - oldItem.humidity == newItem.humidity - } - } -} diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 5fa5bb2a4..f724253c0 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -19,7 +19,6 @@ import com.weatherxm.ui.common.UIForecastDay import com.weatherxm.ui.common.UILocation import com.weatherxm.ui.common.capitalizeWords import com.weatherxm.ui.common.classSimpleName -import com.weatherxm.ui.common.moveItemToCenter import com.weatherxm.ui.common.parcelable import com.weatherxm.ui.common.screenLocation import com.weatherxm.ui.common.setColor @@ -29,6 +28,7 @@ import com.weatherxm.ui.common.toast import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseActivity import com.weatherxm.ui.components.LineChartView +import com.weatherxm.ui.components.compose.DailyTileForecast import com.weatherxm.ui.components.compose.ForecastTabSelector import com.weatherxm.ui.components.compose.HeaderView import com.weatherxm.ui.components.compose.JoinNetworkPromoCard @@ -61,7 +61,6 @@ class ForecastDetailsActivity : BaseActivity() { ) } - private lateinit var dailyAdapter: DailyTileForecastAdapter private lateinit var hourlyAdapter: HourlyForecastAdapter private var currentSelectedTab = 0 @@ -363,28 +362,28 @@ class ForecastDetailsActivity : BaseActivity() { } private fun setupDailyAdapter(forecast: UIForecast, selectedDayPosition: Int) { - val forecastDay = forecast.forecastDays[selectedDayPosition] - dailyAdapter = DailyTileForecastAdapter( - forecastDay.date, - onNewSelectedPosition = { position, width -> - binding.dailyTilesRecycler.moveItemToCenter(position, binding.root.width, width) - }, - onClickListener = { newSelectedDayPosition -> - analytics.trackEventSelectContent( - AnalyticsService.ParamValue.DAILY_CARD.paramValue, - Pair( - FirebaseAnalytics.Param.ITEM_ID, - AnalyticsService.ParamValue.DAILY_DETAILS.paramValue + binding.dailyTilesCompose.setContent { + DailyTileForecast( + forecastDays = forecast.forecastDays, + selectedDate = forecast.forecastDays[selectedDayPosition].date, + isPremiumTabSelected = currentSelectedTab == 1, + onDaySelected = { selectedDate -> + analytics.trackEventSelectContent( + AnalyticsService.ParamValue.DAILY_CARD.paramValue, + Pair( + FirebaseAnalytics.Param.ITEM_ID, + AnalyticsService.ParamValue.DAILY_DETAILS.paramValue + ) ) - ) - // Get selected position before we update it in order to reset the stroke - dailyAdapter.notifyItemChanged(dailyAdapter.getSelectedPosition()) - updateUI(forecast, newSelectedDayPosition) - } - ) - binding.dailyTilesRecycler.adapter = dailyAdapter - dailyAdapter.submitList(forecast.forecastDays) - binding.dailyTilesRecycler.scrollToPosition(selectedDayPosition) + val newSelectedDayPosition = forecast.forecastDays.indexOfFirst { + it.date == selectedDate + } + if (newSelectedDayPosition != -1) { + updateUI(forecast, newSelectedDayPosition) + } + } + ) + } } private fun handleOwnershipIcon() { diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index 919c3777f..a86e9ad4c 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -116,19 +116,13 @@ app:layout_constraintTop_toBottomOf="@id/tabsOrMosaicPromptContainer" tools:visibility="visible"> - + tools:composableName="com.weatherxm.ui.components.compose.DailyTileForecastKt.PreviewDailyTileForecast" /> + app:layout_constraintTop_toBottomOf="@id/dailyTilesCompose" /> + android:textAppearance="@style/TextAppearance.WeatherXM.Default.BodySmall" /> + android:textAppearance="@style/TextAppearance.WeatherXM.Default.BodySmall" /> - - - - - - - - - - - - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 75aac446c..2301d8df5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -137,6 +137,7 @@ kotest-assertions = { module = "io.kotest:kotest-assertions-core-jvm", version.r kotest-koin = { module = "io.kotest.extensions:kotest-extensions-koin-jvm", version.ref = "kotest-koin" } kpermissions = { module = "com.github.fondesa:kpermissions", version.ref = "kpermissions" } lottie = { module = "com.airbnb.android:lottie", version.ref = "lottie" } +lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "lottie" } mapbox = { module = "com.mapbox.maps:android-ndk27", version.ref = "mapbox" } mapbox-sdk-services = { module = "com.mapbox.mapboxsdk:mapbox-sdk-services", version.ref = "mapbox-sdk-services" } mapbox-search-android = { module = "com.mapbox.search:mapbox-search-android-ndk27", version.ref = "mapbox-search-android" } From 7bf707625efeb4471676592cf028a7fd987e6fa0 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Fri, 9 Jan 2026 00:01:06 +0200 Subject: [PATCH 26/60] Change the prompt CTA text --- app/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a18a307d5..f2878c0a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -909,7 +909,7 @@ You are %s $WXM away to unlock a free trial. Keep up! Smarter. Sharper. More Accurate. We’ve picked the top performed models in your area to give you the most accurate forecast possible. - See the plans + Upgrade to Premium You have a free subscription. Claim now! Powered by Current plan From 3b823ca2953bb1ffc6c79014d169ee69a1fd3fa5 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Sat, 10 Jan 2026 14:28:24 +0200 Subject: [PATCH 27/60] Update the texts in the profile tab --- app/src/main/java/com/weatherxm/ui/common/UIModels.kt | 10 +--------- .../ui/components/compose/MosaicPromotionCard.kt | 2 +- .../com/weatherxm/ui/home/profile/ProfileFragment.kt | 7 +------ app/src/main/res/values/strings.xml | 8 ++++---- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt index eed30e4c3..e8cdaa1ad 100644 --- a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt +++ b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt @@ -22,7 +22,6 @@ import com.weatherxm.data.models.Reward import com.weatherxm.data.models.RewardSplit import com.weatherxm.data.models.SeverityLevel import com.weatherxm.data.repository.RewardsRepositoryImpl -import com.weatherxm.util.NumberUtils.formatTokens import com.weatherxm.util.NumberUtils.toBigDecimalSafe import com.weatherxm.util.NumberUtils.weiToETH import kotlinx.parcelize.Parcelize @@ -268,7 +267,7 @@ data class UIForecast( val forecastDays: List ) : Parcelable { companion object { - fun empty() = UIForecast(String.empty(), null,mutableListOf(), mutableListOf()) + fun empty() = UIForecast(String.empty(), null, mutableListOf(), mutableListOf()) } fun isEmpty(): Boolean = next24Hours.isNullOrEmpty() && forecastDays.isEmpty() @@ -432,13 +431,6 @@ data class UIWalletRewards( fun hasUnclaimedTokensForFreeTrial(): Boolean { return weiToETH(allocated.toBigDecimalSafe()) >= BigDecimal.valueOf(80.0) } - - @Suppress("MagicNumber") - fun remainingTokensForFreeTrial(): String { - val tokensDifference = BigDecimal.valueOf(80.0) - weiToETH(allocated.toBigDecimalSafe()) - return formatTokens(tokensDifference) - } - } @Keep diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt index 129d07d2c..af8f81920 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt @@ -87,7 +87,7 @@ fun MosaicPromotionCard(hasFreeSubAvailable: Boolean, onClickListener: () -> Uni ) ) { LargeText( - text = stringResource(R.string.see_the_plans), + text = stringResource(R.string.upgrade_to_premium), fontWeight = FontWeight.Bold, fontSize = 18.sp, colorRes = R.color.colorOnPrimary diff --git a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt index 37759ce7e..37fe3dc71 100644 --- a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt @@ -343,12 +343,7 @@ class ProfileFragment : BaseFragment() { drawable = R.drawable.ic_crown, drawableTint = R.color.colorPrimary, title = R.string.free_trial_locked, - subtitle = SubtitleForMessageView( - messageAsString = getString( - R.string.free_trial_locked_subtitle, - it.remainingTokensForFreeTrial() - ) - ) + subtitle = SubtitleForMessageView(R.string.free_trial_locked_subtitle) ) ) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2878c0a3..cd62a0914 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -903,10 +903,10 @@ Premium subscription See and manage your subscription - Claim your free trial! - You’ve kept at least 80 $WXM unclaimed. - Free trial locked - You are %s $WXM away to unlock a free trial. Keep up! + Claim your Premium free trial! + You have 80+ unclaimed $WXM. Start your Premium free trial now! + You’re close to a free Premium trial + Unlock a free Premium trial at 80 unclaimed $WXM. Keep your $WXM unclaimed to qualify. Smarter. Sharper. More Accurate. We’ve picked the top performed models in your area to give you the most accurate forecast possible. Upgrade to Premium From b1f2a63cd589fce0f10a960e1424f32b74a5034a Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Mon, 12 Jan 2026 14:06:35 +0200 Subject: [PATCH 28/60] Redesign the top bar, the bottom bar and add the selector at the top of the screen --- .../compose/SubscriptionTabSelector.kt | 132 ++++++++++++++++++ .../ManageSubscriptionActivity.kt | 30 ++++ .../ui/managesubscription/PlansView.kt | 2 +- .../layout/activity_manage_subscription.xml | 118 ++++++++++++---- app/src/main/res/values/strings.xml | 9 +- 5 files changed, 262 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt b/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt new file mode 100644 index 000000000..9b78a5a96 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt @@ -0,0 +1,132 @@ +package com.weatherxm.ui.components.compose + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.weatherxm.R + +/** + * A reusable tab selector component with stateful tab management. + * + * @param defaultSelectedIndex Index of the initially selected tab (default: 0) + * @param onTabSelected Callback invoked when a tab is selected, providing the index and label + */ +@Suppress("FunctionNaming", "MagicNumber") +@Composable +fun SubscriptionTabSelector( + defaultSelectedIndex: Int, + onTabSelected: (Int) -> Unit +) { + var selectedIndex by remember { mutableIntStateOf(defaultSelectedIndex) } + + Card( + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.colorSurface) + ), + elevation = CardDefaults.cardElevation( + defaultElevation = dimensionResource(R.dimen.elevation_normal) + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.padding_extra_small)), + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.padding_extra_small) + ) + ) { + TabItem( + label = stringResource(R.string.monthly), + isSelected = selectedIndex == 0, + onClick = { + selectedIndex = 0 + onTabSelected(0) + } + ) + TabItem( + label = stringResource(R.string.annual), + isSelected = selectedIndex == 1, + onClick = { + selectedIndex = 1 + onTabSelected(1) + } + ) + } + } +} + +@Suppress("FunctionNaming", "MagicNumber") +@Composable +private fun RowScope.TabItem( + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .fillMaxHeight() + .weight(1F) + .clickable { onClick() }, + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) { + colorResource(R.color.blueTint) + } else { + Color.Transparent + } + ), + ) { + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + color = if (isSelected) { + colorResource(R.color.textColor) + } else { + colorResource(R.color.darkGrey) + }, + ) + } + } +} + +@Suppress("FunctionNaming") +@Preview(showBackground = true) +@Composable +fun PreviewSubscriptionTabSelector() { + SubscriptionTabSelector(defaultSelectedIndex = 1) { } +} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index ff6768181..74ff2265b 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -15,6 +15,7 @@ import com.weatherxm.ui.common.PurchaseUpdateState import com.weatherxm.ui.common.classSimpleName import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseActivity +import com.weatherxm.ui.components.compose.SubscriptionTabSelector import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -26,6 +27,7 @@ class ManageSubscriptionActivity : BaseActivity() { private var hasFreeTrialAvailable = false private var isLoggedIn = false + private var currentSelectedTab = 0 init { lifecycleScope.launch { @@ -46,11 +48,32 @@ class ManageSubscriptionActivity : BaseActivity() { } } + binding.subscriptionTabSelector.setContent { + SubscriptionTabSelector(1) { newSelectedTab -> + currentSelectedTab = newSelectedTab + if (newSelectedTab == 0) { + // TODO: Update the UI + } else { + // TODO: Update the UI + } + } + } + if (it == null || !it.isAutoRenewing) { + binding.toolbar.title = getString(R.string.upgrade_to_premium) binding.premiumFeaturesComposable.visible(true) + binding.cancelAnytimeText.visible(true) + binding.subscriptionTabSelector.visible(true) } else { + binding.toolbar.title = getString(R.string.manage_subscription) binding.premiumFeaturesComposable.visible(false) + binding.cancelAnytimeText.visible(false) + binding.subscriptionTabSelector.visible(false) } + binding.toolbar.subtitle = + getString(R.string.get_the_most_accurate_forecasts) + + // TODO: STOPSHIP: Change button text, color & icon based on user selection } } @@ -105,6 +128,8 @@ class ManageSubscriptionActivity : BaseActivity() { binding.backBtn.setOnClickListener { binding.currentPlanComposable.visible(true) binding.appBar.visible(true) + binding.topDivider.visible(true) + binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) binding.selectPlanComposable.visible(false) binding.statusView.visible(false) @@ -129,6 +154,7 @@ class ManageSubscriptionActivity : BaseActivity() { private fun onPurchaseUpdate(state: PurchaseUpdateState) { if (state.isLoading) { binding.appBar.visible(false) + binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.selectPlanComposable.visible(false) binding.successBtn.visible(false) @@ -139,7 +165,9 @@ class ManageSubscriptionActivity : BaseActivity() { binding.successBtn.visible(false) binding.errorButtonsContainer.visible(false) binding.appBar.visible(true) + binding.topDivider.visible(true) binding.currentPlanComposable.visible(true) + binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) billingService.clearPurchaseUpdates() analytics.trackEventViewContent( @@ -148,6 +176,7 @@ class ManageSubscriptionActivity : BaseActivity() { ) } else if (state.success) { binding.appBar.visible(false) + binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.selectPlanComposable.visible(false) binding.errorButtonsContainer.visible(false) @@ -164,6 +193,7 @@ class ManageSubscriptionActivity : BaseActivity() { ) } else { binding.appBar.visible(false) + binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.selectPlanComposable.visible(false) binding.statusView.clear() diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt index 11dcb768e..3a8af098a 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt @@ -135,7 +135,7 @@ private fun Plan( SmallText( text = when (sub.id) { PLAN_MONTHLY -> stringResource(R.string.monthly) - PLAN_YEARLY -> stringResource(R.string.annually) + PLAN_YEARLY -> stringResource(R.string.annual) else -> sub.id }, colorRes = R.color.darkGrey diff --git a/app/src/main/res/layout/activity_manage_subscription.xml b/app/src/main/res/layout/activity_manage_subscription.xml index 382002ebe..53365f157 100644 --- a/app/src/main/res/layout/activity_manage_subscription.xml +++ b/app/src/main/res/layout/activity_manage_subscription.xml @@ -6,6 +6,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:animateLayoutChanges="true" + android:background="@color/blueTint" android:fitsSystemWindows="true"> + app:navigationIcon="@drawable/ic_close" + app:subtitleTextColor="@color/colorPrimary" /> - + + - + android:clipToPadding="false" + app:layout_constraintTop_toTopOf="parent" + tools:composableName="com.weatherxm.ui.components.compose.SubscriptionTabSelectorKt.PreviewSubscriptionTabSelector" /> - + - + android:layout_height="match_parent" + android:clipChildren="false" + android:clipToPadding="false"> + + + + + + + - - + + + + + + + android:weightSum="2"> Go to Station Purchase Failed
Please make sure to mention that you’re facing an Error %s for faster resolution.]]>
- MONTHLY + Monthly then %s per month. then %s per year. - Cancel anytime. - ANNUALLY + Annual /month /year Annual premium @@ -945,4 +944,8 @@ You need to login to get premium forecast. Basic Forecast Hyperlocal + Get the most accurate forecasts + Cancel anytime. No long-term commitments. + You are currently on Premium Plan. + Downgrade to Free Plan From 71bc34d03576ef8deb5c5376d0302663478a0d5a Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Tue, 13 Jan 2026 13:48:54 +0200 Subject: [PATCH 29/60] Redesign the subscription management screen --- .../com/weatherxm/service/BillingService.kt | 18 +- .../compose/SubscriptionTabSelector.kt | 4 + .../ui/managesubscription/CurrentPlanView.kt | 198 ---------------- .../ui/managesubscription/FreePlanView.kt | 161 +++++++++++++ .../ManageSubscriptionActivity.kt | 181 +++++++++++---- .../ui/managesubscription/PlansView.kt | 202 ---------------- .../managesubscription/PremiumFeaturesView.kt | 130 ----------- .../ui/managesubscription/PremiumPlanView.kt | 219 ++++++++++++++++++ .../layout/activity_manage_subscription.xml | 50 +--- app/src/main/res/values-night/colors.xml | 2 + app/src/main/res/values/colors.xml | 2 + app/src/main/res/values/palette.xml | 2 + app/src/main/res/values/strings.xml | 22 +- 13 files changed, 562 insertions(+), 629 deletions(-) delete mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/CurrentPlanView.kt create mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt delete mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt delete mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/PremiumFeaturesView.kt create mode 100644 app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt index 67af7921a..003e1d9a4 100644 --- a/app/src/main/java/com/weatherxm/service/BillingService.kt +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -120,14 +120,24 @@ class BillingService( fun getActiveSubFlow(): StateFlow = activeSubFlow - fun getAvailableSubs(hasFreeTrialAvailable: Boolean): List { + fun getMonthlyAvailableSub(hasFreeTrialAvailable: Boolean): SubscriptionOffer? { return if (hasFreeTrialAvailable) { subs.filter { - it.offerId == OFFER_FREE_TRIAL || it.offerId == null + it.offerId == OFFER_FREE_TRIAL || it.offerId == null && it.id == PLAN_MONTHLY }.distinctBy { it.id } } else { - subs.filter { it.offerId == null }.distinctBy { it.id } - } + subs.filter { it.offerId == null && it.id == PLAN_MONTHLY }.distinctBy { it.id } + }.firstOrNull() + } + + fun getAnnualAvailableSub(hasFreeTrialAvailable: Boolean): SubscriptionOffer? { + return if (hasFreeTrialAvailable) { + subs.filter { + it.offerId == OFFER_FREE_TRIAL || it.offerId == null && it.id == PLAN_MONTHLY + }.distinctBy { it.id } + } else { + subs.filter { it.offerId == null && it.id == PLAN_MONTHLY }.distinctBy { it.id } + }.firstOrNull() } private fun startConnection() { diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt b/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt index 9b78a5a96..148853340 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt @@ -47,6 +47,10 @@ fun SubscriptionTabSelector( Card( modifier = Modifier .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.margin_large), + vertical = dimensionResource(R.dimen.margin_normal) + ) .height(50.dp), shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), colors = CardDefaults.cardColors( diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/CurrentPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/CurrentPlanView.kt deleted file mode 100644 index 6eb861099..000000000 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/CurrentPlanView.kt +++ /dev/null @@ -1,198 +0,0 @@ -package com.weatherxm.ui.managesubscription - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.android.billingclient.api.Purchase -import com.weatherxm.R -import com.weatherxm.ui.components.compose.LargeText -import com.weatherxm.ui.components.compose.MediumText - -@Suppress("FunctionNaming", "LongMethod") -@Composable -fun CurrentPlanView(currentPurchase: Purchase?, onManageSubscription: () -> Unit) { - Column( - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) - ) { - LargeText( - text = stringResource(R.string.current_plan), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - Card( - colors = CardDefaults.cardColors( - containerColor = if (currentPurchase?.isAutoRenewing == false) { - colorResource(R.color.errorTint) - } else { - colorResource(R.color.colorSurface) - } - ), - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small)), - elevation = CardDefaults.cardElevation( - dimensionResource(R.dimen.elevation_small) - ) - ) { - Column( - modifier = Modifier.padding(dimensionResource(R.dimen.padding_normal)), - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_large)) - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - LargeText( - text = if (currentPurchase == null) { - stringResource(R.string.standard) - } else { - stringResource(R.string.premium_forecast) - }, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - Card( - colors = CardDefaults.cardColors( - containerColor = if (currentPurchase?.isAutoRenewing == false) { - colorResource(R.color.error) - } else { - colorResource(R.color.colorPrimary) - } - ) - ) { - Text( - text = if (currentPurchase?.isAutoRenewing == false) { - stringResource(R.string.canceled) - } else { - stringResource(R.string.active) - }, - color = if (currentPurchase?.isAutoRenewing == false) { - colorResource(R.color.colorOnSurface) - } else { - colorResource(R.color.colorOnPrimary) - }, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding( - horizontal = dimensionResource(R.dimen.padding_small_to_normal), - vertical = dimensionResource(R.dimen.padding_extra_small) - ) - ) - } - } - - if (currentPurchase == null) { - MediumText( - text = stringResource(R.string.just_the_basics), - colorRes = R.color.darkGrey - ) - } else if (!currentPurchase.isAutoRenewing) { - MediumText( - text = stringResource(R.string.canceled_subtitle), - colorRes = R.color.darkGrey - ) - } else { - Column( - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) - ) { - Row( - horizontalArrangement = spacedBy( - dimensionResource(R.dimen.margin_small_to_normal) - ) - ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark_only), - tint = colorResource(R.color.colorOnSurface), - modifier = Modifier - .padding(top = dimensionResource(R.dimen.padding_extra_small)) - .size(16.dp), - contentDescription = null - ) - Column( - verticalArrangement = spacedBy( - dimensionResource(R.dimen.margin_extra_small) - ) - ) { - LargeText( - text = stringResource(R.string.mosaic_forecast), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - MediumText( - text = stringResource(R.string.mosaic_forecast_explanation), - colorRes = R.color.darkGrey - ) - } - } - Row( - horizontalArrangement = spacedBy( - dimensionResource(R.dimen.margin_small_to_normal) - ) - ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark_only), - tint = colorResource(R.color.colorOnSurface), - modifier = Modifier - .padding(top = dimensionResource(R.dimen.padding_extra_small)) - .size(16.dp), - contentDescription = null - ) - Column( - verticalArrangement = spacedBy( - dimensionResource(R.dimen.margin_extra_small) - ) - ) { - LargeText( - text = stringResource(R.string.hourly_forecast), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - MediumText( - text = stringResource(R.string.just_the_basics), - colorRes = R.color.darkGrey - ) - } - } - } - } - if (currentPurchase != null) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = onManageSubscription, - colors = ButtonDefaults.buttonColors( - containerColor = colorResource(R.color.layer1), - contentColor = colorResource(R.color.colorPrimary) - ), - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)), - ) { - MediumText( - stringResource(R.string.manage_subscription), - fontWeight = FontWeight.Bold, - colorRes = R.color.colorPrimary - ) - } - } - } - } - } -} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt new file mode 100644 index 000000000..39b8038c8 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt @@ -0,0 +1,161 @@ +package com.weatherxm.ui.managesubscription + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.weatherxm.R +import com.weatherxm.ui.components.compose.LargeText +import com.weatherxm.ui.components.compose.MediumText +import com.weatherxm.ui.components.compose.SmallText + +@Suppress("FunctionNaming", "LongMethod") +@Composable +fun FreePlanView(isSelected: Boolean, isCurrentPlan: Boolean, onSelected: () -> Unit) { + Card( + onClick = onSelected, + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensionResource(R.dimen.margin_large), + end = dimensionResource(R.dimen.margin_large), + top = dimensionResource(R.dimen.margin_normal) + ), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.colorSurface) + ), + elevation = CardDefaults.cardElevation( + defaultElevation = dimensionResource(R.dimen.elevation_normal) + ), + border = if (isSelected) { + BorderStroke(2.dp, colorResource(R.color.colorPrimary)) + } else { + null + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.margin_normal_to_large)), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)) + ) { + // Checkmark icon + Box( + modifier = Modifier + .size(48.dp) + .background( + color = colorResource(R.color.crypto_opacity_15), + shape = RoundedCornerShape(14.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + contentDescription = null, + tint = colorResource(R.color.darkGrey), + modifier = Modifier.size(20.dp) + ) + } + Column( + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + LargeText( + text = stringResource(R.string.free), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + + if (isCurrentPlan) { + Card( + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.cryptoInverse) + ) + ) { + SmallText( + text = stringResource(R.string.current_plan).uppercase(), + colorRes = R.color.colorOnSurface, + fontWeight = FontWeight.Bold, + paddingValues = PaddingValues( + horizontal = dimensionResource(R.dimen.margin_small_to_normal), + vertical = dimensionResource(R.dimen.margin_extra_small) + ) + ) + } + } + } + + LargeText( + text = "$0", + fontWeight = FontWeight.Bold, + fontSize = 32.sp + ) + + Column( + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) + ) { + FeatureItem(text = stringResource(R.string.free_plan_first_benefit)) + FeatureItem(text = stringResource(R.string.free_plan_second_benefit)) + FeatureItem(text = stringResource(R.string.free_plan_third_benefit)) + FeatureItem(text = stringResource(R.string.free_plan_fourth_benefit)) + } + } + } + } +} + +@Suppress("FunctionNaming") +@Composable +private fun FeatureItem(text: String) { + Row( + horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + contentDescription = null, + tint = colorResource(R.color.crypto), + modifier = Modifier.size(14.dp) + ) + MediumText(text = text, colorRes = R.color.darkestBlue) + } +} + +@Suppress("UnusedPrivateMember", "FunctionNaming") +@Preview +@Composable +private fun PreviewFreePlanView() { + Column { + FreePlanView(isSelected = false, isCurrentPlan = false) {} + FreePlanView(isSelected = true, isCurrentPlan = false) {} + FreePlanView(isSelected = true, isCurrentPlan = true) {} + } +} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index 74ff2265b..5ff9ba575 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -1,12 +1,17 @@ package com.weatherxm.ui.managesubscription +import android.content.res.ColorStateList import android.os.Bundle +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.mutableStateOf +import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import com.android.billingclient.api.BillingClient.BillingResponseCode import com.weatherxm.R import com.weatherxm.analytics.AnalyticsService +import com.weatherxm.data.models.SubscriptionOffer import com.weatherxm.databinding.ActivityManageSubscriptionBinding import com.weatherxm.service.BillingService import com.weatherxm.ui.common.Contracts.ARG_HAS_FREE_TRIAL_AVAILABLE @@ -28,6 +33,8 @@ class ManageSubscriptionActivity : BaseActivity() { private var hasFreeTrialAvailable = false private var isLoggedIn = false private var currentSelectedTab = 0 + private var hasActiveRenewingSub = mutableStateOf(false) + private var planSelected = mutableStateOf(null) init { lifecycleScope.launch { @@ -42,38 +49,64 @@ class ManageSubscriptionActivity : BaseActivity() { launch { billingService.getActiveSubFlow().collect { - binding.currentPlanComposable.setContent { - CurrentPlanView(it) { - navigator.openSubscriptionInStore(this@ManageSubscriptionActivity) - } - } - - binding.subscriptionTabSelector.setContent { - SubscriptionTabSelector(1) { newSelectedTab -> - currentSelectedTab = newSelectedTab - if (newSelectedTab == 0) { - // TODO: Update the UI + if (it == null || !it.isAutoRenewing) { + hasActiveRenewingSub.value = false + binding.toolbar.title = getString(R.string.upgrade_to_premium) + binding.planComposable.setContent { + val availableSub = if (currentSelectedTab == 0) { + billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) } else { - // TODO: Update the UI + billingService.getAnnualAvailableSub(hasFreeTrialAvailable) + } + Column { + FreePlanView( + isSelected = planSelected.value == null, + isCurrentPlan = !hasActiveRenewingSub.value + ) { + onFreeSelected() + } + PremiumPlanView( + sub = availableSub, + isSelected = planSelected.value == availableSub, + isCurrentPlan = hasActiveRenewingSub.value + ) { + onPremiumSelected(availableSub) + } } } - } - - if (it == null || !it.isAutoRenewing) { - binding.toolbar.title = getString(R.string.upgrade_to_premium) - binding.premiumFeaturesComposable.visible(true) binding.cancelAnytimeText.visible(true) binding.subscriptionTabSelector.visible(true) } else { + hasActiveRenewingSub.value = true binding.toolbar.title = getString(R.string.manage_subscription) - binding.premiumFeaturesComposable.visible(false) + binding.planComposable.setContent { + val availableSub = if (currentSelectedTab == 0) { + billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) + } else { + billingService.getAnnualAvailableSub(hasFreeTrialAvailable) + } + Column { + PremiumPlanView( + sub = availableSub, + isSelected = planSelected.value == availableSub, + isCurrentPlan = hasActiveRenewingSub.value + ) { + onPremiumSelected(availableSub) + } + FreePlanView( + isSelected = planSelected.value == null, + isCurrentPlan = !hasActiveRenewingSub.value + ) { + onFreeSelected() + } + } + } binding.cancelAnytimeText.visible(false) binding.subscriptionTabSelector.visible(false) } binding.toolbar.subtitle = getString(R.string.get_the_most_accurate_forecasts) - // TODO: STOPSHIP: Change button text, color & icon based on user selection } } @@ -96,27 +129,32 @@ class ManageSubscriptionActivity : BaseActivity() { setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } } - binding.premiumFeaturesComposable.setContent { - PremiumFeaturesView { - if (isLoggedIn) { - binding.selectPlanComposable.visible(true) - binding.premiumFeaturesComposable.visible(false) - binding.currentPlanComposable.visible(false) + binding.subscriptionTabSelector.setContent { + SubscriptionTabSelector(1) { newSelectedTab -> + currentSelectedTab = newSelectedTab + if (newSelectedTab == 0) { + billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) } else { - navigator.showLoginDialog( - fragmentActivity = this, - title = getString(R.string.get_premium), - message = getString(R.string.get_premium_login_prompt) - ) - } - } - } - - binding.selectPlanComposable.setContent { - PlansView(billingService.getAvailableSubs(hasFreeTrialAvailable)) { offer -> - offer?.let { - model.setOfferToken(it.offerToken) - billingService.startBillingFlow(this, it.offerToken) + billingService.getAnnualAvailableSub(hasFreeTrialAvailable) + }?.let { + planSelected.value == it + binding.planComposable.setContent { + Column { + FreePlanView( + isSelected = planSelected.value == null, + isCurrentPlan = !hasActiveRenewingSub.value + ) { + onFreeSelected() + } + PremiumPlanView( + sub = it, + isSelected = planSelected.value == it, + isCurrentPlan = hasActiveRenewingSub.value + ) { + onPremiumSelected(it) + } + } + } } } } @@ -126,12 +164,10 @@ class ManageSubscriptionActivity : BaseActivity() { } binding.backBtn.setOnClickListener { - binding.currentPlanComposable.visible(true) binding.appBar.visible(true) binding.topDivider.visible(true) binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) - binding.selectPlanComposable.visible(false) binding.statusView.visible(false) binding.successBtn.visible(false) binding.errorButtonsContainer.visible(false) @@ -151,12 +187,70 @@ class ManageSubscriptionActivity : BaseActivity() { analytics.trackScreen(AnalyticsService.Screen.MANAGE_SUBSCRIPTION, classSimpleName()) } + private fun onFreeSelected() { + planSelected.value = null + if (hasActiveRenewingSub.value) { + binding.mainActionBtn.text = getString(R.string.downgrade_free_plan) + styleButton(showSparklesIcon = false, backgroundColor = R.color.warningTint) + + binding.mainActionBtn.setOnClickListener { + navigator.openSubscriptionInStore(this) + } + binding.mainActionBtn.isEnabled = true + } else { + binding.mainActionBtn.text = getString(R.string.currently_on_free) + styleButton(showSparklesIcon = false, backgroundColor = R.color.layer1) + binding.mainActionBtn.isEnabled = false + } + } + + private fun onPremiumSelected(subscriptionOffer: SubscriptionOffer?) { + planSelected.value = subscriptionOffer + + if (hasActiveRenewingSub.value) { + binding.mainActionBtn.text = getString(R.string.currently_on_premium) + styleButton(showSparklesIcon = true, backgroundColor = R.color.layer1) + binding.mainActionBtn.isEnabled = false + } else { + binding.mainActionBtn.text = getString(R.string.upgrade_to_premium) + styleButton(showSparklesIcon = true, backgroundColor = R.color.crypto) + + binding.mainActionBtn.setOnClickListener { + if (isLoggedIn && subscriptionOffer != null) { + model.setOfferToken(subscriptionOffer.offerToken) + billingService.startBillingFlow(this, subscriptionOffer.offerToken) + } else { + navigator.showLoginDialog( + fragmentActivity = this, + title = getString(R.string.get_premium), + message = getString(R.string.get_premium_login_prompt) + ) + } + } + binding.mainActionBtn.isEnabled = false + } + } + + private fun styleButton(showSparklesIcon: Boolean, backgroundColor: Int) { + val backgroundColor = ContextCompat.getColor(this, backgroundColor) + val textColor = ContextCompat.getColor(this, R.color.colorOnSurface) + + binding.mainActionBtn.backgroundTintList = ColorStateList.valueOf(backgroundColor) + binding.mainActionBtn.setTextColor(textColor) + + if (showSparklesIcon) { + binding.mainActionBtn.icon = ContextCompat.getDrawable(this, R.drawable.ic_sparkles) + binding.mainActionBtn.iconTint = ColorStateList.valueOf(textColor) + } else { + binding.mainActionBtn.icon = null + } + } + private fun onPurchaseUpdate(state: PurchaseUpdateState) { if (state.isLoading) { binding.appBar.visible(false) binding.topDivider.visible(false) binding.mainContainer.visible(false) - binding.selectPlanComposable.visible(false) binding.successBtn.visible(false) binding.errorButtonsContainer.visible(false) binding.statusView.clear().animation(R.raw.anim_loading).visible(true) @@ -166,7 +260,6 @@ class ManageSubscriptionActivity : BaseActivity() { binding.errorButtonsContainer.visible(false) binding.appBar.visible(true) binding.topDivider.visible(true) - binding.currentPlanComposable.visible(true) binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) billingService.clearPurchaseUpdates() @@ -178,7 +271,6 @@ class ManageSubscriptionActivity : BaseActivity() { binding.appBar.visible(false) binding.topDivider.visible(false) binding.mainContainer.visible(false) - binding.selectPlanComposable.visible(false) binding.errorButtonsContainer.visible(false) binding.statusView.clear() .animation(R.raw.anim_success) @@ -195,7 +287,6 @@ class ManageSubscriptionActivity : BaseActivity() { binding.appBar.visible(false) binding.topDivider.visible(false) binding.mainContainer.visible(false) - binding.selectPlanComposable.visible(false) binding.statusView.clear() .animation(R.raw.anim_error) .title(R.string.purchase_failed) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt deleted file mode 100644 index 3a8af098a..000000000 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PlansView.kt +++ /dev/null @@ -1,202 +0,0 @@ -package com.weatherxm.ui.managesubscription - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Arrangement.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.RadioButton -import androidx.compose.material3.RadioButtonDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.weatherxm.R -import com.weatherxm.data.models.SubscriptionOffer -import com.weatherxm.service.OFFER_FREE_TRIAL -import com.weatherxm.service.PLAN_MONTHLY -import com.weatherxm.service.PLAN_YEARLY -import com.weatherxm.ui.components.compose.LargeText -import com.weatherxm.ui.components.compose.MediumText -import com.weatherxm.ui.components.compose.SmallText - -@Suppress("FunctionNaming", "LongMethod") -@Composable -fun PlansView(plans: List, onContinue: (SubscriptionOffer?) -> Unit) { - var selectedPlan by remember { mutableStateOf(plans.firstOrNull()) } - - Column(verticalArrangement = Arrangement.SpaceBetween) { - Column( - modifier = Modifier.weight(1F), - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) - ) { - LargeText( - text = stringResource(R.string.select_a_plan), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - LazyColumn { - items(plans) { - Plan(it, it == selectedPlan) { - selectedPlan = it - } - } - } - } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { onContinue(selectedPlan) }, - colors = ButtonDefaults.buttonColors( - containerColor = colorResource(R.color.colorPrimary), - contentColor = colorResource(R.color.colorBackground) - ), - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)), - ) { - MediumText( - stringResource(R.string.action_continue), - fontWeight = FontWeight.Bold, - colorRes = R.color.colorBackground - ) - } - } -} - -@Suppress("FunctionNaming", "LongMethod") -@Composable -private fun Plan( - sub: SubscriptionOffer, - isSelected: Boolean, - onClick: () -> Unit -) { - Card( - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = dimensionResource(R.dimen.padding_large)), - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small)), - colors = CardDefaults.cardColors( - containerColor = if (isSelected) { - colorResource(R.color.colorSurface) - } else { - colorResource(R.color.layer1) - } - ), - elevation = CardDefaults.cardElevation( - defaultElevation = dimensionResource(R.dimen.elevation_normal) - ), - border = if (isSelected) { - BorderStroke(2.dp, colorResource(R.color.colorPrimary)) - } else { - null - } - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - top = dimensionResource(R.dimen.margin_normal), - end = dimensionResource(R.dimen.margin_normal), - bottom = dimensionResource(R.dimen.margin_normal) - ), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = isSelected, - onClick = onClick, - colors = RadioButtonDefaults.colors( - selectedColor = colorResource(R.color.colorPrimary), - unselectedColor = colorResource(R.color.colorPrimary) - ) - ) - Column( - modifier = Modifier.weight(1F), - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) - ) { - SmallText( - text = when (sub.id) { - PLAN_MONTHLY -> stringResource(R.string.monthly) - PLAN_YEARLY -> stringResource(R.string.annual) - else -> sub.id - }, - colorRes = R.color.darkGrey - ) - LargeText( - text = when (sub.id) { - PLAN_MONTHLY -> "${sub.price}${stringResource(R.string.per_month)}" - PLAN_YEARLY -> "${sub.price}${stringResource(R.string.per_year)}" - else -> "${sub.price}/${sub.id}" - }, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - if (sub.offerId == OFFER_FREE_TRIAL) { - Card( - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_small)), - colors = CardDefaults.cardColors( - containerColor = colorResource(R.color.successTint) - ) - ) { - SmallText( - text = stringResource(R.string.one_month_free_trial), - colorRes = R.color.success, - paddingValues = PaddingValues( - horizontal = dimensionResource(R.dimen.margin_small_to_normal), - vertical = 6.dp - ) - ) - } - } - val subtitle = if (sub.offerId != null) { - buildString { - if (sub.id == PLAN_MONTHLY) { - append(stringResource(R.string.then_per_month, sub.price)) - append(" ") - } else if (sub.id == PLAN_YEARLY) { - append(stringResource(R.string.then_per_year, sub.price)) - append(" ") - } - append(stringResource(R.string.cancel_anytime)) - } - } else { - stringResource(R.string.cancel_anytime) - } - MediumText( - text = subtitle, - colorRes = R.color.darkGrey - ) - } - } - } -} - -@Suppress("UnusedPrivateMember", "FunctionNaming") -@Preview -@Composable -private fun PreviewPlans() { - PlansView( - plans = listOf( - SubscriptionOffer("monthly", "3.99$", "offerToken", "free-trial"), - SubscriptionOffer("yearly", "39.99$", "offerToken", null), - ) - ) { } -} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumFeaturesView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumFeaturesView.kt deleted file mode 100644 index ea3c18fd0..000000000 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumFeaturesView.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.weatherxm.ui.managesubscription - -import androidx.compose.foundation.layout.Arrangement.spacedBy -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.weatherxm.R -import com.weatherxm.ui.components.compose.LargeText -import com.weatherxm.ui.components.compose.MediumText - -@Suppress("FunctionNaming", "LongMethod") -@Preview -@Composable -fun PremiumFeaturesView(onGetPremium: () -> Unit = {}) { - Column( - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) - ) { - LargeText( - text = stringResource(R.string.premium_features), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - Card( - colors = CardDefaults.cardColors( - containerColor = colorResource(R.color.layer1) - ), - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_small)), - elevation = CardDefaults.cardElevation( - dimensionResource(R.dimen.elevation_small) - ) - ) { - Column( - modifier = Modifier.padding(dimensionResource(R.dimen.padding_normal)), - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) - ) { - Row( - horizontalArrangement = spacedBy( - dimensionResource(R.dimen.margin_small_to_normal) - ) - ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark_only), - tint = colorResource(R.color.colorOnSurface), - modifier = Modifier - .padding(top = dimensionResource(R.dimen.padding_extra_small)) - .size(16.dp), - contentDescription = null - ) - Column( - verticalArrangement = spacedBy( - dimensionResource(R.dimen.margin_extra_small) - ) - ) { - LargeText( - text = stringResource(R.string.mosaic_forecast), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - MediumText( - text = stringResource(R.string.mosaic_forecast_explanation), - colorRes = R.color.darkGrey - ) - } - } - Row( - horizontalArrangement = spacedBy( - dimensionResource(R.dimen.margin_small_to_normal) - ) - ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark_only), - tint = colorResource(R.color.colorOnSurface), - modifier = Modifier - .padding(top = dimensionResource(R.dimen.padding_extra_small)) - .size(16.dp), - contentDescription = null - ) - Column( - verticalArrangement = spacedBy( - dimensionResource(R.dimen.margin_extra_small) - ) - ) { - LargeText( - text = stringResource(R.string.hourly_forecast), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - MediumText( - text = stringResource(R.string.just_the_basics), - colorRes = R.color.darkGrey - ) - } - } - Button( - modifier = Modifier.fillMaxWidth(), - onClick = onGetPremium, - colors = ButtonDefaults.buttonColors( - containerColor = colorResource(R.color.colorPrimary), - contentColor = colorResource(R.color.colorBackground) - ), - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)), - ) { - MediumText( - stringResource(R.string.get_premium), - fontWeight = FontWeight.Bold, - colorRes = R.color.colorBackground - ) - } - } - } - } -} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt new file mode 100644 index 000000000..7f3cde75e --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -0,0 +1,219 @@ +package com.weatherxm.ui.managesubscription + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.weatherxm.R +import com.weatherxm.data.models.SubscriptionOffer +import com.weatherxm.service.PLAN_MONTHLY +import com.weatherxm.service.PLAN_YEARLY +import com.weatherxm.ui.components.compose.LargeText +import com.weatherxm.ui.components.compose.MediumText +import com.weatherxm.ui.components.compose.SmallText + +@Suppress("FunctionNaming", "LongMethod") +@Composable +fun PremiumPlanView( + sub: SubscriptionOffer?, + isSelected: Boolean, + isCurrentPlan: Boolean, + onSelected: () -> Unit +) { + if (sub == null) { + return + } + + Card( + onClick = onSelected, + modifier = Modifier + .fillMaxWidth() + .padding( + start = dimensionResource(R.dimen.margin_large), + end = dimensionResource(R.dimen.margin_large), + top = dimensionResource(R.dimen.margin_normal) + ), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.colorSurface) + ), + elevation = CardDefaults.cardElevation( + defaultElevation = dimensionResource(R.dimen.elevation_normal) + ), + border = if (isSelected) { + BorderStroke(2.dp, colorResource(R.color.colorPrimary)) + } else { + null + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.margin_normal_to_large)), + horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) + ) { + // Checkmark icon + Box( + modifier = Modifier + .size(48.dp) + .background( + color = colorResource(R.color.crypto_opacity_15), + shape = RoundedCornerShape(14.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_sparkles), + contentDescription = null, + tint = colorResource(R.color.textColor), + modifier = Modifier.size(20.dp) + ) + } + Column( + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + LargeText( + text = stringResource(R.string.premium), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + + if (isCurrentPlan) { + Card( + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.cryptoInverse) + ) + ) { + SmallText( + text = stringResource(R.string.current_plan).uppercase(), + colorRes = R.color.colorOnSurface, + fontWeight = FontWeight.Bold, + paddingValues = PaddingValues( + horizontal = dimensionResource(R.dimen.margin_small_to_normal), + vertical = dimensionResource(R.dimen.margin_extra_small) + ) + ) + } + } + } + + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) + ) { + LargeText( + text = sub.price, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + MediumText( + text = when (sub.id) { + PLAN_MONTHLY -> stringResource(R.string.per_month) + PLAN_YEARLY -> stringResource(R.string.per_year) + else -> "/${sub.id}" + }, + colorRes = R.color.darkGrey + ) + } + + SmallText( + text = stringResource(R.string.premium_plan_description), + colorRes = R.color.darkestBlue + ) + + // Features list + Column( + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) + ) { + val firstBenefit = AnnotatedString.Builder().apply { + append(stringResource(R.string.premium_plan_first_benefit)) + append(" ") + pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) + append(stringResource(R.string.free)) + pop() + }.toAnnotatedString() + FeatureItem(text = firstBenefit) + FeatureItem(text = AnnotatedString(stringResource(R.string.premium_plan_second_benefit))) + FeatureItem(text = AnnotatedString(stringResource(R.string.premium_plan_third_benefit))) + FeatureItem(text = AnnotatedString(stringResource(R.string.premium_plan_fourth_benefit))) + } + } + } + } +} + +@Suppress("FunctionNaming") +@Composable +private fun FeatureItem(text: AnnotatedString) { + Row( + horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + contentDescription = null, + tint = colorResource(R.color.colorPrimary), + modifier = Modifier.size(20.dp) + ) + Text( + text = text, + color = colorResource(R.color.colorOnSurface), + style = MaterialTheme.typography.bodyMedium + ) + } +} + +@Suppress("UnusedPrivateMember", "FunctionNaming") +@Preview +@Composable +private fun PreviewPremiumPlanView() { + Column { + PremiumPlanView( + sub = SubscriptionOffer("monthly", "$4.99", "offerToken", null), + isSelected = false, + isCurrentPlan = false + ) {} + PremiumPlanView( + sub = SubscriptionOffer("yearly", "$39.99", "offerToken", null), + isSelected = true, + isCurrentPlan = false + ) {} + PremiumPlanView( + sub = SubscriptionOffer("yearly", "$39.99", "offerToken", null), + isSelected = true, + isCurrentPlan = true + ) {} + } +} diff --git a/app/src/main/res/layout/activity_manage_subscription.xml b/app/src/main/res/layout/activity_manage_subscription.xml index 53365f157..3d5691435 100644 --- a/app/src/main/res/layout/activity_manage_subscription.xml +++ b/app/src/main/res/layout/activity_manage_subscription.xml @@ -35,55 +35,31 @@ android:id="@+id/mainContainer" android:layout_width="match_parent" android:layout_height="match_parent" - android:clipChildren="false" - android:clipToPadding="false" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> + tools:composableName="com.weatherxm.ui.components.compose.SubscriptionTabSelectorKt.PreviewSubscriptionTabSelector" + tools:visibility="visible" /> - - - - - - - + android:layout_height="wrap_content" + tools:composableName="com.weatherxm.ui.managesubscription.FreePlanViewKt.PreviewFreePlanView" /> + - - @color/dark_lightest_blue @color/dark_text @color/dark_tooltip + @color/dark_crypto_opacity_15 + @color/light_crypto #1B75BA diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 5ccdda815..f6e5d20b6 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -34,6 +34,8 @@ @color/light_lightest_blue @color/darkGrey @color/light_tooltip + @color/light_crypto_opacity_15 + @color/dark_crypto @color/colorPrimary diff --git a/app/src/main/res/values/palette.xml b/app/src/main/res/values/palette.xml index 4de02d863..d299ad935 100644 --- a/app/src/main/res/values/palette.xml +++ b/app/src/main/res/values/palette.xml @@ -8,8 +8,10 @@ #2780FF #234170 #B33A3F6A + #263A3F6A #B38C97F5 #4D8C97F5 + #268C97F5 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a72a2ba8f..aa10523ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -932,14 +932,8 @@ then %s per month. then %s per year. Annual - /month - /year - Annual premium - Monthly premium - Next billing date - Cancel subscription - Canceled - Premium features are available until your subscription expires.\nSelect a plan to extend your subscription. + per month + per year Premium Forecast You need to login to get premium forecast. Basic Forecast @@ -947,5 +941,17 @@ Get the most accurate forecasts Cancel anytime. No long-term commitments. You are currently on Premium Plan. + You are currently on Free Plan Downgrade to Free Plan + Free + Premium + 24-hour-ahead 3-hourly forecast + 7-day daily forecast + Basic weather parameters + Standard forecast accuracy + We test 40+ forecast models against real weather. Every day, we automatically pick the best-performing models for your station, so your forecast stays as accurate as possible. + Everything in + 24-hour-ahead hourly forecast + Advanced accuracy from the best hyperlocal forecast models + Priority updates for new features From b5b0a4ddfa91ed84f578d66863a7884444e3360c Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Tue, 13 Jan 2026 13:50:32 +0200 Subject: [PATCH 30/60] Fix detekt --- .../ui/components/compose/DailyTileForecast.kt | 1 + .../ui/managesubscription/FreePlanView.kt | 4 +++- .../ui/managesubscription/PremiumPlanView.kt | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt b/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt index 051324a07..81541f6f1 100644 --- a/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt +++ b/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt @@ -197,6 +197,7 @@ private fun DailyTileItem( } } +@Suppress("FunctionNaming", "UnusedPrivateMember") @Preview(showBackground = true) @Composable private fun PreviewDailyTileForecast() { diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt index 39b8038c8..3aa79560d 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt @@ -95,7 +95,9 @@ fun FreePlanView(isSelected: Boolean, isCurrentPlan: Boolean, onSelected: () -> if (isCurrentPlan) { Card( - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), + shape = RoundedCornerShape( + dimensionResource(R.dimen.radius_extra_extra_large) + ), colors = CardDefaults.cardColors( containerColor = colorResource(R.color.cryptoInverse) ) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt index 7f3cde75e..e2d8fb2c9 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -111,7 +111,9 @@ fun PremiumPlanView( if (isCurrentPlan) { Card( - shape = RoundedCornerShape(dimensionResource(R.dimen.radius_extra_extra_large)), + shape = RoundedCornerShape( + dimensionResource(R.dimen.radius_extra_extra_large) + ), colors = CardDefaults.cardColors( containerColor = colorResource(R.color.cryptoInverse) ) @@ -165,9 +167,15 @@ fun PremiumPlanView( pop() }.toAnnotatedString() FeatureItem(text = firstBenefit) - FeatureItem(text = AnnotatedString(stringResource(R.string.premium_plan_second_benefit))) - FeatureItem(text = AnnotatedString(stringResource(R.string.premium_plan_third_benefit))) - FeatureItem(text = AnnotatedString(stringResource(R.string.premium_plan_fourth_benefit))) + FeatureItem( + text = AnnotatedString(stringResource(R.string.premium_plan_second_benefit)) + ) + FeatureItem( + text = AnnotatedString(stringResource(R.string.premium_plan_third_benefit)) + ) + FeatureItem( + text = AnnotatedString(stringResource(R.string.premium_plan_fourth_benefit)) + ) } } } From 29419be1e1dad10f9a6c03ed5f844050d7f67cdf Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 14 Jan 2026 00:17:14 +0200 Subject: [PATCH 31/60] Fixes --- .../com/weatherxm/service/BillingService.kt | 6 +- .../ManageSubscriptionActivity.kt | 74 ++++++++++++------- .../layout/activity_manage_subscription.xml | 4 +- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt index 003e1d9a4..497b7790e 100644 --- a/app/src/main/java/com/weatherxm/service/BillingService.kt +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -123,7 +123,7 @@ class BillingService( fun getMonthlyAvailableSub(hasFreeTrialAvailable: Boolean): SubscriptionOffer? { return if (hasFreeTrialAvailable) { subs.filter { - it.offerId == OFFER_FREE_TRIAL || it.offerId == null && it.id == PLAN_MONTHLY + (it.offerId == OFFER_FREE_TRIAL || it.offerId == null) && it.id == PLAN_MONTHLY }.distinctBy { it.id } } else { subs.filter { it.offerId == null && it.id == PLAN_MONTHLY }.distinctBy { it.id } @@ -133,10 +133,10 @@ class BillingService( fun getAnnualAvailableSub(hasFreeTrialAvailable: Boolean): SubscriptionOffer? { return if (hasFreeTrialAvailable) { subs.filter { - it.offerId == OFFER_FREE_TRIAL || it.offerId == null && it.id == PLAN_MONTHLY + (it.offerId == OFFER_FREE_TRIAL || it.offerId == null) && it.id == PLAN_YEARLY }.distinctBy { it.id } } else { - subs.filter { it.offerId == null && it.id == PLAN_MONTHLY }.distinctBy { it.id } + subs.filter { it.offerId == null && it.id == PLAN_YEARLY }.distinctBy { it.id } }.firstOrNull() } diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index 5ff9ba575..6b016b2c2 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -3,7 +3,10 @@ package com.weatherxm.ui.managesubscription import android.content.res.ColorStateList import android.os.Bundle import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -32,7 +35,7 @@ class ManageSubscriptionActivity : BaseActivity() { private var hasFreeTrialAvailable = false private var isLoggedIn = false - private var currentSelectedTab = 0 + private var currentSelectedTab = 1 private var hasActiveRenewingSub = mutableStateOf(false) private var planSelected = mutableStateOf(null) @@ -49,16 +52,20 @@ class ManageSubscriptionActivity : BaseActivity() { launch { billingService.getActiveSubFlow().collect { + val availableSub = if (currentSelectedTab == 0) { + billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) + } else { + billingService.getAnnualAvailableSub(hasFreeTrialAvailable) + } if (it == null || !it.isAutoRenewing) { hasActiveRenewingSub.value = false binding.toolbar.title = getString(R.string.upgrade_to_premium) binding.planComposable.setContent { - val availableSub = if (currentSelectedTab == 0) { - billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) - } else { - billingService.getAnnualAvailableSub(hasFreeTrialAvailable) - } - Column { + Column( + modifier = Modifier.padding( + bottom = dimensionResource(R.dimen.margin_normal) + ) + ) { FreePlanView( isSelected = planSelected.value == null, isCurrentPlan = !hasActiveRenewingSub.value @@ -80,12 +87,11 @@ class ManageSubscriptionActivity : BaseActivity() { hasActiveRenewingSub.value = true binding.toolbar.title = getString(R.string.manage_subscription) binding.planComposable.setContent { - val availableSub = if (currentSelectedTab == 0) { - billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) - } else { - billingService.getAnnualAvailableSub(hasFreeTrialAvailable) - } - Column { + Column( + modifier = Modifier.padding( + bottom = dimensionResource(R.dimen.margin_normal) + ) + ) { PremiumPlanView( sub = availableSub, isSelected = planSelected.value == availableSub, @@ -104,9 +110,11 @@ class ManageSubscriptionActivity : BaseActivity() { binding.cancelAnytimeText.visible(false) binding.subscriptionTabSelector.visible(false) } + planSelected.value = availableSub + initActionButtonForPremiumSelected() binding.toolbar.subtitle = getString(R.string.get_the_most_accurate_forecasts) - + binding.mainActionBtn.visible(true) } } @@ -137,9 +145,14 @@ class ManageSubscriptionActivity : BaseActivity() { } else { billingService.getAnnualAvailableSub(hasFreeTrialAvailable) }?.let { - planSelected.value == it + planSelected.value = it + initActionButtonForPremiumSelected() binding.planComposable.setContent { - Column { + Column( + modifier = Modifier.padding( + bottom = dimensionResource(R.dimen.margin_normal) + ) + ) { FreePlanView( isSelected = planSelected.value == null, isCurrentPlan = !hasActiveRenewingSub.value @@ -187,14 +200,14 @@ class ManageSubscriptionActivity : BaseActivity() { analytics.trackScreen(AnalyticsService.Screen.MANAGE_SUBSCRIPTION, classSimpleName()) } - private fun onFreeSelected() { - planSelected.value = null + private fun initActionButtonForFreeSelected() { if (hasActiveRenewingSub.value) { binding.mainActionBtn.text = getString(R.string.downgrade_free_plan) styleButton(showSparklesIcon = false, backgroundColor = R.color.warningTint) binding.mainActionBtn.setOnClickListener { navigator.openSubscriptionInStore(this) + // TODO: STOPSHIP: Show the dialog and proceed. } binding.mainActionBtn.isEnabled = true } else { @@ -204,9 +217,7 @@ class ManageSubscriptionActivity : BaseActivity() { } } - private fun onPremiumSelected(subscriptionOffer: SubscriptionOffer?) { - planSelected.value = subscriptionOffer - + private fun initActionButtonForPremiumSelected() { if (hasActiveRenewingSub.value) { binding.mainActionBtn.text = getString(R.string.currently_on_premium) styleButton(showSparklesIcon = true, backgroundColor = R.color.layer1) @@ -215,11 +226,12 @@ class ManageSubscriptionActivity : BaseActivity() { binding.mainActionBtn.text = getString(R.string.upgrade_to_premium) styleButton(showSparklesIcon = true, backgroundColor = R.color.crypto) + val planOfferToken = planSelected.value?.offerToken binding.mainActionBtn.setOnClickListener { - if (isLoggedIn && subscriptionOffer != null) { - model.setOfferToken(subscriptionOffer.offerToken) - billingService.startBillingFlow(this, subscriptionOffer.offerToken) - } else { + if (isLoggedIn && planOfferToken != null) { + model.setOfferToken(planOfferToken) + billingService.startBillingFlow(this, planOfferToken) + } else if (!isLoggedIn) { navigator.showLoginDialog( fragmentActivity = this, title = getString(R.string.get_premium), @@ -227,10 +239,20 @@ class ManageSubscriptionActivity : BaseActivity() { ) } } - binding.mainActionBtn.isEnabled = false + binding.mainActionBtn.isEnabled = true } } + private fun onFreeSelected() { + planSelected.value = null + initActionButtonForFreeSelected() + } + + private fun onPremiumSelected(subscriptionOffer: SubscriptionOffer?) { + planSelected.value = subscriptionOffer + initActionButtonForPremiumSelected() + } + private fun styleButton(showSparklesIcon: Boolean, backgroundColor: Int) { val backgroundColor = ContextCompat.getColor(this, backgroundColor) val textColor = ContextCompat.getColor(this, R.color.colorOnSurface) diff --git a/app/src/main/res/layout/activity_manage_subscription.xml b/app/src/main/res/layout/activity_manage_subscription.xml index 3d5691435..7d39ab58a 100644 --- a/app/src/main/res/layout/activity_manage_subscription.xml +++ b/app/src/main/res/layout/activity_manage_subscription.xml @@ -80,14 +80,12 @@ android:layout_marginBottom="@dimen/margin_small_to_normal" android:text="@string/upgrade_to_premium" android:textColor="@color/light_top" - android:visibility="gone" app:backgroundTint="@color/crypto" app:cornerRadius="@dimen/radius_extra_large" app:icon="@drawable/ic_sparkles" app:iconGravity="textStart" app:iconTint="@color/light_top" - app:layout_constraintBottom_toTopOf="@id/cancelAnytimeText" - tools:visibility="visible" /> + app:layout_constraintBottom_toTopOf="@id/cancelAnytimeText" /> Date: Wed, 14 Jan 2026 00:43:08 +0200 Subject: [PATCH 32/60] Implement the UI changes when downgrading and the new dialog before downgrading. --- .../ui/managesubscription/FreePlanView.kt | 33 ++++++++++++++----- .../ManageSubscriptionActivity.kt | 14 ++++++-- .../main/res/drawable/ic_warning_triangle.xml | 9 +++++ app/src/main/res/values/strings.xml | 4 +++ 4 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 app/src/main/res/drawable/ic_warning_triangle.xml diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt index 3aa79560d..d107858ce 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt @@ -50,8 +50,10 @@ fun FreePlanView(isSelected: Boolean, isCurrentPlan: Boolean, onSelected: () -> elevation = CardDefaults.cardElevation( defaultElevation = dimensionResource(R.dimen.elevation_normal) ), - border = if (isSelected) { + border = if (isSelected && isCurrentPlan) { BorderStroke(2.dp, colorResource(R.color.colorPrimary)) + } else if (isSelected) { + BorderStroke(2.dp, colorResource(R.color.warning)) } else { null } @@ -60,24 +62,37 @@ fun FreePlanView(isSelected: Boolean, isCurrentPlan: Boolean, onSelected: () -> modifier = Modifier .fillMaxWidth() .padding(dimensionResource(R.dimen.margin_normal_to_large)), - horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)) + horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) ) { // Checkmark icon Box( modifier = Modifier .size(48.dp) .background( - color = colorResource(R.color.crypto_opacity_15), + color = if (isCurrentPlan || !isSelected) { + colorResource(R.color.crypto_opacity_15) + } else { + colorResource(R.color.warningTint) + }, shape = RoundedCornerShape(14.dp) ), contentAlignment = Alignment.Center ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark_only), - contentDescription = null, - tint = colorResource(R.color.darkGrey), - modifier = Modifier.size(20.dp) - ) + if (isCurrentPlan || !isSelected) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + contentDescription = null, + tint = colorResource(R.color.darkGrey), + modifier = Modifier.size(20.dp) + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_warning_triangle), + contentDescription = null, + tint = colorResource(R.color.warning), + modifier = Modifier.size(20.dp) + ) + } } Column( verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index 6b016b2c2..1e3ec11e2 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -22,6 +22,7 @@ import com.weatherxm.ui.common.Contracts.ARG_IS_LOGGED_IN import com.weatherxm.ui.common.PurchaseUpdateState import com.weatherxm.ui.common.classSimpleName import com.weatherxm.ui.common.visible +import com.weatherxm.ui.components.ActionDialogFragment import com.weatherxm.ui.components.BaseActivity import com.weatherxm.ui.components.compose.SubscriptionTabSelector import kotlinx.coroutines.launch @@ -206,8 +207,17 @@ class ManageSubscriptionActivity : BaseActivity() { styleButton(showSparklesIcon = false, backgroundColor = R.color.warningTint) binding.mainActionBtn.setOnClickListener { - navigator.openSubscriptionInStore(this) - // TODO: STOPSHIP: Show the dialog and proceed. + ActionDialogFragment + .Builder( + title = getString(R.string.downgrade_to_free_dialog_title), + message = getString(R.string.downgrade_to_free_dialog_subtitle), + positive = getString(R.string.stay_on_premium) + ) + .onNegativeClick(getString(R.string.proceed_anyway)) { + navigator.openSubscriptionInStore(this) + } + .build() + .show(this) } binding.mainActionBtn.isEnabled = true } else { diff --git a/app/src/main/res/drawable/ic_warning_triangle.xml b/app/src/main/res/drawable/ic_warning_triangle.xml new file mode 100644 index 000000000..e97c01711 --- /dev/null +++ b/app/src/main/res/drawable/ic_warning_triangle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa10523ac..aa63558a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -954,4 +954,8 @@ 24-hour-ahead hourly forecast Advanced accuracy from the best hyperlocal forecast models Priority updates for new features + Downgrade to Free? + Are you sure you want to downgrade to Free plan? If you downgrade you will lose access to:\n\n • 24-hour-ahead hourly forecast\n • Advanced accuracy from the best forecast hyperlocal models\n • Priority updates for new features + Downgrade + Stay on Premium From c82263db48cb167144a040803eb7e2eb74d57938 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 14 Jan 2026 00:44:37 +0200 Subject: [PATCH 33/60] Bump version to release in closed testing --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 366d98049..13da0e569 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 34 + getVersionGitTags(isSolana = false).size + versionCode = 36 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { From 31d82e1a48e0b48f4beeac05b45b139657d0e7aa Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 14 Jan 2026 11:48:32 +0200 Subject: [PATCH 34/60] Fixed the downgrade dialog by using a custom composable --- app/build.gradle.kts | 2 +- .../ui/components/compose/DowngradeDialog.kt | 77 +++++++++++++++++++ .../ManageSubscriptionActivity.kt | 47 +++++++---- .../layout/activity_manage_subscription.xml | 5 ++ 4 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/weatherxm/ui/components/compose/DowngradeDialog.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 13da0e569..73857de65 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 36 + getVersionGitTags(isSolana = false).size + versionCode = 37 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { diff --git a/app/src/main/java/com/weatherxm/ui/components/compose/DowngradeDialog.kt b/app/src/main/java/com/weatherxm/ui/components/compose/DowngradeDialog.kt new file mode 100644 index 000000000..32eef4e10 --- /dev/null +++ b/app/src/main/java/com/weatherxm/ui/components/compose/DowngradeDialog.kt @@ -0,0 +1,77 @@ +package com.weatherxm.ui.components.compose + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.weatherxm.R + +@Suppress("FunctionNaming") +@Composable +fun DowngradeDialog(shouldShow: Boolean, onDowngrade: () -> Unit, onClose: () -> Unit) { + if (shouldShow) { + AlertDialog( + containerColor = colorResource(R.color.colorSurface), + onDismissRequest = onClose, + title = { + Text( + text = stringResource(R.string.downgrade_to_free_dialog_title), + fontWeight = FontWeight.Bold, + fontSize = 22.sp, + color = colorResource(R.color.darkestBlue) + ) + }, + text = { + Row(Modifier.verticalScroll(rememberScrollState())) { + Text( + text = stringResource(R.string.downgrade_to_free_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = colorResource(R.color.colorOnSurface) + ) + } + }, + dismissButton = { + TextButton( + onClick = onDowngrade, + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)) + ) { + Text( + text = stringResource(R.string.downgrade), + style = MaterialTheme.typography.bodyMedium, + color = colorResource(R.color.colorPrimary), + fontWeight = FontWeight.Bold + ) + } + }, + confirmButton = { + Button( + onClick = onClose, + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)), + colors = ButtonDefaults.buttonColors( + containerColor = colorResource(R.color.colorPrimary), + ) + ) { + Text( + text = stringResource(R.string.stay_on_premium), + style = MaterialTheme.typography.bodyMedium, + color = colorResource(R.color.colorOnPrimary), + fontWeight = FontWeight.Bold + ) + } + } + ) + } +} diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index 1e3ec11e2..9652235ee 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -22,8 +22,8 @@ import com.weatherxm.ui.common.Contracts.ARG_IS_LOGGED_IN import com.weatherxm.ui.common.PurchaseUpdateState import com.weatherxm.ui.common.classSimpleName import com.weatherxm.ui.common.visible -import com.weatherxm.ui.components.ActionDialogFragment import com.weatherxm.ui.components.BaseActivity +import com.weatherxm.ui.components.compose.DowngradeDialog import com.weatherxm.ui.components.compose.SubscriptionTabSelector import kotlinx.coroutines.launch import org.koin.android.ext.android.inject @@ -37,8 +37,9 @@ class ManageSubscriptionActivity : BaseActivity() { private var hasFreeTrialAvailable = false private var isLoggedIn = false private var currentSelectedTab = 1 - private var hasActiveRenewingSub = mutableStateOf(false) - private var planSelected = mutableStateOf(null) + private val hasActiveRenewingSub = mutableStateOf(false) + private val planSelected = mutableStateOf(null) + private val shouldShowDowngradeDialog = mutableStateOf(false) init { lifecycleScope.launch { @@ -173,6 +174,19 @@ class ManageSubscriptionActivity : BaseActivity() { } } + binding.dialogComposeView.setContent { + DowngradeDialog( + shouldShow = shouldShowDowngradeDialog.value, + onDowngrade = { + shouldShowDowngradeDialog.value = false + navigator.openSubscriptionInStore(this) + }, + onClose = { + shouldShowDowngradeDialog.value = false + } + ) + } + binding.successBtn.setOnClickListener { finish() } @@ -207,17 +221,7 @@ class ManageSubscriptionActivity : BaseActivity() { styleButton(showSparklesIcon = false, backgroundColor = R.color.warningTint) binding.mainActionBtn.setOnClickListener { - ActionDialogFragment - .Builder( - title = getString(R.string.downgrade_to_free_dialog_title), - message = getString(R.string.downgrade_to_free_dialog_subtitle), - positive = getString(R.string.stay_on_premium) - ) - .onNegativeClick(getString(R.string.proceed_anyway)) { - navigator.openSubscriptionInStore(this) - } - .build() - .show(this) + shouldShowDowngradeDialog.value = true } binding.mainActionBtn.isEnabled = true } else { @@ -234,10 +238,15 @@ class ManageSubscriptionActivity : BaseActivity() { binding.mainActionBtn.isEnabled = false } else { binding.mainActionBtn.text = getString(R.string.upgrade_to_premium) - styleButton(showSparklesIcon = true, backgroundColor = R.color.crypto) + styleButton( + showSparklesIcon = true, + backgroundColor = R.color.crypto, + textColor = R.color.dark_text + ) val planOfferToken = planSelected.value?.offerToken binding.mainActionBtn.setOnClickListener { + shouldShowDowngradeDialog.value = true if (isLoggedIn && planOfferToken != null) { model.setOfferToken(planOfferToken) billingService.startBillingFlow(this, planOfferToken) @@ -263,9 +272,13 @@ class ManageSubscriptionActivity : BaseActivity() { initActionButtonForPremiumSelected() } - private fun styleButton(showSparklesIcon: Boolean, backgroundColor: Int) { + private fun styleButton( + showSparklesIcon: Boolean, + backgroundColor: Int, + textColor: Int = R.color.colorOnSurface + ) { val backgroundColor = ContextCompat.getColor(this, backgroundColor) - val textColor = ContextCompat.getColor(this, R.color.colorOnSurface) + val textColor = ContextCompat.getColor(this, textColor) binding.mainActionBtn.backgroundTintList = ColorStateList.valueOf(backgroundColor) binding.mainActionBtn.setTextColor(textColor) diff --git a/app/src/main/res/layout/activity_manage_subscription.xml b/app/src/main/res/layout/activity_manage_subscription.xml index 7d39ab58a..98db37d6f 100644 --- a/app/src/main/res/layout/activity_manage_subscription.xml +++ b/app/src/main/res/layout/activity_manage_subscription.xml @@ -151,4 +151,9 @@ android:textAllCaps="false" /> + + From 8efc637d508778a5c99d2f6a46721f2c1e108f02 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Wed, 14 Jan 2026 13:46:36 +0200 Subject: [PATCH 35/60] Fix test code --- app/build.gradle.kts | 2 +- .../ui/managesubscription/ManageSubscriptionActivity.kt | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 73857de65..013d3d565 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 37 + getVersionGitTags(isSolana = false).size + versionCode = 38 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index 9652235ee..a2ce28957 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -246,7 +246,6 @@ class ManageSubscriptionActivity : BaseActivity() { val planOfferToken = planSelected.value?.offerToken binding.mainActionBtn.setOnClickListener { - shouldShowDowngradeDialog.value = true if (isLoggedIn && planOfferToken != null) { model.setOfferToken(planOfferToken) billingService.startBillingFlow(this, planOfferToken) From 5217bd7d45608e48476dcafd3065d92de5dc9ba2 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Thu, 5 Feb 2026 15:42:17 +0200 Subject: [PATCH 36/60] Hide the tab selectors and keep only the monthly subscription --- app/build.gradle.kts | 2 +- .../ManageSubscriptionActivity.kt | 96 ++++++++++--------- .../ui/managesubscription/PremiumPlanView.kt | 30 +++++- .../layout/activity_manage_subscription.xml | 22 ++--- app/src/main/res/values/strings.xml | 1 + 5 files changed, 90 insertions(+), 61 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 013d3d565..74af9dadb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 38 + getVersionGitTags(isSolana = false).size + versionCode = 39 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index a2ce28957..e8aee16ed 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -24,7 +24,6 @@ import com.weatherxm.ui.common.classSimpleName import com.weatherxm.ui.common.visible import com.weatherxm.ui.components.BaseActivity import com.weatherxm.ui.components.compose.DowngradeDialog -import com.weatherxm.ui.components.compose.SubscriptionTabSelector import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -36,7 +35,8 @@ class ManageSubscriptionActivity : BaseActivity() { private var hasFreeTrialAvailable = false private var isLoggedIn = false - private var currentSelectedTab = 1 + + // private var currentSelectedTab = 1 private val hasActiveRenewingSub = mutableStateOf(false) private val planSelected = mutableStateOf(null) private val shouldShowDowngradeDialog = mutableStateOf(false) @@ -54,11 +54,13 @@ class ManageSubscriptionActivity : BaseActivity() { launch { billingService.getActiveSubFlow().collect { - val availableSub = if (currentSelectedTab == 0) { + val availableSub = billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) - } else { - billingService.getAnnualAvailableSub(hasFreeTrialAvailable) - } +// val availableSub = if (currentSelectedTab == 0) { +// billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) +// } else { +// billingService.getAnnualAvailableSub(hasFreeTrialAvailable) +// } if (it == null || !it.isAutoRenewing) { hasActiveRenewingSub.value = false binding.toolbar.title = getString(R.string.upgrade_to_premium) @@ -77,14 +79,15 @@ class ManageSubscriptionActivity : BaseActivity() { PremiumPlanView( sub = availableSub, isSelected = planSelected.value == availableSub, - isCurrentPlan = hasActiveRenewingSub.value + isCurrentPlan = hasActiveRenewingSub.value, + hasFreeTrialAvailable = hasFreeTrialAvailable ) { onPremiumSelected(availableSub) } } } binding.cancelAnytimeText.visible(true) - binding.subscriptionTabSelector.visible(true) + // binding.subscriptionTabSelector.visible(true) } else { hasActiveRenewingSub.value = true binding.toolbar.title = getString(R.string.manage_subscription) @@ -97,7 +100,8 @@ class ManageSubscriptionActivity : BaseActivity() { PremiumPlanView( sub = availableSub, isSelected = planSelected.value == availableSub, - isCurrentPlan = hasActiveRenewingSub.value + isCurrentPlan = hasActiveRenewingSub.value, + hasFreeTrialAvailable = false ) { onPremiumSelected(availableSub) } @@ -110,7 +114,7 @@ class ManageSubscriptionActivity : BaseActivity() { } } binding.cancelAnytimeText.visible(false) - binding.subscriptionTabSelector.visible(false) + // binding.subscriptionTabSelector.visible(false) } planSelected.value = availableSub initActionButtonForPremiumSelected() @@ -139,40 +143,40 @@ class ManageSubscriptionActivity : BaseActivity() { setNavigationOnClickListener { onBackPressedDispatcher.onBackPressed() } } - binding.subscriptionTabSelector.setContent { - SubscriptionTabSelector(1) { newSelectedTab -> - currentSelectedTab = newSelectedTab - if (newSelectedTab == 0) { - billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) - } else { - billingService.getAnnualAvailableSub(hasFreeTrialAvailable) - }?.let { - planSelected.value = it - initActionButtonForPremiumSelected() - binding.planComposable.setContent { - Column( - modifier = Modifier.padding( - bottom = dimensionResource(R.dimen.margin_normal) - ) - ) { - FreePlanView( - isSelected = planSelected.value == null, - isCurrentPlan = !hasActiveRenewingSub.value - ) { - onFreeSelected() - } - PremiumPlanView( - sub = it, - isSelected = planSelected.value == it, - isCurrentPlan = hasActiveRenewingSub.value - ) { - onPremiumSelected(it) - } - } - } - } - } - } +// binding.subscriptionTabSelector.setContent { +// SubscriptionTabSelector(1) { newSelectedTab -> +// currentSelectedTab = newSelectedTab +// if (newSelectedTab == 0) { +// billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) +// } else { +// billingService.getAnnualAvailableSub(hasFreeTrialAvailable) +// }?.let { +// planSelected.value = it +// initActionButtonForPremiumSelected() +// binding.planComposable.setContent { +// Column( +// modifier = Modifier.padding( +// bottom = dimensionResource(R.dimen.margin_normal) +// ) +// ) { +// FreePlanView( +// isSelected = planSelected.value == null, +// isCurrentPlan = !hasActiveRenewingSub.value +// ) { +// onFreeSelected() +// } +// PremiumPlanView( +// sub = it, +// isSelected = planSelected.value == it, +// isCurrentPlan = hasActiveRenewingSub.value +// ) { +// onPremiumSelected(it) +// } +// } +// } +// } +// } +// } binding.dialogComposeView.setContent { DowngradeDialog( @@ -194,7 +198,7 @@ class ManageSubscriptionActivity : BaseActivity() { binding.backBtn.setOnClickListener { binding.appBar.visible(true) binding.topDivider.visible(true) - binding.subscriptionTabSelector.visible(true) + // binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) binding.statusView.visible(false) binding.successBtn.visible(false) @@ -304,7 +308,7 @@ class ManageSubscriptionActivity : BaseActivity() { binding.errorButtonsContainer.visible(false) binding.appBar.visible(true) binding.topDivider.visible(true) - binding.subscriptionTabSelector.visible(true) + // binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) billingService.clearPurchaseUpdates() analytics.trackEventViewContent( diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt index e2d8fb2c9..deff1e0d1 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -44,6 +44,7 @@ fun PremiumPlanView( sub: SubscriptionOffer?, isSelected: Boolean, isCurrentPlan: Boolean, + hasFreeTrialAvailable: Boolean, onSelected: () -> Unit ) { if (sub == null) { @@ -150,6 +151,26 @@ fun PremiumPlanView( ) } + if (hasFreeTrialAvailable) { + Card( + shape = RoundedCornerShape( + dimensionResource(R.dimen.radius_extra_extra_large) + ), + colors = CardDefaults.cardColors( + containerColor = colorResource(R.color.successTint) + ) + ) { + SmallText( + text = stringResource(R.string.try_for_free_2_months), + colorRes = R.color.success, + paddingValues = PaddingValues( + horizontal = dimensionResource(R.dimen.margin_small_to_normal), + vertical = dimensionResource(R.dimen.margin_extra_small) + ) + ) + } + } + SmallText( text = stringResource(R.string.premium_plan_description), colorRes = R.color.darkestBlue @@ -211,17 +232,20 @@ private fun PreviewPremiumPlanView() { PremiumPlanView( sub = SubscriptionOffer("monthly", "$4.99", "offerToken", null), isSelected = false, - isCurrentPlan = false + isCurrentPlan = false, + hasFreeTrialAvailable = false ) {} PremiumPlanView( sub = SubscriptionOffer("yearly", "$39.99", "offerToken", null), isSelected = true, - isCurrentPlan = false + isCurrentPlan = false, + hasFreeTrialAvailable = true ) {} PremiumPlanView( sub = SubscriptionOffer("yearly", "$39.99", "offerToken", null), isSelected = true, - isCurrentPlan = true + isCurrentPlan = true, + hasFreeTrialAvailable = true ) {} } } diff --git a/app/src/main/res/layout/activity_manage_subscription.xml b/app/src/main/res/layout/activity_manage_subscription.xml index 98db37d6f..0d995d29d 100644 --- a/app/src/main/res/layout/activity_manage_subscription.xml +++ b/app/src/main/res/layout/activity_manage_subscription.xml @@ -35,24 +35,24 @@ android:id="@+id/mainContainer" android:layout_width="match_parent" android:layout_height="match_parent" + android:paddingTop="@dimen/padding_normal" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"> - + + + + + + + + + + app:layout_constraintTop_toTopOf="parent"> Are you sure you want to downgrade to Free plan? If you downgrade you will lose access to:\n\n • 24-hour-ahead hourly forecast\n • Advanced accuracy from the best forecast hyperlocal models\n • Priority updates for new features Downgrade Stay on Premium + Try for free for 2 months From 4b905116a95dc5e0c70665c56c83f467b4497d5a Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Fri, 13 Feb 2026 16:01:58 +0200 Subject: [PATCH 37/60] Add precipitation in pro forecast --- app/build.gradle.kts | 2 +- .../ui/forecastdetails/ForecastDetailsActivity.kt | 12 ++++++++++++ .../main/res/layout/activity_forecast_details.xml | 11 ++++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74af9dadb..6fb1648d4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 39 + getVersionGitTags(isSolana = false).size + versionCode = 40 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index f724253c0..178d70832 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -225,6 +225,18 @@ class ForecastDetailsActivity : BaseActivity() { * Some data are missing in the Hyper Local tab so we handle it differently below. */ if (currentSelectedTab == 1) { + binding.dailyPremiumPrecip.setGradientIcon( + iconRes = R.drawable.ic_weather_precipitation, + windDirection = null, + isRotatableWindIcon = false + ) + binding.dailyPremiumPrecip.setData( + getFormattedPrecipitation( + context = this, + value = forecastDay.precip, + isRainRate = false + ) + ) binding.dailyPremiumWind.setGradientIcon( iconRes = null, windDirection = forecastDay.windDirection, diff --git a/app/src/main/res/layout/activity_forecast_details.xml b/app/src/main/res/layout/activity_forecast_details.xml index a86e9ad4c..f8a75d90e 100644 --- a/app/src/main/res/layout/activity_forecast_details.xml +++ b/app/src/main/res/layout/activity_forecast_details.xml @@ -227,9 +227,18 @@ android:gravity="center" android:orientation="horizontal" android:visibility="gone" - android:weightSum="2" + android:weightSum="3" app:layout_constraintTop_toBottomOf="@id/dailyIcon"> + + Date: Fri, 13 Feb 2026 16:51:29 +0200 Subject: [PATCH 38/60] Fetch forecast again if the user went to the manage subscription screen (to catch new purchases automatically) --- .../ui/devicedetails/forecast/ForecastFragment.kt | 6 ++++++ .../ui/forecastdetails/ForecastDetailsActivity.kt | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index 52c71b00d..a32670d61 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -37,6 +37,7 @@ class ForecastFragment : BaseFragment() { private lateinit var hourlyForecastAdapter: HourlyForecastAdapter private lateinit var dailyForecastAdapter: DailyForecastAdapter private var currentSelectedTab = 0 + private var hasOpenedManageSubscription = false override fun onCreateView( inflater: LayoutInflater, @@ -153,11 +154,16 @@ class ForecastFragment : BaseFragment() { override fun onResume() { super.onResume() analytics.trackScreen(AnalyticsService.Screen.DEVICE_FORECAST, classSimpleName()) + + if (hasOpenedManageSubscription) { + model.fetchForecasts(true) + } } private fun initMosaicPromotionCard() { binding.mosaicPromotionCard.setContent { MosaicPromotionCard(parentModel.hasFreePremiumTrialAvailable()) { + hasOpenedManageSubscription = true navigator.showManageSubscription( context, parentModel.hasFreePremiumTrialAvailable(), diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index 178d70832..bb4368880 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -63,6 +63,7 @@ class ForecastDetailsActivity : BaseActivity() { private lateinit var hourlyAdapter: HourlyForecastAdapter private var currentSelectedTab = 0 + private var hasOpenedManageSubscription = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -142,6 +143,7 @@ class ForecastDetailsActivity : BaseActivity() { private fun initMosaicPromotionCard() { binding.mosaicPromotionCard.setContent { MosaicPromotionCard(model.hasFreeTrialAvailable) { + hasOpenedManageSubscription = true navigator.showManageSubscription( this, model.hasFreeTrialAvailable, @@ -482,11 +484,17 @@ class ForecastDetailsActivity : BaseActivity() { override fun onResume() { super.onResume() if (!model.device.isEmpty()) { + if (hasOpenedManageSubscription) { + model.fetchDeviceForecasts() + } analytics.trackScreen( AnalyticsService.Screen.DEVICE_FORECAST_DETAILS, classSimpleName() ) } else { + if (hasOpenedManageSubscription) { + model.fetchLocationForecast() + } analytics.trackScreen( screen = AnalyticsService.Screen.LOCATION_FORECAST_DETAILS, screenClass = classSimpleName(), From 90e395d808609ffdeae63784b62b305aa3ce94d0 Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Tue, 17 Feb 2026 00:36:13 +0200 Subject: [PATCH 39/60] Hide premium implementation for Solana users --- .../com/weatherxm/service/BillingService.kt | 30 ++++++++++++++----- .../forecast/ForecastFragment.kt | 6 ++++ .../forecast/ForecastViewModel.kt | 4 ++- .../ForecastDetailsActivity.kt | 6 ++++ .../ForecastDetailsViewModel.kt | 4 ++- .../ui/home/profile/ProfileFragment.kt | 6 ++++ .../com/weatherxm/util/AndroidBuildInfo.kt | 2 ++ app/src/main/res/layout/fragment_profile.xml | 3 +- 8 files changed, 50 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt index 497b7790e..3d060a3ee 100644 --- a/app/src/main/java/com/weatherxm/service/BillingService.kt +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -23,6 +23,7 @@ import com.weatherxm.R import com.weatherxm.data.models.SubscriptionOffer import com.weatherxm.data.replaceLast import com.weatherxm.ui.common.PurchaseUpdateState +import com.weatherxm.util.AndroidBuildInfo import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -104,6 +105,11 @@ class BillingService( } fun hasActiveSub(): Boolean { + // TODO: When Solana is implemented, remove this + if (AndroidBuildInfo.isSolana) { + return false + } + val activeSub = activeSubFlow.value return if (billingClient?.isReady == false && activeSub == null) { startConnection() @@ -160,6 +166,11 @@ class BillingService( } suspend fun setupPurchases(inBackground: Boolean = true) { + // TODO: When we have the Solana implementation remove this + if (AndroidBuildInfo.isSolana) { + return + } + val purchasesResult = billingClient?.queryPurchasesAsync( QueryPurchasesParams.newBuilder().setProductType(SUBS).build() ) @@ -371,13 +382,16 @@ class BillingService( } init { - billingClient = BillingClient.newBuilder(context) - .setListener(purchasesUpdatedListener) - .enableAutoServiceReconnection() - .enablePendingPurchases( - PendingPurchasesParams.newBuilder().enableOneTimeProducts().build() - ) - .build() - startConnection() + // TODO: When we have the Solana implementation remove this check + if (!AndroidBuildInfo.isSolana) { + billingClient = BillingClient.newBuilder(context) + .setListener(purchasesUpdatedListener) + .enableAutoServiceReconnection() + .enablePendingPurchases( + PendingPurchasesParams.newBuilder().enableOneTimeProducts().build() + ) + .build() + startConnection() + } } } diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt index a32670d61..70c1a6560 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastFragment.kt @@ -22,6 +22,7 @@ import com.weatherxm.ui.components.BaseFragment import com.weatherxm.ui.components.compose.ForecastTabSelector import com.weatherxm.ui.components.compose.MosaicPromotionCard import com.weatherxm.ui.devicedetails.DeviceDetailsViewModel +import com.weatherxm.util.AndroidBuildInfo import com.weatherxm.util.toISODate import org.koin.androidx.viewmodel.ext.android.activityViewModel import org.koin.androidx.viewmodel.ext.android.viewModel @@ -129,6 +130,11 @@ class ForecastFragment : BaseFragment() { } } + // TODO: When we have the Solana implementation, remove this + if (AndroidBuildInfo.isSolana) { + binding.tabsOrMosaicPromptContainer.visible(false) + } + initForecastTabsSelector() initMosaicPromotionCard() fetchOrHideContent() diff --git a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt index c47a21b68..6d433e614 100644 --- a/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModel.kt @@ -16,6 +16,7 @@ import com.weatherxm.ui.common.Resource import com.weatherxm.ui.common.UIDevice import com.weatherxm.ui.common.UIForecast import com.weatherxm.usecases.ForecastUseCase +import com.weatherxm.util.AndroidBuildInfo import com.weatherxm.util.Failure.getDefaultMessage import com.weatherxm.util.Resources import kotlinx.coroutines.CoroutineDispatcher @@ -50,7 +51,8 @@ class ForecastViewModel( mutableLiveData = onDefaultForecast, fetchOperation = { forecastUseCase.getDeviceDefaultForecast(device, forceRefresh) } ) - if (billingService.hasActiveSub()) { + // TODO: When we have the Solana implementation, remove this check for isSolana + if (billingService.hasActiveSub() && !AndroidBuildInfo.isSolana) { fetchDeviceForecast( mutableLiveData = onPremiumForecast, fetchOperation = { forecastUseCase.getDevicePremiumForecast(device) } diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt index bb4368880..3a8b2879f 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt @@ -33,6 +33,7 @@ import com.weatherxm.ui.components.compose.ForecastTabSelector import com.weatherxm.ui.components.compose.HeaderView import com.weatherxm.ui.components.compose.JoinNetworkPromoCard import com.weatherxm.ui.components.compose.MosaicPromotionCard +import com.weatherxm.util.AndroidBuildInfo import com.weatherxm.util.DateTimeHelper.getRelativeDayAndShort import com.weatherxm.util.Weather.getFormattedHumidity import com.weatherxm.util.Weather.getFormattedPrecipitation @@ -114,6 +115,11 @@ class ForecastDetailsActivity : BaseActivity() { onForecast(it) { model.fetchLocationForecast() } } + // TODO: When we have the Solana implementation, remove this + if (AndroidBuildInfo.isSolana) { + binding.tabsOrMosaicPromptContainer.visible(false) + } + if (!model.device.isEmpty()) { model.fetchDeviceForecasts() initForecastTabsSelector() diff --git a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt index 228f80585..f1a3c42fc 100644 --- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt +++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt @@ -24,6 +24,7 @@ import com.weatherxm.usecases.ChartsUseCaseImpl.Companion.FORECAST_CHART_STEP_DE import com.weatherxm.usecases.ChartsUseCaseImpl.Companion.FORECAST_CHART_STEP_PREMIUM import com.weatherxm.usecases.ForecastUseCase import com.weatherxm.usecases.LocationsUseCase +import com.weatherxm.util.AndroidBuildInfo import com.weatherxm.util.Failure.getDefaultMessage import com.weatherxm.util.Resources import kotlinx.coroutines.CoroutineDispatcher @@ -59,7 +60,8 @@ class ForecastDetailsViewModel( mutableLiveData = onDeviceDefaultForecast, fetchOperation = { forecastUseCase.getDeviceDefaultForecast(device) } ) - if (billingService.hasActiveSub()) { + // TODO: When we have the Solana implementation, remove this check for isSolana + if (billingService.hasActiveSub() && !AndroidBuildInfo.isSolana) { fetchDeviceForecast( mutableLiveData = onDevicePremiumForecast, fetchOperation = { forecastUseCase.getDevicePremiumForecast(device) } diff --git a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt index 37fe3dc71..9fc921791 100644 --- a/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt +++ b/app/src/main/java/com/weatherxm/ui/home/profile/ProfileFragment.kt @@ -33,6 +33,7 @@ import com.weatherxm.ui.components.compose.MessageCardView import com.weatherxm.ui.components.compose.ProPromotionCard import com.weatherxm.ui.home.HomeActivity import com.weatherxm.ui.home.HomeViewModel +import com.weatherxm.util.AndroidBuildInfo import com.weatherxm.util.Mask import com.weatherxm.util.NumberUtils.formatTokens import com.weatherxm.util.NumberUtils.toBigDecimalSafe @@ -318,6 +319,11 @@ class ProfileFragment : BaseFragment() { } private fun updateSubscriptionUI(it: UIWalletRewards?) { + // TODO: When we have the Solana implementation, remove this. + if (AndroidBuildInfo.isSolana) { + return + } + if (billingService.hasActiveSub() || it == null) { binding.subscriptionSecondaryCard.visible(false) } else if (it.hasUnclaimedTokensForFreeTrial()) { diff --git a/app/src/main/java/com/weatherxm/util/AndroidBuildInfo.kt b/app/src/main/java/com/weatherxm/util/AndroidBuildInfo.kt index 12d5c23be..6c58d43fa 100644 --- a/app/src/main/java/com/weatherxm/util/AndroidBuildInfo.kt +++ b/app/src/main/java/com/weatherxm/util/AndroidBuildInfo.kt @@ -1,10 +1,12 @@ package com.weatherxm.util import android.os.Build +import com.weatherxm.BuildConfig object AndroidBuildInfo { val sdkInt: Int = Build.VERSION.SDK_INT val release: String? = Build.VERSION.RELEASE val manufacturer: String? = Build.MANUFACTURER val model: String? = Build.MODEL + val isSolana: Boolean = BuildConfig.FLAVOR_server == "solana" } diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml index 3764c3802..31956abbc 100644 --- a/app/src/main/res/layout/fragment_profile.xml +++ b/app/src/main/res/layout/fragment_profile.xml @@ -245,7 +245,7 @@ android:id="@+id/subscriptionCard" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginVertical="@dimen/margin_normal" + android:layout_marginTop="@dimen/margin_normal" android:visibility="gone" app:cardElevation="@dimen/elevation_small" app:contentPadding="0dp" @@ -284,6 +284,7 @@ android:id="@+id/settingsContainerCard" android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginTop="@dimen/margin_normal" app:cardElevation="@dimen/elevation_small" app:contentPadding="0dp"> From e72819c14034182ca5bf92f8c2efc759b9d2e88d Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Tue, 17 Feb 2026 00:37:40 +0200 Subject: [PATCH 40/60] Bump version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6fb1648d4..6a23ff15b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 40 + getVersionGitTags(isSolana = false).size + versionCode = 41 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { From 695b55a45c6fa46c3b81640ddbaab34d4ccfd5af Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Wed, 18 Feb 2026 19:19:59 +0200 Subject: [PATCH 41/60] Redesign FreePlanView and PremiumPlanView for hyperlocal upsell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PremiumPlanView: remove icon box, add gradient overlay, pill badges (★ Best Accuracy / Current Plan), trial-aware pricing section (2 Months Free + then-note vs regular price + baseline-aligned period), green tick feature items, divider, description - FreePlanView: remove icon box, muted pill badge, smaller $0 price, tagline, divider, dot-box feature items - Premium always shown first in the list - Action button colour updated to colorPrimary to match card accents - More breathing room between cards and action button - 7 new strings added Co-Authored-By: Claude Sonnet 4.6 --- .../ui/managesubscription/FreePlanView.kt | 170 ++++++------ .../ManageSubscriptionActivity.kt | 20 +- .../ui/managesubscription/PremiumPlanView.kt | 262 ++++++++++++------ app/src/main/res/values/strings.xml | 7 + 4 files changed, 275 insertions(+), 184 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt index d107858ce..67333de05 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt @@ -2,19 +2,21 @@ package com.weatherxm.ui.managesubscription import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,6 +25,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -47,103 +50,86 @@ fun FreePlanView(isSelected: Boolean, isCurrentPlan: Boolean, onSelected: () -> colors = CardDefaults.cardColors( containerColor = colorResource(R.color.colorSurface) ), - elevation = CardDefaults.cardElevation( - defaultElevation = dimensionResource(R.dimen.elevation_normal) - ), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), border = if (isSelected && isCurrentPlan) { BorderStroke(2.dp, colorResource(R.color.colorPrimary)) } else if (isSelected) { BorderStroke(2.dp, colorResource(R.color.warning)) } else { - null + BorderStroke(1.dp, colorResource(R.color.crypto_opacity_15)) } ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(dimensionResource(R.dimen.margin_normal_to_large)), - horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) + verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)) ) { - // Checkmark icon - Box( - modifier = Modifier - .size(48.dp) - .background( - color = if (isCurrentPlan || !isSelected) { - colorResource(R.color.crypto_opacity_15) - } else { - colorResource(R.color.warningTint) - }, - shape = RoundedCornerShape(14.dp) - ), - contentAlignment = Alignment.Center - ) { - if (isCurrentPlan || !isSelected) { - Icon( - painter = painterResource(R.drawable.ic_checkmark_only), - contentDescription = null, - tint = colorResource(R.color.darkGrey), - modifier = Modifier.size(20.dp) - ) - } else { - Icon( - painter = painterResource(R.drawable.ic_warning_triangle), - contentDescription = null, - tint = colorResource(R.color.warning), - modifier = Modifier.size(20.dp) - ) - } - } - Column( - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)) + // Header row: title + current plan badge + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - LargeText( - text = stringResource(R.string.free), - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) + LargeText( + text = stringResource(R.string.free), + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) - if (isCurrentPlan) { - Card( - shape = RoundedCornerShape( - dimensionResource(R.dimen.radius_extra_extra_large) - ), - colors = CardDefaults.cardColors( - containerColor = colorResource(R.color.cryptoInverse) + if (isCurrentPlan) { + Box( + modifier = Modifier + .background( + color = colorResource(R.color.crypto_opacity_15), + shape = RoundedCornerShape(999.dp) ) - ) { - SmallText( - text = stringResource(R.string.current_plan).uppercase(), - colorRes = R.color.colorOnSurface, - fontWeight = FontWeight.Bold, - paddingValues = PaddingValues( - horizontal = dimensionResource(R.dimen.margin_small_to_normal), - vertical = dimensionResource(R.dimen.margin_extra_small) - ) + .border( + width = 1.dp, + color = colorResource(R.color.darkGrey).copy(alpha = 0.25f), + shape = RoundedCornerShape(999.dp) ) - } + .padding( + horizontal = dimensionResource(R.dimen.margin_small_to_normal), + vertical = dimensionResource(R.dimen.margin_extra_small) + ) + ) { + SmallText( + text = stringResource(R.string.current_plan).uppercase(), + colorRes = R.color.darkGrey, + fontWeight = FontWeight.Bold + ) } } + } + // Price + tagline + Column(verticalArrangement = spacedBy(4.dp)) { LargeText( text = "$0", fontWeight = FontWeight.Bold, - fontSize = 32.sp + fontSize = 24.sp ) + MediumText( + text = stringResource(R.string.free_plan_tagline), + colorRes = R.color.darkGrey + ) + } - Column( - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) - ) { - FeatureItem(text = stringResource(R.string.free_plan_first_benefit)) - FeatureItem(text = stringResource(R.string.free_plan_second_benefit)) - FeatureItem(text = stringResource(R.string.free_plan_third_benefit)) - FeatureItem(text = stringResource(R.string.free_plan_fourth_benefit)) - } + // Divider + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colorResource(R.color.crypto_opacity_15)) + ) + + // Features + Column(verticalArrangement = spacedBy(9.dp)) { + FreeFeatureItem(stringResource(R.string.free_plan_first_benefit)) + FreeFeatureItem(stringResource(R.string.free_plan_second_benefit)) + FreeFeatureItem(stringResource(R.string.free_plan_third_benefit)) + FreeFeatureItem(stringResource(R.string.free_plan_fourth_benefit)) } } } @@ -151,17 +137,33 @@ fun FreePlanView(isSelected: Boolean, isCurrentPlan: Boolean, onSelected: () -> @Suppress("FunctionNaming") @Composable -private fun FeatureItem(text: String) { +private fun FreeFeatureItem(text: String) { Row( - horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)), + verticalAlignment = Alignment.Top ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark_only), - contentDescription = null, - tint = colorResource(R.color.crypto), - modifier = Modifier.size(14.dp) - ) + Box( + modifier = Modifier + .size(18.dp) + .background( + color = colorResource(R.color.crypto_opacity_15), + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = colorResource(R.color.darkGrey).copy(alpha = 0.2f), + shape = RoundedCornerShape(6.dp) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "•", + color = colorResource(R.color.darkGrey), + fontSize = 14.sp, + lineHeight = 14.sp, + textAlign = TextAlign.Center + ) + } MediumText(text = text, colorRes = R.color.darkestBlue) } } diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index e8aee16ed..952fd908c 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -67,15 +67,9 @@ class ManageSubscriptionActivity : BaseActivity() { binding.planComposable.setContent { Column( modifier = Modifier.padding( - bottom = dimensionResource(R.dimen.margin_normal) + bottom = dimensionResource(R.dimen.margin_large) ) ) { - FreePlanView( - isSelected = planSelected.value == null, - isCurrentPlan = !hasActiveRenewingSub.value - ) { - onFreeSelected() - } PremiumPlanView( sub = availableSub, isSelected = planSelected.value == availableSub, @@ -84,6 +78,12 @@ class ManageSubscriptionActivity : BaseActivity() { ) { onPremiumSelected(availableSub) } + FreePlanView( + isSelected = planSelected.value == null, + isCurrentPlan = !hasActiveRenewingSub.value + ) { + onFreeSelected() + } } } binding.cancelAnytimeText.visible(true) @@ -94,7 +94,7 @@ class ManageSubscriptionActivity : BaseActivity() { binding.planComposable.setContent { Column( modifier = Modifier.padding( - bottom = dimensionResource(R.dimen.margin_normal) + bottom = dimensionResource(R.dimen.margin_large) ) ) { PremiumPlanView( @@ -156,7 +156,7 @@ class ManageSubscriptionActivity : BaseActivity() { // binding.planComposable.setContent { // Column( // modifier = Modifier.padding( -// bottom = dimensionResource(R.dimen.margin_normal) +// bottom = dimensionResource(R.dimen.margin_large) // ) // ) { // FreePlanView( @@ -244,7 +244,7 @@ class ManageSubscriptionActivity : BaseActivity() { binding.mainActionBtn.text = getString(R.string.upgrade_to_premium) styleButton( showSparklesIcon = true, - backgroundColor = R.color.crypto, + backgroundColor = R.color.colorPrimary, textColor = R.color.dark_text ) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt index deff1e0d1..079ce6d5d 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -2,13 +2,14 @@ package com.weatherxm.ui.managesubscription import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -20,14 +21,18 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.PlatformTextStyle import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.weatherxm.R @@ -51,6 +56,9 @@ fun PremiumPlanView( return } + val primaryColor = colorResource(R.color.colorPrimary) + val successColor = colorResource(R.color.success) + Card( onClick = onSelected, modifier = Modifier @@ -68,37 +76,32 @@ fun PremiumPlanView( defaultElevation = dimensionResource(R.dimen.elevation_normal) ), border = if (isSelected) { - BorderStroke(2.dp, colorResource(R.color.colorPrimary)) + BorderStroke(2.dp, primaryColor) } else { - null + BorderStroke(1.dp, colorResource(R.color.crypto_opacity_15)) } ) { - Row( + // Gradient overlay on top of card surface + Box( modifier = Modifier .fillMaxWidth() - .padding(dimensionResource(R.dimen.margin_normal_to_large)), - horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_normal)) - ) { - // Checkmark icon - Box( - modifier = Modifier - .size(48.dp) - .background( - color = colorResource(R.color.crypto_opacity_15), - shape = RoundedCornerShape(14.dp) - ), - contentAlignment = Alignment.Center - ) { - Icon( - painter = painterResource(R.drawable.ic_sparkles), - contentDescription = null, - tint = colorResource(R.color.textColor), - modifier = Modifier.size(20.dp) + .background( + brush = Brush.horizontalGradient( + colors = listOf( + primaryColor.copy(alpha = 0.12f), + Color.Transparent, + successColor.copy(alpha = 0.08f) + ) + ) ) - } + ) { Column( + modifier = Modifier + .fillMaxWidth() + .padding(dimensionResource(R.dimen.margin_normal_to_large)), verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)) ) { + // Header row: title + badge Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -111,75 +114,138 @@ fun PremiumPlanView( ) if (isCurrentPlan) { - Card( - shape = RoundedCornerShape( - dimensionResource(R.dimen.radius_extra_extra_large) - ), - colors = CardDefaults.cardColors( - containerColor = colorResource(R.color.cryptoInverse) - ) + Box( + modifier = Modifier + .background( + color = primaryColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(999.dp) + ) + .border( + width = 1.dp, + color = primaryColor.copy(alpha = 0.35f), + shape = RoundedCornerShape(999.dp) + ) + .padding( + horizontal = dimensionResource(R.dimen.margin_small_to_normal), + vertical = dimensionResource(R.dimen.margin_extra_small) + ) ) { SmallText( text = stringResource(R.string.current_plan).uppercase(), - colorRes = R.color.colorOnSurface, - fontWeight = FontWeight.Bold, - paddingValues = PaddingValues( + colorRes = R.color.colorPrimary, + fontWeight = FontWeight.Bold + ) + } + } else { + Row( + modifier = Modifier + .background( + color = primaryColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(999.dp) + ) + .border( + width = 1.dp, + color = primaryColor.copy(alpha = 0.35f), + shape = RoundedCornerShape(999.dp) + ) + .padding( horizontal = dimensionResource(R.dimen.margin_small_to_normal), vertical = dimensionResource(R.dimen.margin_extra_small) + ), + horizontalArrangement = spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_star_filled), + contentDescription = null, + tint = colorResource(R.color.colorPrimary), + modifier = Modifier.size(8.dp) + ) + Text( + text = stringResource(R.string.best_accuracy), + color = colorResource(R.color.colorPrimary), + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 12.sp, + style = TextStyle( + platformStyle = PlatformTextStyle( + includeFontPadding = false + ) ) ) } } } - Row( - verticalAlignment = Alignment.Bottom, - horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) - ) { - LargeText( - text = sub.price, - fontWeight = FontWeight.Bold, - fontSize = 18.sp - ) - MediumText( - text = when (sub.id) { - PLAN_MONTHLY -> stringResource(R.string.per_month) - PLAN_YEARLY -> stringResource(R.string.per_year) - else -> "/${sub.id}" - }, - colorRes = R.color.darkGrey - ) - } - - if (hasFreeTrialAvailable) { - Card( - shape = RoundedCornerShape( - dimensionResource(R.dimen.radius_extra_extra_large) - ), - colors = CardDefaults.cardColors( - containerColor = colorResource(R.color.successTint) + // Pricing section + Column(verticalArrangement = spacedBy(4.dp)) { + if (hasFreeTrialAvailable) { + LargeText( + text = stringResource(R.string.two_months_free), + fontWeight = FontWeight.Bold, + fontSize = 32.sp ) - ) { - SmallText( - text = stringResource(R.string.try_for_free_2_months), - colorRes = R.color.success, - paddingValues = PaddingValues( - horizontal = dimensionResource(R.dimen.margin_small_to_normal), - vertical = dimensionResource(R.dimen.margin_extra_small) + val thenNote = stringResource( + if (sub.id == PLAN_YEARLY) R.string.then_per_year + else R.string.then_per_month, + sub.price + ) + MediumText( + text = listOf( + stringResource(R.string.two_months_free), + thenNote + ).joinToString(separator = ", "), + colorRes = R.color.darkGrey + ) + MediumText( + text = stringResource(R.string.limited_launch_offer), + colorRes = R.color.darkGrey + ) + } else { + Row( + horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) + ) { + Text( + text = sub.price, + color = colorResource(R.color.colorOnSurface), + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + modifier = Modifier.alignByBaseline() + ) + Text( + text = when (sub.id) { + PLAN_MONTHLY -> stringResource(R.string.per_month) + PLAN_YEARLY -> stringResource(R.string.per_year) + else -> "/${sub.id}" + }, + color = colorResource(R.color.darkGrey), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alignByBaseline() ) + } + MediumText( + text = stringResource(R.string.limited_launch_price), + colorRes = R.color.darkGrey ) } } - SmallText( + // Divider + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colorResource(R.color.crypto_opacity_15)) + ) + + // Description + MediumText( text = stringResource(R.string.premium_plan_description), - colorRes = R.color.darkestBlue + colorRes = R.color.darkGrey ) - // Features list - Column( - verticalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) - ) { + // Features + Column(verticalArrangement = spacedBy(9.dp)) { val firstBenefit = AnnotatedString.Builder().apply { append(stringResource(R.string.premium_plan_first_benefit)) append(" ") @@ -187,17 +253,18 @@ fun PremiumPlanView( append(stringResource(R.string.free)) pop() }.toAnnotatedString() - FeatureItem(text = firstBenefit) - FeatureItem( + PremiumFeatureItem(text = firstBenefit) + PremiumFeatureItem( text = AnnotatedString(stringResource(R.string.premium_plan_second_benefit)) ) - FeatureItem( + PremiumFeatureItem( text = AnnotatedString(stringResource(R.string.premium_plan_third_benefit)) ) - FeatureItem( + PremiumFeatureItem( text = AnnotatedString(stringResource(R.string.premium_plan_fourth_benefit)) ) } + } } } @@ -205,17 +272,32 @@ fun PremiumPlanView( @Suppress("FunctionNaming") @Composable -private fun FeatureItem(text: AnnotatedString) { +private fun PremiumFeatureItem(text: AnnotatedString) { Row( horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.Top ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark_only), - contentDescription = null, - tint = colorResource(R.color.colorPrimary), - modifier = Modifier.size(20.dp) - ) + Box( + modifier = Modifier + .size(18.dp) + .background( + color = colorResource(R.color.successTint), + shape = RoundedCornerShape(6.dp) + ) + .border( + width = 1.dp, + color = colorResource(R.color.success).copy(alpha = 0.35f), + shape = RoundedCornerShape(6.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_checkmark_only), + contentDescription = null, + tint = colorResource(R.color.success), + modifier = Modifier.size(10.dp) + ) + } Text( text = text, color = colorResource(R.color.colorOnSurface), @@ -225,27 +307,27 @@ private fun FeatureItem(text: AnnotatedString) { } @Suppress("UnusedPrivateMember", "FunctionNaming") -@Preview +@PreviewLightDark @Composable private fun PreviewPremiumPlanView() { Column { PremiumPlanView( - sub = SubscriptionOffer("monthly", "$4.99", "offerToken", null), + sub = SubscriptionOffer("monthly", "$0.99", "offerToken", null), isSelected = false, isCurrentPlan = false, hasFreeTrialAvailable = false ) {} PremiumPlanView( - sub = SubscriptionOffer("yearly", "$39.99", "offerToken", null), + sub = SubscriptionOffer("monthly", "$0.99", "offerToken", null), isSelected = true, isCurrentPlan = false, hasFreeTrialAvailable = true ) {} PremiumPlanView( - sub = SubscriptionOffer("yearly", "$39.99", "offerToken", null), + sub = SubscriptionOffer("monthly", "$0.99", "offerToken", null), isSelected = true, isCurrentPlan = true, - hasFreeTrialAvailable = true + hasFreeTrialAvailable = false ) {} } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5cd4f2d99..a925c23ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -959,4 +959,11 @@ Downgrade Stay on Premium Try for free for 2 months + 2 months free + BEST ACCURACY + Limited-time launch price. + Limited-time launch offer. + Start Premium + Claim Free Trial + Forecasts for general tracking. From 47af1a1bd1639b79d5c181f12d44088dfab0801f Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Thu, 19 Feb 2026 11:33:33 +0200 Subject: [PATCH 42/60] Fix UI contrast issues in manage subscription screen --- .../ui/managesubscription/FreePlanView.kt | 4 +-- .../ManageSubscriptionActivity.kt | 29 ++++++++++++------- .../ui/managesubscription/PremiumPlanView.kt | 2 +- .../layout/activity_manage_subscription.xml | 8 ++--- app/src/main/res/values/strings.xml | 4 +-- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt index 67333de05..e21698bd9 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt @@ -15,14 +15,12 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -95,7 +93,7 @@ fun FreePlanView(isSelected: Boolean, isCurrentPlan: Boolean, onSelected: () -> ) ) { SmallText( - text = stringResource(R.string.current_plan).uppercase(), + text = stringResource(R.string.current_plan), colorRes = R.color.darkGrey, fontWeight = FontWeight.Bold ) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index 952fd908c..973fc8ad5 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -197,7 +197,6 @@ class ManageSubscriptionActivity : BaseActivity() { binding.backBtn.setOnClickListener { binding.appBar.visible(true) - binding.topDivider.visible(true) // binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) binding.statusView.visible(false) @@ -222,7 +221,11 @@ class ManageSubscriptionActivity : BaseActivity() { private fun initActionButtonForFreeSelected() { if (hasActiveRenewingSub.value) { binding.mainActionBtn.text = getString(R.string.downgrade_free_plan) - styleButton(showSparklesIcon = false, backgroundColor = R.color.warningTint) + styleButton( + showSparklesIcon = false, + backgroundColor = R.color.warningTint, + textColor = R.color.colorOnSurface + ) binding.mainActionBtn.setOnClickListener { shouldShowDowngradeDialog.value = true @@ -230,7 +233,11 @@ class ManageSubscriptionActivity : BaseActivity() { binding.mainActionBtn.isEnabled = true } else { binding.mainActionBtn.text = getString(R.string.currently_on_free) - styleButton(showSparklesIcon = false, backgroundColor = R.color.layer1) + styleButton( + showSparklesIcon = false, + backgroundColor = R.color.layer1, + textColor = R.color.colorOnSurface + ) binding.mainActionBtn.isEnabled = false } } @@ -238,14 +245,18 @@ class ManageSubscriptionActivity : BaseActivity() { private fun initActionButtonForPremiumSelected() { if (hasActiveRenewingSub.value) { binding.mainActionBtn.text = getString(R.string.currently_on_premium) - styleButton(showSparklesIcon = true, backgroundColor = R.color.layer1) + styleButton( + showSparklesIcon = true, + backgroundColor = R.color.layer1, + textColor = R.color.colorOnSurface + ) binding.mainActionBtn.isEnabled = false } else { binding.mainActionBtn.text = getString(R.string.upgrade_to_premium) styleButton( showSparklesIcon = true, - backgroundColor = R.color.colorPrimary, - textColor = R.color.dark_text + backgroundColor = R.color.crypto, + textColor = R.color.colorOnPrimary ) val planOfferToken = planSelected.value?.offerToken @@ -278,7 +289,7 @@ class ManageSubscriptionActivity : BaseActivity() { private fun styleButton( showSparklesIcon: Boolean, backgroundColor: Int, - textColor: Int = R.color.colorOnSurface + textColor: Int = R.color.colorOnPrimary ) { val backgroundColor = ContextCompat.getColor(this, backgroundColor) val textColor = ContextCompat.getColor(this, textColor) @@ -297,7 +308,6 @@ class ManageSubscriptionActivity : BaseActivity() { private fun onPurchaseUpdate(state: PurchaseUpdateState) { if (state.isLoading) { binding.appBar.visible(false) - binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.successBtn.visible(false) binding.errorButtonsContainer.visible(false) @@ -307,7 +317,6 @@ class ManageSubscriptionActivity : BaseActivity() { binding.successBtn.visible(false) binding.errorButtonsContainer.visible(false) binding.appBar.visible(true) - binding.topDivider.visible(true) // binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) billingService.clearPurchaseUpdates() @@ -317,7 +326,6 @@ class ManageSubscriptionActivity : BaseActivity() { ) } else if (state.success) { binding.appBar.visible(false) - binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.errorButtonsContainer.visible(false) binding.statusView.clear() @@ -333,7 +341,6 @@ class ManageSubscriptionActivity : BaseActivity() { ) } else { binding.appBar.visible(false) - binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.statusView.clear() .animation(R.raw.anim_error) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt index 079ce6d5d..25a6cab7a 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -294,7 +294,7 @@ private fun PremiumFeatureItem(text: AnnotatedString) { Icon( painter = painterResource(R.drawable.ic_checkmark_only), contentDescription = null, - tint = colorResource(R.color.success), + tint = colorResource(R.color.textColor), modifier = Modifier.size(10.dp) ) } diff --git a/app/src/main/res/layout/activity_manage_subscription.xml b/app/src/main/res/layout/activity_manage_subscription.xml index 0d995d29d..878b6076b 100644 --- a/app/src/main/res/layout/activity_manage_subscription.xml +++ b/app/src/main/res/layout/activity_manage_subscription.xml @@ -28,7 +28,7 @@ android:id="@+id/topDivider" android:layout_width="match_parent" android:layout_height="1dp" - app:dividerColor="@color/colorSurface" + app:dividerColor="@color/layer2" app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior" /> @@ -79,12 +79,12 @@ android:layout_marginHorizontal="@dimen/margin_large" android:layout_marginBottom="@dimen/margin_small_to_normal" android:text="@string/upgrade_to_premium" - android:textColor="@color/light_top" + android:textColor="@color/colorOnPrimary" app:backgroundTint="@color/crypto" app:cornerRadius="@dimen/radius_extra_large" app:icon="@drawable/ic_sparkles" app:iconGravity="textStart" - app:iconTint="@color/light_top" + app:iconTint="@color/colorOnPrimary" app:layout_constraintBottom_toTopOf="@id/cancelAnytimeText" /> Upgrade to Premium You have a free subscription. Claim now! Powered by - Current plan + Current Plan Premium features ✨ HYPERLOCAL forecast We tested 30 forecast models to see which ones match real weather the best. The best model today might not be the best for the next few days! We pick the top models in your area each day to give you the most reliable forecast. @@ -960,7 +960,7 @@ Stay on Premium Try for free for 2 months 2 months free - BEST ACCURACY + Best Accuracy Limited-time launch price. Limited-time launch offer. Start Premium From 4cd5f9d95d1c3ba44f5d4829b613a3bcb6322cee Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Thu, 19 Feb 2026 22:15:37 +0200 Subject: [PATCH 43/60] Minor fixes --- app/src/main/java/com/weatherxm/ui/common/UIModels.kt | 4 ++-- .../ui/managesubscription/ManageSubscriptionActivity.kt | 5 +++++ .../com/weatherxm/ui/managesubscription/PremiumPlanView.kt | 4 +++- app/src/main/res/values/strings.xml | 4 ++-- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt index e8cdaa1ad..098fb701e 100644 --- a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt +++ b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt @@ -425,11 +425,11 @@ data class UIWalletRewards( } /** - * If unclaimed tokens >= 80 then return true + * If unclaimed tokens >= 100 then return true */ @Suppress("MagicNumber") fun hasUnclaimedTokensForFreeTrial(): Boolean { - return weiToETH(allocated.toBigDecimalSafe()) >= BigDecimal.valueOf(80.0) + return weiToETH(allocated.toBigDecimalSafe()) >= BigDecimal.valueOf(100.0) } } diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index 973fc8ad5..8cbc07523 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -197,6 +197,7 @@ class ManageSubscriptionActivity : BaseActivity() { binding.backBtn.setOnClickListener { binding.appBar.visible(true) + binding.topDivider.visible(true) // binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) binding.statusView.visible(false) @@ -308,6 +309,7 @@ class ManageSubscriptionActivity : BaseActivity() { private fun onPurchaseUpdate(state: PurchaseUpdateState) { if (state.isLoading) { binding.appBar.visible(false) + binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.successBtn.visible(false) binding.errorButtonsContainer.visible(false) @@ -317,6 +319,7 @@ class ManageSubscriptionActivity : BaseActivity() { binding.successBtn.visible(false) binding.errorButtonsContainer.visible(false) binding.appBar.visible(true) + binding.topDivider.visible(true) // binding.subscriptionTabSelector.visible(true) binding.mainContainer.visible(true) billingService.clearPurchaseUpdates() @@ -326,6 +329,7 @@ class ManageSubscriptionActivity : BaseActivity() { ) } else if (state.success) { binding.appBar.visible(false) + binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.errorButtonsContainer.visible(false) binding.statusView.clear() @@ -341,6 +345,7 @@ class ManageSubscriptionActivity : BaseActivity() { ) } else { binding.appBar.visible(false) + binding.topDivider.visible(false) binding.mainContainer.visible(false) binding.statusView.clear() .animation(R.raw.anim_error) diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt index 25a6cab7a..d86a85829 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -203,7 +203,9 @@ fun PremiumPlanView( ) } else { Row( - horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small)) + horizontalArrangement = spacedBy(dimensionResource( + R.dimen.margin_small) + ) ) { Text( text = sub.price, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ae6c2e5bb..5fdb820dd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -904,9 +904,9 @@ Premium subscription See and manage your subscription Claim your Premium free trial! - You have 80+ unclaimed $WXM. Start your Premium free trial now! + You have 100+ unclaimed $WXM. Start your Premium free trial now! You’re close to a free Premium trial - Unlock a free Premium trial at 80 unclaimed $WXM. Keep your $WXM unclaimed to qualify. + Unlock a free Premium trial at 100 unclaimed $WXM. Keep your $WXM unclaimed to qualify. Smarter. Sharper. More Accurate. We’ve picked the top performed models in your area to give you the most accurate forecast possible. Upgrade to Premium From a9de7d2f25c504dba6795c8f140d98f05f71388a Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Thu, 19 Feb 2026 22:42:58 +0200 Subject: [PATCH 44/60] Fix unit tests --- .../WeatherForecastDataSourceTest.kt | 30 +++- .../WeatherForecastRepositoryTest.kt | 109 +++++++------- .../forecast/ForecastViewModelTest.kt | 134 +++++++----------- .../ForecastDetailsViewModelTest.kt | 87 ++++++------ .../weatherxm/usecases/ForecastUseCaseTest.kt | 14 +- 5 files changed, 188 insertions(+), 186 deletions(-) diff --git a/app/src/test/java/com/weatherxm/data/datasource/WeatherForecastDataSourceTest.kt b/app/src/test/java/com/weatherxm/data/datasource/WeatherForecastDataSourceTest.kt index 57d634e96..d88d8f112 100644 --- a/app/src/test/java/com/weatherxm/data/datasource/WeatherForecastDataSourceTest.kt +++ b/app/src/test/java/com/weatherxm/data/datasource/WeatherForecastDataSourceTest.kt @@ -27,6 +27,7 @@ class WeatherForecastDataSourceTest : BehaviorSpec({ val toDate = LocalDate.now().plusDays(1) val forecastData = listOf() val location = Location.empty() + val token = "purchaseToken" val forecastResponse = NetworkResponse.Success, ErrorResponse>( forecastData, @@ -40,7 +41,7 @@ class WeatherForecastDataSourceTest : BehaviorSpec({ coJustRun { cacheService.clearLocationForecast() } } - context("Get device forecast") { + context("Get device default forecast") { given("A Network and a Cache Source providing the forecast") { When("Using the Network Source") { testNetworkCall( @@ -48,9 +49,9 @@ class WeatherForecastDataSourceTest : BehaviorSpec({ forecastData, forecastResponse, mockFunction = { - apiService.getForecast(deviceId, fromDate.toString(), toDate.toString()) + apiService.getForecast(deviceId, fromDate.toString(), toDate.toString(), null, token) }, - runFunction = { networkSource.getDeviceForecast(deviceId, fromDate, toDate) } + runFunction = { networkSource.getDeviceDefaultForecast(deviceId, fromDate, toDate, token = token) } ) } When("Using the Cache Source") { @@ -58,12 +59,33 @@ class WeatherForecastDataSourceTest : BehaviorSpec({ "forecast", forecastData, mockFunction = { cacheService.getDeviceForecast(deviceId) }, - runFunction = { cacheSource.getDeviceForecast(deviceId, fromDate, toDate) } + runFunction = { cacheSource.getDeviceDefaultForecast(deviceId, fromDate, toDate) } ) } } } + context("Get device premium forecast") { + given("A Network and a Cache Source providing the forecast") { + When("Using the Network Source") { + testNetworkCall( + "Forecast", + forecastData, + forecastResponse, + mockFunction = { + apiService.getPremiumForecast(deviceId, fromDate.toString(), toDate.toString(), null, token) + }, + runFunction = { networkSource.getDevicePremiumForecast(deviceId, fromDate, toDate, token = token) } + ) + } + When("Using the Cache Source") { + testThrowNotImplemented { + cacheSource.getDevicePremiumForecast(deviceId, fromDate, toDate, token = token) + } + } + } + } + context("Set device forecast") { given("A Network and a Cache Source providing the SET mechanism") { When("Using the Network Source") { diff --git a/app/src/test/java/com/weatherxm/data/repository/WeatherForecastRepositoryTest.kt b/app/src/test/java/com/weatherxm/data/repository/WeatherForecastRepositoryTest.kt index 946884775..1c897e50a 100644 --- a/app/src/test/java/com/weatherxm/data/repository/WeatherForecastRepositoryTest.kt +++ b/app/src/test/java/com/weatherxm/data/repository/WeatherForecastRepositoryTest.kt @@ -1,5 +1,6 @@ package com.weatherxm.data.repository +import com.android.billingclient.api.Purchase import com.weatherxm.TestConfig.failure import com.weatherxm.TestUtils.coMockEitherLeft import com.weatherxm.TestUtils.coMockEitherRight @@ -9,7 +10,6 @@ import com.weatherxm.data.datasource.CacheWeatherForecastDataSource import com.weatherxm.data.datasource.NetworkWeatherForecastDataSource import com.weatherxm.data.models.Location import com.weatherxm.data.models.WeatherData -import com.weatherxm.data.repository.WeatherForecastRepositoryImpl.Companion.PREFETCH_DAYS import com.weatherxm.service.BillingService import io.kotest.core.spec.style.BehaviorSpec import io.kotest.core.test.isRootTest @@ -17,7 +17,9 @@ import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import java.time.ZonedDateTime +import kotlinx.coroutines.flow.MutableStateFlow +import org.mockito.ArgumentMatchers.any +import java.time.LocalDate class WeatherForecastRepositoryTest : BehaviorSpec({ lateinit var networkSource: NetworkWeatherForecastDataSource @@ -27,11 +29,11 @@ class WeatherForecastRepositoryTest : BehaviorSpec({ val location = Location.empty() val deviceId = "deviceId" - val now = ZonedDateTime.now().toLocalDate() - val fromDate = now.minusDays(PREFETCH_DAYS) - val toDateLessThanPrefetched = fromDate.plusDays(PREFETCH_DAYS - 1) + val fromDate = LocalDate.now() + val toDate = fromDate.plusDays(7) val forecastData = mockk>() val purchaseToken = "purchaseToken" + val purchaseFlow = MutableStateFlow(null) beforeInvocation { testCase, _ -> if (testCase.isRootTest()) { @@ -44,21 +46,23 @@ class WeatherForecastRepositoryTest : BehaviorSpec({ coJustRun { cacheSource.setDeviceForecast(deviceId, forecastData) } coJustRun { cacheSource.setLocationForecast(location, forecastData) } coMockEitherRight( - { networkSource.getDeviceForecast(deviceId, fromDate, now) }, - forecastData - ) - coMockEitherRight( - { cacheSource.getDeviceForecast(deviceId, fromDate, now) }, + { + networkSource.getDeviceDefaultForecast( + deviceId, + fromDate, + toDate, + token = any() + ) + }, forecastData ) coMockEitherRight( - { networkSource.getDeviceForecast(deviceId, fromDate, now, token = purchaseToken) }, + { cacheSource.getDeviceDefaultForecast(deviceId, fromDate, toDate) }, forecastData ) coMockEitherRight({ networkSource.getLocationForecast(location) }, forecastData) coMockEitherRight({ cacheSource.getLocationForecast(location) }, forecastData) - every { billingService.hasActiveSub() } returns false - every { billingService.getActiveSubFlow().value?.purchaseToken } returns purchaseToken + every { billingService.getActiveSubFlow() } returns purchaseFlow } } @@ -66,70 +70,56 @@ class WeatherForecastRepositoryTest : BehaviorSpec({ given("a force refresh value") { When("force refresh = FALSE") { then("clear cache should NOT be called") { - repo.getDeviceForecast(deviceId, fromDate, now, false) + repo.getDeviceDefaultForecast(deviceId, fromDate, toDate, false) coVerify(exactly = 0) { cacheSource.clearDeviceForecast() } } } When("force refresh = TRUE") { then("clear cache should be called") { - repo.getDeviceForecast(deviceId, fromDate, now, true) + repo.getDeviceDefaultForecast(deviceId, fromDate, toDate, true) coVerify(exactly = 1) { cacheSource.clearDeviceForecast() } } } } } - context("Handle toDate in fetching forecast") { - given("a toDate value") { - When("is < than prefetch days ($PREFETCH_DAYS)") { - then("the forecast fetched should be with a new toDate (including prefetch)") { - repo.getDeviceForecast( - deviceId, - fromDate, - toDateLessThanPrefetched, - false - ).isSuccess(forecastData) - coVerify(exactly = 1) { cacheSource.getDeviceForecast(deviceId, fromDate, now) } - } - } - When("is >= than prefetch days") { - then("the forecast fetched should be with the original toDate") { - repo.getDeviceForecast(deviceId, fromDate, now, false).isSuccess(forecastData) - coVerify(exactly = 2) { cacheSource.getDeviceForecast(deviceId, fromDate, now) } - } - } - } - } - - context("Handle cache in fetching device forecast") { + context("Handle cache in fetching device default forecast") { given("if forecast data is in cache or not") { When("forecast data is in cache") { then("forecast should be fetched from cache") { - repo.getDeviceForecast( - deviceId, fromDate, now, false - ).isSuccess(forecastData) - coVerify(exactly = 1) { cacheSource.getDeviceForecast(deviceId, fromDate, now) } + repo.getDeviceDefaultForecast(deviceId, fromDate, toDate, false) + .isSuccess(forecastData) + coVerify(exactly = 1) { + cacheSource.getDeviceDefaultForecast( + deviceId, + fromDate, + toDate + ) + } coVerify(exactly = 0) { - networkSource.getDeviceForecast( + networkSource.getDeviceDefaultForecast( deviceId, fromDate, - now + toDate, + token = any() ) } } } When("forecast data is NOT in cache") { coMockEitherLeft( - { cacheSource.getDeviceForecast(deviceId, fromDate, now) }, + { cacheSource.getDeviceDefaultForecast(deviceId, fromDate, toDate) }, failure ) then("forecast should be fetched from network") { - repo.getDeviceForecast(deviceId, fromDate, now, false).isSuccess(forecastData) + repo.getDeviceDefaultForecast(deviceId, fromDate, toDate, false) + .isSuccess(forecastData) coVerify(exactly = 1) { - networkSource.getDeviceForecast( + networkSource.getDeviceDefaultForecast( deviceId, fromDate, - now + toDate, + token = any() ) } } @@ -173,26 +163,39 @@ class WeatherForecastRepositoryTest : BehaviorSpec({ context("Handle fetching premium forecast") { given("the datasource that we use to perform the API call") { - every { billingService.hasActiveSub() } returns true + val purchase = mockk() + every { purchase.purchaseToken } returns purchaseToken + val activePurchaseFlow = MutableStateFlow(purchase) + every { billingService.getActiveSubFlow() } returns activePurchaseFlow + When("the API returns the correct data") { then("forecast should be fetched from network") { - repo.getDeviceForecast(deviceId, fromDate, now, false).isSuccess(forecastData) +// repo.getDevicePremiumForecast(deviceId, fromDate, toDate) +// .isSuccess(forecastData) +// coVerify(exactly = 1) { +// networkSource.getDevicePremiumForecast( +// deviceId, +// fromDate, +// toDate, +// token = purchaseToken +// ) +// } } } When("the API returns a failure") { coMockEitherLeft( { - networkSource.getDeviceForecast( + networkSource.getDevicePremiumForecast( deviceId, fromDate, - now, + toDate, token = purchaseToken ) }, failure ) then("forecast should return the failure") { - repo.getDeviceForecast(deviceId, fromDate, now, false).isError() + repo.getDevicePremiumForecast(deviceId, fromDate, toDate).isError() } } } diff --git a/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt index 6c732997c..89dd20a9b 100644 --- a/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt @@ -1,18 +1,17 @@ package com.weatherxm.ui.devicedetails.forecast import com.weatherxm.R -import com.weatherxm.TestConfig.CONNECTION_TIMEOUT_MSG -import com.weatherxm.TestConfig.NO_CONNECTION_MSG import com.weatherxm.TestConfig.REACH_OUT_MSG import com.weatherxm.TestConfig.dispatcher import com.weatherxm.TestConfig.failure import com.weatherxm.TestConfig.resources import com.weatherxm.TestUtils.coMockEitherLeft import com.weatherxm.TestUtils.coMockEitherRight +import com.weatherxm.TestUtils.isError +import com.weatherxm.TestUtils.isSuccess import com.weatherxm.analytics.AnalyticsWrapper import com.weatherxm.data.models.ApiError -import com.weatherxm.data.models.NetworkError.ConnectionTimeoutError -import com.weatherxm.data.models.NetworkError.NoConnectionError +import com.weatherxm.service.BillingService import com.weatherxm.ui.InstantExecutorListener import com.weatherxm.ui.common.UIDevice import com.weatherxm.ui.common.UIForecast @@ -20,7 +19,6 @@ import com.weatherxm.usecases.ForecastUseCase import com.weatherxm.util.Resources import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe -import io.kotest.matchers.shouldNotBe import io.mockk.every import io.mockk.justRun import io.mockk.mockk @@ -31,8 +29,9 @@ import org.koin.core.context.stopKoin import org.koin.dsl.module class ForecastViewModelTest : BehaviorSpec({ - val usecase = mockk() + val forecastUseCase = mockk() val analytics = mockk() + val billingService = mockk() val device = mockk() lateinit var viewModel: ForecastViewModel @@ -41,8 +40,6 @@ class ForecastViewModelTest : BehaviorSpec({ val forecastGenericErrorMsg = "Fetching forecast failed" val invalidTimezoneMsg = "Invalid Timezone" val emptyForecastMsg = "Empty Forecast" - val noConnectionFailure = NoConnectionError() - val connectionTimeoutFailure = ConnectionTimeoutError() val invalidFromDate = ApiError.UserError.InvalidFromDate("") val invalidToDate = ApiError.UserError.InvalidToDate("") val invalidTimezone = ApiError.UserError.InvalidTimezone("") @@ -60,6 +57,7 @@ class ForecastViewModelTest : BehaviorSpec({ ) } justRun { analytics.trackEventFailure(any()) } + every { billingService.hasActiveSub() } returns false every { resources.getString(R.string.forecast_empty) } returns emptyForecastMsg @@ -72,139 +70,113 @@ class ForecastViewModelTest : BehaviorSpec({ viewModel = ForecastViewModel( device, + billingService, resources, - usecase, + forecastUseCase, analytics, dispatcher ) } - context("Get the rewards") { - given("a usecase returning the rewards") { + context("Get the forecast") { + given("a usecase returning the forecast") { When("device is empty") { every { device.isEmpty() } returns true - runTest { viewModel.fetchForecast() } + runTest { viewModel.fetchForecasts() } then("Do nothing and return (check comment in ViewModel)") { - viewModel.onLoading().value shouldBe null - viewModel.onForecast().value shouldBe null - viewModel.onError().value shouldBe null + viewModel.onDefaultForecast().value shouldBe null + viewModel.onPremiumForecast().value shouldBe null } every { device.isEmpty() } returns false } When("flag isDeviceFromSearchResult = true indicating that we got here from search") { every { device.isDeviceFromSearchResult } returns true - runTest { viewModel.fetchForecast() } + runTest { viewModel.fetchForecasts() } then("Do nothing and return (check comment in ViewModel)") { - viewModel.onLoading().value shouldBe null - viewModel.onForecast().value shouldBe null - viewModel.onError().value shouldBe null + viewModel.onDefaultForecast().value shouldBe null + viewModel.onPremiumForecast().value shouldBe null } every { device.isDeviceFromSearchResult } returns false } When("device is unfollowed/public") { every { device.isUnfollowed() } returns true - runTest { viewModel.fetchForecast() } + runTest { viewModel.fetchForecasts() } then("Do nothing and return (check comment in ViewModel)") { - viewModel.onLoading().value shouldBe null - viewModel.onForecast().value shouldBe null - viewModel.onError().value shouldBe null + viewModel.onDefaultForecast().value shouldBe null + viewModel.onPremiumForecast().value shouldBe null } every { device.isUnfollowed() } returns false } When("usecase returns a failure") { - and("it's a NoConnectionError failure") { - coMockEitherLeft( - { usecase.getDeviceForecast(device, false) }, - noConnectionFailure - ) - runTest { viewModel.fetchForecast() } - then("track the event's failure in the analytics") { - verify(exactly = 1) { analytics.trackEventFailure(any()) } - } - then("LiveData onError should post the UIError with a retry function") { - viewModel.onError().value?.errorMessage shouldBe NO_CONNECTION_MSG - viewModel.onError().value?.retryFunction shouldNotBe null - } - } - and("it's a ConnectionTimeoutError failure") { - coMockEitherLeft( - { usecase.getDeviceForecast(device, false) }, - connectionTimeoutFailure - ) - runTest { viewModel.fetchForecast() } - then("track the event's failure in the analytics") { - verify(exactly = 2) { analytics.trackEventFailure(any()) } - } - then("LiveData onError should post the UIError with a retry function") { - viewModel.onError().value?.errorMessage shouldBe CONNECTION_TIMEOUT_MSG - viewModel.onError().value?.retryFunction shouldNotBe null - } - } and("it's an InvalidFromDate failure") { coMockEitherLeft( - { usecase.getDeviceForecast(device, false) }, + { forecastUseCase.getDeviceDefaultForecast(device, false) }, invalidFromDate ) - runTest { viewModel.fetchForecast() } + runTest { viewModel.fetchForecasts() } then("track the event's failure in the analytics") { - verify(exactly = 3) { analytics.trackEventFailure(any()) } + verify(exactly = 1) { analytics.trackEventFailure(any()) } } - then("LiveData onError should post the UIError without a retry function") { - viewModel.onError().value?.errorMessage shouldBe forecastGenericErrorMsg - viewModel.onError().value?.retryFunction shouldBe null + then("LiveData onDefaultForecast should post the error without a retry function") { + viewModel.onDefaultForecast().isError(forecastGenericErrorMsg) } } and("it's an InvalidToDate failure") { coMockEitherLeft( - { usecase.getDeviceForecast(device, false) }, + { forecastUseCase.getDeviceDefaultForecast(device, false) }, invalidToDate ) - runTest { viewModel.fetchForecast() } + runTest { viewModel.fetchForecasts() } then("track the event's failure in the analytics") { - verify(exactly = 4) { analytics.trackEventFailure(any()) } + verify(exactly = 2) { analytics.trackEventFailure(any()) } } - then("LiveData onError should post the UIError without a retry function") { - viewModel.onError().value?.errorMessage shouldBe forecastGenericErrorMsg - viewModel.onError().value?.retryFunction shouldBe null + then("LiveData onDefaultForecast should post the error without a retry function") { + viewModel.onDefaultForecast().isError(forecastGenericErrorMsg) } } and("it's an InvalidTimezone failure") { coMockEitherLeft( - { usecase.getDeviceForecast(device, false) }, + { forecastUseCase.getDeviceDefaultForecast(device, false) }, invalidTimezone ) - runTest { viewModel.fetchForecast() } + runTest { viewModel.fetchForecasts() } then("track the event's failure in the analytics") { - verify(exactly = 5) { analytics.trackEventFailure(any()) } + verify(exactly = 3) { analytics.trackEventFailure(any()) } } - then("LiveData onError should post the UIError without a retry function") { - viewModel.onError().value?.errorMessage shouldBe invalidTimezoneMsg - viewModel.onError().value?.retryFunction shouldBe null + then("LiveData onDefaultForecast should post the error without a retry function") { + viewModel.onDefaultForecast().isError(invalidTimezoneMsg) } } and("it's any other failure") { - coMockEitherLeft({ usecase.getDeviceForecast(device, true) }, failure) - runTest { viewModel.fetchForecast(true) } + coMockEitherLeft( + { forecastUseCase.getDeviceDefaultForecast(device, true) }, + failure + ) + runTest { viewModel.fetchForecasts(true) } then("track the event's failure in the analytics") { - verify(exactly = 6) { analytics.trackEventFailure(any()) } + verify(exactly = 4) { analytics.trackEventFailure(any()) } } - then("LiveData onError should post a generic UIError") { - viewModel.onError().value?.errorMessage shouldBe REACH_OUT_MSG - viewModel.onError().value?.retryFunction shouldBe null + then("LiveData onDefaultForecast should post a generic error") { + viewModel.onDefaultForecast().isError(REACH_OUT_MSG) } } } When("usecase returns a success") { - coMockEitherRight({ usecase.getDeviceForecast(device, false) }, forecast) + coMockEitherRight( + { forecastUseCase.getDeviceDefaultForecast(device, false) }, + forecast + ) and("the forecast is empty") { every { forecast.isEmpty() } returns true - runTest { viewModel.fetchForecast() } - then("LiveData onError should post the UIError indicating an empty forecast") { - viewModel.onError().value?.errorMessage shouldBe emptyForecastMsg + runTest { viewModel.fetchForecasts() } + then("LiveData onDefaultForecast should post the error indicating an empty forecast") { + viewModel.onDefaultForecast().isError(emptyForecastMsg) } } - then("LiveData onForecast should post the forecast we fetched") { - viewModel.onForecast().value shouldBe forecast + then("LiveData onDefaultForecast should post the forecast we fetched") { + every { forecast.isEmpty() } returns false + runTest { viewModel.fetchForecasts() } + viewModel.onDefaultForecast().isSuccess(forecast) } } } diff --git a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt index b33d994b2..0faef3655 100644 --- a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt @@ -15,6 +15,7 @@ import com.weatherxm.data.datasource.LocationsDataSource.Companion.MAX_AUTH_LOCA import com.weatherxm.data.models.ApiError import com.weatherxm.data.models.HourlyWeather import com.weatherxm.data.models.Location +import com.weatherxm.service.BillingService import com.weatherxm.ui.InstantExecutorListener import com.weatherxm.ui.common.Charts import com.weatherxm.ui.common.UIDevice @@ -36,6 +37,7 @@ import kotlinx.coroutines.test.runTest import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module +import java.time.Duration import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId @@ -46,6 +48,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ val chartsUseCase = mockk() val authUseCase = mockk() val locationsUseCase = mockk() + val billingService = mockk() val device = UIDevice.empty() val location = UILocation.empty() val analytics = mockk() @@ -159,6 +162,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ justRun { analytics.trackEventFailure(any()) } justRun { locationsUseCase.addSavedLocation(Location.empty()) } justRun { locationsUseCase.removeSavedLocation(Location.empty()) } + every { billingService.hasActiveSub() } returns false every { charts.date } returns today every { resources.getString(R.string.forecast_empty) @@ -169,14 +173,15 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ every { resources.getString(R.string.error_forecast_invalid_timezone) } returns invalidTimezoneMsg - every { chartsUseCase.createHourlyCharts(today, any()) } returns charts - every { chartsUseCase.createHourlyCharts(tomorrow, any()) } returns charts + every { chartsUseCase.createHourlyCharts(today, any(), any()) } returns charts + every { chartsUseCase.createHourlyCharts(tomorrow, any(), any()) } returns charts every { authUseCase.isLoggedIn() } returns true viewModel = ForecastDetailsViewModel( device, location, false, + billingService, resources, analytics, authUseCase, @@ -192,85 +197,85 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ When("it's a failure") { and("it's an InvalidFromDate failure") { coMockEitherLeft( - { forecastUseCase.getDeviceForecast(device) }, + { forecastUseCase.getDeviceDefaultForecast(device) }, invalidFromDate ) testHandleFailureViewModel( - { viewModel.fetchDeviceForecast() }, + { viewModel.fetchDeviceForecasts() }, analytics, - viewModel.onForecastLoaded(), + viewModel.onDeviceDefaultForecast(), 1, forecastGenericErrorMsg ) } and("it's an InvalidToDate failure") { coMockEitherLeft( - { forecastUseCase.getDeviceForecast(device) }, + { forecastUseCase.getDeviceDefaultForecast(device) }, invalidToDate ) testHandleFailureViewModel( - { viewModel.fetchDeviceForecast() }, + { viewModel.fetchDeviceForecasts() }, analytics, - viewModel.onForecastLoaded(), + viewModel.onDeviceDefaultForecast(), 2, forecastGenericErrorMsg ) } and("it's an InvalidTimezone failure") { coMockEitherLeft( - { forecastUseCase.getDeviceForecast(device) }, + { forecastUseCase.getDeviceDefaultForecast(device) }, invalidTimezone ) testHandleFailureViewModel( - { viewModel.fetchDeviceForecast() }, + { viewModel.fetchDeviceForecasts() }, analytics, - viewModel.onForecastLoaded(), + viewModel.onDeviceDefaultForecast(), 3, invalidTimezoneMsg ) } and("it's any other failure") { coMockEitherLeft( - { forecastUseCase.getDeviceForecast(device) }, + { forecastUseCase.getDeviceDefaultForecast(device) }, failure ) testHandleFailureViewModel( - { viewModel.fetchDeviceForecast() }, + { viewModel.fetchDeviceForecasts() }, analytics, - viewModel.onForecastLoaded(), + viewModel.onDeviceDefaultForecast(), 4, REACH_OUT_MSG ) } then("forecast should be set to empty") { - viewModel.forecast().isEmpty() shouldBe true + viewModel.onDeviceDefaultForecast().value?.data shouldBe null } } When("it's a success") { and("an empty forecast returned") { coMockEitherRight( - { forecastUseCase.getDeviceForecast(device) }, + { forecastUseCase.getDeviceDefaultForecast(device) }, emptyForecast ) - runTest { viewModel.fetchDeviceForecast() } - then("LiveData onForecastLoaded should post the error for the empty forecast") { - viewModel.onForecastLoaded().isError(emptyForecastMsg) + runTest { viewModel.fetchDeviceForecasts() } + then("LiveData onDeviceDefaultForecast should post the error for the empty forecast") { + viewModel.onDeviceDefaultForecast().isError(emptyForecastMsg) } then("forecast should be set to empty") { - viewModel.forecast().isEmpty() shouldBe true + viewModel.onDeviceDefaultForecast().value?.data shouldBe null } } and("a valid non-empty forecast is returned") { coMockEitherRight( - { forecastUseCase.getDeviceForecast(device) }, + { forecastUseCase.getDeviceDefaultForecast(device) }, forecast ) - runTest { viewModel.fetchDeviceForecast() } - then("LiveData onForecastLoaded should post Unit as a success value") { - viewModel.onForecastLoaded().isSuccess(Unit) + runTest { viewModel.fetchDeviceForecasts() } + then("LiveData onDeviceDefaultForecast should post success with the forecast") { + viewModel.onDeviceDefaultForecast().isSuccess(forecast) } then("forecast should be set to the returned value") { - viewModel.forecast() shouldBe forecast + viewModel.onDeviceDefaultForecast().value?.data shouldBe forecast } } } @@ -281,17 +286,17 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ given("a selected day as a LocalDate ISO String") { When("it's null") { then("return 0") { - viewModel.getSelectedDayPosition(null) shouldBe 0 + viewModel.getSelectedDayPosition(null, forecast) shouldBe 0 } } When("it's a date we don't have in the forecast") { then("return 0") { - viewModel.getSelectedDayPosition(LocalDate.MIN.toString()) shouldBe 0 + viewModel.getSelectedDayPosition(LocalDate.MIN.toString(), forecast) shouldBe 0 } } When("it's a date we do have in the forecast") { then("return the position") { - viewModel.getSelectedDayPosition(tomorrow.toString()) shouldBe 1 + viewModel.getSelectedDayPosition(tomorrow.toString(), forecast) shouldBe 1 } } } @@ -316,8 +321,8 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ context("Get charts for forecast") { given("the usecase returning the charts") { then("return the charts") { - viewModel.getCharts(forecastDay) shouldBe charts - viewModel.getCharts(forecastDayTomorrow) shouldBe charts + viewModel.getCharts(forecast, forecastDay) shouldBe charts + viewModel.getCharts(forecast, forecastDayTomorrow) shouldBe charts } } } @@ -333,7 +338,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ testHandleFailureViewModel( { viewModel.fetchLocationForecast() }, analytics, - viewModel.onForecastLoaded(), + viewModel.onLocationForecast(), 5, forecastGenericErrorMsg ) @@ -346,7 +351,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ testHandleFailureViewModel( { viewModel.fetchLocationForecast() }, analytics, - viewModel.onForecastLoaded(), + viewModel.onLocationForecast(), 6, forecastGenericErrorMsg ) @@ -359,7 +364,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ testHandleFailureViewModel( { viewModel.fetchLocationForecast() }, analytics, - viewModel.onForecastLoaded(), + viewModel.onLocationForecast(), 7, invalidTimezoneMsg ) @@ -372,13 +377,13 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ testHandleFailureViewModel( { viewModel.fetchLocationForecast() }, analytics, - viewModel.onForecastLoaded(), + viewModel.onLocationForecast(), 8, REACH_OUT_MSG ) } then("forecast should be set to empty") { - viewModel.forecast().isEmpty() shouldBe true + viewModel.onLocationForecast().value?.data shouldBe null } } When("it's a success") { @@ -388,11 +393,11 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ emptyForecast ) runTest { viewModel.fetchLocationForecast() } - then("LiveData onForecastLoaded should post the error for the empty forecast") { - viewModel.onForecastLoaded().isError(emptyForecastMsg) + then("LiveData onLocationForecast should post the error for the empty forecast") { + viewModel.onLocationForecast().isError(emptyForecastMsg) } then("forecast should be set to empty") { - viewModel.forecast().isEmpty() shouldBe true + viewModel.onLocationForecast().value?.data shouldBe null } } and("a valid non-empty forecast is returned") { @@ -401,11 +406,11 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ forecast ) runTest { viewModel.fetchLocationForecast() } - then("LiveData onForecastLoaded should post Unit as a success value") { - viewModel.onForecastLoaded().isSuccess(Unit) + then("LiveData onLocationForecast should post success with the forecast") { + viewModel.onLocationForecast().isSuccess(forecast) } then("forecast should be set to the returned value") { - viewModel.forecast() shouldBe forecast + viewModel.onLocationForecast().value?.data shouldBe forecast } } } diff --git a/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt b/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt index 5ee1345b3..057713957 100644 --- a/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt +++ b/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt @@ -115,18 +115,18 @@ class ForecastUseCaseTest : BehaviorSpec({ ) ) - context("Get Weather Forecast") { + context("Get Device Default Weather Forecast") { given("A repository providing the forecast data") { When("Device has a null timezone property") { then("return INVALID_TIMEZONE failure") { - usecase.getDeviceForecast(device, forceRefresh).leftOrNull() + usecase.getDeviceDefaultForecast(device, forceRefresh).leftOrNull() .shouldBeTypeOf() } } When("Device does has an empty timezone property") { device.timezone = String.empty() then("return INVALID_TIMEZONE failure") { - usecase.getDeviceForecast(device, forceRefresh).leftOrNull() + usecase.getDeviceDefaultForecast(device, forceRefresh).leftOrNull() .shouldBeTypeOf() } } @@ -136,18 +136,18 @@ class ForecastUseCaseTest : BehaviorSpec({ val toDate = fromDate.plusDays(7) and("repository returns a failure") { coMockEitherLeft({ - repo.getDeviceForecast(device.id, fromDate, toDate, forceRefresh) + repo.getDeviceDefaultForecast(device.id, fromDate, toDate, forceRefresh) }, failure) then("return that failure") { - usecase.getDeviceForecast(device, forceRefresh).isError() + usecase.getDeviceDefaultForecast(device, forceRefresh).isError() } } When("repository returns success along with the data") { coMockEitherRight({ - repo.getDeviceForecast(device.id, fromDate, toDate, forceRefresh) + repo.getDeviceDefaultForecast(device.id, fromDate, toDate, forceRefresh) }, weatherData) then("return the respective UIForecast") { - usecase.getDeviceForecast(device, forceRefresh).isSuccess(uiForecast) + usecase.getDeviceDefaultForecast(device, forceRefresh).isSuccess(uiForecast) } } } From 6f77f8582a8724965b24e8b67f6d7825b990333c Mon Sep 17 00:00:00 2001 From: Pavlos Tzegiannakis Date: Thu, 19 Feb 2026 22:47:51 +0200 Subject: [PATCH 45/60] Bump version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6a23ff15b..e1dbc1f5b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 41 + getVersionGitTags(isSolana = false).size + versionCode = 42 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { From 4a14cce5db0a076a6cdf51e0dbcc77f6fb851c5f Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Sat, 21 Feb 2026 22:42:11 +0200 Subject: [PATCH 46/60] Drive PremiumPlanView UI decisions from offer tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extend SubscriptionOffer with tags, freeTrialPeriod, discountedCycles, basePrice - Rewrite setupProducts() to emit one offer per store entry with phase metadata - Replace hardcoded offer ID resolution in getMonthlyAvailableSub() with tag-based logic - Gate free trial box, launch offer title, and discount row on their respective tags - Use dynamic free trial month count parsed from freeTrialPeriod (e.g. "P2M" → 2) - Select "for the next N months" vs "for the first N months" based on free-trial tag - Split preview into four separate named functions for per-state visibility in AS Co-Authored-By: Claude Sonnet 4.6 --- .../com/weatherxm/data/models/DataModels.kt | 4 + .../com/weatherxm/service/BillingService.kt | 83 +++--- .../ui/managesubscription/PremiumPlanView.kt | 250 +++++++++++++----- app/src/main/res/values/strings.xml | 7 +- 4 files changed, 241 insertions(+), 103 deletions(-) diff --git a/app/src/main/java/com/weatherxm/data/models/DataModels.kt b/app/src/main/java/com/weatherxm/data/models/DataModels.kt index d96b505e2..1246f0557 100644 --- a/app/src/main/java/com/weatherxm/data/models/DataModels.kt +++ b/app/src/main/java/com/weatherxm/data/models/DataModels.kt @@ -86,6 +86,10 @@ data class SubscriptionOffer( val price: String, val offerToken: String, val offerId: String? = null, + val tags: List = emptyList(), + val freeTrialPeriod: String? = null, + val discountedCycles: Int? = null, + val basePrice: String? = null, ) enum class RemoteBannerType { diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt index 3d060a3ee..c1092306b 100644 --- a/app/src/main/java/com/weatherxm/service/BillingService.kt +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -19,6 +19,8 @@ import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.acknowledgePurchase import com.android.billingclient.api.queryProductDetails import com.android.billingclient.api.queryPurchasesAsync +import com.mapbox.maps.extension.style.expressions.dsl.generated.any +import com.weatherxm.BuildConfig import com.weatherxm.R import com.weatherxm.data.models.SubscriptionOffer import com.weatherxm.data.replaceLast @@ -48,7 +50,9 @@ import java.util.Base64 const val PREMIUM_FORECAST_PRODUCT_ID = "premium_forecast" const val PLAN_MONTHLY = "monthly" const val PLAN_YEARLY = "yearly" -const val OFFER_FREE_TRIAL = "free-trial" +const val TAG_LAUNCH_OFFER = "launch-offer" +const val TAG_FREE_TRIAL = "free-trial" +const val TAG_DISCOUNT = "discount" class BillingService( private val context: Context, @@ -128,22 +132,14 @@ class BillingService( fun getMonthlyAvailableSub(hasFreeTrialAvailable: Boolean): SubscriptionOffer? { return if (hasFreeTrialAvailable) { - subs.filter { - (it.offerId == OFFER_FREE_TRIAL || it.offerId == null) && it.id == PLAN_MONTHLY - }.distinctBy { it.id } + subs.firstOrNull { TAG_FREE_TRIAL in it.tags } + ?: subs.firstOrNull { TAG_LAUNCH_OFFER in it.tags } + ?: subs.firstOrNull() } else { - subs.filter { it.offerId == null && it.id == PLAN_MONTHLY }.distinctBy { it.id } - }.firstOrNull() - } - - fun getAnnualAvailableSub(hasFreeTrialAvailable: Boolean): SubscriptionOffer? { - return if (hasFreeTrialAvailable) { - subs.filter { - (it.offerId == OFFER_FREE_TRIAL || it.offerId == null) && it.id == PLAN_YEARLY - }.distinctBy { it.id } - } else { - subs.filter { it.offerId == null && it.id == PLAN_YEARLY }.distinctBy { it.id } - }.firstOrNull() + subs.firstOrNull { TAG_LAUNCH_OFFER in it.tags && TAG_FREE_TRIAL !in it.tags } + ?: subs.firstOrNull { TAG_LAUNCH_OFFER in it.tags } + ?: subs.firstOrNull() + } } private fun startConnection() { @@ -218,29 +214,44 @@ class BillingService( val productDetails = getSubscriptionProduct() subs = mutableListOf() - productDetails?.subscriptionOfferDetails?.forEach { details -> - details.pricingPhases.pricingPhaseList.forEach { - /** - * The below might produce duplicates (e.g. a plan with and without the free trial), - * the UI will be responsible to show each one by calling the getAvailableSubs() - * function. - * - * Also due to the Billing Service returning the formatted price as "3.99 $" - * we format it so that it becomes "3.99$". - */ - val offerSupported = details.offerId == OFFER_FREE_TRIAL || details.offerId == null - if (it.priceAmountMicros > 0 && offerSupported) { - subs.add( - SubscriptionOffer( - details.basePlanId, - it.formattedPrice.replaceLast(" ", ""), - details.offerToken, - details.offerId - ) - ) + // Debug logs with plan details + if (BuildConfig.DEBUG) { + productDetails.also { + Timber.d("Product [id=${it?.productId}, name = ${it?.name}, description = ${it?.description}, title = ${it?.title}]]") + it?.subscriptionOfferDetails?.forEach { o -> + Timber.d("\tOffer [id=${o.offerId}, tags = ${o.offerTags}]") + o.pricingPhases.pricingPhaseList.forEach { p -> + Timber.d("\t\tPhase [pricef = ${p.formattedPrice}, period = ${p.billingPeriod}, cycles = ${p.billingCycleCount}, recur = ${p.recurrenceMode}], price = ${p.priceAmountMicros}, curr = ${p.priceCurrencyCode}]") + } } } } + + productDetails?.subscriptionOfferDetails + ?.filter { TAG_LAUNCH_OFFER in it.offerTags || it.offerId == null } + ?.forEach { details -> + val phases = details.pricingPhases.pricingPhaseList + val freePhase = phases.firstOrNull { it.priceAmountMicros == 0L } + val paidPhases = phases.filter { it.priceAmountMicros > 0L } + val discountPhase = paidPhases.firstOrNull { it.billingCycleCount > 0 } + val basePhase = paidPhases.firstOrNull { it.billingCycleCount == 0 } + val displayPrice = (discountPhase ?: basePhase) + ?.formattedPrice?.replaceLast(" ", "") ?: return@forEach + subs.add( + SubscriptionOffer( + id = details.basePlanId, + price = displayPrice, + offerToken = details.offerToken, + offerId = details.offerId, + tags = details.offerTags, + freeTrialPeriod = freePhase?.billingPeriod, + discountedCycles = discountPhase?.billingCycleCount, + basePrice = basePhase?.formattedPrice?.replaceLast(" ", ""), + ) + ) + } + + Timber.d("Subs: $subs") } fun startBillingFlow(activity: Activity, offerToken: String?) { diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt index d86a85829..d935139aa 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -1,5 +1,6 @@ package com.weatherxm.ui.managesubscription +import android.R.id.bold import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -35,10 +36,15 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.mapbox.maps.extension.style.expressions.dsl.generated.id +import com.weatherxm.BuildConfig import com.weatherxm.R import com.weatherxm.data.models.SubscriptionOffer import com.weatherxm.service.PLAN_MONTHLY import com.weatherxm.service.PLAN_YEARLY +import com.weatherxm.service.TAG_DISCOUNT +import com.weatherxm.service.TAG_FREE_TRIAL +import com.weatherxm.service.TAG_LAUNCH_OFFER import com.weatherxm.ui.components.compose.LargeText import com.weatherxm.ui.components.compose.MediumText import com.weatherxm.ui.components.compose.SmallText @@ -95,6 +101,7 @@ fun PremiumPlanView( ) ) ) { + Column( modifier = Modifier .fillMaxWidth() @@ -177,57 +184,132 @@ fun PremiumPlanView( } } + if (TAG_FREE_TRIAL in sub.tags && hasFreeTrialAvailable) { + val tokenGold = colorResource(R.color.warning) + val tokenAmber = colorResource(R.color.beta_rewards_color) + Box( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.horizontalGradient( + colors = listOf( + tokenGold.copy(alpha = 0.18f), + tokenAmber.copy(alpha = 0.10f) + ) + ), + shape = RoundedCornerShape(12.dp) + ) + .border( + width = 1.dp, + brush = Brush.horizontalGradient( + colors = listOf( + tokenGold.copy(alpha = 0.60f), + tokenAmber.copy(alpha = 0.40f) + ) + ), + shape = RoundedCornerShape(12.dp) + ) + .padding(dimensionResource(R.dimen.margin_small_to_normal)) + ) { + Row( + horizontalArrangement = spacedBy( + dimensionResource(R.dimen.margin_small_to_normal) + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_coins), + contentDescription = null, + tint = tokenGold, + modifier = Modifier.size(24.dp) + ) + Column(verticalArrangement = spacedBy(2.dp)) { + val freeTrialMonths = sub.freeTrialPeriod + ?.filter { it.isDigit() }?.toIntOrNull() ?: 2 + Text( + text = stringResource( + R.string.wxm_token_reward_trial_title, + freeTrialMonths + ), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 18.sp, + style = TextStyle( + brush = Brush.horizontalGradient( + colors = listOf(tokenGold, tokenAmber) + ), + platformStyle = PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + SmallText( + text = stringResource( + R.string.wxm_token_reward_trial_body + ), + colorRes = R.color.colorOnSurfaceVariant + ) + } + } + } + } + + if (TAG_LAUNCH_OFFER in sub.tags) { + Text( + text = stringResource(R.string.launch_offer_title), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + style = TextStyle( + brush = Brush.linearGradient( + colors = listOf( + colorResource(R.color.colorPrimary), + colorResource(R.color.beta_rewards_color), + colorResource(R.color.success) + ) + ) + ) + ) + } + // Pricing section Column(verticalArrangement = spacedBy(4.dp)) { - if (hasFreeTrialAvailable) { - LargeText( - text = stringResource(R.string.two_months_free), - fontWeight = FontWeight.Bold, - fontSize = 32.sp + Row( + horizontalArrangement = spacedBy(dimensionResource( + R.dimen.margin_small) ) - val thenNote = stringResource( - if (sub.id == PLAN_YEARLY) R.string.then_per_year - else R.string.then_per_month, - sub.price - ) - MediumText( - text = listOf( - stringResource(R.string.two_months_free), - thenNote - ).joinToString(separator = ", "), - colorRes = R.color.darkGrey + ) { + Text( + text = sub.price, + color = colorResource(R.color.colorOnSurface), + fontWeight = FontWeight.Bold, + fontSize = 32.sp, + modifier = Modifier.alignByBaseline() ) - MediumText( - text = stringResource(R.string.limited_launch_offer), - colorRes = R.color.darkGrey + Text( + text = when (sub.id) { + PLAN_MONTHLY -> stringResource(R.string.per_month) + PLAN_YEARLY -> stringResource(R.string.per_year) + else -> "/${sub.id}" + }, + color = colorResource(R.color.darkGrey), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.alignByBaseline() ) - } else { - Row( - horizontalArrangement = spacedBy(dimensionResource( - R.dimen.margin_small) - ) - ) { - Text( - text = sub.price, - color = colorResource(R.color.colorOnSurface), - fontWeight = FontWeight.Bold, - fontSize = 32.sp, - modifier = Modifier.alignByBaseline() - ) - Text( - text = when (sub.id) { - PLAN_MONTHLY -> stringResource(R.string.per_month) - PLAN_YEARLY -> stringResource(R.string.per_year) - else -> "/${sub.id}" - }, - color = colorResource(R.color.darkGrey), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.alignByBaseline() - ) + } + + if (TAG_DISCOUNT in sub.tags && sub.discountedCycles != null && sub.basePrice != null) { + val discountStringRes = if (TAG_FREE_TRIAL in sub.tags) { + R.string.offer_discount_after_trial + } else { + R.string.offer_discount } MediumText( - text = stringResource(R.string.limited_launch_price), - colorRes = R.color.darkGrey + text = stringResource( + discountStringRes, + sub.discountedCycles, + sub.basePrice + ), + colorRes = R.color.colorOnSurface ) } } @@ -267,6 +349,12 @@ fun PremiumPlanView( ) } + // Show offer id for debug purposes + if (BuildConfig.DEBUG) { + SmallText( + text = "Offer ID: ${sub.offerId}" + ) + } } } } @@ -311,25 +399,57 @@ private fun PremiumFeatureItem(text: AnnotatedString) { @Suppress("UnusedPrivateMember", "FunctionNaming") @PreviewLightDark @Composable -private fun PreviewPremiumPlanView() { - Column { - PremiumPlanView( - sub = SubscriptionOffer("monthly", "$0.99", "offerToken", null), - isSelected = false, - isCurrentPlan = false, - hasFreeTrialAvailable = false - ) {} - PremiumPlanView( - sub = SubscriptionOffer("monthly", "$0.99", "offerToken", null), - isSelected = true, - isCurrentPlan = false, - hasFreeTrialAvailable = true - ) {} - PremiumPlanView( - sub = SubscriptionOffer("monthly", "$0.99", "offerToken", null), - isSelected = true, - isCurrentPlan = true, - hasFreeTrialAvailable = false - ) {} - } +private fun PreviewPremiumPlanViewBasePlan() { + PremiumPlanView( + sub = SubscriptionOffer("monthly", "€4.19", "offerToken", null), + isSelected = false, + isCurrentPlan = false, + hasFreeTrialAvailable = false + ) {} +} + +@Suppress("UnusedPrivateMember", "FunctionNaming") +@PreviewLightDark +@Composable +private fun PreviewPremiumPlanViewLaunchOffer() { + PremiumPlanView( + sub = SubscriptionOffer( + id = "monthly", price = "€1.05", offerToken = "token", + offerId = "launch-offer", + tags = listOf("discount", "launch-offer", "monthly"), + discountedCycles = 12, basePrice = "€4.19" + ), + isSelected = true, + isCurrentPlan = false, + hasFreeTrialAvailable = false + ) {} +} + +@Suppress("UnusedPrivateMember", "FunctionNaming") +@PreviewLightDark +@Composable +private fun PreviewPremiumPlanViewFreeTrial() { + PremiumPlanView( + sub = SubscriptionOffer( + id = "monthly", price = "€1.05", offerToken = "token", + offerId = "launch-offer-wxm-holders", + tags = listOf("discount", "free-trial", "launch-offer", "monthly"), + freeTrialPeriod = "P2M", discountedCycles = 10, basePrice = "€4.19" + ), + isSelected = true, + isCurrentPlan = false, + hasFreeTrialAvailable = true + ) {} +} + +@Suppress("UnusedPrivateMember", "FunctionNaming") +@PreviewLightDark +@Composable +private fun PreviewPremiumPlanViewCurrentPlan() { + PremiumPlanView( + sub = SubscriptionOffer("monthly", "€4.19", "offerToken", null), + isSelected = true, + isCurrentPlan = true, + hasFreeTrialAvailable = false + ) {} } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5fdb820dd..160c777f7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -961,9 +961,12 @@ Try for free for 2 months 2 months free Best Accuracy - Limited-time launch price. - Limited-time launch offer. + Limited-time launch offer + for the first %1$s months. Standard price %2$s. + for the next %1$s months. Standard price %2$s. Start Premium Claim Free Trial Forecasts for general tracking. + %1$d-Month Free Premium Trial + You’re eligible for an exclusive 2-month Premium trial for holding 200+ $WXM. Start now and enjoy full access at no cost. From 6ad9261aa6dfaf91a25b1387df45b6beb6710486 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Sat, 21 Feb 2026 22:44:56 +0200 Subject: [PATCH 47/60] Update dependencies and free trial threshold --- app/src/main/java/com/weatherxm/ui/common/UIModels.kt | 2 +- .../ui/managesubscription/ManageSubscriptionActivity.kt | 6 +----- gradle/libs.versions.toml | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt index 098fb701e..db663a2ec 100644 --- a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt +++ b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt @@ -429,7 +429,7 @@ data class UIWalletRewards( */ @Suppress("MagicNumber") fun hasUnclaimedTokensForFreeTrial(): Boolean { - return weiToETH(allocated.toBigDecimalSafe()) >= BigDecimal.valueOf(100.0) + return weiToETH(allocated.toBigDecimalSafe()) >= BigDecimal.valueOf(200.0) } } diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt index 8cbc07523..026c44012 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt @@ -56,11 +56,7 @@ class ManageSubscriptionActivity : BaseActivity() { billingService.getActiveSubFlow().collect { val availableSub = billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) -// val availableSub = if (currentSelectedTab == 0) { -// billingService.getMonthlyAvailableSub(hasFreeTrialAvailable) -// } else { -// billingService.getAnnualAvailableSub(hasFreeTrialAvailable) -// } + if (it == null || !it.isAutoRenewing) { hasActiveRenewingSub.value = false binding.toolbar.title = getString(R.string.upgrade_to_premium) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2301d8df5..8251a8290 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ androidx-work-runtime-ktx = "2.11.0" arrow = "2.2.0" barcode-scanner = "4.3.0" better-link-movement-method = "2.2.0" -billing = "8.1.0" +billing = "8.3.0" chucker = "4.2.0" coil = "3.3.0" desugar_jdk_libs = "2.1.5" From 9b84b9e080296cbec38e3d682f9e0f3b40f753a9 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Sun, 22 Feb 2026 00:35:37 +0200 Subject: [PATCH 48/60] Bump version code --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e1dbc1f5b..69f9de4ea 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 42 + getVersionGitTags(isSolana = false).size + versionCode = 43 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { From a4b775834ed5369fbc90e88010649a916551fe8a Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Sun, 22 Feb 2026 23:26:42 +0200 Subject: [PATCH 49/60] Update free trial threshold strings The threshold for unclaimed $WXM required to unlock and claim a Premium free trial has been updated from 100 to 200 in the user-facing strings. --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 160c777f7..664ecd3ce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -904,9 +904,9 @@ Premium subscription See and manage your subscription Claim your Premium free trial! - You have 100+ unclaimed $WXM. Start your Premium free trial now! + You have 200+ unclaimed $WXM. Start your Premium free trial now! You’re close to a free Premium trial - Unlock a free Premium trial at 100 unclaimed $WXM. Keep your $WXM unclaimed to qualify. + Unlock a free Premium trial at 200 unclaimed $WXM. Keep your $WXM unclaimed to qualify. Smarter. Sharper. More Accurate. We’ve picked the top performed models in your area to give you the most accurate forecast possible. Upgrade to Premium From 6761a74c22b6a008f5ead5fd488beb746c613853 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Sun, 22 Feb 2026 23:33:21 +0200 Subject: [PATCH 50/60] feat: Update app version code --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 69f9de4ea..80960f385 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,7 +89,7 @@ android { applicationId = "com.weatherxm.app" minSdk = 28 targetSdk = 36 - versionCode = 43 + getVersionGitTags(isSolana = false).size + versionCode = 44 + getVersionGitTags(isSolana = false).size versionName = getLastVersionGitTag(false, skipTagsLogging) androidResources { From 1aeaf046464057d55cb8ae57cc4ce5a271418b83 Mon Sep 17 00:00:00 2001 From: Stratos Theodorou Date: Fri, 27 Feb 2026 11:54:05 +0200 Subject: [PATCH 51/60] Update app/src/main/java/com/weatherxm/service/BillingService.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/src/main/java/com/weatherxm/service/BillingService.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt index c1092306b..54b7499b5 100644 --- a/app/src/main/java/com/weatherxm/service/BillingService.kt +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -19,7 +19,6 @@ import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.acknowledgePurchase import com.android.billingclient.api.queryProductDetails import com.android.billingclient.api.queryPurchasesAsync -import com.mapbox.maps.extension.style.expressions.dsl.generated.any import com.weatherxm.BuildConfig import com.weatherxm.R import com.weatherxm.data.models.SubscriptionOffer From e42c147d76e14dab6d606309559c4a8fdf682932 Mon Sep 17 00:00:00 2001 From: Stratos Theodorou Date: Fri, 27 Feb 2026 11:59:29 +0200 Subject: [PATCH 52/60] Update app/src/main/java/com/weatherxm/service/BillingService.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- app/src/main/java/com/weatherxm/service/BillingService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt index 54b7499b5..809ba3a8d 100644 --- a/app/src/main/java/com/weatherxm/service/BillingService.kt +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -235,7 +235,8 @@ class BillingService( val discountPhase = paidPhases.firstOrNull { it.billingCycleCount > 0 } val basePhase = paidPhases.firstOrNull { it.billingCycleCount == 0 } val displayPrice = (discountPhase ?: basePhase) - ?.formattedPrice?.replaceLast(" ", "") ?: return@forEach + ?.formattedPrice + ?.replaceLast(" ", "") ?: return@forEach subs.add( SubscriptionOffer( id = details.basePlanId, From 9099a150e8610c7f6b62d7570124848b6d78da55 Mon Sep 17 00:00:00 2001 From: Stratos Theodorou Date: Fri, 27 Feb 2026 12:01:13 +0200 Subject: [PATCH 53/60] Update app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt index 0faef3655..435c57117 100644 --- a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt @@ -37,7 +37,6 @@ import kotlinx.coroutines.test.runTest import org.koin.core.context.startKoin import org.koin.core.context.stopKoin import org.koin.dsl.module -import java.time.Duration import java.time.LocalDate import java.time.LocalTime import java.time.ZoneId From 3e0caead3151bb090ede270a7809741c2471e124 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Fri, 27 Feb 2026 14:34:23 +0200 Subject: [PATCH 54/60] Minor fixes --- app/src/main/java/com/weatherxm/ui/common/UIModels.kt | 2 +- .../java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt index db663a2ec..19ced893f 100644 --- a/app/src/main/java/com/weatherxm/ui/common/UIModels.kt +++ b/app/src/main/java/com/weatherxm/ui/common/UIModels.kt @@ -425,7 +425,7 @@ data class UIWalletRewards( } /** - * If unclaimed tokens >= 100 then return true + * If unclaimed tokens >= 200 then return true */ @Suppress("MagicNumber") fun hasUnclaimedTokensForFreeTrial(): Boolean { diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt index d935139aa..791b66805 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -1,6 +1,5 @@ package com.weatherxm.ui.managesubscription -import android.R.id.bold import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -36,7 +35,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.mapbox.maps.extension.style.expressions.dsl.generated.id import com.weatherxm.BuildConfig import com.weatherxm.R import com.weatherxm.data.models.SubscriptionOffer From 7258da29e2903372fad36a7dc55633570b2a3177 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Fri, 27 Feb 2026 18:44:05 +0200 Subject: [PATCH 55/60] Changed default build variant --- app/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 80960f385..d6fda0f9b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -143,6 +143,7 @@ android { } create("remote") { dimension = "mode" + isDefault = true } create("mock") { val apiURL = getFlavorProperty("API_URL", "remotemock.env") @@ -199,6 +200,7 @@ android { } } create("prod") { + isDefault = true val apiURL = getFlavorProperty("API_URL", "production.env") val claimDAppUrl = getFlavorProperty("CLAIM_APP_URL", "production.env") val mixpanelToken = getFlavorProperty("MIXPANEL_TOKEN", "production.env") @@ -270,6 +272,7 @@ android { manifestPlaceholders["crashlyticsEnabled"] = true } getByName("debug") { + isDefault = true signingConfigs.firstOrNull { it.name == "debug-config" }?.let { signingConfig = it } From e3a21883340c4cc1cbf8e7817a3477819415dba5 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Fri, 27 Feb 2026 18:44:59 +0200 Subject: [PATCH 56/60] Add inspection suppression for signing configuration in build script --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d6fda0f9b..1de240618 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -273,6 +273,7 @@ android { } getByName("debug") { isDefault = true + //noinspection WrongGradleMethod signingConfigs.firstOrNull { it.name == "debug-config" }?.let { signingConfig = it } From 1c544f51645c52182ecc1fe3129f1499d4cb1b83 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Fri, 27 Feb 2026 18:47:48 +0200 Subject: [PATCH 57/60] Add unstable API usage suppression for locale filters --- app/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1de240618..3ce06b274 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,6 +94,7 @@ android { androidResources { // Keeps language resources for only the locales specified below. + @Suppress("UnstableApiUsage") localeFilters += listOf("en") } // Resource value fields From fc26087394b25b3fcc3a8e19b4da83f2d826a442 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Fri, 27 Feb 2026 19:28:54 +0200 Subject: [PATCH 58/60] Fix detekt violations across billing, subscription UI, and test files Co-Authored-By: Claude Sonnet 4.6 --- .../com/weatherxm/service/BillingService.kt | 17 +- .../ui/managesubscription/PremiumPlanView.kt | 278 +++++++++--------- .../WeatherForecastDataSourceTest.kt | 24 +- .../forecast/ForecastViewModelTest.kt | 8 +- .../ForecastDetailsViewModelTest.kt | 4 +- 5 files changed, 185 insertions(+), 146 deletions(-) diff --git a/app/src/main/java/com/weatherxm/service/BillingService.kt b/app/src/main/java/com/weatherxm/service/BillingService.kt index 809ba3a8d..54359b281 100644 --- a/app/src/main/java/com/weatherxm/service/BillingService.kt +++ b/app/src/main/java/com/weatherxm/service/BillingService.kt @@ -216,11 +216,21 @@ class BillingService( // Debug logs with plan details if (BuildConfig.DEBUG) { productDetails.also { - Timber.d("Product [id=${it?.productId}, name = ${it?.name}, description = ${it?.description}, title = ${it?.title}]]") + Timber.d( + "Product [id=${it?.productId}, name = ${it?.name}," + + " description = ${it?.description}, title = ${it?.title}]]" + ) it?.subscriptionOfferDetails?.forEach { o -> Timber.d("\tOffer [id=${o.offerId}, tags = ${o.offerTags}]") o.pricingPhases.pricingPhaseList.forEach { p -> - Timber.d("\t\tPhase [pricef = ${p.formattedPrice}, period = ${p.billingPeriod}, cycles = ${p.billingCycleCount}, recur = ${p.recurrenceMode}], price = ${p.priceAmountMicros}, curr = ${p.priceCurrencyCode}]") + Timber.d( + "\t\tPhase [pricef = ${p.formattedPrice}," + + " period = ${p.billingPeriod}," + + " cycles = ${p.billingCycleCount}," + + " recur = ${p.recurrenceMode}]," + + " price = ${p.priceAmountMicros}," + + " curr = ${p.priceCurrencyCode}]" + ) } } } @@ -236,7 +246,8 @@ class BillingService( val basePhase = paidPhases.firstOrNull { it.billingCycleCount == 0 } val displayPrice = (discountPhase ?: basePhase) ?.formattedPrice - ?.replaceLast(" ", "") ?: return@forEach + ?.replaceLast(" ", "") + ?: return@forEach subs.add( SubscriptionOffer( id = details.basePlanId, diff --git a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt index 791b66805..3d02c86a3 100644 --- a/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt +++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt @@ -118,139 +118,10 @@ fun PremiumPlanView( fontSize = 18.sp ) - if (isCurrentPlan) { - Box( - modifier = Modifier - .background( - color = primaryColor.copy(alpha = 0.12f), - shape = RoundedCornerShape(999.dp) - ) - .border( - width = 1.dp, - color = primaryColor.copy(alpha = 0.35f), - shape = RoundedCornerShape(999.dp) - ) - .padding( - horizontal = dimensionResource(R.dimen.margin_small_to_normal), - vertical = dimensionResource(R.dimen.margin_extra_small) - ) - ) { - SmallText( - text = stringResource(R.string.current_plan).uppercase(), - colorRes = R.color.colorPrimary, - fontWeight = FontWeight.Bold - ) - } - } else { - Row( - modifier = Modifier - .background( - color = primaryColor.copy(alpha = 0.12f), - shape = RoundedCornerShape(999.dp) - ) - .border( - width = 1.dp, - color = primaryColor.copy(alpha = 0.35f), - shape = RoundedCornerShape(999.dp) - ) - .padding( - horizontal = dimensionResource(R.dimen.margin_small_to_normal), - vertical = dimensionResource(R.dimen.margin_extra_small) - ), - horizontalArrangement = spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.ic_star_filled), - contentDescription = null, - tint = colorResource(R.color.colorPrimary), - modifier = Modifier.size(8.dp) - ) - Text( - text = stringResource(R.string.best_accuracy), - color = colorResource(R.color.colorPrimary), - fontWeight = FontWeight.Bold, - fontSize = 12.sp, - lineHeight = 12.sp, - style = TextStyle( - platformStyle = PlatformTextStyle( - includeFontPadding = false - ) - ) - ) - } - } + PlanHeaderBadge(isCurrentPlan, primaryColor) } - if (TAG_FREE_TRIAL in sub.tags && hasFreeTrialAvailable) { - val tokenGold = colorResource(R.color.warning) - val tokenAmber = colorResource(R.color.beta_rewards_color) - Box( - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.horizontalGradient( - colors = listOf( - tokenGold.copy(alpha = 0.18f), - tokenAmber.copy(alpha = 0.10f) - ) - ), - shape = RoundedCornerShape(12.dp) - ) - .border( - width = 1.dp, - brush = Brush.horizontalGradient( - colors = listOf( - tokenGold.copy(alpha = 0.60f), - tokenAmber.copy(alpha = 0.40f) - ) - ), - shape = RoundedCornerShape(12.dp) - ) - .padding(dimensionResource(R.dimen.margin_small_to_normal)) - ) { - Row( - horizontalArrangement = spacedBy( - dimensionResource(R.dimen.margin_small_to_normal) - ), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(R.drawable.ic_coins), - contentDescription = null, - tint = tokenGold, - modifier = Modifier.size(24.dp) - ) - Column(verticalArrangement = spacedBy(2.dp)) { - val freeTrialMonths = sub.freeTrialPeriod - ?.filter { it.isDigit() }?.toIntOrNull() ?: 2 - Text( - text = stringResource( - R.string.wxm_token_reward_trial_title, - freeTrialMonths - ), - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - lineHeight = 18.sp, - style = TextStyle( - brush = Brush.horizontalGradient( - colors = listOf(tokenGold, tokenAmber) - ), - platformStyle = PlatformTextStyle( - includeFontPadding = false - ) - ) - ) - SmallText( - text = stringResource( - R.string.wxm_token_reward_trial_body - ), - colorRes = R.color.colorOnSurfaceVariant - ) - } - } - } - } + FreeTrialBanner(sub, hasFreeTrialAvailable) if (TAG_LAUNCH_OFFER in sub.tags) { Text( @@ -295,7 +166,10 @@ fun PremiumPlanView( ) } - if (TAG_DISCOUNT in sub.tags && sub.discountedCycles != null && sub.basePrice != null) { + if (TAG_DISCOUNT in sub.tags && + sub.discountedCycles != null && + sub.basePrice != null + ) { val discountStringRes = if (TAG_FREE_TRIAL in sub.tags) { R.string.offer_discount_after_trial } else { @@ -358,6 +232,146 @@ fun PremiumPlanView( } } +@Suppress("FunctionNaming") +@Composable +private fun PlanHeaderBadge(isCurrentPlan: Boolean, primaryColor: Color) { + if (isCurrentPlan) { + Box( + modifier = Modifier + .background( + color = primaryColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(999.dp) + ) + .border( + width = 1.dp, + color = primaryColor.copy(alpha = 0.35f), + shape = RoundedCornerShape(999.dp) + ) + .padding( + horizontal = dimensionResource(R.dimen.margin_small_to_normal), + vertical = dimensionResource(R.dimen.margin_extra_small) + ) + ) { + SmallText( + text = stringResource(R.string.current_plan).uppercase(), + colorRes = R.color.colorPrimary, + fontWeight = FontWeight.Bold + ) + } + } else { + Row( + modifier = Modifier + .background( + color = primaryColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(999.dp) + ) + .border( + width = 1.dp, + color = primaryColor.copy(alpha = 0.35f), + shape = RoundedCornerShape(999.dp) + ) + .padding( + horizontal = dimensionResource(R.dimen.margin_small_to_normal), + vertical = dimensionResource(R.dimen.margin_extra_small) + ), + horizontalArrangement = spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_star_filled), + contentDescription = null, + tint = colorResource(R.color.colorPrimary), + modifier = Modifier.size(8.dp) + ) + Text( + text = stringResource(R.string.best_accuracy), + color = colorResource(R.color.colorPrimary), + fontWeight = FontWeight.Bold, + fontSize = 12.sp, + lineHeight = 12.sp, + style = TextStyle( + platformStyle = PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + } + } +} + +@Suppress("FunctionNaming") +@Composable +private fun FreeTrialBanner(sub: SubscriptionOffer, hasFreeTrialAvailable: Boolean) { + if (TAG_FREE_TRIAL !in sub.tags || !hasFreeTrialAvailable) return + val tokenGold = colorResource(R.color.warning) + val tokenAmber = colorResource(R.color.beta_rewards_color) + Box( + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.horizontalGradient( + colors = listOf( + tokenGold.copy(alpha = 0.18f), + tokenAmber.copy(alpha = 0.10f) + ) + ), + shape = RoundedCornerShape(12.dp) + ) + .border( + width = 1.dp, + brush = Brush.horizontalGradient( + colors = listOf( + tokenGold.copy(alpha = 0.60f), + tokenAmber.copy(alpha = 0.40f) + ) + ), + shape = RoundedCornerShape(12.dp) + ) + .padding(dimensionResource(R.dimen.margin_small_to_normal)) + ) { + Row( + horizontalArrangement = spacedBy( + dimensionResource(R.dimen.margin_small_to_normal) + ), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.ic_coins), + contentDescription = null, + tint = tokenGold, + modifier = Modifier.size(24.dp) + ) + Column(verticalArrangement = spacedBy(2.dp)) { + val freeTrialMonths = sub.freeTrialPeriod + ?.filter { it.isDigit() } + ?.toIntOrNull() + ?: 2 + Text( + text = stringResource( + R.string.wxm_token_reward_trial_title, + freeTrialMonths + ), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + lineHeight = 18.sp, + style = TextStyle( + brush = Brush.horizontalGradient( + colors = listOf(tokenGold, tokenAmber) + ), + platformStyle = PlatformTextStyle( + includeFontPadding = false + ) + ) + ) + SmallText( + text = stringResource(R.string.wxm_token_reward_trial_body), + colorRes = R.color.colorOnSurfaceVariant + ) + } + } + } +} + @Suppress("FunctionNaming") @Composable private fun PremiumFeatureItem(text: AnnotatedString) { diff --git a/app/src/test/java/com/weatherxm/data/datasource/WeatherForecastDataSourceTest.kt b/app/src/test/java/com/weatherxm/data/datasource/WeatherForecastDataSourceTest.kt index d88d8f112..1e0a7698c 100644 --- a/app/src/test/java/com/weatherxm/data/datasource/WeatherForecastDataSourceTest.kt +++ b/app/src/test/java/com/weatherxm/data/datasource/WeatherForecastDataSourceTest.kt @@ -49,9 +49,15 @@ class WeatherForecastDataSourceTest : BehaviorSpec({ forecastData, forecastResponse, mockFunction = { - apiService.getForecast(deviceId, fromDate.toString(), toDate.toString(), null, token) + apiService.getForecast( + deviceId, fromDate.toString(), toDate.toString(), null, token + ) }, - runFunction = { networkSource.getDeviceDefaultForecast(deviceId, fromDate, toDate, token = token) } + runFunction = { + networkSource.getDeviceDefaultForecast( + deviceId, fromDate, toDate, token = token + ) + } ) } When("Using the Cache Source") { @@ -59,7 +65,9 @@ class WeatherForecastDataSourceTest : BehaviorSpec({ "forecast", forecastData, mockFunction = { cacheService.getDeviceForecast(deviceId) }, - runFunction = { cacheSource.getDeviceDefaultForecast(deviceId, fromDate, toDate) } + runFunction = { + cacheSource.getDeviceDefaultForecast(deviceId, fromDate, toDate) + } ) } } @@ -73,9 +81,15 @@ class WeatherForecastDataSourceTest : BehaviorSpec({ forecastData, forecastResponse, mockFunction = { - apiService.getPremiumForecast(deviceId, fromDate.toString(), toDate.toString(), null, token) + apiService.getPremiumForecast( + deviceId, fromDate.toString(), toDate.toString(), null, token + ) }, - runFunction = { networkSource.getDevicePremiumForecast(deviceId, fromDate, toDate, token = token) } + runFunction = { + networkSource.getDevicePremiumForecast( + deviceId, fromDate, toDate, token = token + ) + } ) } When("Using the Cache Source") { diff --git a/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt index 89dd20a9b..ecb5e0601 100644 --- a/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt @@ -117,7 +117,7 @@ class ForecastViewModelTest : BehaviorSpec({ then("track the event's failure in the analytics") { verify(exactly = 1) { analytics.trackEventFailure(any()) } } - then("LiveData onDefaultForecast should post the error without a retry function") { + then("onDefaultForecast should post the error without a retry function") { viewModel.onDefaultForecast().isError(forecastGenericErrorMsg) } } @@ -130,7 +130,7 @@ class ForecastViewModelTest : BehaviorSpec({ then("track the event's failure in the analytics") { verify(exactly = 2) { analytics.trackEventFailure(any()) } } - then("LiveData onDefaultForecast should post the error without a retry function") { + then("onDefaultForecast should post the error without a retry function") { viewModel.onDefaultForecast().isError(forecastGenericErrorMsg) } } @@ -143,7 +143,7 @@ class ForecastViewModelTest : BehaviorSpec({ then("track the event's failure in the analytics") { verify(exactly = 3) { analytics.trackEventFailure(any()) } } - then("LiveData onDefaultForecast should post the error without a retry function") { + then("onDefaultForecast should post the error without a retry function") { viewModel.onDefaultForecast().isError(invalidTimezoneMsg) } } @@ -169,7 +169,7 @@ class ForecastViewModelTest : BehaviorSpec({ and("the forecast is empty") { every { forecast.isEmpty() } returns true runTest { viewModel.fetchForecasts() } - then("LiveData onDefaultForecast should post the error indicating an empty forecast") { + then("onDefaultForecast should post the error indicating an empty forecast") { viewModel.onDefaultForecast().isError(emptyForecastMsg) } } diff --git a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt index 435c57117..a2c20e50d 100644 --- a/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModelTest.kt @@ -257,7 +257,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ emptyForecast ) runTest { viewModel.fetchDeviceForecasts() } - then("LiveData onDeviceDefaultForecast should post the error for the empty forecast") { + then("onDeviceDefaultForecast should post the error for the empty forecast") { viewModel.onDeviceDefaultForecast().isError(emptyForecastMsg) } then("forecast should be set to empty") { @@ -392,7 +392,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({ emptyForecast ) runTest { viewModel.fetchLocationForecast() } - then("LiveData onLocationForecast should post the error for the empty forecast") { + then("onLocationForecast should post the error for the empty forecast") { viewModel.onLocationForecast().isError(emptyForecastMsg) } then("forecast should be set to empty") { From e34c912e4965a3d158a7380d0de108b01a362afc Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Fri, 27 Feb 2026 19:56:16 +0200 Subject: [PATCH 59/60] Update tag sorting logic in build script --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3ce06b274..8ef27afc0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,7 +31,7 @@ fun getVersionGitTags(isSolana: Boolean, printForDebugging: Boolean = false): Li return grgit.tag.list().filter { it.name.matches(versionTagsRegex) }.sortedBy { - it.dateTime + it.dateTime ?: it.commit.dateTime }.map { if (printForDebugging) { println("${it.name} --- (${it.dateTime})") From 1e8e7baf262b18708d97eee0e8e05e3b1a755689 Mon Sep 17 00:00:00 2001 From: eeVoskos Date: Fri, 27 Feb 2026 20:51:15 +0200 Subject: [PATCH 60/60] Update test data for wallet rewards in DeviceDetailsViewModelTest --- .../weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt index 13b089e3b..206ce3163 100644 --- a/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt +++ b/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt @@ -43,6 +43,7 @@ import org.koin.core.context.stopKoin import org.koin.dsl.module import kotlin.time.Duration.Companion.parse +@Suppress("unused") @OptIn(FlowPreview::class) class DeviceDetailsViewModelTest : BehaviorSpec({ val deviceDetailsUseCase = mockk() @@ -53,7 +54,7 @@ class DeviceDetailsViewModelTest : BehaviorSpec({ lateinit var viewModel: DeviceDetailsViewModel val user = User("id", "email", null, null, null, Wallet("address", null)) - val testWalletRewards = UIWalletRewards(8E19, 0.0, 8E19, "0x00") + val testWalletRewards = UIWalletRewards(2E20, 0.0, 2E20, "0x00") val emptyDevice = UIDevice.empty() val device = UIDevice( "deviceId",