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 39a092810..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})")
@@ -89,16 +89,22 @@ android {
applicationId = "com.weatherxm.app"
minSdk = 28
targetSdk = 36
- versionCode = 30 + getVersionGitTags(isSolana = false).size
+ versionCode = 44 + getVersionGitTags(isSolana = false).size
versionName = getLastVersionGitTag(false, skipTagsLogging)
androidResources {
// Keeps language resources for only the locales specified below.
+ @Suppress("UnstableApiUsage")
localeFilters += listOf("en")
}
// 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"
@@ -138,6 +144,7 @@ android {
}
create("remote") {
dimension = "mode"
+ isDefault = true
}
create("mock") {
val apiURL = getFlavorProperty("API_URL", "remotemock.env")
@@ -194,6 +201,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")
@@ -265,6 +273,8 @@ android {
manifestPlaceholders["crashlyticsEnabled"] = true
}
getByName("debug") {
+ isDefault = true
+ //noinspection WrongGradleMethod
signingConfigs.firstOrNull { it.name == "debug-config" }?.let {
signingConfig = it
}
@@ -559,6 +569,7 @@ dependencies {
// Animations
implementation(libs.lottie)
+ implementation(libs.lottie.compose)
// Charts
implementation(libs.mpAndroidCharts)
@@ -624,4 +635,7 @@ dependencies {
// Markdown Renderer
implementation(libs.markdown.renderer)
+
+ // Billing
+ implementation(libs.billing)
}
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..46f12b7b2
--- /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": 10,
+ "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": 5,
+ "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,
+ "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,
+ "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,
+ "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,
+ "icon": "partly-cloudy-day-drizzle"
+ }
+ }
+]
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" />
+
{ Dispatchers.IO }
+ single { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
}
private val preferences = module {
@@ -773,6 +778,7 @@ private val viewmodels = module {
viewModelOf(::HomeViewModel)
viewModelOf(::LocationsViewModel)
viewModelOf(::LoginViewModel)
+ viewModelOf(::ManageSubscriptionViewModel)
viewModelOf(::NetworkSearchViewModel)
viewModelOf(::NetworkStatsViewModel)
viewModelOf(::OnboardingViewModel)
@@ -795,6 +801,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 +822,6 @@ val modules = listOf(
repositories,
usecases,
utilities,
- viewmodels
+ viewmodels,
+ billingService
)
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..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,15 +12,26 @@ class CacheWeatherForecastDataSource(
private val cacheService: CacheService
) : WeatherForecastDataSource {
- override suspend fun getDeviceForecast(
+ override suspend fun getDeviceDefaultForecast(
deviceId: String,
fromDate: LocalDate,
toDate: LocalDate,
- exclude: String?
+ exclude: String?,
+ token: String?
): Either> {
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 da55d9fb6..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,17 +12,19 @@ class NetworkWeatherForecastDataSource(
private val apiService: ApiService
) : WeatherForecastDataSource {
- override suspend fun getDeviceForecast(
+ override suspend fun getDeviceDefaultForecast(
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()
}
@@ -32,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/RemoteBannersDataSource.kt b/app/src/main/java/com/weatherxm/data/datasource/RemoteBannersDataSource.kt
index 514c448ab..176ac27f4 100644
--- a/app/src/main/java/com/weatherxm/data/datasource/RemoteBannersDataSource.kt
+++ b/app/src/main/java/com/weatherxm/data/datasource/RemoteBannersDataSource.kt
@@ -42,6 +42,7 @@ class RemoteBannersDataSourceImpl(
const val ANNOUNCEMENT_SHOW = "announcement_show"
const val ANNOUNCEMENT_DISMISSABLE = "announcement_dismissable"
const val ANNOUNCEMENT_LOCAL_PRO_ACTION_URL = "weatherxm://announcement/weatherxm_pro"
+ const val ANNOUNCEMENT_LOCAL_PREMIUM = "weatherxm://announcement/premium"
}
override fun getSurvey(): Survey? {
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..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,11 +19,20 @@ interface WeatherForecastDataSource {
@Retention(AnnotationRetention.SOURCE)
private annotation class Exclude
- suspend fun getDeviceForecast(
+ suspend fun getDeviceDefaultForecast(
deviceId: String,
fromDate: LocalDate,
toDate: LocalDate,
- exclude: @Exclude String? = null
+ exclude: @Exclude String? = null,
+ 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)
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/models/DataModels.kt b/app/src/main/java/com/weatherxm/data/models/DataModels.kt
index 609b548f5..1246f0557 100644
--- a/app/src/main/java/com/weatherxm/data/models/DataModels.kt
+++ b/app/src/main/java/com/weatherxm/data/models/DataModels.kt
@@ -80,6 +80,18 @@ data class RemoteBanner(
val showCloseButton: Boolean
) : Parcelable
+@JsonClass(generateAdapter = true)
+data class SubscriptionOffer(
+ val id: String,
+ 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 {
INFO_BANNER,
ANNOUNCEMENT
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/data/network/ApiService.kt b/app/src/main/java/com/weatherxm/data/network/ApiService.kt
index 22d541902..36c9edcee 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,18 @@ interface ApiService {
@Query("fromDate") fromDate: String,
@Query("toDate") toDate: String,
@Query("exclude") exclude: String? = null,
+ @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
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..7e4614f49 100644
--- a/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt
+++ b/app/src/main/java/com/weatherxm/data/repository/WeatherForecastRepository.kt
@@ -6,23 +6,30 @@ 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 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(
+ private val billingService: BillingService,
private val networkSource: NetworkWeatherForecastDataSource,
private val cacheSource: CacheWeatherForecastDataSource,
) : WeatherForecastRepository {
@@ -31,7 +38,7 @@ class WeatherForecastRepositoryImpl(
const val PREFETCH_DAYS = 7L
}
- override suspend fun getDeviceForecast(
+ override suspend fun getDeviceDefaultForecast(
deviceId: String,
fromDate: LocalDate,
toDate: LocalDate,
@@ -41,24 +48,36 @@ class WeatherForecastRepositoryImpl(
clearDeviceForecastFromCache()
}
- val to = if (ChronoUnit.DAYS.between(fromDate, toDate) < PREFETCH_DAYS) {
- fromDate.plusDays(PREFETCH_DAYS)
- } else {
- toDate
- }
-
- return cacheSource.getDeviceForecast(deviceId, fromDate, to)
+ return cacheSource.getDeviceDefaultForecast(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].")
+ 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)
}
}
}
+ override suspend fun getDevicePremiumForecast(
+ deviceId: String,
+ fromDate: LocalDate,
+ toDate: LocalDate
+ ): Either> {
+ 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() {
cacheSource.clearLocationForecast()
}
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..54359b281
--- /dev/null
+++ b/app/src/main/java/com/weatherxm/service/BillingService.kt
@@ -0,0 +1,419 @@
+package com.weatherxm.service
+
+import android.app.Activity
+import android.content.Context
+import com.android.billingclient.api.AcknowledgePurchaseParams
+import com.android.billingclient.api.BillingClient
+import com.android.billingclient.api.BillingClient.BillingResponseCode
+import com.android.billingclient.api.BillingClient.ProductType.SUBS
+import com.android.billingclient.api.BillingClientStateListener
+import com.android.billingclient.api.BillingFlowParams
+import com.android.billingclient.api.BillingResult
+import com.android.billingclient.api.PendingPurchasesParams
+import com.android.billingclient.api.ProductDetails
+import com.android.billingclient.api.Purchase
+import com.android.billingclient.api.Purchase.PurchaseState
+import com.android.billingclient.api.PurchasesUpdatedListener
+import com.android.billingclient.api.QueryProductDetailsParams
+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.weatherxm.BuildConfig
+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
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import java.security.KeyFactory
+import java.security.NoSuchAlgorithmException
+import java.security.Signature
+import java.security.spec.InvalidKeySpecException
+import java.security.spec.X509EncodedKeySpec
+import java.util.Base64
+
+const val PREMIUM_FORECAST_PRODUCT_ID = "premium_forecast"
+const val PLAN_MONTHLY = "monthly"
+const val PLAN_YEARLY = "yearly"
+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,
+ private val coroutineScope: CoroutineScope,
+ private val dispatcher: CoroutineDispatcher
+) {
+ private var billingClient: BillingClient? = null
+
+ private var hasFetchedPurchases: Boolean = false
+ private var subs = mutableListOf()
+ 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 ->
+ 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 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 {
+ // TODO: When Solana is implemented, remove this
+ if (AndroidBuildInfo.isSolana) {
+ return false
+ }
+
+ val activeSub = activeSubFlow.value
+ return if (billingClient?.isReady == false && activeSub == null) {
+ startConnection()
+ false
+ } else if (!hasFetchedPurchases) {
+ coroutineScope.launch(dispatcher) {
+ setupPurchases()
+ }
+ false
+ } else {
+ activeSub != null
+ }
+ }
+
+ fun getActiveSubFlow(): StateFlow = activeSubFlow
+
+ fun getMonthlyAvailableSub(hasFreeTrialAvailable: Boolean): SubscriptionOffer? {
+ return if (hasFreeTrialAvailable) {
+ subs.firstOrNull { TAG_FREE_TRIAL in it.tags }
+ ?: subs.firstOrNull { TAG_LAUNCH_OFFER in it.tags }
+ ?: subs.firstOrNull()
+ } else {
+ 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() {
+ billingClient?.startConnection(object : BillingClientStateListener {
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
+ if (billingResult.responseCode == BillingResponseCode.OK) {
+ coroutineScope.launch(dispatcher) {
+ setupPurchases()
+ }
+ coroutineScope.launch(dispatcher) {
+ setupProducts()
+ }
+ }
+ }
+
+ override fun onBillingServiceDisconnected() {
+ // Not used as we have enableAutoServiceReconnection() above.
+ }
+ })
+ }
+
+ 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()
+ )
+
+ /**
+ * Got an error in the process. Terminate it.
+ */
+ if (purchasesResult?.billingResult?.responseCode != BillingResponseCode.OK) {
+ return
+ }
+ hasFetchedPurchases = true
+
+ val latestPurchase = purchasesResult.purchasesList.firstOrNull()
+ if (latestPurchase != null) {
+ if (latestPurchase.isAcknowledged) {
+ activeSubFlow.tryEmit(latestPurchase)
+ } else {
+ handlePurchase(latestPurchase, inBackground)
+ }
+ } else {
+ 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()
+
+ // 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?) {
+ 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
+ }
+ }
+
+ init {
+ // 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/Navigator.kt b/app/src/main/java/com/weatherxm/ui/Navigator.kt
index a689f10c2..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
@@ -45,7 +46,9 @@ 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_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
@@ -86,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
@@ -477,18 +481,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)
}
@@ -568,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,
@@ -709,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 5e3d5f2af..bfa4a42f8 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,6 @@ 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"
+ const val ARG_IS_LOGGED_IN = "is_logged_in"
}
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..843f26918 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,11 @@ 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(true)
+ } ?: binding.precipProbabilityContainer.visible(false)
}
}
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..19ced893f 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,10 @@ 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.toBigDecimalSafe
+import com.weatherxm.util.NumberUtils.weiToETH
import kotlinx.parcelize.Parcelize
+import java.math.BigDecimal
import java.time.LocalDate
import java.time.ZonedDateTime
@@ -259,11 +262,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()
@@ -419,6 +423,14 @@ data class UIWalletRewards(
companion object {
fun empty() = UIWalletRewards(0.0, 0.0, 0.0, String.empty())
}
+
+ /**
+ * If unclaimed tokens >= 200 then return true
+ */
+ @Suppress("MagicNumber")
+ fun hasUnclaimedTokensForFreeTrial(): Boolean {
+ return weiToETH(allocated.toBigDecimalSafe()) >= BigDecimal.valueOf(200.0)
+ }
}
@Keep
@@ -725,6 +737,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 +748,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
@@ -751,6 +765,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/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/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/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/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/components/compose/DailyTileForecast.kt b/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt
new file mode 100644
index 000000000..81541f6f1
--- /dev/null
+++ b/app/src/main/java/com/weatherxm/ui/components/compose/DailyTileForecast.kt
@@ -0,0 +1,255 @@
+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
+ )
+ }
+ }
+}
+
+@Suppress("FunctionNaming", "UnusedPrivateMember")
+@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/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/components/compose/ForecastTabSelector.kt b/app/src/main/java/com/weatherxm/ui/components/compose/ForecastTabSelector.kt
new file mode 100644
index 000000000..107594ded
--- /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.horizontalGradient(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/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..7a472cd41
--- /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 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("LongMethod", "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/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..af8f81920
--- /dev/null
+++ b/app/src/main/java/com/weatherxm/ui/components/compose/MosaicPromotionCard.kt
@@ -0,0 +1,116 @@
+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
+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)
+ ),
+ border = BorderStroke(2.dp, colorResource(R.color.dark_crypto_opacity_30))
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(dimensionResource(R.dimen.padding_normal_to_large)),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ 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)),
+ 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_normal)),
+ 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_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.colorOnPrimary)
+ ),
+ 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.upgrade_to_premium),
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp,
+ colorRes = R.color.colorOnPrimary
+ )
+ }
+ 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/components/compose/RoundedRangeView.kt b/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt
new file mode 100644
index 000000000..ced9779c4
--- /dev/null
+++ b/app/src/main/java/com/weatherxm/ui/components/compose/RoundedRangeView.kt
@@ -0,0 +1,75 @@
+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.graphics.Brush
+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", "LongParameterList")
+@Composable
+fun RoundedRangeView(
+ height: Dp,
+ currentRange: ClosedFloatingPointRange,
+ totalRange: ClosedFloatingPointRange,
+ inactiveColorResId: Int,
+ activeColorResId: Int,
+ activeColorBrush: Brush? = null
+) {
+ 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
+ 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)
+ )
+ }
+ }
+}
+
+@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/components/compose/SubscriptionTabSelector.kt b/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt
new file mode 100644
index 000000000..148853340
--- /dev/null
+++ b/app/src/main/java/com/weatherxm/ui/components/compose/SubscriptionTabSelector.kt
@@ -0,0 +1,136 @@
+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()
+ .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(
+ 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/devicedetails/DeviceDetailsActivity.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/DeviceDetailsActivity.kt
index 5afc9ea46..aff771a1d 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
@@ -153,21 +159,28 @@ 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 -> resources.getString(R.string.forecast)
- REWARDS_TAB_POSITION -> resources.getString(R.string.rewards)
- else -> throw IllegalStateException("Oops! You forgot to add a tab here.")
- }
- }.attach()
+ setupTabs()
updateDeviceInfo()
}
override fun onResume() {
super.onResume()
+ 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(
AnalyticsService.Screen.EXPLORER_DEVICE, classSimpleName(), model.device.id
@@ -175,6 +188,60 @@ class DeviceDetailsActivity : BaseActivity() {
}
}
+ 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), premiumColor)
+ } 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 custom tab properly.
+ */
+ it.customView?.apply {
+ findViewById(
+ R.id.forecastIcon
+ )?.setColor(R.color.forecast_premium)
+ findViewById(R.id.forecastTitle)?.setTextColor(
+ premiumColor
+ )
+ }
+ } else {
+ 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/DailyForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/devicedetails/forecast/DailyForecastAdapter.kt
index 2121c416d..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
@@ -2,21 +2,30 @@ 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
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
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
@@ -27,10 +36,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:
@@ -90,12 +104,29 @@ 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 {
+ 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,
+ brushColor
+ )
}
}
binding.minTemperature.text =
@@ -103,23 +134,85 @@ 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
- )
+ // 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)
+ } else {
+ binding.precip.text = Weather.getFormattedPrecipitation(
+ context = itemView.context,
+ 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)
+ }
}
}
+ @Suppress("FunctionNaming")
+ @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
+ )
+ }
+
+ @Suppress("FunctionNaming")
+ @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 {
@@ -132,9 +225,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
}
}
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..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
@@ -10,17 +10,19 @@ import com.weatherxm.analytics.AnalyticsService
import com.weatherxm.databinding.FragmentDeviceDetailsForecastBinding
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
-import com.weatherxm.ui.components.ProPromotionDialogFragment
-import com.weatherxm.ui.components.compose.ProPromotionCard
+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
@@ -33,6 +35,11 @@ class ForecastFragment : BaseFragment() {
parametersOf(parentModel.device)
}
+ private lateinit var hourlyForecastAdapter: HourlyForecastAdapter
+ private lateinit var dailyForecastAdapter: DailyForecastAdapter
+ private var currentSelectedTab = 0
+ private var hasOpenedManageSubscription = false
+
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -47,22 +54,23 @@ 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,
device = model.device,
location = UILocation.empty(),
- forecastSelectedISODate = it.date.toString()
+ forecastSelectedISODate = it.date.toString(),
+ hasFreeTrialAvailable = parentModel.hasFreePremiumTrialAvailable()
)
}
- val hourlyForecastAdapter = HourlyForecastAdapter {
+ hourlyForecastAdapter = HourlyForecastAdapter {
analytics.trackEventSelectContent(
AnalyticsService.ParamValue.HOURLY_DETAILS_CARD.paramValue,
Pair(
@@ -75,7 +83,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
@@ -106,79 +115,78 @@ class ForecastFragment : BaseFragment() {
parentModel.onDeviceFirstFetch().observe(viewLifecycleOwner) {
model.device = it
- model.fetchForecast(true)
+ model.fetchForecasts(true)
}
- model.onForecast().observe(viewLifecycleOwner) {
- hourlyForecastAdapter.submitList(it.next24Hours)
- dailyForecastAdapter.submitList(it.forecastDays)
- binding.proPromotionCard.visible(true)
- binding.dailyForecastRecycler.visible(true)
- binding.dailyForecastTitle.visible(true)
- binding.temperatureBarsInfoButton.visible(true)
- binding.hourlyForecastRecycler.visible(true)
- binding.hourlyForecastTitle.visible(true)
+ model.onDefaultForecast().observe(viewLifecycleOwner) {
+ if (currentSelectedTab == 0) {
+ onForecast(it) { model.fetchForecasts(true) }
+ }
}
- model.onLoading().observe(viewLifecycleOwner) {
- onLoading(it)
+ model.onPremiumForecast().observe(viewLifecycleOwner) {
+ if (currentSelectedTab == 1) {
+ onForecast(it) { model.fetchForecasts() }
+ }
}
- model.onError().observe(viewLifecycleOwner) {
- showSnackbarMessage(binding.root, it.errorMessage, it.retryFunction)
+ // TODO: When we have the Solana implementation, remove this
+ if (AndroidBuildInfo.isSolana) {
+ binding.tabsOrMosaicPromptContainer.visible(false)
}
- initProPromotionCard()
+ 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())
- }
- 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)
- }
+ if (hasOpenedManageSubscription) {
+ model.fetchForecasts(true)
}
}
- private fun onLoading(isLoading: Boolean) {
- if (isLoading && binding.swiperefresh.isRefreshing) {
- binding.progress.invisible()
- } else if (isLoading) {
- binding.proPromotionCard.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 initMosaicPromotionCard() {
+ binding.mosaicPromotionCard.setContent {
+ MosaicPromotionCard(parentModel.hasFreePremiumTrialAvailable()) {
+ hasOpenedManageSubscription = true
+ navigator.showManageSubscription(
+ context,
+ parentModel.hasFreePremiumTrialAvailable(),
+ true
+ )
+ }
}
}
private fun fetchOrHideContent() {
if (model.device.relation != UNFOLLOWED) {
binding.hiddenContentContainer.visible(false)
- binding.proPromotionCard.visible(true)
- model.fetchForecast()
+ model.fetchForecasts()
} else if (model.device.relation == UNFOLLOWED) {
- binding.proPromotionCard.visible(false)
- binding.hourlyForecastTitle.visible(false)
- binding.hourlyForecastRecycler.visible(false)
- binding.dailyForecastRecycler.visible(false)
- binding.dailyForecastTitle.visible(false)
- binding.temperatureBarsInfoButton.visible(false)
+ binding.mosaicPromotionCard.visible(false)
+ binding.forecastTabSelector.visible(false)
+ binding.mainContainer.visible(false)
binding.hiddenContentContainer.visible(true)
}
}
@@ -203,4 +211,42 @@ class ForecastFragment : BaseFragment() {
}
}
}
+
+ private fun onForecast(resource: Resource, onErrorRetry: () -> Unit) {
+ when (resource.status) {
+ 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)
+ 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)
+ 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))
+ .subtitle(resource.message)
+ .listener { onErrorRetry.invoke() }
+ .visible(true)
+ }
+ Status.LOADING -> {
+ if (binding.swiperefresh.isRefreshing) {
+ binding.statusView.visible(false)
+ } else {
+ 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 c0b1bf1a7..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
@@ -4,42 +4,39 @@ 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.service.BillingService
+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.AndroidBuildInfo
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(),
+ private val billingService: BillingService,
private val resources: Resources,
private val forecastUseCase: ForecastUseCase,
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 +47,54 @@ class ForecastViewModel(
if (device.isEmpty() || device.isDeviceFromSearchResult || device.isUnfollowed()) {
return
}
- onLoading.postValue(true)
+ fetchDeviceForecast(
+ mutableLiveData = onDefaultForecast,
+ fetchOperation = { forecastUseCase.getDeviceDefaultForecast(device, forceRefresh) }
+ )
+ // TODO: When we have the Solana implementation, remove this check for isSolana
+ if (billingService.hasActiveSub() && !AndroidBuildInfo.isSolana) {
+ 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/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/forecastdetails/DailyTileForecastAdapter.kt b/app/src/main/java/com/weatherxm/ui/forecastdetails/DailyTileForecastAdapter.kt
deleted file mode 100644
index f9500b911..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: (UIForecastDay) -> 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(item)
- 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 4c2fc5a4e..3a8b2879f 100644
--- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt
+++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsActivity.kt
@@ -5,18 +5,20 @@ import com.google.firebase.analytics.FirebaseAnalytics
import com.weatherxm.R
import com.weatherxm.analytics.AnalyticsService
import com.weatherxm.databinding.ActivityForecastDetailsBinding
+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
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
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
@@ -26,10 +28,12 @@ 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.DailyTileForecast
+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.ProPromotionCard
+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
@@ -53,12 +57,14 @@ class ForecastDetailsActivity : BaseActivity() {
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)
)
}
- private lateinit var dailyAdapter: DailyTileForecastAdapter
private lateinit var hourlyAdapter: HourlyForecastAdapter
+ private var currentSelectedTab = 0
+ private var hasOpenedManageSubscription = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -92,55 +98,112 @@ class ForecastDetailsActivity : BaseActivity() {
initSavedLocationIcon()
binding.displayTimeNotice.visible(false)
}
- setupChartsAndListeners()
- 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) {
+ if (currentSelectedTab == 0) {
+ onForecast(it) { model.fetchDeviceForecasts() }
+ }
+ }
+
+ model.onDevicePremiumForecast().observe(this) {
+ if (currentSelectedTab == 1) {
+ onForecast(it) { model.fetchDeviceForecasts() }
}
}
+ model.onLocationForecast().observe(this) {
+ 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.fetchDeviceForecast()
+ model.fetchDeviceForecasts()
+ initForecastTabsSelector()
+ initMosaicPromotionCard()
} else if (!model.location.isEmpty()) {
model.fetchLocationForecast()
}
}
- private fun updateUI(forecast: UIForecastDay) {
+ 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) {
+ hasOpenedManageSubscription = true
+ navigator.showManageSubscription(
+ this,
+ model.hasFreeTrialAvailable,
+ model.isLoggedIn()
+ )
+ }
+ }
+ }
+
+ private fun onForecast(resource: Resource, onErrorRetry: () -> Unit) {
+ 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)
+ .action(getString(R.string.action_retry))
+ .listener { onErrorRetry.invoke() }
+ binding.mainContainer.visible(false)
+ }
+ Status.LOADING -> {
+ binding.statusView.clear().animation(R.raw.anim_loading)
+ binding.mainContainer.visible(false)
+ binding.statusView.visible(true)
+ }
+ }
+ }
+
+ @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
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
)
@@ -148,62 +211,151 @@ class ForecastDetailsActivity : BaseActivity() {
}
}
+ // Update the "Powered By" card
+ 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
- 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))
+ 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)
+
+ /**
+ * 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,
+ 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)
+ binding.dailyPremiumRow.visible(true)
+ } else {
+ binding.precipProbabilityCard.setData(
+ getFormattedPrecipitationProbability(forecastDay.precipProbability)
+ )
+ binding.windCard.setIcon(getWindDirectionDrawable(this, forecastDay.windDirection))
+ binding.humidityCard.setIcon(getDrawable(R.drawable.ic_weather_humidity))
+ binding.windCard.setData(
+ getFormattedWind(
+ this,
+ forecastDay.windSpeed,
+ forecastDay.windDirection
+ )
+ )
+ binding.dailyPrecipCard.setData(
+ getFormattedPrecipitation(
+ context = this,
+ value = forecastDay.precip,
+ isRainRate = false
+ )
+ )
+ 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
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, forecastDay)
+ }
+
+ 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)
- 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))
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()) }
@@ -217,7 +369,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,
@@ -226,31 +378,32 @@ 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(forecastDay: UIForecastDay, selectedDayPosition: Int) {
- dailyAdapter = DailyTileForecastAdapter(
- forecastDay.date,
- onNewSelectedPosition = { position, width ->
- binding.dailyTilesRecycler.moveItemToCenter(position, binding.root.width, width)
- },
- onClickListener = {
- analytics.trackEventSelectContent(
- AnalyticsService.ParamValue.DAILY_CARD.paramValue,
- Pair(
- FirebaseAnalytics.Param.ITEM_ID,
- AnalyticsService.ParamValue.DAILY_DETAILS.paramValue
+ private fun setupDailyAdapter(forecast: UIForecast, selectedDayPosition: Int) {
+ 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(it)
- }
- )
- binding.dailyTilesRecycler.adapter = dailyAdapter
- dailyAdapter.submitList(model.forecast().forecastDays)
- binding.dailyTilesRecycler.scrollToPosition(selectedDayPosition)
+ val newSelectedDayPosition = forecast.forecastDays.indexOfFirst {
+ it.date == selectedDate
+ }
+ if (newSelectedDayPosition != -1) {
+ updateUI(forecast, newSelectedDayPosition)
+ }
+ }
+ )
+ }
}
private fun handleOwnershipIcon() {
@@ -337,11 +490,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(),
@@ -353,25 +512,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..f1a3c42fc 100644
--- a/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt
+++ b/app/src/main/java/com/weatherxm/ui/forecastdetails/ForecastDetailsViewModel.kt
@@ -4,12 +4,14 @@ 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
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
@@ -18,19 +20,25 @@ 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.AndroidBuildInfo
import com.weatherxm.util.Failure.getDefaultMessage
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")
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,
@@ -39,75 +47,86 @@ 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: When we have the Solana implementation, remove this check for isSolana
+ if (billingService.hasActiveSub() && !AndroidBuildInfo.isSolana) {
+ 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
}
@@ -142,10 +161,17 @@ 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 == false) {
+ Duration.ofHours(FORECAST_CHART_STEP_DEFAULT)
+ } else {
+ Duration.ofHours(FORECAST_CHART_STEP_PREMIUM)
+ }
return chartsUseCase.createHourlyCharts(
- forecastDay.date, forecastDay.hourlyWeather ?: mutableListOf()
+ forecastDay.date,
+ forecastDay.hourlyWeather ?: mutableListOf(),
+ chartStep
)
}
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 cb82e246f..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
@@ -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
@@ -32,11 +33,13 @@ 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
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 +47,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 =
@@ -101,6 +105,14 @@ class ProfileFragment : BaseFragment() {
navigator.showPreferences(this)
}
+ binding.subscriptionCard.setOnClickListener {
+ navigator.showManageSubscription(
+ context,
+ model.onWalletRewards().value?.data?.hasUnclaimedTokensForFreeTrial() == true,
+ true
+ )
+ }
+
return binding.root
}
@@ -148,12 +160,14 @@ class ProfileFragment : BaseFragment() {
Status.SUCCESS -> {
resource.data?.let {
updateRewardsUI(it)
+ updateSubscriptionUI(it)
}
toggleLoading(false)
}
Status.ERROR -> {
Timber.d("Got error: $resource.message")
onNotAvailableRewards()
+ updateSubscriptionUI(null)
resource.message?.let { context.toast(it) }
toggleLoading(false)
}
@@ -224,6 +238,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 +318,46 @@ class ProfileFragment : BaseFragment() {
binding.rewardsContainerCard.visible(true)
}
+ 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()) {
+ 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(R.string.free_trial_locked_subtitle)
+ )
+ )
+ }
+ 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/java/com/weatherxm/ui/managesubscription/FreePlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt
new file mode 100644
index 000000000..e21698bd9
--- /dev/null
+++ b/app/src/main/java/com/weatherxm/ui/managesubscription/FreePlanView.kt
@@ -0,0 +1,178 @@
+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.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.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
+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 = 0.dp),
+ border = if (isSelected && isCurrentPlan) {
+ BorderStroke(2.dp, colorResource(R.color.colorPrimary))
+ } else if (isSelected) {
+ BorderStroke(2.dp, colorResource(R.color.warning))
+ } else {
+ BorderStroke(1.dp, colorResource(R.color.crypto_opacity_15))
+ }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(dimensionResource(R.dimen.margin_normal_to_large)),
+ 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
+ ) {
+ LargeText(
+ text = stringResource(R.string.free),
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp
+ )
+
+ if (isCurrentPlan) {
+ Box(
+ modifier = Modifier
+ .background(
+ color = colorResource(R.color.crypto_opacity_15),
+ shape = RoundedCornerShape(999.dp)
+ )
+ .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),
+ colorRes = R.color.darkGrey,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+
+ // Price + tagline
+ Column(verticalArrangement = spacedBy(4.dp)) {
+ LargeText(
+ text = "$0",
+ fontWeight = FontWeight.Bold,
+ fontSize = 24.sp
+ )
+ MediumText(
+ text = stringResource(R.string.free_plan_tagline),
+ colorRes = R.color.darkGrey
+ )
+ }
+
+ // 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))
+ }
+ }
+ }
+}
+
+@Suppress("FunctionNaming")
+@Composable
+private fun FreeFeatureItem(text: String) {
+ Row(
+ horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)),
+ verticalAlignment = Alignment.Top
+ ) {
+ 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)
+ }
+}
+
+@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
new file mode 100644
index 000000000..026c44012
--- /dev/null
+++ b/app/src/main/java/com/weatherxm/ui/managesubscription/ManageSubscriptionActivity.kt
@@ -0,0 +1,365 @@
+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
+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
+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 com.weatherxm.ui.components.compose.DowngradeDialog
+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
+
+ // private var currentSelectedTab = 1
+ private val hasActiveRenewingSub = mutableStateOf(false)
+ private val planSelected = mutableStateOf(null)
+ private val shouldShowDowngradeDialog = mutableStateOf(false)
+
+ init {
+ lifecycleScope.launch {
+ repeatOnLifecycle(Lifecycle.State.RESUMED) {
+ launch {
+ billingService.getPurchaseUpdates().collect { state ->
+ state?.let {
+ onPurchaseUpdate(it)
+ }
+ }
+ }
+
+ launch {
+ billingService.getActiveSubFlow().collect {
+ val availableSub =
+ billingService.getMonthlyAvailableSub(hasFreeTrialAvailable)
+
+ if (it == null || !it.isAutoRenewing) {
+ hasActiveRenewingSub.value = false
+ binding.toolbar.title = getString(R.string.upgrade_to_premium)
+ binding.planComposable.setContent {
+ Column(
+ modifier = Modifier.padding(
+ bottom = dimensionResource(R.dimen.margin_large)
+ )
+ ) {
+ PremiumPlanView(
+ sub = availableSub,
+ isSelected = planSelected.value == availableSub,
+ isCurrentPlan = hasActiveRenewingSub.value,
+ hasFreeTrialAvailable = hasFreeTrialAvailable
+ ) {
+ onPremiumSelected(availableSub)
+ }
+ FreePlanView(
+ isSelected = planSelected.value == null,
+ isCurrentPlan = !hasActiveRenewingSub.value
+ ) {
+ onFreeSelected()
+ }
+ }
+ }
+ binding.cancelAnytimeText.visible(true)
+ // binding.subscriptionTabSelector.visible(true)
+ } else {
+ hasActiveRenewingSub.value = true
+ binding.toolbar.title = getString(R.string.manage_subscription)
+ binding.planComposable.setContent {
+ Column(
+ modifier = Modifier.padding(
+ bottom = dimensionResource(R.dimen.margin_large)
+ )
+ ) {
+ PremiumPlanView(
+ sub = availableSub,
+ isSelected = planSelected.value == availableSub,
+ isCurrentPlan = hasActiveRenewingSub.value,
+ hasFreeTrialAvailable = false
+ ) {
+ onPremiumSelected(availableSub)
+ }
+ FreePlanView(
+ isSelected = planSelected.value == null,
+ isCurrentPlan = !hasActiveRenewingSub.value
+ ) {
+ onFreeSelected()
+ }
+ }
+ }
+ 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)
+ }
+ }
+
+ 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.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_large)
+// )
+// ) {
+// 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(
+ shouldShow = shouldShowDowngradeDialog.value,
+ onDowngrade = {
+ shouldShowDowngradeDialog.value = false
+ navigator.openSubscriptionInStore(this)
+ },
+ onClose = {
+ shouldShowDowngradeDialog.value = false
+ }
+ )
+ }
+
+ binding.successBtn.setOnClickListener {
+ finish()
+ }
+
+ binding.backBtn.setOnClickListener {
+ binding.appBar.visible(true)
+ binding.topDivider.visible(true)
+ // binding.subscriptionTabSelector.visible(true)
+ binding.mainContainer.visible(true)
+ 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 initActionButtonForFreeSelected() {
+ if (hasActiveRenewingSub.value) {
+ binding.mainActionBtn.text = getString(R.string.downgrade_free_plan)
+ styleButton(
+ showSparklesIcon = false,
+ backgroundColor = R.color.warningTint,
+ textColor = R.color.colorOnSurface
+ )
+
+ binding.mainActionBtn.setOnClickListener {
+ shouldShowDowngradeDialog.value = true
+ }
+ binding.mainActionBtn.isEnabled = true
+ } else {
+ binding.mainActionBtn.text = getString(R.string.currently_on_free)
+ styleButton(
+ showSparklesIcon = false,
+ backgroundColor = R.color.layer1,
+ textColor = R.color.colorOnSurface
+ )
+ binding.mainActionBtn.isEnabled = false
+ }
+ }
+
+ private fun initActionButtonForPremiumSelected() {
+ if (hasActiveRenewingSub.value) {
+ binding.mainActionBtn.text = getString(R.string.currently_on_premium)
+ 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.crypto,
+ textColor = R.color.colorOnPrimary
+ )
+
+ val planOfferToken = planSelected.value?.offerToken
+ binding.mainActionBtn.setOnClickListener {
+ if (isLoggedIn && planOfferToken != null) {
+ model.setOfferToken(planOfferToken)
+ billingService.startBillingFlow(this, planOfferToken)
+ } else if (!isLoggedIn) {
+ navigator.showLoginDialog(
+ fragmentActivity = this,
+ title = getString(R.string.get_premium),
+ message = getString(R.string.get_premium_login_prompt)
+ )
+ }
+ }
+ 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,
+ textColor: Int = R.color.colorOnPrimary
+ ) {
+ val backgroundColor = ContextCompat.getColor(this, backgroundColor)
+ val textColor = ContextCompat.getColor(this, textColor)
+
+ 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.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.topDivider.visible(true)
+ // binding.subscriptionTabSelector.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.topDivider.visible(false)
+ binding.mainContainer.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.topDivider.visible(false)
+ binding.mainContainer.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/PremiumPlanView.kt b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt
new file mode 100644
index 000000000..3d02c86a3
--- /dev/null
+++ b/app/src/main/java/com/weatherxm/ui/managesubscription/PremiumPlanView.kt
@@ -0,0 +1,467 @@
+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.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.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.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.PreviewLightDark
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+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
+
+@Suppress("FunctionNaming", "LongMethod")
+@Composable
+fun PremiumPlanView(
+ sub: SubscriptionOffer?,
+ isSelected: Boolean,
+ isCurrentPlan: Boolean,
+ hasFreeTrialAvailable: Boolean,
+ onSelected: () -> Unit
+) {
+ if (sub == null) {
+ return
+ }
+
+ val primaryColor = colorResource(R.color.colorPrimary)
+ val successColor = colorResource(R.color.success)
+
+ 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, primaryColor)
+ } else {
+ BorderStroke(1.dp, colorResource(R.color.crypto_opacity_15))
+ }
+ ) {
+ // Gradient overlay on top of card surface
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .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,
+ verticalAlignment = Alignment.Top
+ ) {
+ LargeText(
+ text = stringResource(R.string.premium),
+ fontWeight = FontWeight.Bold,
+ fontSize = 18.sp
+ )
+
+ PlanHeaderBadge(isCurrentPlan, primaryColor)
+ }
+
+ FreeTrialBanner(sub, hasFreeTrialAvailable)
+
+ 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)) {
+ 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(
+ discountStringRes,
+ sub.discountedCycles,
+ sub.basePrice
+ ),
+ colorRes = R.color.colorOnSurface
+ )
+ }
+ }
+
+ // 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.darkGrey
+ )
+
+ // Features
+ Column(verticalArrangement = spacedBy(9.dp)) {
+ 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()
+ PremiumFeatureItem(text = firstBenefit)
+ PremiumFeatureItem(
+ text = AnnotatedString(stringResource(R.string.premium_plan_second_benefit))
+ )
+ PremiumFeatureItem(
+ text = AnnotatedString(stringResource(R.string.premium_plan_third_benefit))
+ )
+ PremiumFeatureItem(
+ text = AnnotatedString(stringResource(R.string.premium_plan_fourth_benefit))
+ )
+ }
+
+ // Show offer id for debug purposes
+ if (BuildConfig.DEBUG) {
+ SmallText(
+ text = "Offer ID: ${sub.offerId}"
+ )
+ }
+ }
+ }
+ }
+}
+
+@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) {
+ Row(
+ horizontalArrangement = spacedBy(dimensionResource(R.dimen.margin_small_to_normal)),
+ verticalAlignment = Alignment.Top
+ ) {
+ 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.textColor),
+ modifier = Modifier.size(10.dp)
+ )
+ }
+ Text(
+ text = text,
+ color = colorResource(R.color.colorOnSurface),
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+}
+
+@Suppress("UnusedPrivateMember", "FunctionNaming")
+@PreviewLightDark
+@Composable
+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/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/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/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 21c6f6378..2ca288245 100644
--- a/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt
+++ b/app/src/main/java/com/weatherxm/usecases/ForecastUseCaseImpl.kt
@@ -21,25 +21,41 @@ class ForecastUseCaseImpl(
) : 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)
}
}
+ override suspend fun getDeviceDefaultForecast(
+ device: UIDevice,
+ forceRefresh: Boolean
+ ): Either {
+ return getDeviceForecast(false, device, forceRefresh)
+ }
+
+ 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
@@ -81,6 +97,7 @@ class ForecastUseCaseImpl(
return UIForecast(
address = data[0].address,
+ isPremium = data[0].isPremium,
next24Hours = nextHourlyWeatherForecast,
forecastDays = forecastDays
)
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/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_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/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/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/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 6768923e3..f8a75d90e 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">
-
-
-
-
-
+ android:layout_marginTop="@dimen/margin_large"
+ android:clipChildren="false"
+ app:layout_constraintTop_toBottomOf="@id/header">
-
-
-
-
-
+ android:layout_height="wrap_content"
+ android:clipChildren="false"
+ android:visibility="gone"
+ tools:composableName="com.weatherxm.ui.components.compose.ForecastTabSelectorKt.PreviewForecastTabSelector"
+ tools:visibility="visible" />
-
+
+
-
+
-
+
-
-
+
+
+
-
+ android:layout_height="wrap_content">
+
+
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
-
+ 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" />
+
+
+
+
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/activity_manage_subscription.xml b/app/src/main/res/layout/activity_manage_subscription.xml
new file mode 100644
index 000000000..878b6076b
--- /dev/null
+++ b/app/src/main/res/layout/activity_manage_subscription.xml
@@ -0,0 +1,159 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/fragment_device_details_forecast.xml b/app/src/main/res/layout/fragment_device_details_forecast.xml
index 811c143df..bc54e4279 100644
--- a/app/src/main/res/layout/fragment_device_details_forecast.xml
+++ b/app/src/main/res/layout/fragment_device_details_forecast.xml
@@ -1,158 +1,243 @@
-
-
-
-
+
+
+ android:clipToPadding="false"
+ android:fillViewport="true"
+ android:paddingBottom="@dimen/padding_small"
+ tools:targetApi="s">
-
-
-
-
-
+ android:layout_marginHorizontal="@dimen/margin_normal"
+ android:clipChildren="false"
+ android:clipToOutline="false"
+ android:clipToPadding="false">
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:layout_marginTop="@dimen/margin_extra_large"
+ android:clipChildren="false"
+ app:layout_constraintTop_toBottomOf="@id/tabsOrMosaicPromptContainer">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_profile.xml b/app/src/main/res/layout/fragment_profile.xml
index e66214369..31956abbc 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"
@@ -241,10 +241,50 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/list_item_daily_tile_forecast.xml b/app/src/main/res/layout/list_item_daily_tile_forecast.xml
deleted file mode 100644
index b7b803530..000000000
--- a/app/src/main/res/layout/list_item_daily_tile_forecast.xml
+++ /dev/null
@@ -1,63 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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" />
-
-
-
-
-
+ 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" />
diff --git a/app/src/main/res/layout/view_forecast_premium_tab.xml b/app/src/main/res/layout/view_forecast_premium_tab.xml
new file mode 100644
index 000000000..376db859a
--- /dev/null
+++ b/app/src/main/res/layout/view_forecast_premium_tab.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
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">
+
+
-
+ android:layout_marginHorizontal="@dimen/margin_small"
+ tools:composableName="com.weatherxm.ui.components.compose.RoundedRangeViewKt.PreviewRoundedRangeView" />
diff --git a/app/src/main/res/layout/view_weather_measurement_card.xml b/app/src/main/res/layout/view_weather_measurement_card.xml
index 32474a0a0..c405ddae1 100644
--- a/app/src/main/res/layout/view_weather_measurement_card.xml
+++ b/app/src/main/res/layout/view_weather_measurement_card.xml
@@ -20,6 +20,12 @@
android:gravity="center"
android:orientation="vertical">
+
+
@color/dark_lightest_blue
@color/dark_text
@color/dark_tooltip
+ @color/dark_crypto_opacity_15
+ @color/light_crypto
#1B75BA
@@ -48,8 +50,9 @@
@color/dark_primary
- @color/dark_top
- @color/layer1
+ @color/layer1
+ @color/dark_top
+ #CD9EFC
@color/dark_crypto
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
index 548683e4c..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
@@ -50,6 +52,7 @@
@color/light_layer1
@color/light_top
+ #800162
@color/light_crypto
diff --git a/app/src/main/res/values/palette.xml b/app/src/main/res/values/palette.xml
index 5bc01bcbb..d299ad935 100644
--- a/app/src/main/res/values/palette.xml
+++ b/app/src/main/res/values/palette.xml
@@ -8,7 +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 f48ee549b..664ecd3ce 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
@@ -899,4 +899,74 @@
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 Premium free trial!
+ 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 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
+ You have a free subscription. Claim now!
+ Powered by
+ 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.
+ 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.
+ Annual
+ per month
+ per year
+ Premium Forecast
+ 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.
+ 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
+ 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
+ Try for free for 2 months
+ 2 months free
+ Best Accuracy
+ 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.
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
-
-
-
-
-
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..1e0a7698c 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,15 @@ 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,9 +65,38 @@ 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)
+ }
+ }
}
}
diff --git a/app/src/test/java/com/weatherxm/data/datasource/WeatherHistoryDataSourceTest.kt b/app/src/test/java/com/weatherxm/data/datasource/WeatherHistoryDataSourceTest.kt
index 1dabc4bd8..f2bfe0d31 100644
--- a/app/src/test/java/com/weatherxm/data/datasource/WeatherHistoryDataSourceTest.kt
+++ b/app/src/test/java/com/weatherxm/data/datasource/WeatherHistoryDataSourceTest.kt
@@ -59,6 +59,7 @@ class WeatherHistoryDataSourceTest : BehaviorSpec({
address = "",
date = toDate,
tz = "Europe/Athens",
+ isPremium = false,
hourly = hourlyWeather,
daily = null
)
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..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,52 +1,68 @@
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
+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
+import kotlinx.coroutines.flow.MutableStateFlow
+import org.mockito.ArgumentMatchers.any
+import java.time.LocalDate
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"
- 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()) {
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) }
coJustRun { cacheSource.setLocationForecast(location, forecastData) }
coMockEitherRight(
- { networkSource.getDeviceForecast(deviceId, fromDate, now) },
+ {
+ networkSource.getDeviceDefaultForecast(
+ deviceId,
+ fromDate,
+ toDate,
+ token = any()
+ )
+ },
forecastData
)
coMockEitherRight(
- { cacheSource.getDeviceForecast(deviceId, fromDate, now) },
+ { cacheSource.getDeviceDefaultForecast(deviceId, fromDate, toDate) },
forecastData
)
coMockEitherRight({ networkSource.getLocationForecast(location) }, forecastData)
coMockEitherRight({ cacheSource.getLocationForecast(location) }, forecastData)
+ every { billingService.getActiveSubFlow() } returns purchaseFlow
}
}
@@ -54,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()
)
}
}
@@ -159,4 +161,43 @@ class WeatherForecastRepositoryTest : BehaviorSpec({
}
}
+ context("Handle fetching premium forecast") {
+ given("the datasource that we use to perform the API call") {
+ 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.getDevicePremiumForecast(deviceId, fromDate, toDate)
+// .isSuccess(forecastData)
+// coVerify(exactly = 1) {
+// networkSource.getDevicePremiumForecast(
+// deviceId,
+// fromDate,
+// toDate,
+// token = purchaseToken
+// )
+// }
+ }
+ }
+ When("the API returns a failure") {
+ coMockEitherLeft(
+ {
+ networkSource.getDevicePremiumForecast(
+ deviceId,
+ fromDate,
+ toDate,
+ token = purchaseToken
+ )
+ },
+ failure
+ )
+ then("forecast should return the failure") {
+ repo.getDevicePremiumForecast(deviceId, fromDate, toDate).isError()
+ }
+ }
+ }
+ }
})
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..206ce3163 100644
--- a/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt
+++ b/app/src/test/java/com/weatherxm/ui/devicedetails/DeviceDetailsViewModelTest.kt
@@ -14,13 +14,17 @@ 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
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
@@ -39,14 +43,18 @@ 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()
val authUseCase = mockk()
val followUseCase = mockk()
+ val userUseCase = mockk()
val analytics = mockk()
lateinit var viewModel: DeviceDetailsViewModel
+ val user = User("id", "email", null, null, null, Wallet("address", null))
+ val testWalletRewards = UIWalletRewards(2E20, 0.0, 2E20, "0x00")
val emptyDevice = UIDevice.empty()
val device = UIDevice(
"deviceId",
@@ -116,6 +124,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,
@@ -123,6 +133,7 @@ class DeviceDetailsViewModelTest : BehaviorSpec({
deviceDetailsUseCase,
authUseCase,
followUseCase,
+ userUseCase,
resources,
analytics,
dispatcher
@@ -146,6 +157,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/devicedetails/forecast/ForecastViewModelTest.kt b/app/src/test/java/com/weatherxm/ui/devicedetails/forecast/ForecastViewModelTest.kt
index 6c732997c..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
@@ -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("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("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("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("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 b25f3b832..a2c20e50d 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
@@ -46,6 +47,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()
@@ -118,8 +120,12 @@ class ForecastDetailsViewModelTest : BehaviorSpec({
null
)
val emptyForecast: UIForecast = UIForecast.empty()
- val forecast =
- UIForecast(device.address, 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) {
@@ -155,6 +161,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)
@@ -165,13 +172,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,
@@ -187,85 +196,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("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
}
}
}
@@ -276,17 +285,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
}
}
}
@@ -311,8 +320,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
}
}
}
@@ -328,7 +337,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({
testHandleFailureViewModel(
{ viewModel.fetchLocationForecast() },
analytics,
- viewModel.onForecastLoaded(),
+ viewModel.onLocationForecast(),
5,
forecastGenericErrorMsg
)
@@ -341,7 +350,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({
testHandleFailureViewModel(
{ viewModel.fetchLocationForecast() },
analytics,
- viewModel.onForecastLoaded(),
+ viewModel.onLocationForecast(),
6,
forecastGenericErrorMsg
)
@@ -354,7 +363,7 @@ class ForecastDetailsViewModelTest : BehaviorSpec({
testHandleFailureViewModel(
{ viewModel.fetchLocationForecast() },
analytics,
- viewModel.onForecastLoaded(),
+ viewModel.onLocationForecast(),
7,
invalidTimezoneMsg
)
@@ -367,13 +376,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") {
@@ -383,11 +392,11 @@ class ForecastDetailsViewModelTest : BehaviorSpec({
emptyForecast
)
runTest { viewModel.fetchLocationForecast() }
- then("LiveData onForecastLoaded should post the error for the empty forecast") {
- viewModel.onForecastLoaded().isError(emptyForecastMsg)
+ then("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") {
@@ -396,11 +405,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/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/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt b/app/src/test/java/com/weatherxm/usecases/ForecastUseCaseTest.kt
index 9d0daf01d..057713957 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(
@@ -113,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()
}
}
@@ -134,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)
}
}
}
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,
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index c8ae722d5..8251a8290 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.3.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" }
@@ -135,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" }
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=