diff --git a/.gitignore b/.gitignore
index 33afd0a1..aabb1218 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ build/
.gradle/
terraform.tfvars
.terraform/
+/spotiflyer-ios/Pods/
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
index 4ca61100..5afd25b0 100644
--- a/android/build.gradle.kts
+++ b/android/build.gradle.kts
@@ -75,7 +75,7 @@ android {
}
compileOptions {
// Flag to enable support for the new language APIs
- coreLibraryDesugaringEnabled = true
+ isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
@@ -132,7 +132,7 @@ dependencies {
implementation("dev.icerock.moko:parcelize:0.6.1")
implementation("com.github.shabinder:storage-chooser:2.0.4.45")
- implementation("com.google.accompanist:accompanist-insets:0.8.1")
+ implementation("com.google.accompanist:accompanist-insets:0.9.0")
// Test
testImplementation("junit:junit:4.13.2")
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index 5015d070..58b5947d 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -32,7 +32,7 @@ repositories {
}
dependencies {
- implementation("com.android.tools.build:gradle:4.0.2")
+ implementation("com.android.tools.build:gradle:4.2.0")
implementation("com.google.gms:google-services:4.3.5")
implementation("com.google.firebase:perf-plugin:1.3.5")
implementation("com.google.firebase:firebase-crashlytics-gradle:2.5.2")
diff --git a/buildSrc/buildSrc/build.gradle.kts b/buildSrc/buildSrc/build.gradle.kts
index 3d7a9541..876c922b 100644
--- a/buildSrc/buildSrc/build.gradle.kts
+++ b/buildSrc/buildSrc/build.gradle.kts
@@ -3,5 +3,5 @@ plugins {
}
repositories {
- jcenter()
+ mavenCentral()
}
diff --git a/buildSrc/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/buildSrc/src/main/kotlin/Versions.kt
index 5acf9636..6e6a3b56 100644
--- a/buildSrc/buildSrc/src/main/kotlin/Versions.kt
+++ b/buildSrc/buildSrc/src/main/kotlin/Versions.kt
@@ -106,7 +106,7 @@ object Decompose {
const val extensionsCompose = "com.arkivanov.decompose:extensions-compose-jetbrains:$VERSION"
}
object MVIKotlin {
- private const val VERSION = "2.0.2"
+ private const val VERSION = "2.0.3"
const val rx = "com.arkivanov.mvikotlin:rx:$VERSION"
const val mvikotlin = "com.arkivanov.mvikotlin:mvikotlin:$VERSION"
const val mvikotlinMain = "com.arkivanov.mvikotlin:mvikotlin-main:$VERSION"
diff --git a/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts b/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts
index d04c331e..310de475 100644
--- a/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts
+++ b/buildSrc/src/main/kotlin/multiplatform-setup.gradle.kts
@@ -78,6 +78,7 @@ kotlin {
implementation(Extras.kermit)
implementation("co.touchlab:stately-common:1.1.6")
implementation("dev.icerock.moko:parcelize:0.6.1")
+ // implementation("io.github.reactivecircus.cache4k:cache4k:0.2.0-SNAPSHOT") // Local Maven
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3-native-mt") {
isForce = true
diff --git a/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt b/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt
index 05f9dcdf..0ee8eb3a 100644
--- a/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt
+++ b/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/AndroidImages.kt
@@ -19,6 +19,7 @@
package com.shabinder.common.uikit
import androidx.compose.animation.Crossfade
+import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -129,6 +130,12 @@ actual fun YoutubeMusicLogo() = painterResource(R.drawable.ic_youtube_music_logo
@Composable
actual fun GithubLogo() = painterResource(R.drawable.ic_github)
+@Composable
+actual fun PaypalLogo() = painterResource(R.drawable.ic_paypal_logo)
+
+@Composable
+actual fun RazorPay() = painterResource(R.drawable.ic_indian_rupee)
+
@Composable
actual fun Toast(
text: String,
diff --git a/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/DonationDialog.kt b/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/DonationDialog.kt
new file mode 100644
index 00000000..4a067acd
--- /dev/null
+++ b/common/compose/src/androidMain/kotlin/com/shabinder/common/uikit/DonationDialog.kt
@@ -0,0 +1,141 @@
+package com.shabinder.common.uikit
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.AlertDialog
+import androidx.compose.material.Card
+import androidx.compose.material.Icon
+import androidx.compose.material.Text
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.CardGiftcard
+import androidx.compose.material.icons.rounded.Share
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import com.shabinder.common.models.methods
+import com.shabinder.common.uikit.PaypalLogo
+import com.shabinder.common.uikit.RazorPay
+import com.shabinder.common.uikit.SpotiFlyerShapes
+import com.shabinder.common.uikit.SpotiFlyerTypography
+import com.shabinder.common.uikit.colorAccent
+
+@OptIn(ExperimentalAnimationApi::class)
+@Composable
+actual fun DonationDialog(
+ isVisible:Boolean,
+ onDismiss:()->Unit
+){
+ AnimatedVisibility(
+ isVisible
+ ) {
+
+ Dialog(onDismiss) {
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ border = BorderStroke(1.dp, Color.Gray) // Gray
+ ) {
+ Column(Modifier.padding(16.dp)) {
+ Text(
+ "Support Us",
+ style = SpotiFlyerTypography.h5,
+ textAlign = TextAlign.Center,
+ color = colorAccent,
+ modifier = Modifier
+ )
+ Spacer(modifier = Modifier.padding(vertical = 4.dp))
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().clickable(
+ onClick = {
+ onDismiss()
+ methods.value.openPlatform("", "https://www.paypal.com/paypalme/shabinder")
+ }
+ )
+ .padding(vertical = 6.dp)
+ ) {
+ Icon(PaypalLogo(), "Paypal Logo", tint = Color(0xFFCCCCCC))
+ Spacer(modifier = Modifier.padding(start = 16.dp))
+ Column {
+ Text(
+ text = "Paypal",
+ style = SpotiFlyerTypography.h6
+ )
+ Text(
+ text = "International Donations (Outside India).",
+ style = SpotiFlyerTypography.subtitle2
+ )
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 6.dp)
+ .clickable(onClick = {
+ onDismiss()
+ methods.value.giveDonation()
+ }),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(RazorPay(), "Indian Rupee Logo", Modifier.size(32.dp), tint = Color(0xFFCCCCCC))
+ Spacer(modifier = Modifier.padding(start = 16.dp))
+ Column {
+ Text(
+ text = "RazorPay",
+ style = SpotiFlyerTypography.h6
+ )
+ Text(
+ text = "Indian Donations (UPI / PayTM / PhonePe / Cards).",
+ style = SpotiFlyerTypography.subtitle2
+ )
+ }
+ }
+ }
+ }
+ }
+
+ /*AlertDialog(
+ buttons = {
+ *//* TextButton({
+ //Retry Network Connection
+ },
+ Modifier.padding(bottom = 16.dp,start = 16.dp,end = 16.dp).fillMaxWidth().background(Color(0xFFFC5C7D),shape = RoundedCornerShape(size = 8.dp)).padding(horizontal = 8.dp),
+ ){
+ Text("Retry",color = Color.Black,fontSize = 18.sp,textAlign = TextAlign.Center)
+ Icon(Icons.Rounded.SyncProblem,"Check Network Connection Again")
+ }
+ *//*},
+ *//*title = {
+ Column {
+ Text(
+ "Support Us",
+ style = SpotiFlyerTypography.h5,
+ textAlign = TextAlign.Center,
+ color = colorAccent,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.padding(vertical = 16.dp))
+ }
+ },*//*
+ backgroundColor = Color.DarkGray,
+ text = {
+
+ }
+ ,shape = SpotiFlyerShapes.medium,
+ onDismissRequest = onDismiss
+ )*/
+ }
+}
\ No newline at end of file
diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/Dialogs.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/Dialogs.kt
deleted file mode 100644
index 3c87dede..00000000
--- a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/Dialogs.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-/*
- * * Copyright (c) 2021 Shabinder Singh
- * * This program is free software: you can redistribute it and/or modify
- * * it under the terms of the GNU General Public License as published by
- * * the Free Software Foundation, either version 3 of the License, or
- * * (at your option) any later version.
- * *
- * * This program is distributed in the hope that it will be useful,
- * * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * * GNU General Public License for more details.
- * *
- * * You should have received a copy of the GNU General Public License
- * * along with this program. If not, see .
- */
-
-package com.shabinder.common.uikit
diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt
index 237281e9..21fa52de 100644
--- a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt
+++ b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/ExpectImages.kt
@@ -61,6 +61,12 @@ expect fun YoutubeMusicLogo(): Painter
@Composable
expect fun GithubLogo(): Painter
+@Composable
+expect fun PaypalLogo(): Painter
+
+@Composable
+expect fun RazorPay(): Painter
+
@Composable
expect fun HeartIcon(): Painter
@@ -69,3 +75,9 @@ expect fun DownloadImageError()
@Composable
expect fun DownloadImageArrow(modifier: Modifier)
+
+@Composable
+expect fun DonationDialog(
+ isVisible:Boolean,
+ onDismiss:()->Unit
+)
\ No newline at end of file
diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt
index 03432144..8019ba73 100644
--- a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt
+++ b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/SpotiFlyerMainUi.kt
@@ -59,6 +59,9 @@ import androidx.compose.material.icons.rounded.Flag
import androidx.compose.material.icons.rounded.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -318,9 +321,18 @@ fun AboutColumn(modifier: Modifier = Modifier) {
)
}
}
+
+ var isDonationDialogVisible by remember { mutableStateOf(false) }
+
+ DonationDialog(
+ isDonationDialogVisible
+ ) {
+ isDonationDialogVisible = false
+ }
+
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 6.dp)
- .clickable(onClick = { methods.value.giveDonation() }),
+ .clickable(onClick = { isDonationDialogVisible = true }),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Rounded.CardGiftcard, "Support Developer")
@@ -331,7 +343,8 @@ fun AboutColumn(modifier: Modifier = Modifier) {
style = SpotiFlyerTypography.h6
)
Text(
- text = "If you think I deserve to get paid for my work, you can leave me some money here.",
+ text = "If you think I deserve to get paid for my work, you can support me here.",
+ //text = "SpotiFlyer will always be, Free and Open-Source. You can however show us that you care by sending a small donation.",
style = SpotiFlyerTypography.subtitle2
)
}
diff --git a/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/dialogs/Donation.kt b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/dialogs/Donation.kt
new file mode 100644
index 00000000..d11b3008
--- /dev/null
+++ b/common/compose/src/commonMain/kotlin/com/shabinder/common/uikit/dialogs/Donation.kt
@@ -0,0 +1,2 @@
+package com.shabinder.common.uikit.dialogs
+
diff --git a/common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt b/common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt
index 22c8e5c5..d46815e6 100644
--- a/common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt
+++ b/common/compose/src/desktopMain/kotlin/com/shabinder/common/uikit/DesktopImages.kt
@@ -67,6 +67,14 @@ actual fun DownloadImageTick() {
)
}
+@Composable
+actual fun DonationDialog(
+ isVisible:Boolean,
+ onDismiss:()->Unit
+){
+
+}
+
actual fun montserratFont() = FontFamily(
Font("font/montserrat_light.ttf", FontWeight.Light),
Font("font/montserrat_regular.ttf", FontWeight.Normal),
@@ -125,3 +133,9 @@ actual fun YoutubeMusicLogo() = rememberVectorPainter(vectorXmlResource("drawabl
@Composable
actual fun GithubLogo() = rememberVectorPainter(vectorXmlResource("drawable/ic_github.xml")) as Painter
+
+@Composable
+actual fun PaypalLogo() = rememberVectorPainter(vectorXmlResource("drawable/ic_paypal_logo.xml")) as Painter
+
+@Composable
+actual fun RazorPay() = rememberVectorPainter(vectorXmlResource("drawable/ic_indian_rupee.xml")) as Painter
diff --git a/common/data-models/build.gradle.kts b/common/data-models/build.gradle.kts
index ce4b4b92..16dff061 100644
--- a/common/data-models/build.gradle.kts
+++ b/common/data-models/build.gradle.kts
@@ -21,10 +21,29 @@ plugins {
kotlin("plugin.serialization")
}
+val statelyVersion = "1.1.6"
+val statelyIsoVersion = "1.1.6-a1"
+
kotlin {
sourceSets {
+ /*
+ * Depend on https://github.com/ReactiveCircus/cache4k
+ * -As Soon as Kotlin 1.5 and Compose becomes compatible
+ * */
+ all {
+ languageSettings.apply {
+ progressiveMode = true
+ enableLanguageFeature("NewInference")
+ useExperimentalAnnotation("kotlin.Experimental")
+ useExperimentalAnnotation("kotlin.time.ExperimentalTime")
+ }
+ }
commonMain {
- dependencies {}
+ dependencies {
+ implementation("co.touchlab:stately-concurrency:$statelyVersion")
+ implementation("co.touchlab:stately-isolate:$statelyIsoVersion")
+ implementation("co.touchlab:stately-iso-collections:$statelyIsoVersion")
+ }
}
}
}
diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/Cache.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/Cache.kt
new file mode 100644
index 00000000..8882b7ae
--- /dev/null
+++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/Cache.kt
@@ -0,0 +1,152 @@
+package com.shabinder.common.caching
+
+import kotlin.time.Duration
+import kotlin.time.TimeSource
+
+/**
+ * An in-memory key-value cache with support for time-based (expiration) and size-based evictions.
+ */
+public interface Cache {
+
+ /**
+ * Returns the value associated with [key] in this cache, or null if there is no
+ * cached value for [key].
+ */
+ public fun get(key: Key): Value?
+
+ /**
+ * Returns the value associated with [key] in this cache if exists,
+ * otherwise gets the value by invoking [loader], associates the value with [key] in the cache,
+ * and returns the cached value.
+ *
+ * Any exceptions thrown by the [loader] will be propagated to the caller of this function.
+ */
+ public suspend fun get(key: Key, loader: suspend () -> Value): Value
+
+ /**
+ * Associates [value] with [key] in this cache. If the cache previously contained a
+ * value associated with [key], the old value is replaced by [value].
+ */
+ public fun put(key: Key, value: Value)
+
+ /**
+ * Discards any cached value for key [key].
+ */
+ public fun invalidate(key: Key)
+
+ /**
+ * Discards all entries in the cache.
+ */
+ public fun invalidateAll()
+
+ /**
+ * Returns a defensive copy of cache entries as [Map].
+ */
+ public fun asMap(): Map
+
+ /**
+ * Main entry point for creating a [Cache].
+ */
+ public interface Builder {
+
+ /**
+ * Specifies that each entry should be automatically removed from the cache once a fixed duration
+ * has elapsed after the entry's creation or the most recent replacement of its value.
+ *
+ * When [duration] is zero, the cache's max size will be set to 0
+ * meaning no values will be cached.
+ */
+ public fun expireAfterWrite(duration: Duration): Builder
+
+ /**
+ * Specifies that each entry should be automatically removed from the cache once a fixed duration
+ * has elapsed after the entry's creation, the most recent replacement of its value, or its last
+ * access.
+ *
+ * When [duration] is zero, the cache's max size will be set to 0
+ * meaning no values will be cached.
+ */
+ public fun expireAfterAccess(duration: Duration): Builder
+
+ /**
+ * Specifies the maximum number of entries the cache may contain.
+ * Cache eviction policy is based on LRU - i.e. least recently accessed entries get evicted first.
+ *
+ * When [size] is 0, entries will be discarded immediately and no values will be cached.
+ *
+ * If not set, cache size will be unlimited.
+ */
+ public fun maximumCacheSize(size: Long): Builder
+
+ /**
+ * Specifies a [FakeTimeSource] for programmatically advancing the reading of the underlying
+ * [TimeSource] used for expiry checks in tests.
+ *
+ * If not specified, [TimeSource.Monotonic] will be used for expiry checks.
+ */
+ public fun fakeTimeSource(fakeTimeSource: FakeTimeSource): Builder
+
+ /**
+ * Builds a new instance of [Cache] with the specified configurations.
+ */
+ public fun build(): Cache
+
+ public companion object {
+
+ /**
+ * Returns a new [Cache.Builder] instance.
+ */
+ public fun newBuilder(): Builder = CacheBuilderImpl()
+ }
+ }
+}
+
+/**
+ * A default implementation of [Cache.Builder].
+ */
+internal class CacheBuilderImpl : Cache.Builder {
+
+ private var expireAfterWriteDuration = Duration.INFINITE
+
+ private var expireAfterAccessDuration = Duration.INFINITE
+ private var maxSize = UNSET_LONG
+ private var fakeTimeSource: FakeTimeSource? = null
+
+ override fun expireAfterWrite(duration: Duration): CacheBuilderImpl = apply {
+ require(duration.isPositive()) {
+ "expireAfterWrite duration must be positive"
+ }
+ this.expireAfterWriteDuration = duration
+ }
+
+ override fun expireAfterAccess(duration: Duration): CacheBuilderImpl = apply {
+ require(duration.isPositive()) {
+ "expireAfterAccess duration must be positive"
+ }
+ this.expireAfterAccessDuration = duration
+ }
+
+ override fun maximumCacheSize(size: Long): CacheBuilderImpl = apply {
+ require(size >= 0) {
+ "maximum size must not be negative"
+ }
+ this.maxSize = size
+ }
+
+ override fun fakeTimeSource(fakeTimeSource: FakeTimeSource): CacheBuilderImpl = apply {
+ this.fakeTimeSource = fakeTimeSource
+ }
+
+ override fun build(): Cache {
+ return RealCache(
+ expireAfterWriteDuration,
+ expireAfterAccessDuration,
+ maxSize,
+ fakeTimeSource ?: TimeSource.Monotonic,
+ )
+ }
+
+ companion object {
+ internal const val UNSET_LONG: Long = -1
+ }
+}
diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/FakeTimeSource.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/FakeTimeSource.kt
new file mode 100644
index 00000000..58b9e5c2
--- /dev/null
+++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/FakeTimeSource.kt
@@ -0,0 +1,51 @@
+package com.shabinder.common.caching
+
+import co.touchlab.stately.concurrency.AtomicLong
+import kotlin.time.AbstractLongTimeSource
+import kotlin.time.Duration
+import kotlin.time.DurationUnit
+
+/**
+ * A time source that has programmatically updatable readings with support for multi-threaded access in Kotlin/Native.
+ *
+ * Implementation is identical to [kotlin.time.TestTimeSource] except the internal [reading] is an [AtomicLong].
+ */
+public class FakeTimeSource : AbstractLongTimeSource(unit = DurationUnit.NANOSECONDS) {
+
+ private val reading = AtomicLong(0)
+
+ override fun read(): Long = reading.get()
+
+ /**
+ * Advances the current reading value of this time source by the specified [duration].
+ *
+ * [duration] value is rounded down towards zero when converting it to a [Long] number of nanoseconds.
+ * For example, if the duration being added is `0.6.nanoseconds`, the reading doesn't advance because
+ * the duration value is rounded to zero nanoseconds.
+ *
+ * @throws IllegalStateException when the reading value overflows as the result of this operation.
+ */
+ public operator fun plusAssign(duration: Duration) {
+ val delta = duration.toDouble(unit)
+ val longDelta = delta.toLong()
+ reading.set(
+ reading.get().let { currentReading ->
+ if (longDelta != Long.MIN_VALUE && longDelta != Long.MAX_VALUE) {
+ // when delta fits in long, add it as long
+ val newReading = currentReading + longDelta
+ if (currentReading xor longDelta >= 0 && currentReading xor newReading < 0) overflow(duration)
+ newReading
+ } else {
+ // when delta is greater than long, add it as double
+ val newReading = currentReading + delta
+ if (newReading > Long.MAX_VALUE || newReading < Long.MIN_VALUE) overflow(duration)
+ newReading.toLong()
+ }
+ }
+ )
+ }
+
+ private fun overflow(duration: Duration) {
+ throw IllegalStateException("FakeTimeSource will overflow if its reading ${reading}ns is advanced by $duration.")
+ }
+}
diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/RealCache.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/RealCache.kt
new file mode 100644
index 00000000..3830f9b4
--- /dev/null
+++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/RealCache.kt
@@ -0,0 +1,250 @@
+package com.shabinder.common.caching
+
+import co.touchlab.stately.collections.IsoMutableMap
+import co.touchlab.stately.collections.IsoMutableSet
+import co.touchlab.stately.concurrency.AtomicReference
+import co.touchlab.stately.concurrency.value
+import kotlin.time.Duration
+import kotlin.time.TimeMark
+import kotlin.time.TimeSource
+
+/**
+ * A Kotlin Multiplatform [Cache] implementation powered by touchlab/Stately.
+ *
+ * Two types of evictions are supported:
+ *
+ * 1. Time-based evictions (expiration)
+ * 2. Size-based evictions
+ *
+ * Time-based evictions are enabled by specifying [expireAfterWriteDuration] and/or [expireAfterAccessDuration].
+ * When [expireAfterWriteDuration] is specified, entries will be automatically removed from the cache
+ * once a fixed duration has elapsed after the entry's creation
+ * or most recent replacement of its value.
+ * When [expireAfterAccessDuration] is specified, entries will be automatically removed from the cache
+ * once a fixed duration has elapsed after the entry's creation,
+ * the most recent replacement of its value, or its last access.
+ *
+ * Note that creation and replacement of an entry is also considered an access.
+ *
+ * Size-based evictions are enabled by specifying [maxSize]. When the size of the cache entries grows
+ * beyond [maxSize], least recently accessed entries will be evicted.
+ */
+internal class RealCache(
+ val expireAfterWriteDuration: Duration,
+ val expireAfterAccessDuration: Duration,
+ val maxSize: Long,
+ val timeSource: TimeSource,
+) : Cache {
+
+ private val cacheEntries = IsoMutableMap>()
+
+ /**
+ * Whether to perform size based evictions.
+ */
+ private val evictsBySize = maxSize >= 0
+
+ /**
+ * Whether to perform write-time based expiration.
+ */
+ private val expiresAfterWrite = expireAfterWriteDuration.isFinite()
+
+ /**
+ * Whether to perform access-time (both read and write) based expiration.
+ */
+ private val expiresAfterAccess = expireAfterAccessDuration.isFinite()
+
+ /**
+ * A queue of unique cache entries ordered by write time.
+ * Used for performing write-time based cache expiration.
+ */
+ private val writeQueue: IsoMutableSet>? =
+ takeIf { expiresAfterWrite }?.let {
+ ReorderingIsoMutableSet()
+ }
+
+ /**
+ * A queue of unique cache entries ordered by access time.
+ * Used for performing both write-time and read-time based cache expiration
+ * as well as size-based eviction.
+ *
+ * Note that a write is also considered an access.
+ */
+ private val accessQueue: IsoMutableSet>? =
+ takeIf { expiresAfterAccess || evictsBySize }?.let {
+ ReorderingIsoMutableSet()
+ }
+
+ override fun get(key: Key): Value? {
+ return cacheEntries[key]?.let {
+ if (it.isExpired()) {
+ // clean up expired entries and return null
+ expireEntries()
+ null
+ } else {
+ // update eviction metadata
+ recordRead(it)
+ it.value.get()
+ }
+ }
+ }
+
+ override suspend fun get(key: Key, loader: suspend () -> Value): Value {
+ return cacheEntries[key]?.let {
+ if (it.isExpired()) {
+ // clean up expired entries
+ expireEntries()
+ null
+ } else {
+ // update eviction metadata
+ recordRead(it)
+ it.value.get()
+ }
+ } ?: loader().let { loadedValue ->
+ val existingValue = get(key)
+ if (existingValue != null) {
+ existingValue
+ } else {
+ put(key, loadedValue)
+ loadedValue
+ }
+ }
+ }
+
+ override fun put(key: Key, value: Value) {
+ expireEntries()
+
+ val existingEntry = cacheEntries[key]
+ if (existingEntry != null) {
+ // cache entry found
+ recordWrite(existingEntry)
+ existingEntry.value.set(value)
+ } else {
+ // create a new cache entry
+ val nowTimeMark = timeSource.markNow()
+ val newEntry = CacheEntry(
+ key = key,
+ value = AtomicReference(value),
+ accessTimeMark = AtomicReference(nowTimeMark),
+ writeTimeMark = AtomicReference(nowTimeMark),
+ )
+ recordWrite(newEntry)
+ cacheEntries[key] = newEntry
+ }
+
+ evictEntries()
+ }
+
+ override fun invalidate(key: Key) {
+ expireEntries()
+ cacheEntries.remove(key)?.also {
+ writeQueue?.remove(it)
+ accessQueue?.remove(it)
+ }
+ }
+
+ override fun invalidateAll() {
+ cacheEntries.clear()
+ writeQueue?.clear()
+ accessQueue?.clear()
+ }
+
+ override fun asMap(): Map {
+ return cacheEntries.values.associate { entry ->
+ entry.key to entry.value.get()
+ }
+ }
+
+ /**
+ * Remove all expired entries.
+ */
+ private fun expireEntries() {
+ val queuesToProcess = listOfNotNull(
+ if (expiresAfterWrite) writeQueue else null,
+ if (expiresAfterAccess) accessQueue else null
+ )
+
+ queuesToProcess.forEach { queue ->
+ queue.access {
+ val iterator = queue.iterator()
+ for (entry in iterator) {
+ if (entry.isExpired()) {
+ cacheEntries.remove(entry.key)
+ // remove the entry from the current queue
+ iterator.remove()
+ } else {
+ // found unexpired entry, no need to look any further
+ break
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * Check whether the [CacheEntry] has expired based on either access time or write time.
+ */
+ private fun CacheEntry.isExpired(): Boolean {
+ return expiresAfterAccess && (accessTimeMark.get() + expireAfterAccessDuration).hasPassedNow() ||
+ expiresAfterWrite && (writeTimeMark.get() + expireAfterWriteDuration).hasPassedNow()
+ }
+
+ /**
+ * Evict least recently accessed entries until [cacheEntries] is no longer over capacity.
+ */
+ private fun evictEntries() {
+ if (!evictsBySize) {
+ return
+ }
+
+ checkNotNull(accessQueue)
+
+ while (cacheEntries.size > maxSize) {
+ accessQueue.access {
+ it.firstOrNull()?.run {
+ cacheEntries.remove(key)
+ writeQueue?.remove(this)
+ accessQueue.remove(this)
+ }
+ }
+ }
+ }
+
+ /**
+ * Update the eviction metadata on the [cacheEntry] which has just been read.
+ */
+ private fun recordRead(cacheEntry: CacheEntry) {
+ if (expiresAfterAccess) {
+ val accessTimeMark = cacheEntry.accessTimeMark.value
+ cacheEntry.accessTimeMark.set(accessTimeMark + accessTimeMark.elapsedNow())
+ }
+ accessQueue?.add(cacheEntry)
+ }
+
+ /**
+ * Update the eviction metadata on the [CacheEntry] which is about to be written.
+ * Note that a write is also considered an access.
+ */
+ private fun recordWrite(cacheEntry: CacheEntry) {
+ if (expiresAfterAccess) {
+ val accessTimeMark = cacheEntry.accessTimeMark.value
+ cacheEntry.accessTimeMark.set(accessTimeMark + accessTimeMark.elapsedNow())
+ }
+ if (expiresAfterWrite) {
+ val writeTimeMark = cacheEntry.writeTimeMark.value
+ cacheEntry.writeTimeMark.set(writeTimeMark + writeTimeMark.elapsedNow())
+ }
+ accessQueue?.add(cacheEntry)
+ writeQueue?.add(cacheEntry)
+ }
+}
+
+/**
+ * A cache entry holds the [key] and [value] pair,
+ * along with the metadata needed to perform cache expiration and eviction.
+ */
+private class CacheEntry(
+ val key: Key,
+ val value: AtomicReference,
+ val accessTimeMark: AtomicReference,
+ val writeTimeMark: AtomicReference,
+)
diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/ReorderingIsoMutableSet.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/ReorderingIsoMutableSet.kt
new file mode 100644
index 00000000..add167a1
--- /dev/null
+++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/caching/ReorderingIsoMutableSet.kt
@@ -0,0 +1,17 @@
+package com.shabinder.common.caching
+
+import co.touchlab.stately.collections.IsoMutableSet
+
+/**
+ * A custom [IsoMutableSet] that updates the insertion order when an element is re-inserted,
+ * i.e. an inserted element will always be placed at the end
+ * regardless of whether the element already exists.
+ */
+internal class ReorderingIsoMutableSet : IsoMutableSet(), MutableSet {
+ override fun add(element: T): Boolean = access {
+ val exists = remove(element)
+ super.add(element)
+ // respect the contract "true if this set did not already contain the specified element"
+ !exists
+ }
+}
diff --git a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt
index 9d3c9dde..808fd330 100644
--- a/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt
+++ b/common/data-models/src/commonMain/kotlin/com/shabinder/common/models/Actions.kt
@@ -42,8 +42,8 @@ interface Actions {
}
-private fun stubActions() = object :Actions{
- override val platformActions = object: PlatformActions{}
+private fun stubActions() = object :Actions {
+ override val platformActions = StubPlatformActions
override fun showPopUpMessage(string: String, long: Boolean) {}
override fun setDownloadDirectoryAction() {}
override fun queryActiveTracks() {}
diff --git a/common/data-models/src/main/res/drawable/ic_download_arrow.xml b/common/data-models/src/main/res/drawable/ic_download_arrow.xml
index 4a92ae81..deadedca 100644
--- a/common/data-models/src/main/res/drawable/ic_download_arrow.xml
+++ b/common/data-models/src/main/res/drawable/ic_download_arrow.xml
@@ -21,6 +21,6 @@
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
diff --git a/common/data-models/src/main/res/drawable/ic_indian_rupee.xml b/common/data-models/src/main/res/drawable/ic_indian_rupee.xml
new file mode 100644
index 00000000..637c6b56
--- /dev/null
+++ b/common/data-models/src/main/res/drawable/ic_indian_rupee.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/common/data-models/src/main/res/drawable/ic_paypal_logo.xml b/common/data-models/src/main/res/drawable/ic_paypal_logo.xml
new file mode 100644
index 00000000..933369b5
--- /dev/null
+++ b/common/data-models/src/main/res/drawable/ic_paypal_logo.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/common/dependency-injection/build.gradle.kts b/common/dependency-injection/build.gradle.kts
index dfdf80a1..fd0b94b8 100644
--- a/common/dependency-injection/build.gradle.kts
+++ b/common/dependency-injection/build.gradle.kts
@@ -41,7 +41,6 @@ kotlin {
implementation(compose.materialIconsExtended)
implementation(Extras.Android.razorpay)
implementation(Extras.mp3agic)
- //implementation(Extras.jaudioTagger)
implementation("com.github.shabinder:storage-chooser:2.0.4.45")
// implementation(files("$rootDir/libs/mobile-ffmpeg.aar"))
}
diff --git a/common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt b/common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt
index fc7dab53..aeea3f48 100644
--- a/common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt
+++ b/common/list/src/commonMain/kotlin/com/shabinder/common/list/integration/SpotiFlyerListImpl.kt
@@ -28,6 +28,7 @@ import com.shabinder.common.list.store.SpotiFlyerListStore.Intent
import com.shabinder.common.list.store.SpotiFlyerListStoreProvider
import com.shabinder.common.list.store.getStore
import com.shabinder.common.models.TrackDetails
+import com.shabinder.common.caching.Cache
internal class SpotiFlyerListImpl(
componentContext: ComponentContext,
@@ -49,6 +50,11 @@ internal class SpotiFlyerListImpl(
).provide()
}
+ private val cache = Cache.Builder
+ .newBuilder()
+ .maximumCacheSize(150)
+ .build()
+
override val models: Value = store.asValue()
override fun onDownloadAllClicked(trackList: List) {
@@ -67,5 +73,9 @@ internal class SpotiFlyerListImpl(
store.accept(Intent.RefreshTracksStatuses)
}
- override suspend fun loadImage(url: String): Picture = dir.loadImage(url)
+ override suspend fun loadImage(url: String): Picture {
+ return cache.get(url) {
+ dir.loadImage(url)
+ }
+ }
}
diff --git a/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt b/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt
index eeef1b1d..b8b0e15f 100644
--- a/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt
+++ b/common/list/src/commonMain/kotlin/com/shabinder/common/list/store/SpotiFlyerListStoreProvider.kt
@@ -44,6 +44,7 @@ internal class SpotiFlyerListStoreProvider(
private val downloadProgressFlow: MutableSharedFlow>
) {
val logger = getLogger()
+
fun provide(): SpotiFlyerListStore =
object :
SpotiFlyerListStore,
diff --git a/common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt b/common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt
index ce417a6c..554991a2 100644
--- a/common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt
+++ b/common/main/src/commonMain/kotlin/com/shabinder/common/main/integration/SpotiFlyerMainImpl.kt
@@ -18,6 +18,7 @@ package com.shabinder.common.main.integration
import co.touchlab.stately.ensureNeverFrozen
import com.arkivanov.decompose.ComponentContext
+import com.arkivanov.decompose.lifecycle.doOnDestroy
import com.arkivanov.decompose.value.Value
import com.shabinder.common.di.Picture
import com.shabinder.common.di.utils.asValue
@@ -30,6 +31,7 @@ import com.shabinder.common.main.store.SpotiFlyerMainStore.Intent
import com.shabinder.common.main.store.SpotiFlyerMainStoreProvider
import com.shabinder.common.main.store.getStore
import com.shabinder.common.models.methods
+import com.shabinder.common.caching.Cache
internal class SpotiFlyerMainImpl(
componentContext: ComponentContext,
@@ -38,6 +40,9 @@ internal class SpotiFlyerMainImpl(
init {
instanceKeeper.ensureNeverFrozen()
+ lifecycle.doOnDestroy {
+ cache.invalidateAll()
+ }
}
private val store =
@@ -48,6 +53,11 @@ internal class SpotiFlyerMainImpl(
).provide()
}
+ private val cache = Cache.Builder
+ .newBuilder()
+ .maximumCacheSize(20)
+ .build()
+
override val models: Value = store.asValue()
override fun onLinkSearch(link: String) {
@@ -63,5 +73,9 @@ internal class SpotiFlyerMainImpl(
store.accept(Intent.SelectCategory(category))
}
- override suspend fun loadImage(url: String): Picture = dir.loadImage(url)
+ override suspend fun loadImage(url: String): Picture {
+ return cache.get(url) {
+ dir.loadImage(url)
+ }
+ }
}
diff --git a/spotiflyer-ios b/spotiflyer-ios
index 777e45a5..30c16869 160000
--- a/spotiflyer-ios
+++ b/spotiflyer-ios
@@ -1 +1 @@
-Subproject commit 777e45a555bcb7a8a0713705cbf159d05a45adb0
+Subproject commit 30c16869ebc81aace8a21cc0ecb0f0314f72b0bc
diff --git a/web-app/build.gradle.kts b/web-app/build.gradle.kts
index 7db58009..f7236985 100644
--- a/web-app/build.gradle.kts
+++ b/web-app/build.gradle.kts
@@ -16,14 +16,12 @@
plugins {
kotlin("js")
- //id("org.jetbrains.kotlin.js") version "1.4.31"
}
group = "com.shabinder"
version = "0.1"
repositories {
- jcenter()
mavenCentral()
maven(url = "https://dl.bintray.com/kotlin/kotlin-js-wrappers")
}