diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index a353fbae..be918577 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -150,7 +150,6 @@ \ No newline at end of file diff --git a/.idea/deploymentTargetDropDown.xml b/.idea/deploymentTargetDropDown.xml deleted file mode 100644 index d5ae7daa..00000000 --- a/.idea/deploymentTargetDropDown.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations/Home_Unit_Tests.xml b/.idea/runConfigurations/Home_Unit_Tests.xml new file mode 100644 index 00000000..fc4c95c2 --- /dev/null +++ b/.idea/runConfigurations/Home_Unit_Tests.xml @@ -0,0 +1,23 @@ + + + + + + + false + true + false + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b8145837..874bf3b0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,9 @@ plugins { id("wakatimeapp.android.application") - id("androidx.navigation.safeargs.kotlin") +} + +android { + namespace = "com.jacob.wakatimeapp" } dependencies { @@ -14,7 +17,7 @@ dependencies { implementation("androidx.core:core-splashscreen:1.0.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") // Core Testing testImplementation("junit:junit:4.13.2") @@ -25,7 +28,7 @@ dependencies { androidTestImplementation("androidx.test:runner:1.4.0") androidTestImplementation("androidx.arch.core:core-testing:2.1.0") androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0") - androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.2.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.3.0") androidTestImplementation("androidx.test.espresso:espresso-intents:3.4.0") androidTestImplementation("com.google.dagger:hilt-android-testing:2.42") @@ -34,10 +37,4 @@ dependencies { androidTestImplementation("com.google.truth:truth:1.1.3") androidTestImplementation("com.squareup.okhttp3:mockwebserver:4.9.3") androidTestImplementation("io.mockk:mockk-android:1.12.4") - - debugImplementation("androidx.compose.ui:ui-tooling:1.2.1") - debugImplementation("androidx.compose.ui:ui-test-manifest:1.2.1") -} -android { - namespace = "com.jacob.wakatimeapp" } diff --git a/build.gradle.kts b/build.gradle.kts index 4438f1ee..bc0182dd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,9 +4,8 @@ buildscript { mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:7.3.0") + classpath("com.android.tools.build:gradle:8.0.0-alpha07") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10") - classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.5.2") classpath("com.google.dagger:hilt-android-gradle-plugin:2.42") classpath("de.mannodermaus.gradle.plugins:android-junit5:1.8.2.1") } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 84d82527..0460d119 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -30,10 +30,10 @@ buildscript { } dependencies { - implementation("com.android.tools.build:gradle:7.3.0") + implementation("com.android.tools.build:gradle:8.0.0-alpha07") implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10") - implementation("org.jetbrains.kotlin:kotlin-serialization:1.7.10") + implementation("org.jetbrains.kotlin:kotlin-serialization:1.7.20") implementation("com.google.devtools.ksp:symbol-processing-api:1.7.10-1.0.6") implementation("io.gitlab.arturbosch.detekt:detekt-gradle-plugin:1.22.0-RC1") diff --git a/buildSrc/src/main/kotlin/wakatimeapp.android.application.gradle b/buildSrc/src/main/kotlin/wakatimeapp.android.application.gradle index 5467a914..51ea57a8 100644 --- a/buildSrc/src/main/kotlin/wakatimeapp.android.application.gradle +++ b/buildSrc/src/main/kotlin/wakatimeapp.android.application.gradle @@ -40,7 +40,10 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { jvmTarget = "11" } + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.3.1" } @@ -60,22 +63,24 @@ dependencies { implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.5.1") implementation("androidx.legacy:legacy-support-v4:1.0.0") + implementation("com.google.android.material:material:1.7.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") // Compose - implementation("androidx.compose.ui:ui:1.2.1") - implementation("com.google.android.material:material:1.7.0-rc01") - implementation("androidx.compose.material3:material3:1.0.0-rc01") - implementation("androidx.compose.ui:ui-tooling-preview:1.2.1") - implementation("androidx.activity:activity-compose:1.6.0") + implementation(platform("androidx.compose:compose-bom:2022.10.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.activity:activity-compose:1.6.1") + + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") // Navigation implementation("io.github.raamcosta.compose-destinations:core:1.6.19-beta") ksp("io.github.raamcosta.compose-destinations:ksp:1.6.19-beta") - implementation("androidx.hilt:hilt-navigation-fragment:1.0.0") - // Hilt implementation("com.google.dagger:hilt-android:2.43.1") kapt("com.google.dagger:hilt-android-compiler:2.43.1") @@ -84,6 +89,7 @@ dependencies { implementation("com.jakewharton.timber:timber:5.0.1") implementation("io.arrow-kt:arrow-core:1.0.1") + } kapt { diff --git a/buildSrc/src/main/kotlin/wakatimeapp.android.feature.gradle b/buildSrc/src/main/kotlin/wakatimeapp.android.feature.gradle index d5c661f5..18d6ed5e 100644 --- a/buildSrc/src/main/kotlin/wakatimeapp.android.feature.gradle +++ b/buildSrc/src/main/kotlin/wakatimeapp.android.feature.gradle @@ -38,7 +38,10 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { jvmTarget = "11" } + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + } buildFeatures { compose = true } composeOptions { kotlinCompilerExtensionVersion = "1.3.1" } @@ -50,18 +53,22 @@ dependencies { implementation(project(":core:common")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.5.1") - implementation("com.google.android.material:material:1.7.0-rc01") + implementation("com.google.android.material:material:1.7.0") // Compose - implementation("androidx.compose.ui:ui:1.2.1") - implementation("androidx.compose.material3:material3:1.0.0-rc01") - implementation("androidx.compose.ui:ui-tooling-preview:1.2.1") - implementation("androidx.activity:activity-compose:1.6.0") + implementation(platform("androidx.compose:compose-bom:2022.10.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + implementation("androidx.activity:activity-compose:1.6.1") + + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") // Hilt implementation("com.google.dagger:hilt-android:2.43.1") @@ -79,13 +86,23 @@ dependencies { // Base testing dependencies testImplementation("org.junit.jupiter:junit-jupiter-api:5.8.2") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2") + + // Coroutines & Flows testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") + testImplementation("app.cash.turbine:turbine:0.12.0") + + // Mocks testImplementation("io.mockk:mockk:1.13.2") + + // Assertions testImplementation("io.kotest:kotest-assertions-core:5.5.0") + testImplementation("io.kotest.extensions:kotest-assertions-arrow:1.2.5") androidTestImplementation("androidx.test.ext:junit:1.1.3") - testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.8.2") + debugImplementation("androidx.compose.ui:ui-tooling:1.3.0") + debugImplementation("androidx.compose.ui:ui-test-manifest:1.3.0") } kapt { diff --git a/buildSrc/src/main/kotlin/wakatimeapp.android.library.gradle b/buildSrc/src/main/kotlin/wakatimeapp.android.library.gradle index a1fb350d..be2904e5 100644 --- a/buildSrc/src/main/kotlin/wakatimeapp.android.library.gradle +++ b/buildSrc/src/main/kotlin/wakatimeapp.android.library.gradle @@ -22,12 +22,15 @@ android { targetCompatibility = JavaVersion.VERSION_11 } - kotlinOptions { jvmTarget = "11" } + kotlinOptions { + jvmTarget = "11" + freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + } } dependencies { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0") diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index d3751f5c..b59be7dd 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -2,6 +2,10 @@ plugins { id("wakatimeapp.android.library") } +android { + namespace = "com.jacob.wakatimeapp.core.common" +} + dependencies { implementation(project(":core:models")) @@ -25,6 +29,3 @@ dependencies { implementation("com.squareup.okhttp3:okhttp") implementation("com.squareup.okhttp3:logging-interceptor") } -android { - namespace = "com.jacob.wakatimeapp.core.common" -} diff --git a/core/common/src/main/java/com/jacob/wakatimeapp/core/common/Utils.kt b/core/common/src/main/java/com/jacob/wakatimeapp/core/common/Utils.kt index 97b53feb..cd9c3c94 100644 --- a/core/common/src/main/java/com/jacob/wakatimeapp/core/common/Utils.kt +++ b/core/common/src/main/java/com/jacob/wakatimeapp/core/common/Utils.kt @@ -1,8 +1,14 @@ -package com.jacob.wakatimeapp.core.common // ktlint-disable filename +package com.jacob.wakatimeapp.core.common import java.time.format.TextStyle.SHORT import java.util.Locale +import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +fun Instant.toDate(timeZone: TimeZone = TimeZone.currentSystemDefault()) = + toLocalDateTime(timeZone).date fun LocalDate.getDisplayNameForDay(): String = dayOfWeek.getDisplayName(SHORT, Locale.getDefault()) diff --git a/core/models/build.gradle.kts b/core/models/build.gradle.kts index c24a46d4..df237481 100644 --- a/core/models/build.gradle.kts +++ b/core/models/build.gradle.kts @@ -2,8 +2,9 @@ plugins { id("wakatimeapp.android.library") } -dependencies { -} android { namespace = "com.jacob.wakatimeapp.core.models" } + +dependencies { +} diff --git a/core/models/src/main/java/com/jacob/wakatimeapp/core/models/Stats.kt b/core/models/src/main/java/com/jacob/wakatimeapp/core/models/Stats.kt new file mode 100644 index 00000000..8df9015c --- /dev/null +++ b/core/models/src/main/java/com/jacob/wakatimeapp/core/models/Stats.kt @@ -0,0 +1,7 @@ +package com.jacob.wakatimeapp.core.models + +data class Stats( + val totalTime: Time, + val dailyStats: List, + val range: StatsRange, +) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 2abb2fe6..df92a7b6 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -18,14 +18,19 @@ dependencies { implementation("androidx.core:core-ktx:1.9.0") implementation("androidx.appcompat:appcompat:1.5.1") - implementation("com.google.android.material:material:1.7.0-rc01") - implementation("androidx.compose.material3:material3:1.0.0-rc01") + implementation("com.google.android.material:material:1.7.0") + implementation("androidx.compose.material3:material3:1.0.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") // Compose - implementation("androidx.compose.ui:ui:1.2.1") - implementation("androidx.compose.ui:ui-tooling-preview:1.2.1") + implementation(platform("androidx.compose:compose-bom:2022.10.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.material3:material3") + + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") implementation("com.airbnb.android:lottie-compose:5.0.3") diff --git a/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/WtaPreviews.kt b/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/WtaPreviews.kt new file mode 100644 index 00000000..04e9451e --- /dev/null +++ b/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/WtaPreviews.kt @@ -0,0 +1,31 @@ +package com.jacob.wakatimeapp.core.ui + +import android.content.res.Configuration +import androidx.compose.ui.tooling.preview.Preview + +@Preview( + name = "Light Mode", + uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL, + group = "component" +) +@Preview( + name = "Dark Mode", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, + group = "component" +) +@Preview( + name = "Full Device Light ", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_NO or Configuration.UI_MODE_TYPE_NORMAL, + showSystemUi = true, + group = "full" +) +@Preview( + name = "Full Device Dark ", + showBackground = true, + uiMode = Configuration.UI_MODE_NIGHT_YES or Configuration.UI_MODE_TYPE_NORMAL, + showSystemUi = true, + group = "full" +) +annotation class WtaPreviews diff --git a/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/modifiers/RemoveFontPadding.kt b/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/modifiers/RemoveFontPadding.kt new file mode 100644 index 00000000..bea7a73f --- /dev/null +++ b/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/modifiers/RemoveFontPadding.kt @@ -0,0 +1,21 @@ +package com.jacob.wakatimeapp.core.ui.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.TextStyle + +/** + * Removes excess padding from the top and bottom of the text. + * + * [Source](https://issuetracker.google.com/issues/171394808#comment38) + */ +fun Modifier.removeFontPadding( + textStyle: TextStyle, +) = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val heightWithoutPadding = + placeable.height - placeable.height.mod(textStyle.lineHeight.roundToPx()) + layout(placeable.width, heightWithoutPadding) { + placeable.placeRelative(0, 0) + } +} diff --git a/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/theme/colors/Gradients.kt b/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/theme/colors/Gradients.kt index b158c22c..17715407 100644 --- a/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/theme/colors/Gradients.kt +++ b/core/ui/src/main/java/com/jacob/wakatimeapp/core/ui/theme/colors/Gradients.kt @@ -14,7 +14,9 @@ data class Gradient( val opacity: Int = 100, val onStartColor: Color = startColor, val onEndColor: Color = endColor, -) +) { + val colorList get() = listOf(startColor, endColor) +} @Immutable data class Gradients( diff --git a/details/build.gradle.kts b/details/build.gradle.kts index 71ee21de..3450928a 100644 --- a/details/build.gradle.kts +++ b/details/build.gradle.kts @@ -2,8 +2,9 @@ plugins { id("wakatimeapp.android.feature") } -dependencies { -} android { namespace = "com.jacob.wakatimeapp.details" } + +dependencies { +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2cb9da42..b222286c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Sep 30 10:26:09 IST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/home/build.gradle.kts b/home/build.gradle.kts index d854e77e..562c6a24 100644 --- a/home/build.gradle.kts +++ b/home/build.gradle.kts @@ -2,9 +2,13 @@ plugins { id("wakatimeapp.android.feature") } +android { + namespace = "com.jacob.wakatimeapp.home" +} + dependencies { // Image Loading, Charts, Lottie Animations - implementation("io.coil-kt:coil-compose:2.1.0") + implementation("io.coil-kt:coil-compose:2.2.2") implementation("com.github.PhilJay:MPAndroidChart:v3.1.0") implementation("androidx.datastore:datastore-preferences:1.0.0") @@ -15,6 +19,3 @@ dependencies { annotationProcessor("androidx.room:room-compiler:2.4.3") ksp("androidx.room:room-compiler:2.4.3") } -android { - namespace = "com.jacob.wakatimeapp.home" -} diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/data/local/HomePageCache.kt b/home/src/main/java/com/jacob/wakatimeapp/home/data/local/HomePageCache.kt index 44070e63..e6070d39 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/data/local/HomePageCache.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/data/local/HomePageCache.kt @@ -10,14 +10,13 @@ import arrow.core.left import arrow.core.right import com.jacob.wakatimeapp.core.models.Error import com.jacob.wakatimeapp.core.models.Error.DatabaseError -import com.jacob.wakatimeapp.home.domain.models.HomePageUiData +import com.jacob.wakatimeapp.home.domain.InstantProvider +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats +import com.jacob.wakatimeapp.home.domain.models.StreakRange import javax.inject.Inject import javax.inject.Singleton -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -28,45 +27,52 @@ import timber.log.Timber class HomePageCache @Inject constructor( private val dataStore: DataStore, private val json: Json, + private val instantProvider: InstantProvider, ) { - suspend fun getLastRequestTime(): Instant = dataStore.data.map { + fun getLastRequestTime() = dataStore.data.map { val value = it[KEY_LAST_REQUEST_TIME] value?.let(Instant::fromEpochMilliseconds) ?: Instant.DISTANT_PAST } .catch { Instant.DISTANT_PAST } - .first() - suspend fun updateLastRequestTime(time: Instant = Clock.System.now()) { + suspend fun updateLastRequestTime(time: Instant = instantProvider.now()) { dataStore.edit { it[KEY_LAST_REQUEST_TIME] = time.toEpochMilliseconds() } } - fun getCachedData(): Flow> = dataStore.data.map { - val emptyCacheError: Either = DatabaseError.EmptyCache("") - .left() - val stringUiData = it[KEY_CACHED_HOME_PAGE_UI_DATA] ?: return@map emptyCacheError - json.decodeFromString(stringUiData) - .right() + fun getLast7DaysStats() = dataStore.data.map { + it[KEY_LAST_7_DAYS_STATS]?.let(json::decodeFromString).right() + }.catch> { + Timber.e(it) + emit(DatabaseError.UnknownError(it.message!!, it).left()) } - .catch { - Timber.e(it) - emit( - DatabaseError.UnknownError(it.message!!, it) - .left() - ) + + suspend fun updateLast7DaysStats(homePageUiData: Last7DaysStats) { + dataStore.edit { + it[KEY_LAST_7_DAYS_STATS] = json.encodeToString(homePageUiData) } + } + + fun getCurrentStreak() = dataStore.data.map> { + val streakRange = it[KEY_CURRENT_STREAK]?.let(json::decodeFromString) + ?: StreakRange.ZERO + streakRange.right() + }.catch { + Timber.e(it) + emit(DatabaseError.UnknownError(it.message!!, it).left()) + } - suspend fun updateCache(homePageUiData: HomePageUiData) { + suspend fun updateCurrentStreak(streakRange: StreakRange) { dataStore.edit { - it[KEY_CACHED_HOME_PAGE_UI_DATA] = json.encodeToString(homePageUiData) + it[KEY_CURRENT_STREAK] = json.encodeToString(streakRange) } } companion object { private val KEY_LAST_REQUEST_TIME = longPreferencesKey("KEY_LAST_REQUEST_TIME") - private val KEY_CACHED_HOME_PAGE_UI_DATA = - stringPreferencesKey("KEY_CACHE_HOME_PAGE_UI_DATA") + private val KEY_LAST_7_DAYS_STATS = stringPreferencesKey("KEY_LAST_7_DAYS_STATS") + private val KEY_CURRENT_STREAK = stringPreferencesKey("KEY_CURRENT_STREAK") } } diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/data/network/HomePageAPI.kt b/home/src/main/java/com/jacob/wakatimeapp/home/data/network/HomePageAPI.kt index a4732d56..9a663ec0 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/data/network/HomePageAPI.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/data/network/HomePageAPI.kt @@ -3,9 +3,11 @@ package com.jacob.wakatimeapp.home.data.network import com.jacob.wakatimeapp.home.data.network.dtos.AllTimeDataDTO import com.jacob.wakatimeapp.home.data.network.dtos.GetDailyStatsResDTO import com.jacob.wakatimeapp.home.data.network.dtos.GetLast7DaysStatsResDTO +import com.jacob.wakatimeapp.home.data.network.dtos.GetStatsForRangeResDTO import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Header +import retrofit2.http.Path interface HomePageAPI { @GET("/api/v1/users/current/all_time_since_today") @@ -16,4 +18,11 @@ interface HomePageAPI { @GET("/api/v1/users/current/summaries?range=last_7_days") suspend fun getLast7DaysStats(@Header("Authorization") token: String): Response + + @GET("/api/v1/users/current/summaries?start={start}&end={end}") + suspend fun getStatsForRange( + @Header("Authorization") token: String, + @Path("start") start: String, + @Path("end") end: String, + ): Response } diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/data/network/HomePageNetworkData.kt b/home/src/main/java/com/jacob/wakatimeapp/home/data/network/HomePageNetworkData.kt index f02cb171..69f27fc1 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/data/network/HomePageNetworkData.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/data/network/HomePageNetworkData.kt @@ -9,6 +9,7 @@ import com.jacob.wakatimeapp.core.models.Error.NetworkErrors import com.jacob.wakatimeapp.home.data.network.dtos.AllTimeDataDTO import com.jacob.wakatimeapp.home.data.network.dtos.GetDailyStatsResDTO import com.jacob.wakatimeapp.home.data.network.dtos.GetLast7DaysStatsResDTO +import com.jacob.wakatimeapp.home.data.network.dtos.GetStatsForRangeResDTO import com.jacob.wakatimeapp.home.data.network.mappers.toModel import java.net.UnknownHostException import javax.inject.Inject @@ -55,6 +56,15 @@ class HomePageNetworkData @Inject constructor( Timber.e(exception) handleNetworkException(exception) } + + suspend fun getStatsForRange(start: String, end: String) = try { + homePageAPI.getStatsForRange("Bearer $token", start, end) + .checkResponse() + .map(GetStatsForRangeResDTO::toModel) + } catch (exception: Exception) { + Timber.e(exception) + handleNetworkException(exception) + } } private fun Response.checkResponse(): Either = if (isSuccessful) { diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/data/network/dtos/GetStatsForRangeResDTO.kt b/home/src/main/java/com/jacob/wakatimeapp/home/data/network/dtos/GetStatsForRangeResDTO.kt new file mode 100644 index 00000000..a63fb2f9 --- /dev/null +++ b/home/src/main/java/com/jacob/wakatimeapp/home/data/network/dtos/GetStatsForRangeResDTO.kt @@ -0,0 +1,36 @@ +package com.jacob.wakatimeapp.home.data.network.dtos + +import com.jacob.wakatimeapp.core.common.data.dtos.CategoryDTO +import com.jacob.wakatimeapp.core.common.data.dtos.CumulativeTotalDTO +import com.jacob.wakatimeapp.core.common.data.dtos.DependencyDTO +import com.jacob.wakatimeapp.core.common.data.dtos.EditorDTO +import com.jacob.wakatimeapp.core.common.data.dtos.GrandTotalDTO +import com.jacob.wakatimeapp.core.common.data.dtos.LanguageDTO +import com.jacob.wakatimeapp.core.common.data.dtos.MachineDTO +import com.jacob.wakatimeapp.core.common.data.dtos.OperatingSystemDTO +import com.jacob.wakatimeapp.core.common.data.dtos.ProjectDTO +import com.jacob.wakatimeapp.core.common.data.dtos.RangeDTO +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GetStatsForRangeResDTO( + val data: List, + val start: String, + val end: String, + @SerialName("cummulative_total") val cumulativeTotal: CumulativeTotalDTO, +) { + + @Serializable + data class Data( + val categories: List, + val dependencies: List, + val editors: List, + val languages: List, + val machines: List, + val projects: List, + val range: RangeDTO, + @SerialName("operating_systems") val operatingSystems: List, + @SerialName("grand_total") val grandTotal: GrandTotalDTO, + ) +} diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/data/network/mappers/GetStatsForRangeResMapper.kt b/home/src/main/java/com/jacob/wakatimeapp/home/data/network/mappers/GetStatsForRangeResMapper.kt new file mode 100644 index 00000000..d77edbd4 --- /dev/null +++ b/home/src/main/java/com/jacob/wakatimeapp/home/data/network/mappers/GetStatsForRangeResMapper.kt @@ -0,0 +1,41 @@ +package com.jacob.wakatimeapp.home.data.network.mappers + +import com.jacob.wakatimeapp.core.common.data.dtos.EditorDTO +import com.jacob.wakatimeapp.core.common.data.dtos.LanguageDTO +import com.jacob.wakatimeapp.core.common.data.dtos.OperatingSystemDTO +import com.jacob.wakatimeapp.core.common.data.dtos.ProjectDTO +import com.jacob.wakatimeapp.core.common.data.mappers.toModel +import com.jacob.wakatimeapp.core.models.DailyStats +import com.jacob.wakatimeapp.core.models.Stats +import com.jacob.wakatimeapp.core.models.StatsRange +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.home.data.network.dtos.GetStatsForRangeResDTO +import com.jacob.wakatimeapp.home.data.network.dtos.GetStatsForRangeResDTO.Data +import kotlinx.datetime.toLocalDate + +fun GetStatsForRangeResDTO.toModel() = Stats( + totalTime = Time.createFrom(cumulativeTotal.digital, cumulativeTotal.decimal), + dailyStats = getDailyStatsFromDto(data), + range = StatsRange( + startDate = start.takeWhile { it != 'T' } + .toLocalDate(), + endDate = end.takeWhile { it != 'T' } + .toLocalDate(), + ) +) + +private fun getDailyStatsFromDto(data: List) = data.map { + DailyStats( + timeSpent = Time.createFrom( + digitalString = it.grandTotal.digital, + decimal = it.grandTotal.decimal + ), + mostUsedEditor = it.editors.maxByOrNull(EditorDTO::percent)?.name ?: "NA", + mostUsedLanguage = it.languages.maxByOrNull(LanguageDTO::percent)?.name ?: "NA", + mostUsedOs = it.operatingSystems.maxByOrNull(OperatingSystemDTO::percent)?.name ?: "NA", + date = it.range.date.toLocalDate(), + projectsWorkedOn = it.projects + .filterNot(ProjectDTO::isUnknownProject) + .map(ProjectDTO::toModel) + ) +} diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/domain/Utils.kt b/home/src/main/java/com/jacob/wakatimeapp/home/domain/Utils.kt new file mode 100644 index 00000000..babc1468 --- /dev/null +++ b/home/src/main/java/com/jacob/wakatimeapp/home/domain/Utils.kt @@ -0,0 +1,16 @@ +package com.jacob.wakatimeapp.home.domain // ktlint-disable filename + +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import kotlinx.datetime.LocalDate + +fun Map.getLatestStreakInRange() = toSortedMap() + .entries + .reversed() + .takeWhile { it.value != Time.ZERO } + .let { + if (it.isEmpty()) StreakRange.ZERO else StreakRange( + start = it.last().key, + end = it.first().key, + ) + } diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/domain/models/HomePageUiData.kt b/home/src/main/java/com/jacob/wakatimeapp/home/domain/models/HomePageUiData.kt index a97c21e1..4e2edda7 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/domain/models/HomePageUiData.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/domain/models/HomePageUiData.kt @@ -4,28 +4,86 @@ import com.jacob.wakatimeapp.core.models.Project import com.jacob.wakatimeapp.core.models.Time import com.jacob.wakatimeapp.core.models.UserDetails import com.jacob.wakatimeapp.core.models.WeeklyStats +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.daysUntil +import kotlinx.datetime.minus +import kotlinx.datetime.plus +import kotlinx.datetime.toLocalDateTime import kotlinx.serialization.Serializable +import timber.log.Timber + +data class CachedHomePageUiData( + val last7DaysStats: Last7DaysStats, + val userDetails: HomePageUserDetails, + val streaks: Streaks, + val isStaleData: Boolean, +) @Serializable -data class HomePageUiData( +data class Last7DaysStats( val timeSpentToday: Time, val projectsWorkedOn: List, val weeklyTimeSpent: Map, val mostUsedLanguage: String, val mostUsedEditor: String, val mostUsedOs: String, - val photoUrl: String, +) + +@Serializable +data class HomePageUserDetails( val fullName: String, + val photoUrl: String, +) + +@Serializable +data class Streaks( + val currentStreak: StreakRange, + val longestStreak: StreakRange, ) -fun WeeklyStats.toLoadedStateData(userDetails: UserDetails) = HomePageUiData( +@Serializable +data class StreakRange( + val start: LocalDate, + val end: LocalDate, +) { + val days = start.daysUntil(end) + 1 + + operator fun plus(other: StreakRange): StreakRange = when { + end == other.start -> StreakRange(start, other.end) + other.end == start -> StreakRange(other.start, end) + end == other.start.minus(1, DateTimeUnit.DAY) -> StreakRange(start, other.end) + start == other.end.plus(1, DateTimeUnit.DAY) -> StreakRange(other.start, end) + start < other.start && end > other.end -> StreakRange(start, end) + other.start < start && other.end > end -> StreakRange(other.start, other.end) + start < other.start && end > other.start -> StreakRange(start, other.end) + start < other.end && end > other.start -> StreakRange(other.start, end) + else -> { + Timber.e("Cannot add streaks $this and $other") + ZERO + } + } + + companion object { + val ZERO = StreakRange( + Instant.DISTANT_PAST.toLocalDateTime(TimeZone.currentSystemDefault()).date, + Instant.DISTANT_PAST.toLocalDateTime(TimeZone.currentSystemDefault()).date + ) + } +} + +fun WeeklyStats.toLoadedStateData() = Last7DaysStats( timeSpentToday = todaysStats.timeSpent, projectsWorkedOn = todaysStats.projectsWorkedOn, weeklyTimeSpent = dailyStats.associate { it.date to it.timeSpent }, mostUsedLanguage = todaysStats.mostUsedLanguage, mostUsedEditor = todaysStats.mostUsedEditor, mostUsedOs = todaysStats.mostUsedOs, - photoUrl = userDetails.photoUrl, - fullName = userDetails.fullName, +) + +fun UserDetails.toHomePageUserDetails() = HomePageUserDetails( + fullName = fullName, + photoUrl = photoUrl ) diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUC.kt b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUC.kt new file mode 100644 index 00000000..83e93afe --- /dev/null +++ b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUC.kt @@ -0,0 +1,72 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import arrow.core.Either +import arrow.core.computations.either +import arrow.core.right +import com.jacob.wakatimeapp.core.common.toDate +import com.jacob.wakatimeapp.core.models.Error +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.home.data.local.HomePageCache +import com.jacob.wakatimeapp.home.domain.InstantProvider +import com.jacob.wakatimeapp.home.domain.getLatestStreakInRange +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.minus + +@Singleton +class CalculateCurrentStreakUC @Inject constructor( + private val homePageCache: HomePageCache, + private val instantProvider: InstantProvider, + private val recalculateLatestStreakUC: RecalculateLatestStreakUC, +) { + + suspend operator fun invoke(): Either = either { + val last7DaysStats = + homePageCache.getLast7DaysStats().first().bind() ?: return@either StreakRange.ZERO + val currentStreak = homePageCache.getCurrentStreak().first().bind() + + val today = instantProvider.now().toDate() + val todaysStats = last7DaysStats.weeklyTimeSpent[today] ?: Time.ZERO + + val endOfCurrentStreakIsYesterday = currentStreak.end == today.minus(1, DateTimeUnit.DAY) + + val recalculatedStreakForLast7Days = last7DaysStats.weeklyTimeSpent + .getLatestStreakInRange() + + val combinedStreak = currentStreak + recalculatedStreakForLast7Days + val failedToCombine = combinedStreak == StreakRange.ZERO + + when { + endOfCurrentStreakIsYesterday -> whenEndOfCurrentStreakIsYesterday( + currentStreak, + todaysStats + ) + + failedToCombine -> whenFailedToCombine(recalculatedStreakForLast7Days).bind() + else -> combinedStreak + } + } + + private fun whenEndOfCurrentStreakIsYesterday( + currentStreak: StreakRange, + todaysStats: Time, + ) = when (todaysStats) { + Time.ZERO -> currentStreak + else -> currentStreak.copy(end = instantProvider.now().toDate()) + } + + private suspend fun whenFailedToCombine( + recalculatedStreakForLast7Days: StreakRange, + ) = when (recalculatedStreakForLast7Days.days) { + 7 -> recalculateLatestStreakUC.calculate( + start = instantProvider.now().toDate().minus(8, DateTimeUnit.DAY), + value = 1, + unit = DateTimeUnit.MONTH + ) + + else -> recalculatedStreakForLast7Days.right() + } +} diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUC.kt b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUC.kt new file mode 100644 index 00000000..ec31d0ba --- /dev/null +++ b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUC.kt @@ -0,0 +1,86 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import arrow.core.Either.Right +import arrow.core.computations.either +import com.jacob.wakatimeapp.core.common.auth.AuthDataStore +import com.jacob.wakatimeapp.core.models.Error +import com.jacob.wakatimeapp.home.data.local.HomePageCache +import com.jacob.wakatimeapp.home.domain.InstantProvider +import com.jacob.wakatimeapp.home.domain.models.CachedHomePageUiData +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import com.jacob.wakatimeapp.home.domain.models.Streaks +import com.jacob.wakatimeapp.home.domain.models.toHomePageUserDetails +import com.jacob.wakatimeapp.home.domain.usecases.GetCachedHomePageUiDataUC.CacheValidity.DEFAULT +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.combine +import kotlinx.datetime.Instant +import kotlinx.datetime.toLocalDateTime + +@Singleton +class GetCachedHomePageUiDataUC @Inject constructor( + private val instantProvider: InstantProvider, + private val homePageCache: HomePageCache, + private val authDataStore: AuthDataStore, +) { + + /** + * @return [CachedHomePageUiData] if there is data for the current day in the cache, otherwise null + */ + operator fun invoke(cacheValidity: CacheValidity = DEFAULT) = channelFlow { + combine( + getLast7DaysStats(), + getHomePageUserDetails(), + getStreaks(), + homePageCache.getLastRequestTime(), + ) { last7DaysStatsEither, userDetails, streaksEither, lastRequestTime -> + if (lastRequestTime.isFirstRequestOfDay()) return@combine Right(null) + + either { + val last7DaysStats = last7DaysStatsEither.bind() ?: return@either null + val streakRange = streaksEither.bind() + val streaks = Streaks( + currentStreak = streakRange, + longestStreak = StreakRange.ZERO + ) + + CachedHomePageUiData( + last7DaysStats = last7DaysStats, + userDetails = userDetails.toHomePageUserDetails(), + streaks = streaks, + isStaleData = !validDataInCache( + lastRequestTime = lastRequestTime, + cacheValidityTime = cacheValidity + ) + ) + } + }.collect { send(it) } + } + + private fun getHomePageUserDetails() = authDataStore.getUserDetails() + + private fun getLast7DaysStats() = homePageCache.getLast7DaysStats() + + private fun getStreaks() = homePageCache.getCurrentStreak() + + private fun Instant.isFirstRequestOfDay(): Boolean { + val lastRequestDate = toLocalDateTime(instantProvider.timeZone).date.toEpochDays() + val currentDate = + instantProvider.now().toLocalDateTime(instantProvider.timeZone).date.toEpochDays() + return currentDate - lastRequestDate >= 1 + } + + private fun validDataInCache( + lastRequestTime: Instant, + cacheValidityTime: CacheValidity, + ): Boolean { + val minutesBetweenLastRequest = instantProvider.now() - lastRequestTime + return minutesBetweenLastRequest.inWholeMinutes < cacheValidityTime.minutes + } + + enum class CacheValidity(val minutes: Long) { + DEFAULT(15L), + INVALID(0L), + } +} diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUC.kt b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUC.kt index 17208db1..2d2bb4f4 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUC.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUC.kt @@ -1,99 +1,16 @@ package com.jacob.wakatimeapp.home.domain.usecases -import arrow.core.left -import com.jacob.wakatimeapp.core.models.Error -import com.jacob.wakatimeapp.core.models.UserDetails -import com.jacob.wakatimeapp.home.data.local.HomePageCache import com.jacob.wakatimeapp.home.data.network.HomePageNetworkData -import com.jacob.wakatimeapp.home.domain.InstantProvider -import com.jacob.wakatimeapp.home.domain.models.HomePageUiData import com.jacob.wakatimeapp.home.domain.models.toLoadedStateData -import com.jacob.wakatimeapp.home.domain.usecases.GetLast7DaysStatsUC.CacheValidity.INVALID import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.CoroutineContext -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.launch -import kotlinx.datetime.Instant -import kotlinx.datetime.toLocalDateTime @Singleton class GetLast7DaysStatsUC @Inject constructor( - dispatcher: CoroutineContext = Dispatchers.IO, private val homePageNetworkData: HomePageNetworkData, - private val homePageCache: HomePageCache, - private val instantProvider: InstantProvider, ) { - private val ioScope = CoroutineScope(dispatcher) - operator fun invoke(userDetails: UserDetails, cacheValidity: CacheValidity = INVALID) = - channelFlow { - val lastRequestTime = homePageCache.getLastRequestTime() - - when { - firstRequestOfDay(lastRequestTime) -> - makeRequestAndUpdateCache(userDetails)?.let { send(it) } - - !validDataInCache( - lastRequestTime, - cacheValidity - ) -> launch { makeRequestAndUpdateCache(userDetails)?.let { send(it) } } - } - - dataFromCache().collect { send(it) } - } - - private fun dataFromCache() = homePageCache.getCachedData() - .catch { throwable -> - Error.UnknownError(throwable.message!!, throwable) - .left() - .let { emit(it) } - } - - /** - * Gets data from network and updates the cache is successful. - * - * Returns an [Either.Left] if any errors happened. - */ - private suspend fun makeRequestAndUpdateCache(userDetails: UserDetails) = - homePageNetworkData.getLast7DaysStats() - .map { it.toLoadedStateData(userDetails) } - .tap { it.updateCaches() } - .fold(ifLeft = Error::left, ifRight = { null }) - - private suspend fun HomePageUiData.updateCaches() { - listOf( - ioScope.async { homePageCache.updateCache(this@updateCaches) }, - ioScope.async { homePageCache.updateLastRequestTime() }, - ) - .awaitAll() - } - - private fun validDataInCache( - lastRequestTime: Instant, - cacheValidityTime: CacheValidity, - ): Boolean { - val minutesBetweenLastRequest = instantProvider.now() - lastRequestTime - return minutesBetweenLastRequest.inWholeMinutes < cacheValidityTime.minutes - } - - private fun firstRequestOfDay(lastRequestTime: Instant) = lastRequestTime.isPreviousDay() - - private fun Instant.isPreviousDay(): Boolean { - val lastRequestDate = this.toLocalDateTime(instantProvider.timeZone).date.toEpochDays() - val currentDate = instantProvider.now() - .toLocalDateTime(instantProvider.timeZone).date.toEpochDays() - - return currentDate - lastRequestDate >= 1 - } - - enum class CacheValidity(val minutes: Long) { - DEFAULT(15L), - INVALID(0L), - } + suspend operator fun invoke() = homePageNetworkData.getLast7DaysStats() + .map { it.toLoadedStateData() } } diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUC.kt b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUC.kt new file mode 100644 index 00000000..2894da92 --- /dev/null +++ b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUC.kt @@ -0,0 +1,50 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import arrow.core.Either +import arrow.core.computations.either +import com.jacob.wakatimeapp.core.models.Error +import com.jacob.wakatimeapp.home.data.network.HomePageNetworkData +import com.jacob.wakatimeapp.home.domain.getLatestStreakInRange +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DateTimeUnit.DateBased +import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil +import kotlinx.datetime.minus + +@Singleton +class RecalculateLatestStreakUC @Inject constructor( + private val homePageNetworkData: HomePageNetworkData, +) { + suspend fun calculate( + start: LocalDate, + value: Int, + unit: DateBased, + ): Either = either { + val end = start.minus(value, unit) + val count = end.daysUntil(start) + 1 + homePageNetworkData.getStatsForRange(start.toString(), end.toString()) + .map { stats -> + stats.dailyStats + .associate { it.date to it.timeSpent } + .getLatestStreakInRange() + } + .map { + when (it.days) { + count -> { + val streakFromNextDuration = calculate( + start = end.minus(1, DateTimeUnit.DAY), + value = value, + unit = unit + ).bind() + if (streakFromNextDuration == StreakRange.ZERO) it else it + streakFromNextDuration + } + + else -> it + } + } + .bind() + } +} diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/UpdateCachedHomePageUiData.kt b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/UpdateCachedHomePageUiData.kt new file mode 100644 index 00000000..5359a39d --- /dev/null +++ b/home/src/main/java/com/jacob/wakatimeapp/home/domain/usecases/UpdateCachedHomePageUiData.kt @@ -0,0 +1,18 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import com.jacob.wakatimeapp.home.data.local.HomePageCache +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats +import com.jacob.wakatimeapp.home.domain.models.Streaks +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UpdateCachedHomePageUiData @Inject constructor( + private val homePageCache: HomePageCache, +) { + suspend operator fun invoke(last7DaysStats: Last7DaysStats, streaks: Streaks) { + homePageCache.updateLast7DaysStats(last7DaysStats) + homePageCache.updateCurrentStreak(streaks.currentStreak) + homePageCache.updateLastRequestTime() + } +} diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/ui/HomePageScreen.kt b/home/src/main/java/com/jacob/wakatimeapp/home/ui/HomePageScreen.kt index 53a3b894..c345c722 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/ui/HomePageScreen.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/ui/HomePageScreen.kt @@ -29,7 +29,10 @@ import com.jacob.wakatimeapp.core.ui.theme.WakaTimeAppTheme import com.jacob.wakatimeapp.core.ui.theme.assets import com.jacob.wakatimeapp.core.ui.theme.gradients import com.jacob.wakatimeapp.core.ui.theme.spacing -import com.jacob.wakatimeapp.home.domain.models.HomePageUiData +import com.jacob.wakatimeapp.home.domain.models.HomePageUserDetails +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import com.jacob.wakatimeapp.home.domain.models.Streaks import com.jacob.wakatimeapp.home.ui.components.OtherDailyStatsSection import com.jacob.wakatimeapp.home.ui.components.RecentProjects import com.jacob.wakatimeapp.home.ui.components.UserDetailsSection @@ -98,8 +101,8 @@ private fun HomePageLoaded( .verticalScroll(scrollState) ) { UserDetailsSection( - fullName = homePageViewState.contentData.photoUrl, - photoUrl = homePageViewState.contentData.fullName + fullName = homePageViewState.userDetails.fullName, + photoUrl = homePageViewState.userDetails.photoUrl ) TimeSpentCard( @@ -107,22 +110,23 @@ private fun HomePageLoaded( roundedCornerPercent = 25, iconId = icons.time, mainText = "Total Time Spent Today", - time = homePageViewState.contentData.timeSpentToday, + time = homePageViewState.last7DaysStats.timeSpentToday, onClick = toDetailsPage ) Spacer(modifier = Modifier.height(spacing.small)) - RecentProjects(homePageViewState.contentData.projectsWorkedOn) + RecentProjects(homePageViewState.last7DaysStats.projectsWorkedOn) Spacer(modifier = Modifier.height(spacing.extraSmall)) - WeeklyReport(homePageViewState.contentData.weeklyTimeSpent) + WeeklyReport(homePageViewState.last7DaysStats.weeklyTimeSpent) Spacer(modifier = Modifier.height(spacing.small)) OtherDailyStatsSection( - mostUsedLanguage = homePageViewState.contentData.mostUsedLanguage, - mostUsedOs = homePageViewState.contentData.mostUsedOs, - mostUsedEditor = homePageViewState.contentData.mostUsedEditor, onClick = {}, + mostUsedLanguage = homePageViewState.last7DaysStats.mostUsedLanguage, + mostUsedOs = homePageViewState.last7DaysStats.mostUsedOs, + mostUsedEditor = homePageViewState.last7DaysStats.mostUsedEditor, + currentStreak = homePageViewState.streaks.currentStreak, ) Spacer(modifier = Modifier.height(spacing.medium)) } @@ -166,15 +170,21 @@ class HomePagePreviewProvider : CollectionPreviewParameterProvider(HomePageViewState.Loading) val homePageState = _homePageState.asStateFlow() - private val userDetailsFlow = authDataStore.getUserDetails() - .distinctUntilChanged() init { viewModelScope.launch(ioDispatcher) { - getLast7DaysStatsUC(userDetailsFlow.first()).collect { - _homePageState.value = when (it) { - is Left -> HomePageViewState.Error(it.value) - is Right -> HomePageViewState.Loaded( - contentData = it.value, - ) - } + getCachedHomePageUiDataUC().collect { eitherCachedData -> + either { + val cachedData = eitherCachedData.bind() + Timber.d("cachedData: $cachedData") + + when { + cachedData == null -> updateCacheWithNewData().bind() + cachedData.isStaleData -> { + _homePageState.value = HomePageViewState.Loaded( + last7DaysStats = cachedData.last7DaysStats, + userDetails = cachedData.userDetails, + streaks = cachedData.streaks, + ) + updateCacheWithNewData().bind() + } + + else -> _homePageState.value = HomePageViewState.Loaded( + last7DaysStats = cachedData.last7DaysStats, + userDetails = cachedData.userDetails, + streaks = cachedData.streaks, + ) + } + }.tapLeft { _homePageState.value = HomePageViewState.Error(it) } } } } + + private suspend fun updateCacheWithNewData() = either { + val last7DaysStats = getLast7DaysStatsUC().bind() + val streakRange = calculateCurrentStreakUC().bind() + + updateCachedHomePageUiData( + last7DaysStats = last7DaysStats, + streaks = Streaks( + currentStreak = streakRange, + longestStreak = StreakRange.ZERO + ) + ) + } } diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/ui/HomePageViewState.kt b/home/src/main/java/com/jacob/wakatimeapp/home/ui/HomePageViewState.kt index 777fa393..3e66b8b8 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/ui/HomePageViewState.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/ui/HomePageViewState.kt @@ -1,11 +1,15 @@ package com.jacob.wakatimeapp.home.ui import com.jacob.wakatimeapp.core.models.Error as CoreModelsError -import com.jacob.wakatimeapp.home.domain.models.HomePageUiData +import com.jacob.wakatimeapp.home.domain.models.HomePageUserDetails +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats +import com.jacob.wakatimeapp.home.domain.models.Streaks sealed class HomePageViewState { data class Loaded( - val contentData: HomePageUiData, + val last7DaysStats: Last7DaysStats, + val userDetails: HomePageUserDetails, + val streaks: Streaks, ) : HomePageViewState() data class Error(val error: CoreModelsError) : HomePageViewState() diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/ui/components/OtherDailyStatsSection.kt b/home/src/main/java/com/jacob/wakatimeapp/home/ui/components/OtherDailyStatsSection.kt index 83f32e4a..bb784b24 100644 --- a/home/src/main/java/com/jacob/wakatimeapp/home/ui/components/OtherDailyStatsSection.kt +++ b/home/src/main/java/com/jacob/wakatimeapp/home/ui/components/OtherDailyStatsSection.kt @@ -2,46 +2,100 @@ package com.jacob.wakatimeapp.home.ui.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +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.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.jacob.wakatimeapp.core.ui.WtaPreviews import com.jacob.wakatimeapp.core.ui.components.cards.OtherStatsCard +import com.jacob.wakatimeapp.core.ui.theme.Spacing +import com.jacob.wakatimeapp.core.ui.theme.WakaTimeAppTheme import com.jacob.wakatimeapp.core.ui.theme.assets import com.jacob.wakatimeapp.core.ui.theme.gradients import com.jacob.wakatimeapp.core.ui.theme.sectionSubtitle import com.jacob.wakatimeapp.core.ui.theme.sectionTitle import com.jacob.wakatimeapp.core.ui.theme.spacing +import com.jacob.wakatimeapp.home.domain.models.StreakRange @Composable fun OtherDailyStatsSection( onClick: () -> Unit, - modifier: Modifier = Modifier, mostUsedLanguage: String, mostUsedOs: String, mostUsedEditor: String, + currentStreak: StreakRange, + longestStreak: StreakRange = StreakRange.ZERO, + modifier: Modifier = Modifier, ) = Column( modifier = modifier.fillMaxWidth() ) { - val gradients = MaterialTheme.gradients val spacing = MaterialTheme.spacing - val icons = MaterialTheme.assets.icons - val typography = MaterialTheme.typography + + SectionHeader() + Spacer(modifier = Modifier.height(spacing.extraSmall)) + Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(spacing.small) ) { - Text(text = "Other Daily Stats", style = typography.sectionTitle) - Text(text = "Details", color = colorScheme.primary, style = typography.sectionSubtitle) + CurrentStreakCard( + currentStreak = longestStreak, + gradient = MaterialTheme.gradients.orangeYellow, + cornerPercentage = 10, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(spacing.small) + ) { + CurrentStreakCard( + currentStreak = currentStreak, + gradient = MaterialTheme.gradients.purpleCyanDark, + cornerPercentage = 20 + ) + CurrentStreakCard( + currentStreak = currentStreak, + gradient = MaterialTheme.gradients.redPurple, + cornerPercentage = 20 + ) + } } - Spacer(modifier = Modifier.height(spacing.extraSmall)) + + Spacer(modifier = Modifier.height(spacing.sMedium)) + + SecondaryStats( + mostUsedLanguage = mostUsedLanguage, + onClick = onClick, + spacing = spacing, + mostUsedOs = mostUsedOs, + mostUsedEditor = mostUsedEditor + ) +} + +@Composable +private fun SecondaryStats( + mostUsedLanguage: String, + onClick: () -> Unit, + spacing: Spacing, + mostUsedOs: String, + mostUsedEditor: String, +) = Column { + val gradients = MaterialTheme.gradients + val icons = MaterialTheme.assets.icons + OtherStatsCard( gradient = gradients.greenCyan, iconId = icons.codeFile, @@ -66,3 +120,29 @@ fun OtherDailyStatsSection( onClick = onClick ) } + +@Composable +private fun SectionHeader() = Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() +) { + val typography = MaterialTheme.typography + Text(text = "Other Daily Stats", style = typography.sectionTitle) + Text(text = "Details", color = colorScheme.primary, style = typography.sectionSubtitle) +} + +@WtaPreviews +@Composable +fun OtherDailyStatsSectionPreview() = WakaTimeAppTheme { + Surface { + OtherDailyStatsSection( + onClick = {}, + mostUsedLanguage = "Kotlin", + mostUsedOs = "Linux", + mostUsedEditor = "Android Studio", + modifier = Modifier.padding(horizontal = 8.dp), + currentStreak = StreakRange.ZERO + ) + } +} diff --git a/home/src/main/java/com/jacob/wakatimeapp/home/ui/components/StreaksCards.kt b/home/src/main/java/com/jacob/wakatimeapp/home/ui/components/StreaksCards.kt new file mode 100644 index 00000000..68c62d85 --- /dev/null +++ b/home/src/main/java/com/jacob/wakatimeapp/home/ui/components/StreaksCards.kt @@ -0,0 +1,125 @@ +package com.jacob.wakatimeapp.home.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.jacob.wakatimeapp.core.ui.WtaPreviews +import com.jacob.wakatimeapp.core.ui.modifiers.removeFontPadding +import com.jacob.wakatimeapp.core.ui.theme.WakaTimeAppTheme +import com.jacob.wakatimeapp.core.ui.theme.assets +import com.jacob.wakatimeapp.core.ui.theme.colors.Gradient +import com.jacob.wakatimeapp.core.ui.theme.gradients +import com.jacob.wakatimeapp.core.ui.theme.spacing +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import kotlinx.datetime.LocalDate + +@Composable +internal fun CurrentStreakCard( + currentStreak: StreakRange, + gradient: Gradient, + cornerPercentage: Int, + modifier: Modifier = Modifier, +) { + val gradientBrush = Brush.horizontalGradient(gradient.colorList) + val shape = RoundedCornerShape(cornerPercentage) + + Box( + modifier = modifier + .fillMaxWidth() + .background(gradientBrush, shape) + ) { + Image( + painter = painterResource(id = MaterialTheme.assets.icons.time), + contentDescription = null, + colorFilter = ColorFilter.tint(gradient.onEndColor), + modifier = Modifier + .padding( + end = MaterialTheme.spacing.small, + bottom = MaterialTheme.spacing.extraSmall + ) + .size(size = 50.dp) + .align(Alignment.BottomEnd) + ) + Column( + modifier = Modifier + .padding( + horizontal = MaterialTheme.spacing.medium, + vertical = MaterialTheme.spacing.small, + ), + ) { + val streakValueTextStyle = MaterialTheme.typography.displayMedium + + Text( + text = buildAnnotatedString { + withStyle(style = streakValueTextStyle.toSpanStyle()) { + append( + currentStreak.days.toString() + ) + } + withStyle(style = MaterialTheme.typography.headlineSmall.toSpanStyle()) { + append(" days") + } + }, + color = gradient.onStartColor, + modifier = Modifier.removeFontPadding(streakValueTextStyle) + ) + Text( + text = "Current Streak", + color = gradient.onStartColor, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Light) + ) + } + } +} + +@Composable +internal fun LongestStreakCard(modifier: Modifier = Modifier): Unit = TODO(modifier.toString()) + +@WtaPreviews +@Composable +private fun CurrentStreakCardPreview() = WakaTimeAppTheme { + Surface { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + CurrentStreakCard( + currentStreak = StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 11) + ), + gradient = MaterialTheme.gradients.greenCyan, + cornerPercentage = 20, + modifier = Modifier.weight(0.5F) + ) + CurrentStreakCard( + currentStreak = StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 5) + ), + gradient = MaterialTheme.gradients.greenCyan, + cornerPercentage = 20, + modifier = Modifier.weight(0.5F) + ) + } + } +} diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/models/StreakRangeTest.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/models/StreakRangeTest.kt new file mode 100644 index 00000000..67702423 --- /dev/null +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/models/StreakRangeTest.kt @@ -0,0 +1,110 @@ +package com.jacob.wakatimeapp.home.domain.models + +import io.kotest.matchers.shouldBe +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Test + +internal class StreakRangeTest { + @Test + internal fun `when adding 2 streaks that do not overlap, then result should be zero`() { + val streakRange1 = StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 5) + ) + val streakRange2 = StreakRange( + start = LocalDate(2022, 1, 7), + end = LocalDate(2022, 1, 10) + ) + + (streakRange1 + streakRange2) shouldBe StreakRange.ZERO + (streakRange2 + streakRange1) shouldBe StreakRange.ZERO + } + + @Test + internal fun `when adding a streak that starts on the same day another ends, then streaks should be added`() { + val streakRange1 = StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 5) + ) + val streakRange2 = StreakRange( + start = LocalDate(2022, 1, 5), + end = LocalDate(2022, 1, 10) + ) + + (streakRange1 + streakRange2) shouldBe StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + + (streakRange2 + streakRange1) shouldBe StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + } + + @Test + internal fun `when adding a streak that starts on the day after another ends, then streaks should be added`() { + val streakRange1 = StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 5) + ) + val streakRange2 = StreakRange( + start = LocalDate(2022, 1, 6), + end = LocalDate(2022, 1, 10) + ) + + (streakRange1 + streakRange2) shouldBe StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + + (streakRange2 + streakRange1) shouldBe StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + } + + @Test + internal fun `when adding a streak that overlaps with another streak, then streaks should be added`() { + val streakRange1 = StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 5) + ) + val streakRange2 = StreakRange( + start = LocalDate(2022, 1, 3), + end = LocalDate(2022, 1, 10) + ) + + (streakRange1 + streakRange2) shouldBe StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + + (streakRange2 + streakRange1) shouldBe StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + } + + @Test + internal fun `when adding a streak that is contained in another streak, then return containing streak`() { + val streakRange1 = StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + val streakRange2 = StreakRange( + start = LocalDate(2022, 1, 3), + end = LocalDate(2022, 1, 8) + ) + + (streakRange1 + streakRange2) shouldBe StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + + (streakRange2 + streakRange1) shouldBe StreakRange( + start = LocalDate(2022, 1, 1), + end = LocalDate(2022, 1, 10) + ) + } +} diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCRobot.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCRobot.kt new file mode 100644 index 00000000..7c74d638 --- /dev/null +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCRobot.kt @@ -0,0 +1,114 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import arrow.core.Either +import com.jacob.wakatimeapp.core.models.Error +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.home.data.local.HomePageCache +import com.jacob.wakatimeapp.home.domain.InstantProvider +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import io.kotest.assertions.asClue +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDateTime + +internal class CalculateCurrentStreakUCRobot { + private lateinit var useCase: CalculateCurrentStreakUC + + private var result: Either? = null + + private val mockCache: HomePageCache = mockk(relaxUnitFun = true) + private val mockRecalculateStreak: RecalculateLatestStreakUC = mockk() + + fun buildUseCase() = apply { + clearMocks(mockCache, mockRecalculateStreak) + result = null + + useCase = CalculateCurrentStreakUC( + instantProvider = object : InstantProvider { + override val timeZone = TimeZone.UTC + + override fun now() = currentDayInstant + }, + homePageCache = mockCache, + recalculateLatestStreakUC = mockRecalculateStreak, + ) + } + + suspend fun callUseCase() = apply { + result = useCase() + } + + fun resultsShouldBe(expected: Either) = apply { + result.asClue { + it shouldBe expected + } + } + + fun mockGetCurrentStreak(data: Either) = apply { + coEvery { mockCache.getCurrentStreak() } returns flowOf(data) + } + + fun mockGetLast7DaysStats(data: Either) = apply { + coEvery { mockCache.getLast7DaysStats() } returns flowOf(data) + } + + fun mockRecalculateStreak(start: LocalDate, result: Either) = apply { + coEvery { mockRecalculateStreak.calculate(start, any(), any()) } returns result + } + + internal companion object { + + /** + * Start of a random day + * + * Value: + * - date: 11/10/2022 (dd/mm/yyyy) + * - time: 00:00:00 (hh:mm::ss) + */ + private val startOfDay = Instant.parse("2022-10-11T00:00:00Z") + + val currentDayInstant = startOfDay + 1.hours + 30.minutes + + val currentDay = currentDayInstant.toLocalDateTime(TimeZone.UTC).date + + val noWeeklyStats = mapOf( + currentDay to Time.ZERO, + currentDay.minus(1, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(2, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(3, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(4, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(5, DateTimeUnit.DAY) to Time.ZERO, + currentDay.minus(6, DateTimeUnit.DAY) to Time.ZERO, + ) + + val continuousWeeklyStats = mutableMapOf( + currentDay to Time.fromDecimal(1f), + currentDay.minus(1, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(2, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(3, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(4, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(5, DateTimeUnit.DAY) to Time.fromDecimal(1f), + currentDay.minus(6, DateTimeUnit.DAY) to Time.fromDecimal(1f), + ) + + val last7DaysStats = Last7DaysStats( + timeSpentToday = Time.ZERO, + projectsWorkedOn = listOf(), + weeklyTimeSpent = noWeeklyStats, + mostUsedLanguage = "", + mostUsedEditor = "", + mostUsedOs = "" + ) + } +} diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCTest.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCTest.kt new file mode 100644 index 00000000..a32c7b2c --- /dev/null +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/CalculateCurrentStreakUCTest.kt @@ -0,0 +1,156 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import arrow.core.right +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.continuousWeeklyStats +import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.currentDay +import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.last7DaysStats +import com.jacob.wakatimeapp.home.domain.usecases.CalculateCurrentStreakUCRobot.Companion.noWeeklyStats +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.minus +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class CalculateCurrentStreakUCTest { + private val robot = CalculateCurrentStreakUCRobot() + + @Test + internal fun `when there is no value in the cache, then set current streak value to 0`() = + runTest { + robot.buildUseCase() + .mockGetCurrentStreak(StreakRange.ZERO.right()) + .mockGetLast7DaysStats(last7DaysStats.right()) + .callUseCase() + .resultsShouldBe(StreakRange.ZERO.right()) + } + + @Test + internal fun `when there is a streak in the cache that ends the previous day and there is no coding stats for that day, then do not change to value`() = + runTest { + val streakRange = StreakRange( + start = currentDay.minus(3, DateTimeUnit.DAY), + end = currentDay.minus(1, DateTimeUnit.DAY) + ) + robot.buildUseCase() + .mockGetCurrentStreak(streakRange.right()) + .mockGetLast7DaysStats(last7DaysStats.copy(weeklyTimeSpent = noWeeklyStats).right()) + .callUseCase() + .resultsShouldBe(streakRange.right()) + } + + @Test + internal fun `when there is a streak in the cache that ends the previous day and there is coding stats for that day, then streak should increase by 1 day`() = + runTest { + val streakRange = StreakRange( + start = currentDay.minus(3, DateTimeUnit.DAY), + end = currentDay.minus(1, DateTimeUnit.DAY) + ) + robot.buildUseCase() + .mockGetCurrentStreak(streakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = continuousWeeklyStats).right() + ) + .callUseCase() + .resultsShouldBe(streakRange.copy(end = currentDay).right()) + } + + @Test + internal fun `when there is a streak in the cache but it ended within the last 7 days and there is no coding stats since then, then set current streak to zero`() = + runTest { + val streakRange = StreakRange( + start = currentDay.minus(8, DateTimeUnit.DAY), + end = currentDay.minus(4, DateTimeUnit.DAY) + ) + robot.buildUseCase() + .mockGetCurrentStreak(streakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = noWeeklyStats).right() + ) + .callUseCase() + .resultsShouldBe(StreakRange.ZERO.right()) + } + + @Test + internal fun `when there is a streak in the cache but it ended within the last 7 days and there is coding stats since then, then re-calculate the streak`() = + runTest { + val initialStreakRange = StreakRange( + start = currentDay.minus(8, DateTimeUnit.DAY), + end = currentDay.minus(4, DateTimeUnit.DAY) + ) + + val result = StreakRange( + start = currentDay.minus(2, DateTimeUnit.DAY), + end = currentDay, + ) + + val updatedWeeklyStats = continuousWeeklyStats.toMutableMap().also { + it[currentDay.minus(3, DateTimeUnit.DAY)] = Time.ZERO + it[currentDay.minus(4, DateTimeUnit.DAY)] = Time.ZERO + } + + robot.buildUseCase() + .mockGetCurrentStreak(initialStreakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = updatedWeeklyStats).right() + ) + .callUseCase() + .resultsShouldBe(result.right()) + } + + @Test + internal fun `when the current streak has not been updated for less than a week and there is more coding activity since then, then calculating should update the original streak`() = + runTest { + val initialStreakRange = StreakRange( + start = currentDay.minus(10, DateTimeUnit.DAY), + end = currentDay.minus(4, DateTimeUnit.DAY) + ) + + val result = StreakRange( + start = currentDay.minus(10, DateTimeUnit.DAY), + end = currentDay, + ) + + robot.buildUseCase() + .mockGetCurrentStreak(initialStreakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = continuousWeeklyStats).right() + ) + .callUseCase() + .resultsShouldBe(result.right()) + } + + @Test + internal fun `when the current streak has not been updated for over a week and there is more coding activity since then, then calculating should update the original streak`() = + runTest { + val initialStreakRange = StreakRange( + start = currentDay.minus(15, DateTimeUnit.DAY), + end = currentDay.minus(10, DateTimeUnit.DAY) + ) + + val result = StreakRange( + start = currentDay.minus(15, DateTimeUnit.DAY), + end = currentDay, + ) + + robot.buildUseCase() + .mockGetCurrentStreak(initialStreakRange.right()) + .mockGetLast7DaysStats( + last7DaysStats.copy(weeklyTimeSpent = continuousWeeklyStats).right() + ) + .mockRecalculateStreak(currentDay.minus(8, DateTimeUnit.DAY), result.right()) + .callUseCase() + .resultsShouldBe(result.right()) + } + + @Test + internal fun `when last 7 days stats is null, then return zero`() = runTest { + robot.buildUseCase() + .mockGetLast7DaysStats(null.right()) + .mockGetCurrentStreak(StreakRange.ZERO.right()) + .callUseCase() + .resultsShouldBe(StreakRange.ZERO.right()) + } +} diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUCRobot.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUCRobot.kt new file mode 100644 index 00000000..310b15aa --- /dev/null +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUCRobot.kt @@ -0,0 +1,203 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import app.cash.turbine.ReceiveTurbine +import app.cash.turbine.testIn +import arrow.core.Either +import com.jacob.wakatimeapp.core.common.auth.AuthDataStore +import com.jacob.wakatimeapp.core.models.Error +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.core.models.UserDetails +import com.jacob.wakatimeapp.home.data.local.HomePageCache +import com.jacob.wakatimeapp.home.domain.InstantProvider +import com.jacob.wakatimeapp.home.domain.models.CachedHomePageUiData +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import com.jacob.wakatimeapp.home.domain.usecases.GetCachedHomePageUiDataUCRobot.Companion.currentDayInstant +import io.kotest.assertions.arrow.core.shouldBeLeft +import io.kotest.assertions.arrow.core.shouldBeRight +import io.kotest.assertions.asClue +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.mockk +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone + +@OptIn(ExperimentalCoroutinesApi::class) +internal class GetCachedHomePageUiDataUCRobot { + private lateinit var useCase: GetCachedHomePageUiDataUC + + private var receiveTurbine: ReceiveTurbine>? = null + + private val mockHomePageCache: HomePageCache = mockk() + private val mockAuthDataStore: AuthDataStore = mockk() + + private lateinit var last7DaysStatsFlow: MutableSharedFlow> + private lateinit var userDetailsFlow: MutableSharedFlow + private lateinit var lastRequestTimeFlow: MutableSharedFlow + private lateinit var currentStreakFlow: MutableSharedFlow> + + fun buildUseCase(currentInstant: Instant = currentDayInstant) = apply { + clearMocks(mockHomePageCache, mockAuthDataStore) + receiveTurbine = null + last7DaysStatsFlow = MutableSharedFlow() + userDetailsFlow = MutableSharedFlow() + lastRequestTimeFlow = MutableSharedFlow(replay = 1) + currentStreakFlow = MutableSharedFlow() + + useCase = GetCachedHomePageUiDataUC( + instantProvider = object : InstantProvider { + override val timeZone = TimeZone.UTC + + override fun now() = currentInstant + }, + homePageCache = mockHomePageCache, + authDataStore = mockAuthDataStore, + ) + } + + fun callUseCase(testScope: TestScope) = apply { + receiveTurbine = useCase().testIn(testScope, timeout = 5.seconds) + } + + suspend fun withNextItem( + block: context(ItemAssertionContext) GetCachedHomePageUiDataUCRobot.() -> Unit, + ) = apply { + val item = receiveTurbine!!.awaitItem() + val context = object : ItemAssertionContext { + override val item = item + } + + block(context, this@GetCachedHomePageUiDataUCRobot) + } + + context (ItemAssertionContext) + fun itemShouldBe(expected: Either) = apply { + item shouldBe expected + } + + context (ItemAssertionContext) + fun itemShouldBeRight() = apply { + item.shouldBeRight() + } + + context (ItemAssertionContext) + fun itemShouldBeLeft() = apply { + item.shouldBeLeft() + } + + context (ItemAssertionContext) + fun itemShouldNotBeNull() = apply { + item.fold( + ifLeft = { 1 shouldBe 2 }, + ifRight = { it.shouldNotBeNull() } + ) + } + + context (ItemAssertionContext) + fun itemShouldBeNull() = apply { + item shouldBeRight null + } + + context (ItemAssertionContext) + fun itemShouldNotBeStale() = apply { + item.asClue { + item.map { it!!.isStaleData } shouldBeRight false + } + } + + context (ItemAssertionContext) + fun itemShouldBeStale() = apply { + item.asClue { + item.map { it!!.isStaleData } shouldBeRight true + } + } + + suspend fun expectNoMoreItems() = apply { + receiveTurbine!!.cancelAndConsumeRemainingEvents().asClue { + it.size shouldBe 0 + } + } + + fun mockAllFunctions() = apply { + coEvery { mockHomePageCache.getLast7DaysStats() } returns last7DaysStatsFlow + coEvery { mockAuthDataStore.getUserDetails() } returns userDetailsFlow + coEvery { mockHomePageCache.getLastRequestTime() } returns lastRequestTimeFlow + coEvery { mockHomePageCache.getCurrentStreak() } returns currentStreakFlow + } + + suspend fun sendLastRequestTime(value: Instant) = apply { + lastRequestTimeFlow.emit(value) + } + + suspend fun sendUserDetails(value: UserDetails) = apply { + userDetailsFlow.emit(value) + } + + suspend fun sendLast7DaysStats(value: Either) = apply { + last7DaysStatsFlow.emit(value) + } + + suspend fun sendCurrentStreak(value: Either) = apply { + currentStreakFlow.emit(value) + } + + companion object { + + /** + * Start of a random day + * + * Value: + * - date: 11/10/2022 (dd/mm/yyyy) + * - time: 00:00:00 (hh:mm::ss) + */ + val startOfDay = Instant.parse("2022-10-11T00:00:00Z") + + val currentDayInstant = startOfDay + 1.hours + 30.minutes + + /** + * Takes [currentDayInstant] and subtracts 1 day from it + */ + val previousDayInstant = currentDayInstant.minus(1.days) + + val currentStreak = StreakRange.ZERO + + val last7DaysStats = Last7DaysStats( + timeSpentToday = Time.ZERO, + projectsWorkedOn = listOf(), + weeklyTimeSpent = mapOf(), + mostUsedLanguage = "", + mostUsedEditor = "", + mostUsedOs = "" + + ) + + val userDetails = UserDetails( + fullName = "John Doe", + photoUrl = "https://example.com/photo.jpg", + email = "", + bio = "", + id = "", + timeout = 0, + timezone = "", + username = "", + displayName = "", + lastProject = "", + durationsSliceBy = "", + createdAt = "", + dateFormat = "", + ) + } + + interface ItemAssertionContext { + val item: Either + } +} diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUCUCTest.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUCUCTest.kt new file mode 100644 index 00000000..f3648f44 --- /dev/null +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetCachedHomePageUiDataUCUCTest.kt @@ -0,0 +1,203 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import arrow.core.Either +import arrow.core.left +import arrow.core.right +import com.jacob.wakatimeapp.core.models.Error +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.home.domain.models.CachedHomePageUiData +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import com.jacob.wakatimeapp.home.domain.models.Streaks +import com.jacob.wakatimeapp.home.domain.models.toHomePageUserDetails +import com.jacob.wakatimeapp.home.domain.usecases.GetCachedHomePageUiDataUCRobot.Companion.currentDayInstant +import com.jacob.wakatimeapp.home.domain.usecases.GetCachedHomePageUiDataUCRobot.Companion.currentStreak +import com.jacob.wakatimeapp.home.domain.usecases.GetCachedHomePageUiDataUCRobot.Companion.last7DaysStats +import com.jacob.wakatimeapp.home.domain.usecases.GetCachedHomePageUiDataUCRobot.Companion.previousDayInstant +import com.jacob.wakatimeapp.home.domain.usecases.GetCachedHomePageUiDataUCRobot.Companion.startOfDay +import com.jacob.wakatimeapp.home.domain.usecases.GetCachedHomePageUiDataUCRobot.Companion.userDetails +import kotlin.time.Duration.Companion.minutes +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class GetCachedHomePageUiDataUCTest { + private val robot = GetCachedHomePageUiDataUCRobot() + + @Test + internal fun `when last request was made the previous day and time difference is more than cache lifetime, then use case should return null`() = + runTest { + robot.buildUseCase() + .mockAllFunctions() + .callUseCase(this) + .sendLastRequestTime(previousDayInstant) + .sendUserDetails(userDetails) + .sendLast7DaysStats(last7DaysStats.right()) + .sendCurrentStreak(currentStreak.right()) + .withNextItem { + itemShouldBeNull() + } + .expectNoMoreItems() + } + + @Test + internal fun `when last request was made the previous day and time difference is less than cache lifetime, then use case should return null`() = + runTest { + robot.buildUseCase() + .mockAllFunctions() + .callUseCase(this) + .sendLastRequestTime(startOfDay - 5.minutes) + .sendUserDetails(userDetails) + .sendLast7DaysStats(last7DaysStats.right()) + .sendCurrentStreak(currentStreak.right()) + .withNextItem { + itemShouldBeNull() + } + .expectNoMoreItems() + } + + @Test + internal fun `when last request time is more than default cache lifetime, then cached data should be returned with is stale set to true`() = + runTest { + robot.buildUseCase() + .mockAllFunctions() + .callUseCase(this) + .sendLastRequestTime(currentDayInstant - 20.minutes) + .sendUserDetails(userDetails) + .sendLast7DaysStats(last7DaysStats.right()) + .sendCurrentStreak(currentStreak.right()) + .withNextItem { + itemShouldBeRight() + .itemShouldNotBeNull() + .itemShouldBeStale() + } + .expectNoMoreItems() + } + + @Test + internal fun `when last request time is less than default cache lifetime, the caches data should be returned with is stale set to false`() = + runTest { + robot.buildUseCase() + .mockAllFunctions() + .callUseCase(this) + .sendLastRequestTime(currentDayInstant - 10.minutes) + .sendUserDetails(userDetails) + .sendLast7DaysStats(last7DaysStats.right()) + .sendCurrentStreak(currentStreak.right()) + .withNextItem { + itemShouldBeRight() + .itemShouldNotBeNull() + .itemShouldNotBeStale() + } + .expectNoMoreItems() + } + + @Test + internal fun `when an error occurs while getting data from the cache, then the errors should be propagated up`() = + runTest { + val networkError = Error.NetworkErrors.create("Error", 400).left() + val databaseError = Error.DatabaseError.UnknownError("Error", Throwable()).left() + robot.buildUseCase() + .mockAllFunctions() + .callUseCase(this) + .sendLastRequestTime(currentDayInstant - 20.minutes) + .sendUserDetails(userDetails) + .sendLast7DaysStats(networkError) + .sendCurrentStreak(databaseError) + .withNextItem { + itemShouldBeLeft() + .itemShouldBe(networkError) + } + .expectNoMoreItems() + } + + @Test + internal fun `when last 7 days stats sends multiple emissions, then use case sends multiple values`() = + runTest { + val cachedData = CachedHomePageUiData( + userDetails = userDetails.toHomePageUserDetails(), + last7DaysStats = last7DaysStats, + streaks = Streaks(currentStreak, StreakRange.ZERO), + isStaleData = true + ) + val copy1 = last7DaysStats.copy(timeSpentToday = Time.fromDecimal(2.0f)) + val copy2 = last7DaysStats.copy(timeSpentToday = Time.fromDecimal(3.0f)) + + robot.buildUseCase() + .mockAllFunctions() + .callUseCase(this) + .sendLastRequestTime(previousDayInstant) + .sendUserDetails(userDetails) + .sendLast7DaysStats(last7DaysStats.right()) + .sendCurrentStreak(currentStreak.right()) + .withNextItem { + itemShouldBeRight() + .itemShouldBeNull() + } + .`when new data is sent, then new item should be emitted with correct values for previous data`( + currentDayInstant - 20.minutes, + cachedData + ) + .`when new data is sent, then new item should be emitted with correct values for previous data`( + copy1.right(), + cachedData.copy(last7DaysStats = copy1) + ) + .`when new data is sent, then new item should be emitted with correct values for previous data`( + currentDayInstant, + cachedData.copy(last7DaysStats = copy1, isStaleData = false) + ) + .`when new data is sent, then new item should be emitted with correct values for previous data`( + copy2.right(), + cachedData.copy(last7DaysStats = copy2, isStaleData = false) + ) + .expectNoMoreItems() + } + + @Test + internal fun `when there is no data in cache for last 7 days stats, then should return null`() = + runTest { + robot.buildUseCase() + .mockAllFunctions() + .callUseCase(this) + .sendLast7DaysStats(null.right()) + .sendLastRequestTime(Instant.DISTANT_PAST) + .sendUserDetails(userDetails) + .sendCurrentStreak(StreakRange.ZERO.right()) + .withNextItem { + itemShouldBeRight() + .itemShouldBeNull() + } + .sendLastRequestTime(currentDayInstant) + .withNextItem { + itemShouldBeRight() + .itemShouldBeNull() + } + .expectNoMoreItems() + } + + private suspend fun GetCachedHomePageUiDataUCRobot.`when new data is sent, then new item should be emitted with correct values for previous data`( + newData: Either, + result: CachedHomePageUiData, + ) = apply { + sendLast7DaysStats(newData) + .withNextItem { + itemShouldBeRight() + .itemShouldNotBeNull() + .itemShouldBe(result.right()) + } + } + + private suspend fun GetCachedHomePageUiDataUCRobot.`when new data is sent, then new item should be emitted with correct values for previous data`( + newData: Instant, + result: CachedHomePageUiData, + ) = apply { + sendLastRequestTime(newData) + .withNextItem { + itemShouldBeRight() + .itemShouldNotBeNull() + .itemShouldBe(result.right()) + } + } +} diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUCRobot.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUCRobot.kt index 381a525e..0a2332be 100644 --- a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUCRobot.kt +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUCRobot.kt @@ -1,151 +1,50 @@ package com.jacob.wakatimeapp.home.domain.usecases import arrow.core.Either -import arrow.core.right import com.jacob.wakatimeapp.core.models.DailyStats import com.jacob.wakatimeapp.core.models.Error import com.jacob.wakatimeapp.core.models.StatsRange import com.jacob.wakatimeapp.core.models.Time -import com.jacob.wakatimeapp.core.models.UserDetails import com.jacob.wakatimeapp.core.models.WeeklyStats -import com.jacob.wakatimeapp.home.data.local.HomePageCache import com.jacob.wakatimeapp.home.data.network.HomePageNetworkData -import com.jacob.wakatimeapp.home.domain.InstantProvider -import com.jacob.wakatimeapp.home.domain.models.HomePageUiData -import com.jacob.wakatimeapp.home.domain.usecases.GetLast7DaysStatsUC.CacheValidity.DEFAULT -import io.kotest.matchers.collections.shouldContain -import io.kotest.matchers.collections.shouldContainExactly +import com.jacob.wakatimeapp.home.domain.models.Last7DaysStats import io.kotest.matchers.shouldBe import io.mockk.clearMocks import io.mockk.coEvery -import io.mockk.coJustRun -import io.mockk.coVerify import io.mockk.mockk -import kotlin.time.Duration.Companion.days -import kotlin.time.Duration.Companion.minutes -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.datetime.Clock -import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate -import kotlinx.datetime.TimeZone -@OptIn(ExperimentalCoroutinesApi::class) internal class GetLast7DaysStatsUCRobot { - private val results: MutableList> = mutableListOf() private lateinit var useCase: GetLast7DaysStatsUC + private var result: Either? = null + private val networkDataMock: HomePageNetworkData = mockk(relaxUnitFun = true) - private val cacheMock: HomePageCache = mockk(relaxUnitFun = true) - fun build(instantProvider: InstantProvider? = null) = apply { - clearMocks(networkDataMock, cacheMock) + fun buildViewModel() = apply { + clearMocks(networkDataMock) + result = null useCase = GetLast7DaysStatsUC( - dispatcher = UnconfinedTestDispatcher(), homePageNetworkData = networkDataMock, - homePageCache = cacheMock, - instantProvider = instantProvider ?: object : InstantProvider { - override val timeZone = TimeZone.UTC - - override fun now() = Clock.System.now() - }, ) } suspend fun callUseCase() = apply { - useCase(userDetails, DEFAULT).toList(results) - } - - fun resultSizeShouldBe(size: Int = 1) = apply { - results.size shouldBe size - } - - fun resultsShouldContain(expected: Either) = apply { - results shouldContain expected + result = useCase() } - fun resultsShouldContain(expected: List>) = apply { - results shouldContainExactly expected - } - - fun mockCacheLastRequestTime(instant: Instant) = apply { - coEvery { cacheMock.getLastRequestTime() } returns instant - } - - fun mockUpdateCacheData(data: HomePageUiData) = apply { - coJustRun { cacheMock.updateCache(data) } - } - - fun mockCachedData(vararg data: HomePageUiData) = apply { - coEvery { cacheMock.getCachedData() } returns ( - data.map(HomePageUiData::right) - .asFlow() - ) + fun resultsShouldBe(expected: Either) = apply { + result shouldBe expected } fun mockNetworkData(data: Either) = apply { coEvery { networkDataMock.getLast7DaysStats() } returns data } - fun verifyCacheGetCachedDataCalled(count: Int = 1) = apply { - coVerify(exactly = count) { cacheMock.getCachedData() } - } - - fun verifyCacheSetCachedDataCalled(data: HomePageUiData, count: Int = 1) = apply { - coVerify(exactly = count) { cacheMock.updateCache(eq(data)) } - } - - fun verifyCacheUpdateLastRequestTimeCalled(count: Int = 1) = apply { - coVerify(exactly = count) { cacheMock.updateLastRequestTime(any()) } - } - - fun verifyGetLast7DaysStatsCalled(count: Int = 1) = apply { - coVerify(exactly = count) { networkDataMock.getLast7DaysStats() } - } - companion object { - - val previousDay = Clock.System.now() - .minus(1.days) - - val validDataInstant = Clock.System.now() - .minus(10.minutes) - - val invalidDataInstant = Clock.System.now() - .minus(20.minutes) - private val todaysDate = LocalDate(2022, 10, 10) - private val userDetails = UserDetails( - bio = "", - email = "", - id = "", - timeout = 0, - timezone = "", - username = "", - displayName = "", - lastProject = "", - fullName = "", - durationsSliceBy = "", - createdAt = "", - dateFormat = "", - photoUrl = "" - ) - - val homePageUiData = HomePageUiData( - timeSpentToday = Time.ZERO, - projectsWorkedOn = listOf(), - weeklyTimeSpent = mapOf(), - mostUsedLanguage = "", - mostUsedEditor = "", - mostUsedOs = "", - photoUrl = "", - fullName = "" - ) - val weeklyStats = WeeklyStats( totalTime = Time.ZERO, dailyStats = listOf(), @@ -162,5 +61,14 @@ internal class GetLast7DaysStatsUCRobot { date = todaysDate, ) ) + + val last7DaysStats = Last7DaysStats( + timeSpentToday = Time.fromDecimal(1.0f), + projectsWorkedOn = listOf(), + weeklyTimeSpent = mapOf(), + mostUsedLanguage = "", + mostUsedEditor = "", + mostUsedOs = "", + ) } } diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUCTest.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUCTest.kt index c13e8ab3..460e1607 100644 --- a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUCTest.kt +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/GetLast7DaysStatsUCTest.kt @@ -2,19 +2,11 @@ package com.jacob.wakatimeapp.home.domain.usecases import arrow.core.left import arrow.core.right -import com.jacob.wakatimeapp.core.models.Error.UnknownError -import com.jacob.wakatimeapp.home.domain.InstantProvider -import com.jacob.wakatimeapp.home.domain.usecases.GetLast7DaysStatsUCRobot.Companion.homePageUiData -import com.jacob.wakatimeapp.home.domain.usecases.GetLast7DaysStatsUCRobot.Companion.invalidDataInstant -import com.jacob.wakatimeapp.home.domain.usecases.GetLast7DaysStatsUCRobot.Companion.previousDay -import com.jacob.wakatimeapp.home.domain.usecases.GetLast7DaysStatsUCRobot.Companion.validDataInstant +import com.jacob.wakatimeapp.core.models.Error +import com.jacob.wakatimeapp.home.domain.usecases.GetLast7DaysStatsUCRobot.Companion.last7DaysStats import com.jacob.wakatimeapp.home.domain.usecases.GetLast7DaysStatsUCRobot.Companion.weeklyStats -import kotlin.time.Duration.Companion.minutes import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toInstant import org.junit.jupiter.api.Test @OptIn(ExperimentalCoroutinesApi::class) @@ -22,122 +14,20 @@ internal class GetLast7DaysStatsUCTest { private val useCaseRobot: GetLast7DaysStatsUCRobot = GetLast7DaysStatsUCRobot() @Test - internal fun `when making first call of the day, then api call is made`() = runTest { - useCaseRobot.build() - .mockCacheLastRequestTime(previousDay) + internal fun `when network request returns valid data, then return null`() = runTest { + useCaseRobot.buildViewModel() .mockNetworkData(weeklyStats.right()) - .mockCachedData(homePageUiData) .callUseCase() - .verifyGetLast7DaysStatsCalled() + .resultsShouldBe(last7DaysStats.right()) } @Test - internal fun `when making first call of the day, data from cache is sent after getting from api`() = - runTest { - val cacheData = homePageUiData.copy() - useCaseRobot.build() - .mockCacheLastRequestTime(previousDay) - .mockNetworkData(weeklyStats.right()) - .mockCachedData(cacheData) - .callUseCase() - .resultSizeShouldBe(1) - .resultsShouldContain(cacheData.right()) - .verifyCacheGetCachedDataCalled() - } + internal fun `when network request fails with an error, then return the error`() = runTest { + val error = Error.UnknownError("error").left() - @Test - internal fun `when making first request of the day but data is valid, api request is made`() = - runTest { - val startOfDay = LocalDateTime( - year = 2022, - monthNumber = 10, - dayOfMonth = 1, - hour = 0, - minute = 0, - second = 0, - nanosecond = 0 - ).toInstant(TimeZone.UTC) - - useCaseRobot.build( - instantProvider = object : InstantProvider { - override val timeZone = TimeZone.UTC - - override fun now() = startOfDay + 5.minutes - } - ) - .mockCacheLastRequestTime(startOfDay - 5.minutes) - .mockNetworkData(weeklyStats.right()) - .mockCachedData(homePageUiData) - .callUseCase() - .verifyGetLast7DaysStatsCalled() - } - - @Test - internal fun `when valid data is available in cache, no api call is made`() = runTest { - useCaseRobot.build() - .mockCacheLastRequestTime(validDataInstant) - .mockCachedData(homePageUiData) - .callUseCase() - .verifyGetLast7DaysStatsCalled(0) - .verifyCacheUpdateLastRequestTimeCalled(0) - .verifyCacheGetCachedDataCalled() - } - - @Test - internal fun `when invalid data is present in cache, api call is made`() = runTest { - val cachedStats = homePageUiData.copy() - useCaseRobot.build() - .mockCacheLastRequestTime(invalidDataInstant) - .mockNetworkData(weeklyStats.right()) - .mockCachedData(cachedStats) + useCaseRobot.buildViewModel() + .mockNetworkData(error) .callUseCase() - .verifyCacheGetCachedDataCalled() - .verifyGetLast7DaysStatsCalled() - .verifyCacheUpdateLastRequestTimeCalled() + .resultsShouldBe(error) } - - @Test - internal fun `when invalid data is present in cache, then first old data is sent followed by new data`() = - runTest { - val oldCacheData = homePageUiData.copy() - val newCacheData = homePageUiData.copy() - - useCaseRobot.build() - .mockCacheLastRequestTime(invalidDataInstant) - .mockNetworkData(weeklyStats.right()) - .mockCachedData(oldCacheData, newCacheData) - .mockUpdateCacheData(homePageUiData) - .callUseCase() - .resultSizeShouldBe(2) - .resultsShouldContain(listOf(oldCacheData.right(), newCacheData.right())) - } - - @Test - internal fun `when making first request of the day and request fails, then error is propagated`() = - runTest { - val error = UnknownError("error").left() - - useCaseRobot.build() - .mockCacheLastRequestTime(previousDay) - .mockNetworkData(error) - .mockCachedData() - .callUseCase() - .resultSizeShouldBe(1) - .resultsShouldContain(error) - } - - @Test - internal fun `when api request during invalid data is made and request fails, then old data is shown and error is sent after that`() = - runTest { - val cachedStats = homePageUiData.copy() - val error = UnknownError("error").left() - - useCaseRobot.build() - .mockCacheLastRequestTime(invalidDataInstant) - .mockNetworkData(error) - .mockCachedData(cachedStats) - .callUseCase() - .resultSizeShouldBe(2) - .resultsShouldContain(listOf(cachedStats.right(), error)) - } } diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUCRobot.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUCRobot.kt new file mode 100644 index 00000000..ab42edcd --- /dev/null +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUCRobot.kt @@ -0,0 +1,68 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import arrow.core.Either +import com.jacob.wakatimeapp.core.models.DailyStats +import com.jacob.wakatimeapp.core.models.Error +import com.jacob.wakatimeapp.core.models.Stats +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.home.data.network.HomePageNetworkData +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import io.kotest.assertions.asClue +import io.kotest.matchers.shouldBe +import io.mockk.clearMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus + +internal class RecalculateLatestStreakUCRobot { + private lateinit var useCase: RecalculateLatestStreakUC + + private val mockHomePageNetworkData: HomePageNetworkData = mockk() + + private var result: Either? = null + + fun buildService() = apply { + clearMocks(mockHomePageNetworkData) + result = null + + useCase = RecalculateLatestStreakUC(mockHomePageNetworkData) + } + + suspend fun calculate(start: LocalDate, value: Int, unit: DateTimeUnit.DateBased) = apply { + result = useCase.calculate( + start, + value, + unit, + ) + } + + fun resultShouldBe(expected: Either) = apply { + result.asClue { + result shouldBe expected + } + } + + fun mockGetDataForRange(start: String, end: String, data: Either) = apply { + coEvery { mockHomePageNetworkData.getStatsForRange(start, end) } returns data + } + + fun verifyGetDataForRange(start: String, end: String, count: Int = 1) = apply { + coVerify(exactly = count) { mockHomePageNetworkData.getStatsForRange(start, end) } + } + + companion object { + fun createDailyStats(size: Int, days: List, end: LocalDate) = List(size) { + DailyStats( + timeSpent = if (it in days) Time.fromDecimal(1f) else Time.ZERO, + projectsWorkedOn = emptyList(), + mostUsedLanguage = "", + mostUsedEditor = "", + mostUsedOs = "", + date = end.plus(it, DateTimeUnit.DAY) + ) + } + } +} diff --git a/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUCTest.kt b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUCTest.kt new file mode 100644 index 00000000..01bff848 --- /dev/null +++ b/home/src/test/java/com/jacob/wakatimeapp/home/domain/usecases/RecalculateLatestStreakUCTest.kt @@ -0,0 +1,359 @@ +package com.jacob.wakatimeapp.home.domain.usecases + +import arrow.core.right +import com.jacob.wakatimeapp.core.models.Stats +import com.jacob.wakatimeapp.core.models.StatsRange +import com.jacob.wakatimeapp.core.models.Time +import com.jacob.wakatimeapp.home.domain.models.StreakRange +import com.jacob.wakatimeapp.home.domain.usecases.RecalculateLatestStreakUCRobot.Companion.createDailyStats +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.daysUntil +import kotlinx.datetime.minus +import kotlinx.datetime.toLocalDate +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +internal class RecalculateLatestStreakUCTest { + private val robot = RecalculateLatestStreakUCRobot() + + @Test + internal fun `when called with duration and there is no valid streak, then return empty streak`() = + runTest { + val start = "2022-03-01" + val end = "2022-02-01" + + robot.buildService() + .mockGetDataForRange( + start, + end, + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(30, emptyList(), end.toLocalDate()), + range = StatsRange(start.toLocalDate(), end.toLocalDate()) + ).right() + ) + .calculate( + start = start.toLocalDate(), + value = 1, + unit = DateTimeUnit.MONTH, + ) + .resultShouldBe(StreakRange.ZERO.right()) + .verifyGetDataForRange(start, end) + } + + @Test + internal fun `when there are non-continuous stats that include end of the duration, then return latest streak`() = + runTest { + val days = listOf(0, 1, 2, 5, 6, 7, 8, 11, 12, 13, 14) + val start = LocalDate(2022, 3, 31) + val end = LocalDate(2022, 3, 17) + + robot.buildService() + .mockGetDataForRange( + start.toString(), + end.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats( + size = 15, + days = days, + end = end + ), + range = StatsRange(start, end) + ).right() + ) + .calculate( + start = start, + value = 2, + unit = DateTimeUnit.WEEK, + ) + .resultShouldBe( + StreakRange( + start = LocalDate(2022, 3, 28), + end = start + ).right() + ) + .verifyGetDataForRange(start.toString(), end.toString()) + } + + @Test + internal fun `when there are none continuous stats that do not include end of the duration, then return empty streak`() = + runTest { + val days = listOf(0, 1, 2, 5, 6, 7, 8, 10, 11, 12) + val start = "2022-03-31" + val end = "2022-03-17" + + robot.buildService() + .mockGetDataForRange( + start, + end, + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(14, days, end.toLocalDate()), + range = StatsRange(start.toLocalDate(), end.toLocalDate()) + ).right() + ) + .calculate( + start = start.toLocalDate(), + value = 2, + unit = DateTimeUnit.WEEK, + ) + .resultShouldBe(StreakRange.ZERO.right()) + .verifyGetDataForRange(start, end) + } + + @Test + internal fun `when entire duration is part of the streak, then should itself again with next duration`() = + runTest { + val start = "2022-03-01" + val end = "2022-02-01" + val count = end.toLocalDate().daysUntil(start.toLocalDate()) + 1 + val days = List(count) { it } + val secondStart = end.toLocalDate().minus(1, DateTimeUnit.DAY).toString() + val secondEnd = secondStart.toLocalDate().minus(1, DateTimeUnit.MONTH).toString() + + robot.buildService() + .mockGetDataForRange( + start, + end, + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count, days, end.toLocalDate()), + range = StatsRange(start.toLocalDate(), end.toLocalDate()) + ).right(), + + ) + .mockGetDataForRange( + secondStart, + secondEnd, + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count, emptyList(), end.toLocalDate()), + range = StatsRange( + start.toLocalDate().minus(1, DateTimeUnit.MONTH), + end.toLocalDate().minus(1, DateTimeUnit.MONTH) + ) + ).right() + ) + .calculate( + start = start.toLocalDate(), + value = 1, + unit = DateTimeUnit.MONTH, + ) + .verifyGetDataForRange(start, end) + .verifyGetDataForRange( + secondStart, + secondEnd + ) + } + + @Test + internal fun `when entire duration is part of the streak and next duration has a few days that's part of the streak, then streak should be sum of the 2`() = + runTest { + val start = LocalDate(2022, 3, 31) + val end = LocalDate(2022, 3, 17) + val count = end.daysUntil(start) + 1 + val days = List(count) { it } + val secondStart = LocalDate(2022, 3, 16) + val secondEnd = LocalDate(2022, 3, 2) + val days2 = listOf(0, 1, 2, 3, 8, 9, 11, 12, 13, 14) + + robot.buildService() + .mockGetDataForRange( + start.toString(), + end.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count, days, end), + range = StatsRange(start, end) + ).right(), + ) + .mockGetDataForRange( + secondStart.toString(), + secondEnd.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count, days2, secondEnd), + range = StatsRange( + start.minus(1, DateTimeUnit.MONTH), + end.minus(1, DateTimeUnit.MONTH) + ) + ).right() + ) + .calculate( + start = start, + value = 2, + unit = DateTimeUnit.WEEK, + ) + .resultShouldBe( + StreakRange( + start = "2022-03-13".toLocalDate(), + end = "2022-03-31".toLocalDate() + ).right() + ) + } + + @Test + internal fun `when entire duration is part of the streak and next duration has a few days that's not part of the streak, then streak should not be added`() = + runTest { + val start = LocalDate(2022, 3, 31) + val end = LocalDate(2022, 3, 17) + val count = end.daysUntil(start) + 1 + val days = List(count) { it } + val secondStart = LocalDate(2022, 3, 16) + val secondEnd = LocalDate(2022, 3, 2) + val days2 = listOf(0, 1, 2, 3, 8, 9, 11, 12, 13) + + robot.buildService() + .mockGetDataForRange( + start.toString(), + end.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count, days, end), + range = StatsRange(start, end) + ).right(), + ) + .mockGetDataForRange( + secondStart.toString(), + secondEnd.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count, days2, secondEnd), + range = StatsRange( + start.minus(1, DateTimeUnit.MONTH), + end.minus(1, DateTimeUnit.MONTH) + ) + ).right() + ) + .calculate( + start = start, + value = 2, + unit = DateTimeUnit.WEEK, + ) + .resultShouldBe( + StreakRange( + start = "2022-03-17".toLocalDate(), + end = "2022-03-31".toLocalDate() + ).right() + ) + } + + @Test + internal fun `when streak lasts for multiple months, then result should be sum of all the streaks`() = + runTest { + val start1 = LocalDate(2022, 4, 30) + val end1 = start1.minus(1, DateTimeUnit.MONTH) + val count1 = end1.daysUntil(start1) + 1 + val days1 = List(count1) { it } + + val start2 = end1.minus(1, DateTimeUnit.DAY) + val end2 = start2.minus(1, DateTimeUnit.MONTH) + val count2 = end2.daysUntil(start2) + 1 + val days2 = List(count2) { it } + + val start3 = end2.minus(1, DateTimeUnit.DAY) + val end3 = start3.minus(1, DateTimeUnit.MONTH) + val count3 = end3.daysUntil(start3) + 1 + val days3 = List(count3) { it } + + val start4 = end3.minus(1, DateTimeUnit.DAY) + val end4 = start4.minus(1, DateTimeUnit.MONTH) + val count4 = end4.daysUntil(start4) + 1 + val days4 = List(count4) { it } + + val start5 = end4.minus(1, DateTimeUnit.DAY) + val end5 = start5.minus(1, DateTimeUnit.MONTH) + val count5 = end5.daysUntil(start5) + 1 + val days5 = List(count5) { it } + + val start6 = end5.minus(1, DateTimeUnit.DAY) + val end6 = start6.minus(1, DateTimeUnit.MONTH) + val count6 = end6.daysUntil(start6) + 1 + val days6 = List(count6) { it } + + val start7 = end6.minus(1, DateTimeUnit.DAY) + val end7 = start7.minus(1, DateTimeUnit.MONTH) + val count7 = end7.daysUntil(start7) + 1 + val days7 = emptyList() + + robot.buildService() + .mockGetDataForRange( + start1.toString(), + end1.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count1, days1, end1), + range = StatsRange(start1, end1) + ).right(), + ) + .mockGetDataForRange( + start2.toString(), + end2.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count2, days2, end2), + range = StatsRange(start2, end2) + ).right(), + ) + .mockGetDataForRange( + start3.toString(), + end3.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count3, days3, end3), + range = StatsRange(start3, end3) + ).right(), + ) + .mockGetDataForRange( + start4.toString(), + end4.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count4, days4, end4), + range = StatsRange(start4, end4) + ).right(), + ) + .mockGetDataForRange( + start5.toString(), + end5.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count5, days5, end5), + range = StatsRange(start5, end5) + ).right(), + ) + .mockGetDataForRange( + start6.toString(), + end6.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count6, days6, end6), + range = StatsRange(start6, end6) + ).right(), + ) + .mockGetDataForRange( + start7.toString(), + end7.toString(), + Stats( + totalTime = Time.ZERO, + dailyStats = createDailyStats(count7, days7, end7), + range = StatsRange(start7, end7) + ).right(), + ) + .calculate( + start = start1, + value = 1, + unit = DateTimeUnit.MONTH, + ) + .resultShouldBe( + StreakRange( + start = end6, + end = start1, + ).right() + ) + } +}