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=