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()
+ )
+ }
+}