diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32c61a6..5a03f6d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -21,6 +21,8 @@ kotlin { optIn.addAll( "androidx.compose.material3.ExperimentalMaterial3Api", "androidx.compose.foundation.ExperimentalFoundationApi", + "kotlin.ExperimentalStdlibApi", + "kotlinx.coroutines.ExperimentalCoroutinesApi", ) freeCompilerArgs.addAll( "-Xjsr305=strict", @@ -43,13 +45,6 @@ kotlin { val aqVersionCode = providers.gradleProperty("aq_versioncode").map(String::toLong).get() val aqVersionName = providers.gradleProperty("aq_versionname").get() -buildConfig { - packageName("dev.jvmname.acquisitive") - buildConfigField("String", "FS_VERSION_NAME", "\"$aqVersionName - $aqVersionCode\"") - buildConfigField("Long", "VERSION_CODE", aqVersionCode) - generateAtSync = true -} - android { namespace = "dev.jvmname.acquisitive" compileSdk = 35 @@ -75,6 +70,22 @@ android { ) } } + + applicationVariants.all variant@{ + buildConfig { + generateAtSync = true + useKotlinOutput() + sourceSets.named(this@variant.name) { + className.set("BuildConfig") + packageName("dev.jvmname.acquisitive") + buildConfigField("DEBUG", this@variant.buildType.isDebuggable) + buildConfigField("VERSION_NAME", "\"$aqVersionName - $aqVersionCode\"") + buildConfigField("VERSION_CODE", aqVersionCode) + } + } + + } + compileOptions { sourceCompatibility = libs.versions.jvmTarget.map(JavaVersion::toVersion).get() targetCompatibility = libs.versions.jvmTarget.map(JavaVersion::toVersion).get() @@ -91,7 +102,10 @@ android { ksp { arg("circuit.codegen.mode", "kotlin_inject_anvil") // arg("me.tatarka.inject.dumpGraph", "true") - arg("kotlin-inject-anvil-contributing-annotations", "com.slack.circuit.codegen.annotations.CircuitInject") + arg( + "kotlin-inject-anvil-contributing-annotations", + "com.slack.circuit.codegen.annotations.CircuitInject" + ) } dependencies { @@ -136,14 +150,17 @@ dependencies { implementation(platform(libs.square.retrofit.bom)) implementation(libs.square.retrofit) + implementation(libs.square.okhttpLogging) implementation(libs.square.retrofit.moshi) implementation(libs.square.moshi) ksp(libs.square.moshiKotlin) implementation(libs.square.moshiAdapters) implementation(libs.square.moshiSealed) ksp(libs.square.moshiSealedCodegen) + implementation(libs.square.logcat) implementation(libs.sqkon) implementation(libs.mnf.store) + implementation("io.github.theapache64:rebugger:1.0.0-rc03") } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 70af707..78a5968 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + logcat(tag = tag, message = { message }) } + ) + System.setProperty("kotlinx.coroutines.debug", if (BuildConfig.DEBUG) "on" else "off") enableEdgeToEdge() - val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) val component = AcqComponent::class.create( contextDelegate = applicationContext, coroutineScopeDelegate = scope, ) + logcat { "***entering compose" } setContent { CircuitCompositionLocals(component.circuit) { AcquisitiveTheme { + logcat { "***entered theme" } val backstack = rememberSaveableBackStack(root = MainListScreen()) val navigator = rememberAndroidScreenAwareNavigator( rememberCircuitNavigator(backstack), // Decorated navigator diff --git a/app/src/main/java/dev/jvmname/acquisitive/network/HNClient.kt b/app/src/main/java/dev/jvmname/acquisitive/network/HNClient.kt index 2dd7946..20dceaa 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/network/HNClient.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/network/HNClient.kt @@ -8,17 +8,28 @@ import dev.jvmname.acquisitive.network.model.UserId import dev.jvmname.acquisitive.util.ItemIdArray import dev.jvmname.acquisitive.util.emptyItemIdArray import dev.jvmname.acquisitive.util.fetchAsync +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext +import logcat.asLog +import logcat.logcat import me.tatarka.inject.annotations.Inject import software.amazon.lastmile.kotlin.inject.anvil.AppScope import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding - abstract class HnClient { //TODO figure out if i want to take the boxing hit to have an ApiResult type - protected suspend fun wrap(call: suspend () -> T): T { - return withContext(Dispatchers.IO) { call() } + protected suspend inline fun wrap(crossinline call: suspend () -> T): T { + return withContext(Dispatchers.IO + CoroutineName("HnClientWrapped")) { + try { + if (!isActive) logcat { "***scope not active" } + call() + } catch (e: Exception) { + logcat { "***error: " + e.asLog() } + throw e + } + } } abstract suspend fun getStories(mode: FetchMode): ItemIdArray @@ -34,7 +45,7 @@ abstract class HnClient { } @[Inject ContributesBinding(AppScope::class)] -class RealHnClient (factory: NetworkComponent.RetrofitFactory) : HnClient() { +class RealHnClient(factory: NetworkComponent.RetrofitFactory) : HnClient() { private val storyClient = factory.create("https://hacker-news.firebaseio.com/v0/") // private val userClient = factory.create("https://news.ycombinator.com/") @@ -55,7 +66,11 @@ class RealHnClient (factory: NetworkComponent.RetrofitFactory) : HnClient() { } override suspend fun getTopStories(): ItemIdArray { - return wrap { storyClient.getTopStories() } + return try { + storyClient.getTopStories() + } catch (e: Exception) { + throw e + } ?: emptyItemIdArray() } diff --git a/app/src/main/java/dev/jvmname/acquisitive/network/ItemIdArrayAdapterFactory.kt b/app/src/main/java/dev/jvmname/acquisitive/network/ItemIdArrayAdapterFactory.kt new file mode 100644 index 0000000..e8ac53d --- /dev/null +++ b/app/src/main/java/dev/jvmname/acquisitive/network/ItemIdArrayAdapterFactory.kt @@ -0,0 +1,31 @@ +package dev.jvmname.acquisitive.network + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import dev.jvmname.acquisitive.util.ItemIdArray +import java.lang.reflect.Type + +object ItemIdArrayAdapterFactory : JsonAdapter.Factory { + override fun create( + type: Type, + annotations: MutableSet, + moshi: Moshi, + ): JsonAdapter? { + if (type != ItemIdArray::class.java) return null + + val delegate = moshi.adapter() + return object : JsonAdapter() { + override fun fromJson(reader: JsonReader): ItemIdArray? { + return delegate.fromJson(reader)?.let(::ItemIdArray) + } + + override fun toJson(writer: JsonWriter, value: ItemIdArray?) { + delegate.toJson(writer, value?.storage) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/network/NetworkModule.kt b/app/src/main/java/dev/jvmname/acquisitive/network/NetworkModule.kt index 0b25e32..ff6a2cc 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/network/NetworkModule.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/network/NetworkModule.kt @@ -1,13 +1,15 @@ package dev.jvmname.acquisitive.network -import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi +import com.squareup.moshi.addAdapter import dev.jvmname.acquisitive.network.adapters.IdAdapter import dev.jvmname.acquisitive.network.adapters.InstantAdapter import kotlinx.datetime.Instant +import logcat.logcat import me.tatarka.inject.annotations.Inject import me.tatarka.inject.annotations.Provides import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import software.amazon.lastmile.kotlin.inject.anvil.AppScope @@ -20,32 +22,36 @@ interface NetworkComponent { @[Provides SingleIn(AppScope::class)] fun providesMoshi(): Moshi { return Moshi.Builder() - .add(Instant::class.java, InstantAdapter) + .addAdapter(InstantAdapter) .add(IdAdapter.create()) + .add(ItemIdArrayAdapterFactory) .build() } @Provides fun provideMoshiConverterFactory(moshi: Moshi) = MoshiConverterFactory.create(moshi) -@Provides -fun provideOkhttpClient(): OkHttpClient { -return OkHttpClient.Builder() - .build() -} + @Provides + fun provideOkhttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addNetworkInterceptor(HttpLoggingInterceptor { logcat(tag = "OkhttpInterceptor") { it } }.apply { + level = HttpLoggingInterceptor.Level.BASIC + }) + .build() + } @[Inject SingleIn(AppScope::class)] class RetrofitFactory( - private val okhttp: () -> OkHttpClient, + private val okhttp: OkHttpClient, private val moshiConverterFactory: MoshiConverterFactory, ) { fun create(baseURL: String, clazz: KClass): T { return Retrofit.Builder() + .client(okhttp) .baseUrl(baseURL) .addConverterFactory(moshiConverterFactory) .validateEagerly(true) - .callFactory { request -> okhttp().newCall(request) } .build() .create(clazz.java) } diff --git a/app/src/main/java/dev/jvmname/acquisitive/network/adapters/InstantAdapter.kt b/app/src/main/java/dev/jvmname/acquisitive/network/adapters/InstantAdapter.kt index 4bb5586..e4f55ff 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/network/adapters/InstantAdapter.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/network/adapters/InstantAdapter.kt @@ -4,11 +4,6 @@ import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader import com.squareup.moshi.JsonWriter import kotlinx.datetime.Instant -import me.tatarka.inject.annotations.Inject -import software.amazon.lastmile.kotlin.inject.anvil.AppScope -import software.amazon.lastmile.kotlin.inject.anvil.ContributesBinding - - object InstantAdapter : JsonAdapter() { override fun fromJson(reader: JsonReader): Instant { diff --git a/app/src/main/java/dev/jvmname/acquisitive/network/model/HnItem.kt b/app/src/main/java/dev/jvmname/acquisitive/network/model/HnItem.kt index 66d6271..c387867 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/network/model/HnItem.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/network/model/HnItem.kt @@ -1,17 +1,29 @@ package dev.jvmname.acquisitive.network.model +import android.os.Parcelable +import androidx.compose.runtime.Immutable import com.squareup.moshi.JsonClass import dev.drewhamilton.poko.Poko import dev.jvmname.acquisitive.util.ItemIdArray import dev.zacsweers.moshix.sealed.annotations.TypeLabel import kotlinx.datetime.Instant +import kotlinx.parcelize.Parcelize +@Immutable +sealed interface ShadedHnItem { + @JvmInline + value class Shallow(val item: ItemId) : ShadedHnItem -@JvmInline -value class ItemId(val id: Int) + @JvmInline + value class Full(val item: HnItem) : ShadedHnItem +} + +@[JvmInline Parcelize Immutable JsonClass(generateAdapter = false)] +value class ItemId(val id: Int) : Parcelable -@JsonClass(generateAdapter = true, generator = "sealed:type") -sealed class HnItem(val id: ItemId) { +@[Immutable JsonClass(generateAdapter = true, generator = "sealed:type")] +sealed interface HnItem { + abstract val id: ItemId abstract val by: String? abstract val time: Instant abstract val dead: Boolean? @@ -20,72 +32,72 @@ sealed class HnItem(val id: ItemId) { @[Poko TypeLabel("story") JsonClass(generateAdapter = true)] class Story( - id: ItemId, + override val id: ItemId, override val by: String?, override val time: Instant, - override val dead: Boolean? = null, - override val deleted: Boolean? = null, - override val kids: ItemIdArray? = null, + override val dead: Boolean?, + override val deleted: Boolean?, + override val kids: ItemIdArray?, val title: String, val url: String?, val score: Int, val descendants: Int?, val text: String?, - ) : HnItem(id) + ) : HnItem @[Poko TypeLabel("comment") JsonClass(generateAdapter = true)] class Comment( - id: ItemId, + override val id: ItemId, override val by: String?, override val time: Instant, - override val dead: Boolean? = null, - override val deleted: Boolean? = null, - override val kids: ItemIdArray? = null, + override val dead: Boolean?, + override val deleted: Boolean?, + override val kids: ItemIdArray?, val text: String?, val parent: Int, - ) : HnItem(id) + ) : HnItem @[Poko TypeLabel("job") JsonClass(generateAdapter = true)] class Job( - id: ItemId, + override val id: ItemId, override val by: String?, override val time: Instant, - override val dead: Boolean? = null, - override val deleted: Boolean? = null, - override val kids: ItemIdArray? = null, + override val dead: Boolean?, + override val deleted: Boolean?, + override val kids: ItemIdArray?, val title: String, val text: String?, val url: String?, val score: Int, - ) : HnItem(id) + ) : HnItem @[Poko TypeLabel("poll") JsonClass(generateAdapter = true)] class Poll( - id: ItemId, + override val id: ItemId, override val by: String?, override val time: Instant, - override val dead: Boolean? = null, - override val deleted: Boolean? = null, - override val kids: ItemIdArray? = null, + override val dead: Boolean?, + override val deleted: Boolean?, + override val kids: ItemIdArray?, val title: String, val text: String?, val parts: ItemIdArray, val score: Int, val descendants: Int?, - ) : HnItem(id) + ) : HnItem @[Poko TypeLabel("pollopt") JsonClass(generateAdapter = true)] class PollOption( - id: ItemId, + override val id: ItemId, override val by: String?, override val time: Instant, - override val dead: Boolean? = null, - override val deleted: Boolean? = null, - override val kids: ItemIdArray? = null, + override val dead: Boolean?, + override val deleted: Boolean?, + override val kids: ItemIdArray?, val poll: ItemId, val text: String?, val score: Int, - ) : HnItem(id) + ) : HnItem } val HnItem.score: Int @@ -176,4 +188,7 @@ fun HnItem.copy( text = text, score = score ) -} \ No newline at end of file +} + +fun ItemId.shaded() = ShadedHnItem.Shallow(this) +fun HnItem.shaded() = ShadedHnItem.Full(this) diff --git a/app/src/main/java/dev/jvmname/acquisitive/repo/HnItemStore.kt b/app/src/main/java/dev/jvmname/acquisitive/repo/HnItemStore.kt new file mode 100644 index 0000000..f52adb1 --- /dev/null +++ b/app/src/main/java/dev/jvmname/acquisitive/repo/HnItemStore.kt @@ -0,0 +1,250 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package dev.jvmname.acquisitive.repo + +import androidx.compose.runtime.Immutable +import com.mercury.sqkon.db.KeyValueStorage +import com.mercury.sqkon.db.OrderBy +import com.mercury.sqkon.db.OrderDirection +import com.mercury.sqkon.db.Sqkon +import com.mercury.sqkon.db.eq +import dev.drewhamilton.poko.Poko +import dev.jvmname.acquisitive.di.AppCrScope +import dev.jvmname.acquisitive.network.HnClient +import dev.jvmname.acquisitive.network.model.FetchMode +import dev.jvmname.acquisitive.network.model.HnItem +import dev.jvmname.acquisitive.network.model.ItemId +import dev.jvmname.acquisitive.network.model.ShadedHnItem +import dev.jvmname.acquisitive.util.ItemIdArray +import dev.jvmname.acquisitive.util.fetchAsync +import dev.jvmname.acquisitive.util.retry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import logcat.asLog +import logcat.logcat +import me.tatarka.inject.annotations.Inject +import org.mobilenativefoundation.store.store5.Converter +import org.mobilenativefoundation.store.store5.Fetcher +import org.mobilenativefoundation.store.store5.FetcherResult +import org.mobilenativefoundation.store.store5.SourceOfTruth +import org.mobilenativefoundation.store.store5.StoreBuilder +import org.mobilenativefoundation.store.store5.StoreReadRequest +import org.mobilenativefoundation.store.store5.StoreReadResponse + + +@Immutable +sealed interface StoryItemKey { + @[JvmInline Immutable] + value class Single(val id: ItemId) : StoryItemKey + + @[Poko Immutable] + class All(val fetchMode: FetchMode, val window: Int) : StoryItemKey +} + +sealed interface StoryItemResult { + @[JvmInline Immutable] + value class Single(val item: T) : StoryItemResult + + @[Poko Immutable] + class All(val items: List, val fetchMode: FetchMode) : StoryItemResult +} + +private sealed interface NetworkResult { + @[JvmInline Immutable] + value class Single(val item: T) : NetworkResult + + @[Poko Immutable] + class All( + val full: List, + val shallow: ItemIdArray, + val fetchMode: FetchMode, + ) : NetworkResult +} + + +private typealias NetworkItem = NetworkResult +private typealias OutputItem = StoryItemResult + + +@Inject +class HnItemStore( + skn: Sqkon, + private val client: HnClient, + @AppCrScope scope: CoroutineScope, +) { + private val storage = skn.keyValueStorage( + name = HnItemEntity::class.simpleName!!, + config = KeyValueStorage.Config(dispatcher = Dispatchers.IO) + ) + private val store = StoreBuilder + .from( + fetcher = buildFetcher(), + sourceOfTruth = buildSoT(), + converter = buildConverter(), + ) + .scope(scope) +// .cachePolicy( +// MemoryPolicy.MemoryPolicyBuilder() +// .setMaxSize(20_971_520L) // 20 MB +// .setExpireAfterWrite(30.minutes) +// .build() +// ) + .build() + + suspend fun get(key: StoryItemKey): OutputItem { + return store.stream(StoreReadRequest.cached(key, refresh = true)) + .filterNot { it is StoreReadResponse.Loading || it is StoreReadResponse.NoNewData } + .first() + .requireData() + } + + fun stream(key: StoryItemKey): Flow { + return store.stream(StoreReadRequest.cached(key, refresh = true)) + .mapNotNull { it.dataOrNull() } + } + + /* + * below is the setup methods for the store + */ + + private fun buildFetcher() = Fetcher.of { key -> + when (key) { + is StoryItemKey.Single -> { + NetworkResult.Single(retry(3) { client.getItem(key.id) }) + } + + is StoryItemKey.All -> { + val itemsIds = try { + logcat { "***fetching items for $key" } + client.getStories(key.fetchMode) + } catch (e: Exception) { + logcat { "***fetcher internal error: " + e.asLog() } + throw e + } + + val full = itemsIds + .take(key.window) + .fetchAsync { + retry(2) { + client.getItem(it) //todo if this fails return the old ID so I don't lose it + } + } + val shallow = ItemIdArray(itemsIds.size - key.window) { i -> + itemsIds[key.window + i] + } + NetworkResult.All( + full = full, + shallow = shallow, + fetchMode = key.fetchMode + + ) + } + } + } + + private fun buildSoT(): SourceOfTruth, OutputItem> { + return SourceOfTruth.of( + reader = { key -> + when (key) { + is StoryItemKey.Single -> try { + storage.selectByKey(key.toStringId()) + .map { entity -> + entity?.let { StoryItemResult.Single(it.toShadedItem()) } + } + } catch (e: Exception) { + logcat { "***skn error: " + e.asLog() } + throw e + } + + is StoryItemKey.All -> try { + storage.select( + where = HnItemEntity::fetchMode eq key.fetchMode.name, + orderBy = listOf(OrderBy(HnItemEntity::index, OrderDirection.ASC)) + ) + .map { list -> + StoryItemResult.All( + list.map(HnItemEntity::toShadedItem), + key.fetchMode + ) + } + } catch (e: Exception) { + logcat { "***skn error: " + e.asLog() } + throw e + } + } + }, + writer = { key, item -> + when (key) { + is StoryItemKey.Single -> { + try { + storage.upsert(key.toStringId(), item.first()) + } catch (e: Exception) { + logcat { "***skn error: " + e.asLog() } + throw e + } + } + + is StoryItemKey.All -> try { + storage.upsertAll(item.associateBy { it.id.toString() }) + } catch (e: Exception) { + logcat { "***skn error: " + e.asLog() } + throw e + } + } + }, + delete = { key -> + when (key) { + is StoryItemKey.Single -> storage.deleteByKey(key.toStringId()) + is StoryItemKey.All -> storage.delete(HnItemEntity::fetchMode eq key.fetchMode.name) + } + }, + deleteAll = storage::deleteAll, + ) + } + + private fun buildConverter() = Converter.Builder, OutputItem>() + .fromNetworkToLocal { networkItem -> + when (networkItem) { + is NetworkResult.Single -> listOfNotNull(networkItem.item.toEntity()) + is NetworkResult.All -> { + val full = networkItem.full + val shallow = networkItem.shallow + buildList(full.size + shallow.size) { + full.forEachIndexed { it, item -> + add(item.toEntity(networkItem.fetchMode, index = it)) + } + shallow.forEachIndexed { it, item -> + add(item.toEntity(networkItem.fetchMode, index = it + full.size)) + } + } + } + } + } + .fromOutputToLocal { outputItem -> + when (outputItem) { + is StoryItemResult.Single -> listOf(outputItem.item.toEntity(index = -1)) + is StoryItemResult.All -> outputItem.items.mapIndexed { i, item -> + item.toEntity(outputItem.fetchMode, i) + } + } + } + .build() +} + +private fun Fetcher.Companion.fetcherResult(fetch: suspend (K) -> R): Fetcher { + return ofResult { k -> + try { + FetcherResult.Data(fetch(k)) + } catch (e: Exception) { + logcat { "***fetcher error: " + e.asLog() } + FetcherResult.Error.Exception(e) + } + } +} + +private inline fun StoryItemKey.Single.toStringId() = id.id.toString() \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/repo/StoryItemRepo.kt b/app/src/main/java/dev/jvmname/acquisitive/repo/StoryItemRepo.kt index bc07b77..f437424 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/repo/StoryItemRepo.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/repo/StoryItemRepo.kt @@ -1,153 +1,44 @@ -@file:Suppress("NOTHING_TO_INLINE") - package dev.jvmname.acquisitive.repo -import com.mercury.sqkon.db.Sqkon -import com.mercury.sqkon.db.eq -import dev.drewhamilton.poko.Poko -import dev.jvmname.acquisitive.di.AppCrScope -import dev.jvmname.acquisitive.network.HnClient import dev.jvmname.acquisitive.network.model.FetchMode -import dev.jvmname.acquisitive.network.model.HnItem import dev.jvmname.acquisitive.network.model.ItemId +import dev.jvmname.acquisitive.network.model.ShadedHnItem +import dev.jvmname.acquisitive.ui.screen.mainlist.debugToString1 +import dev.jvmname.acquisitive.ui.screen.mainlist.debugToString2 import dev.jvmname.acquisitive.util.fetchAsync -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull +import logcat.logcat import me.tatarka.inject.annotations.Inject -import org.mobilenativefoundation.store.store5.Converter -import org.mobilenativefoundation.store.store5.Fetcher -import org.mobilenativefoundation.store.store5.FetcherResult -import org.mobilenativefoundation.store.store5.MemoryPolicy -import org.mobilenativefoundation.store.store5.SourceOfTruth -import org.mobilenativefoundation.store.store5.StoreBuilder -import org.mobilenativefoundation.store.store5.StoreReadRequest -import org.mobilenativefoundation.store.store5.impl.extensions.get -import kotlin.time.Duration.Companion.minutes - -private sealed interface StoryItemKey { - @JvmInline - value class Single(val id: ItemId) : StoryItemKey - - @JvmInline - value class All(val fetchMode: FetchMode) : StoryItemKey -} - -private sealed interface StoryItemResult { - @JvmInline - value class Single(val item: T) : StoryItemResult - - @Poko - class All(val items: List, val fetchMode: FetchMode) : StoryItemResult -} -private typealias NetworkItem = StoryItemResult @Inject -class StoryItemRepo( - skn: Sqkon, - private val client: HnClient, - @AppCrScope scope: CoroutineScope, -) { - private val storage = skn.keyValueStorage("storyItem") - private val store = StoreBuilder - .from( - fetcher = buildFetcher(), - sourceOfTruth = buildSoT(), - converter = buildConverter(), - ) - .scope(scope) - .cachePolicy( - MemoryPolicy.MemoryPolicyBuilder() - .setMaxSize(20_971_520L) // 20 MB - .setExpireAfterWrite(30.minutes) - .build() - ) - .build() - - suspend fun getStory(id: ItemId): HnItem { +class StoryItemRepo(private val store: HnItemStore) { + suspend fun getStory(id: ItemId): ShadedHnItem { val result = store.get(StoryItemKey.Single(id)) result as StoryItemResult.Single return result.item } - fun observeStories(fetchMode: FetchMode): Flow> { - val key = StoryItemKey.All(fetchMode) - return store.stream(StoreReadRequest.cached(key, refresh = true)) - .mapNotNull { it.dataOrNull() as? StoryItemResult.All } - .map { it.items } - } - - - private fun buildFetcher() = - Fetcher.fetcherResult { key -> - when (key) { - is StoryItemKey.Single -> { - StoryItemResult.Single(client.getItem(key.id)) - } - - is StoryItemKey.All -> { - val items = client.getStories(key.fetchMode) - .fetchAsync { client.getItem(it) } - StoryItemResult.All(items, key.fetchMode) - } + suspend fun getStories(storyIds: List): List { + return storyIds.fetchAsync { item -> + when (item) { + is ShadedHnItem.Shallow -> getStory(item.item) + is ShadedHnItem.Full -> item } } - - private fun buildSoT(): SourceOfTruth, NetworkItem> { - return SourceOfTruth.of( - reader = { key -> - when (key) { - is StoryItemKey.Single -> storage.selectByKey(key.toStringId()) - .map { entity -> - entity?.let { StoryItemResult.Single(it.toHnItem()) } - } - - is StoryItemKey.All -> storage.select(HnItemEntity::fetchMode eq key.fetchMode.name) - .map { list -> - StoryItemResult.All(list.map(HnItemEntity::toHnItem), key.fetchMode) - } - } - }, - writer = { key, item -> - when (key) { - is StoryItemKey.Single -> storage.insert(key.toStringId(), item.first()) - is StoryItemKey.All -> storage.insertAll(item.associateBy { it.id.toString() }) - } - }, - delete = { key -> - when (key) { - is StoryItemKey.Single -> storage.deleteByKey(key.toStringId()) - is StoryItemKey.All -> storage.delete(HnItemEntity::fetchMode eq key.fetchMode.name) - } - }, - deleteAll = storage::deleteAll, - ) } - private fun buildConverter(): Converter, NetworkItem> { - val networkToEntity: (network: NetworkItem) -> List = { network -> - when (network) { - is StoryItemResult.Single -> listOfNotNull(network.item?.toEntity(null)) - is StoryItemResult.All -> network.items.map { it.toEntity(network.fetchMode) } + fun observeStories(fetchMode: FetchMode, window: Int = 5): Flow> { + val key = StoryItemKey.All(fetchMode, window) + return store.stream(key) + .map { + (it as StoryItemResult.All).items.also { + logcat { + "***observeStories produces: " + it.debugToString1() + } + } } - } - return Converter.Builder, NetworkItem>() - .fromNetworkToLocal(networkToEntity) - .fromOutputToLocal(networkToEntity) - .build() - } -} - -private fun Fetcher.Companion.fetcherResult(fetch: suspend (K) -> R): Fetcher { - return ofResult { k -> - try { - FetcherResult.Data(fetch(k)) - } catch (e: Exception) { - FetcherResult.Error.Exception(e) - } } } -private inline fun StoryItemKey.Single.toStringId() = id.id.toString() \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/repo/types.kt b/app/src/main/java/dev/jvmname/acquisitive/repo/types.kt index 852f091..6046307 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/repo/types.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/repo/types.kt @@ -1,11 +1,10 @@ package dev.jvmname.acquisitive.repo -import androidx.compose.ui.graphics.vector.ImageVector import dev.drewhamilton.poko.Poko import dev.jvmname.acquisitive.network.model.FetchMode import dev.jvmname.acquisitive.network.model.HnItem import dev.jvmname.acquisitive.network.model.ItemId -import dev.jvmname.acquisitive.ui.types.HnScreenItem +import dev.jvmname.acquisitive.network.model.ShadedHnItem import dev.jvmname.acquisitive.util.ItemIdArray import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -13,9 +12,9 @@ import kotlinx.serialization.Serializable @[Poko Serializable] class HnItemEntity( val id: Int, - val type: String, // "story", "comment", "job", "poll", "pollopt" + val type: String?, // "story", "comment", "job", "poll", "pollopt" val author: String?, - val time: Instant, + val time: Instant?, val dead: Boolean?, val deleted: Boolean?, val kids: ItemIdArray?, @@ -28,79 +27,111 @@ class HnItemEntity( val poll: Int?, val parts: ItemIdArray?, val fetchMode: FetchMode?, + val index: Int, ) -fun HnItemEntity.toHnItem(): HnItem { - return when (type) { - "story" -> HnItem.Story( - id = ItemId(id), - by = author, - time = time, - dead = dead, - deleted = deleted, - kids = kids, - title = title ?: error("Story must have title"), - url = url, - score = score ?: 0, - descendants = descendants, - text = text - ) +fun ShadedHnItem.toEntity(fetchMode: FetchMode? = null, index: Int): HnItemEntity = when (this) { + is ShadedHnItem.Shallow -> item.toEntity(fetchMode, index) + is ShadedHnItem.Full -> item.toEntity(fetchMode, index) +} - "comment" -> HnItem.Comment( - id = ItemId(id), - by = author, - time = time, - dead = dead, - deleted = deleted, - kids = kids, - text = text, - parent = parent ?: error("Comment must have parent") - ) - "job" -> HnItem.Job( - id = ItemId(id), - by = author, - time = time, - dead = dead, - deleted = deleted, - kids = kids, - title = title ?: error("Job must have title"), - text = text, - url = url, - score = score ?: 0, - ) +fun HnItemEntity.toShadedItem(): ShadedHnItem { + if (type == null || time == null) return ShadedHnItem.Shallow(ItemId(id)) + return ShadedHnItem.Full( + when (type) { + "story" -> HnItem.Story( + id = ItemId(id), + by = author, + time = time, + dead = dead, + deleted = deleted, + kids = kids, + title = title ?: error("Story must have title"), + url = url, + score = score ?: 0, + descendants = descendants, + text = text + ) - "poll" -> HnItem.Poll( - id = ItemId(id), - by = author, - time = time, - dead = dead, - deleted = deleted, - kids = kids, - title = title ?: error("Poll must have title"), - text = text, - parts = parts ?: error("Poll must have parts"), - score = score ?: 0, - descendants = descendants - ) + "comment" -> HnItem.Comment( + id = ItemId(id), + by = author, + time = time, + dead = dead, + deleted = deleted, + kids = kids, + text = text, + parent = parent ?: error("Comment must have parent") + ) - "pollopt" -> HnItem.PollOption( - id = ItemId(id), - by = author, - time = time, - dead = dead, - deleted = deleted, - kids = kids, - poll = ItemId(poll ?: error("PollOption must have poll")), - text = text, - score = score ?: 0, - ) + "job" -> HnItem.Job( + id = ItemId(id), + by = author, + time = time, + dead = dead, + deleted = deleted, + kids = kids, + title = title ?: error("Job must have title"), + text = text, + url = url, + score = score ?: 0, + ) - else -> error("Unknown item type: $type") - } + "poll" -> HnItem.Poll( + id = ItemId(id), + by = author, + time = time, + dead = dead, + deleted = deleted, + kids = kids, + title = title ?: error("Poll must have title"), + text = text, + parts = parts ?: error("Poll must have parts"), + score = score ?: 0, + descendants = descendants + ) + + "pollopt" -> HnItem.PollOption( + id = ItemId(id), + by = author, + time = time, + dead = dead, + deleted = deleted, + kids = kids, + poll = ItemId(poll ?: error("PollOption must have poll")), + text = text, + score = score ?: 0, + ) + + else -> error("Unknown item type: $type") + } + ) +} + +fun ItemId.toEntity(fetchMode: FetchMode? = null, index: Int): HnItemEntity { + return HnItemEntity( + id = this.id, + type = null, + author = null, + time = null, + dead = null, + deleted = null, + kids = null, + title = null, + url = null, + text = null, + score = null, + descendants = null, + parent = null, + poll = null, + parts = null, + fetchMode = fetchMode, + index = index + ) } -fun HnItem.toEntity(fetchMode: FetchMode? = null): HnItemEntity { +fun HnItem.toEntity(fetchMode: FetchMode? = null, index:Int = -1): HnItemEntity { val type = when (this) { is HnItem.Story -> "story" is HnItem.Comment -> "comment" @@ -159,6 +190,7 @@ fun HnItem.toEntity(fetchMode: FetchMode? = null): HnItemEntity { is HnItem.Poll -> parts else -> null }, - fetchMode = fetchMode + fetchMode = fetchMode, + index = index ) } \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/common/BackPress.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/common/BackPress.kt new file mode 100644 index 0000000..7c35739 --- /dev/null +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/common/BackPress.kt @@ -0,0 +1,39 @@ +package dev.jvmname.acquisitive.ui.common + +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.foundation.Image +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter + +@Composable +fun BackPressNavIcon( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + iconButtonContent: @Composable () -> Unit = { ClosedIconImage() }, +) { + val backPressOwner = LocalOnBackPressedDispatcherOwner.current + val finalOnClick = remember { + onClick + ?: backPressOwner?.onBackPressedDispatcher?.let { dispatcher -> dispatcher::onBackPressed } + ?: error("No local LocalOnBackPressedDispatcherOwner found.") + } + IconButton(modifier = modifier, onClick = finalOnClick) { iconButtonContent() } +} + + +@Composable +internal fun ClosedIconImage(modifier: Modifier = Modifier) { + Image( + modifier = modifier, + painter = rememberVectorPainter(image = Icons.Filled.Close), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + contentDescription = "Close", + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/common/CommonScaffold.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/common/CommonScaffold.kt new file mode 100644 index 0000000..7c7bb19 --- /dev/null +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/common/CommonScaffold.kt @@ -0,0 +1,88 @@ +package dev.jvmname.acquisitive.ui.common + +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.EaseInOutCubic +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.IntOffset +import com.slack.circuit.foundation.CircuitContent +import com.slack.circuit.retained.rememberRetained +import dev.jvmname.acquisitive.ui.theme.AcquisitiveTheme +import kotlinx.collections.immutable.persistentListOf +/* + +@Composable +fun foo(modifier: Modifier = Modifier) { + var contentComposed by rememberRetained { mutableStateOf(false) } + Scaffold( + modifier = modifier.fillMaxWidth(), + contentWindowInsets = WindowInsets(0, 0, 0, 0), + containerColor = Color.Transparent, + bottomBar = { + AcquisitiveTheme { + Layout( + modifier = Modifier, + measurePolicy = { measurables, constraints -> + val placeable = measurables.first().measure(constraints) + layout( + placeable.width, + placeable.height + ) { placeable.place(IntOffset.Zero) } + }, + content = { + BottomNavigationBar( + selectedIndex = state.selectedIndex, + onSelectedIndex = { index -> state.eventSink(ClickNavItem(index)) }, + ) + }, + ) + } + }, + ) { paddingValues -> + contentComposed = true + val screen = state.navItems[state.selectedIndex].screen + CircuitContent( + screen, + modifier = Modifier.padding(paddingValues), + onNavEvent = { event -> state.eventSink(ChildNav(event)) }, + ) + } +} + +@Composable +private fun BottomNavigationBar( + selectedIndex: Int, + onSelectedIndex: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.primaryContainer, + modifier = modifier + ) { + NAV_ITEMS.forEachIndexed { index, item -> + NavigationBarItem( + icon = { Icon(imageVector = item.icon, contentDescription = item.title) }, + label = { Text(text = item.title) }, + alwaysShowLabel = true, + selected = selectedIndex == index, + onClick = { onSelectedIndex(index) }, + ) + } + } +}*/ diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/commentlist/presenter.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/commentlist/presenter.kt new file mode 100644 index 0000000..42d8202 --- /dev/null +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/commentlist/presenter.kt @@ -0,0 +1,29 @@ +package dev.jvmname.acquisitive.ui.screen.commentlist + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.screen.Screen +import dev.jvmname.acquisitive.network.model.HnItem +import dev.jvmname.acquisitive.network.model.ItemId +import kotlinx.parcelize.Parcelize +import software.amazon.lastmile.kotlin.inject.anvil.AppScope + +@Parcelize +data class CommentListScreen(val parentItemId: ItemId) : Screen { + data class CommentListState( + val parentItemId: ItemId, + val eventSink: (CommentListEvent) -> Unit, + ) : CircuitUiState +} + +sealed class CommentListEvent : CircuitUiEvent { + +} + +@[Composable CircuitInject(CommentListScreen::class, AppScope::class)] +fun CommentListContent(state: CommentListScreen.CommentListState, modifier: Modifier){ + TODO() +} \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/commentlist/screen.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/commentlist/screen.kt new file mode 100644 index 0000000..680ec8d --- /dev/null +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/commentlist/screen.kt @@ -0,0 +1,22 @@ +package dev.jvmname.acquisitive.ui.screen.commentlist + +import androidx.compose.runtime.Composable +import com.slack.circuit.codegen.annotations.CircuitInject +import com.slack.circuit.runtime.Navigator +import com.slack.circuit.runtime.presenter.Presenter +import dev.jvmname.acquisitive.repo.StoryItemRepo +import me.tatarka.inject.annotations.Assisted +import me.tatarka.inject.annotations.Inject +import software.amazon.lastmile.kotlin.inject.anvil.AppScope + +@[Inject CircuitInject(CommentListScreen::class, AppScope::class)] +class CommentListPresenter( + private val repo: StoryItemRepo, + @Assisted private val screen: CommentListScreen, + @Assisted private val navigator: Navigator, +) : Presenter { + @Composable + override fun present(): CommentListScreen.CommentListState { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/presenter.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/presenter.kt index 7ad6cfa..71c1695 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/presenter.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/presenter.kt @@ -1,30 +1,35 @@ package dev.jvmname.acquisitive.ui.screen.mainlist +import android.annotation.SuppressLint import android.net.Uri import androidx.annotation.VisibleForTesting -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Poll -import androidx.compose.material.icons.filled.Work import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.vector.ImageVector import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter +import com.theapache64.rebugger.Rebugger import dev.jvmname.acquisitive.network.model.FetchMode import dev.jvmname.acquisitive.network.model.HnItem import dev.jvmname.acquisitive.network.model.ItemId +import dev.jvmname.acquisitive.network.model.ShadedHnItem import dev.jvmname.acquisitive.network.model.getDisplayedTitle import dev.jvmname.acquisitive.network.model.score import dev.jvmname.acquisitive.repo.StoryItemRepo import dev.jvmname.acquisitive.ui.types.HnScreenItem import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapLatest import kotlinx.datetime.Clock import kotlinx.datetime.DateTimePeriod import kotlinx.datetime.toDateTimePeriod +import logcat.logcat import me.tatarka.inject.annotations.Assisted import me.tatarka.inject.annotations.Inject import software.amazon.lastmile.kotlin.inject.anvil.AppScope @@ -35,37 +40,93 @@ class MainScreenPresenter( @Assisted private val screen: MainListScreen, @Assisted private val navigator: Navigator, ) : Presenter { + @SuppressLint("StateFlowValueCalledInComposition") @Composable override fun present(): MainListScreen.MainListState { + val inflateChannel = remember { MutableStateFlow(-1) } + val now = remember { Clock.System.now() } val fetchMode by remember { mutableStateOf(screen.fetchMode) } - val items by repo.observeStories(fetchMode) - .collectAsState(emptyList(), Dispatchers.IO) - - val screenItems = items.mapIndexed({ i, item -> - val isHot = item.score >= fetchMode.hotThreshold - val icon = when (item) { - is HnItem.Job -> "💼" - is HnItem.Poll -> "🗳️" - else -> null + SideEffect { logcat { "***before repo.observeStories" } } + val storyIds by repo.observeStories(fetchMode) + .collectAsState(emptyList(), context = Dispatchers.IO) + + val updatedRange: Pair>? by inflateChannel + .filter { it >= 0 } + .mapLatest { index -> + logcat { "***consuming new index $index" } + val range = index..index + INFLATE_WINDOW + val sliced = storyIds.slice(range) + if (sliced.all { it is ShadedHnItem.Full }) { + null // no need to update anything + } else { + range to repo.getStories(sliced) + } } + .collectAsState(null, context = Dispatchers.IO) + + val items by remember { + derivedStateOf { + logcat { "***building new items list; currently ${storyIds.size} items" } + logcat { "***storyIds1 = " + storyIds.debugToString1() } - val time = (now - item.time).toDateTimePeriod().toAbbreviatedDuration() - val urlHost = when (item) { - is HnItem.Job -> item.url?.let(::extractUrlHost) - is HnItem.Story -> item.url?.let(::extractUrlHost) - else -> null + if (storyIds.isEmpty()) return@derivedStateOf emptyList() + val updated = updatedRange + ?.let { (range, updatedItems) -> + buildList { + storyIds.slice(0..range.first) + addAll(updatedItems) + addAll(storyIds.slice(range.last..storyIds.lastIndex)) + } + } ?: storyIds + + updated.mapIndexed { i, shaded -> + val item = when (shaded) { + is ShadedHnItem.Shallow -> return@mapIndexed shaded.toScreenItem() + is ShadedHnItem.Full -> shaded.item + } + + shaded.toScreenItem( + rank = i + 1, + isHot = item.score >= fetchMode.hotThreshold, + icon = when { + item.dead == true -> "☠️" + item.deleted == true -> "🗑️" + item is HnItem.Job -> "💼" + item is HnItem.Poll -> "🗳️" + else -> null + }, + time = (now - item.time).toDateTimePeriod().toAbbreviatedDuration(), + urlHost = when (item) { + is HnItem.Job -> item.url?.let(::extractUrlHost) + is HnItem.Story -> item.url?.let(::extractUrlHost) + else -> null + }, + ) + } } + } - item.toScreenItem( - rank = i + 1, - isHot = isHot, - icon = icon, - time = time, - urlHost = urlHost, - ) - }) - return MainListScreen.MainListState(fetchMode, screenItems) { event -> + Rebugger( + trackMap = mapOf( + "inflateChannel" to inflateChannel, + "now" to now, + "fetchMode" to fetchMode, +// "storyIds" to storyIds , + "updatedRange" to updatedRange, + "items" to items, + ), + ) + + SideEffect { + logcat { "***after rebugger before return" } + logcat { "***storyIds2 = " + items.debugToString2() } + } + return MainListScreen.MainListState( + fetchMode = fetchMode, + stories = items, + inflateItemsAfter = inflateChannel + ) { event -> when (event) { MainListEvent.AddComment -> navigator MainListEvent.CommentsClick -> TODO() @@ -75,7 +136,10 @@ class MainScreenPresenter( } } + companion object { + private const val INFLATE_WINDOW = 10 + private const val HOT_THRESHOLD_HIGH = 900 private const val HOT_THRESHOLD_NORMAL = 300 private const val HOT_THRESHOLD_LOW = 30 @@ -109,39 +173,64 @@ class MainScreenPresenter( } } -fun HnItem.toScreenItem( +fun ShadedHnItem.Shallow.toScreenItem(): HnScreenItem = HnScreenItem.Shallow(item) + +fun ShadedHnItem.Full.toScreenItem( rank: Int, isHot: Boolean, time: String, urlHost: String?, icon: String? = null, -): HnScreenItem = when (this) { - is HnItem.Comment -> HnScreenItem.CommentItem( - text = text.orEmpty(), - time = time, - author = by.orEmpty(), - numChildren = kids?.size ?: 0, - parent = ItemId(parent) - ) - - else -> HnScreenItem.StoryItem( - id = id, - title = getDisplayedTitle(), - isHot = isHot, - rank = rank, - score = when (this) { - is HnItem.Story -> score - is HnItem.Job -> score - is HnItem.Poll -> score - is HnItem.PollOption -> score - else -> 0 - }, - urlHost = urlHost, - numChildren = kids?.size ?: 0, - time = time, - author = by.orEmpty(), - isDead = dead ?: false, - isDeleted = deleted ?: false, - titleSuffix = icon - ) -} \ No newline at end of file +): HnScreenItem = when (item) { + is HnItem.Comment -> with(item) { + HnScreenItem.CommentItem( + text = text.orEmpty(), + time = time, + author = by.orEmpty(), + numChildren = kids?.size ?: 0, + parent = ItemId(parent) + ) + } + + else -> with(item) { + HnScreenItem.StoryItem( + id = id, + title = getDisplayedTitle(), + isHot = isHot, + rank = rank, + score = when (this) { + is HnItem.Story -> score + is HnItem.Job -> score + is HnItem.Poll -> score + is HnItem.PollOption -> score + else -> 0 + }, + urlHost = urlHost, + numChildren = kids?.size ?: 0, + time = time, + author = by.orEmpty(), + isDead = dead ?: false, + isDeleted = deleted ?: false, + titleSuffix = icon + ) + } +} + + + fun List.debugToString1(): String = joinToString("") { + when (it) { + is ShadedHnItem.Full -> "F." + is ShadedHnItem.Shallow -> "s." + } +} + +inline fun List.debugToString2(): String = + joinToString("") { + when (it) { + is HnScreenItem.CommentItem -> "c." + is HnScreenItem.Shallow -> "s." + is HnScreenItem.StoryItem -> "S." + else -> "" + } + } + diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/screen.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/screen.kt index 132231d..24878a0 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/screen.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/screen/mainlist/screen.kt @@ -4,11 +4,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Comment import androidx.compose.material.icons.filled.LocalFireDepartment @@ -17,6 +20,7 @@ import androidx.compose.material.icons.outlined.FavoriteBorder import androidx.compose.material.icons.outlined.KeyboardDoubleArrowUp import androidx.compose.material.icons.outlined.LocalFireDepartment import androidx.compose.material3.ButtonColors +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -29,6 +33,9 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -40,6 +47,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout import androidx.constraintlayout.compose.Dimension +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.flowWithLifecycle import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState @@ -49,7 +58,16 @@ import dev.jvmname.acquisitive.network.model.ItemId import dev.jvmname.acquisitive.ui.theme.AcquisitiveTheme import dev.jvmname.acquisitive.ui.theme.primaryDarkMediumContrast import dev.jvmname.acquisitive.ui.types.HnScreenItem +import kotlinx.atomicfu.TraceBase.None.append +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.isActive +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.parcelize.Parcelize +import logcat.logcat import software.amazon.lastmile.kotlin.inject.anvil.AppScope private val MIN_ITEM_HEIGHT = 200.dp @@ -59,6 +77,7 @@ data class MainListScreen(val fetchMode: FetchMode = FetchMode.TOP) : Screen { data class MainListState( val fetchMode: FetchMode, val stories: List, + val inflateItemsAfter: MutableStateFlow, val eventSink: (MainListEvent) -> Unit, ) : CircuitUiState } @@ -75,181 +94,254 @@ fun MainListContent(state: MainListScreen.MainListState, modifier: Modifier = Mo Scaffold( modifier = modifier, topBar = { TopAppBar(title = { Text(state.fetchMode.name) }) }) { innerPadding -> - LazyColumn(modifier = Modifier.padding(innerPadding)) { - items(state.stories) { item -> - MainListItem(modifier, item as HnScreenItem.StoryItem, state.eventSink) + + val scrollState = rememberLazyListState() + + LazyColumn( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize(), + state = scrollState + ) { + logcat { "***list size: ${state.stories.size}; shallow: ${state.stories.count { it is HnScreenItem.Shallow }} other: ${state.stories.count { it !is HnScreenItem.Shallow }}" } + items(state.stories, + key = { + when (it) { + is HnScreenItem.Shallow -> it.id + is HnScreenItem.StoryItem -> it.id + is HnScreenItem.CommentItem -> error("unexpected item type") + } + }) { item -> + MainListItem(modifier, item, state.eventSink) } } + + val lifecycleOwner = LocalLifecycleOwner.current + val lifecycle = remember { lifecycleOwner.lifecycle } + LaunchedEffect(scrollState) { + snapshotFlow { scrollState.firstVisibleItemIndex } + .filter { topIndex -> + logcat { "***saw scroll to $topIndex" } + if (state.stories.lastIndex > topIndex) { + state.stories[topIndex] is HnScreenItem.Shallow + } else false + } + .flowWithLifecycle(lifecycle) + .collect { index -> + logcat { "***updated scroll to $index" } + state.inflateItemsAfter.update { index } + } + } } } @Composable fun MainListItem( modifier: Modifier, - item: HnScreenItem.StoryItem, + item: HnScreenItem, eventSink: (MainListEvent) -> Unit, ) { - OutlinedCard(modifier = modifier) { - ConstraintLayout( - modifier = modifier - .heightIn(max = MIN_ITEM_HEIGHT) - .fillMaxWidth() - ) { - val (rankScoreBox, actionBox) = createRefs() - val (title, urlHost, timeAuthor) = createRefs() + val f = + remember { logcat("MainListItem") { "entered MLI with ${item::class.simpleName}" }; false } + //TODO: fork Swipe and put the mutative actions on the righthand side + when (item) { + is HnScreenItem.Shallow -> CircularProgressIndicator( + modifier = Modifier.width(64.dp), + color = MaterialTheme.colorScheme.secondary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + + is HnScreenItem.StoryItem -> OutlinedCard(modifier = modifier) { + ConstraintLayout( + modifier = modifier + .heightIn(max = MIN_ITEM_HEIGHT) + .fillMaxWidth() + ) { + val (rankScoreBox, actionBox) = createRefs() + val (title, urlHost, timeAuthor) = createRefs() - val startGuide = createGuidelineFromStart(8.dp) - val endGuide = createGuidelineFromEnd(8.dp) + val startGuide = createGuidelineFromStart(8.dp) + val endGuide = createGuidelineFromEnd(8.dp) - Column( - modifier - .constrainAs(rankScoreBox) { - top.linkTo(parent.top) - start.linkTo(startGuide) - bottom.linkTo(parent.bottom) - width = Dimension.ratio("1:1") + Column( + modifier + .constrainAs(rankScoreBox) { + top.linkTo(parent.top) + start.linkTo(startGuide) + bottom.linkTo(parent.bottom) + width = Dimension.ratio("1:1") + } + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(vertical = 2.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + item.rank.toString(), + style = MaterialTheme.typography.labelSmall + ) + Text( + item.score.toString(), + style = MaterialTheme.typography.labelSmall, + color = if (item.isHot) primaryDarkMediumContrast else Color.Unspecified + ) + if (item.isHot) { + Icon( + Icons.Default.LocalFireDepartment, + "hot", + tint = primaryDarkMediumContrast + ) } - .background(MaterialTheme.colorScheme.surfaceVariant) - .padding(top = 2.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - item.rank.toString(), - style = MaterialTheme.typography.labelSmall - ) + + } + Text( - item.score.toString(), - style = MaterialTheme.typography.labelSmall, - color = if (item.isHot) primaryDarkMediumContrast else Color.Unspecified + buildTitleText(item), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .constrainAs(title) { + top.linkTo(parent.top, margin = 8.dp) + start.linkTo(rankScoreBox.end, margin = 8.dp) + end.linkTo(endGuide) + width = Dimension.fillToConstraints + } ) - if (item.isHot) { - Icon(Icons.Default.LocalFireDepartment, "hot", tint = primaryDarkMediumContrast) - } - } + if (item.urlHost != null) { + Text( + item.urlHost, style = MaterialTheme.typography.labelSmall, + modifier = Modifier.constrainAs(urlHost) { + top.linkTo(title.bottom) + start.linkTo(rankScoreBox.end, margin = 8.dp) + end.linkTo(actionBox.start) + width = Dimension.fillToConstraints + }, + textAlign = TextAlign.Start, + overflow = TextOverflow.Ellipsis - Text( - buildTitleText(item), - style = MaterialTheme.typography.bodySmall, - modifier = Modifier - .constrainAs(title) { - top.linkTo(parent.top, margin = 8.dp) + ) + } else { + Spacer(Modifier.constrainAs(urlHost) { + top.linkTo(title.bottom) start.linkTo(rankScoreBox.end, margin = 8.dp) - end.linkTo(endGuide) - width = Dimension.fillToConstraints - } - ) + }) + } - if (item.urlHost != null) { Text( - item.urlHost, style = MaterialTheme.typography.labelSmall, - modifier = Modifier.constrainAs(urlHost) { - top.linkTo(title.bottom) + (if (item.isDead) "[dead]\n" else "") + "${item.time} - ${item.author}", + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.constrainAs(timeAuthor) { + top.linkTo(urlHost.bottom) start.linkTo(rankScoreBox.end, margin = 8.dp) end.linkTo(actionBox.start) width = Dimension.fillToConstraints }, textAlign = TextAlign.Start, overflow = TextOverflow.Ellipsis - ) - } else { - Spacer(Modifier.constrainAs(urlHost) { - top.linkTo(title.bottom) - start.linkTo(rankScoreBox.end, margin = 8.dp) - }) - } - Text( - (if (item.isDead) "[dead]\n" else "") + "${item.time} - ${item.author}", - style = MaterialTheme.typography.labelSmall, - modifier = Modifier.constrainAs(timeAuthor) { - top.linkTo(urlHost.bottom) - start.linkTo(rankScoreBox.end, margin = 8.dp) - end.linkTo(actionBox.start) - width = Dimension.fillToConstraints - }, - textAlign = TextAlign.Start, - overflow = TextOverflow.Ellipsis - ) - - Row(modifier = Modifier.constrainAs(actionBox) { - top.linkTo(title.bottom) - end.linkTo(endGuide) - bottom.linkTo(timeAuthor.bottom) - height = Dimension.wrapContent - width = Dimension.wrapContent - }) { - if (!item.isDeleted && !item.isDead) { - IconButton(onClick = { - eventSink(MainListEvent.FavoriteClick) - }) { - Icon(Icons.Outlined.FavoriteBorder, "Favorite") - } + //TODO: fork Swipe and put the mutative actions on the righthand side + Row(modifier = Modifier.constrainAs(actionBox) { + top.linkTo(title.bottom) + end.linkTo(endGuide) + bottom.linkTo(timeAuthor.bottom) + height = Dimension.wrapContent + width = Dimension.wrapContent + }) { + if (!item.isDeleted && !item.isDead) { + IconButton(onClick = { + eventSink(MainListEvent.FavoriteClick) + }) { + Icon(Icons.Outlined.FavoriteBorder, "Favorite") + } - IconButton(onClick = { - eventSink(MainListEvent.UpvoteClick) + IconButton(onClick = { + eventSink(MainListEvent.UpvoteClick) - }) { - Icon(Icons.Outlined.KeyboardDoubleArrowUp, "Upvote") + }) { + Icon(Icons.Outlined.KeyboardDoubleArrowUp, "Upvote") + } } - } - TextButton( - onClick = { - eventSink(MainListEvent.CommentsClick) + TextButton( + onClick = { + eventSink(MainListEvent.CommentsClick) - }, - colors = with( - IconButtonDefaults.iconButtonColors() - ) { - ButtonColors( - containerColor = containerColor, - contentColor = contentColor, - disabledContainerColor = disabledContainerColor, - disabledContentColor = disabledContentColor - ) - }, - ) { - val icon = if (item.isHot) Icons.Outlined.LocalFireDepartment - else Icons.AutoMirrored.Outlined.Comment - CompositionLocalProvider(LocalContentColor provides primaryDarkMediumContrast) { - Icon(icon, "Comments") - if (item.numChildren > 0) { - Text( - item.numChildren.toString(), - modifier = Modifier.padding(start = 2.dp) + }, + colors = with( + IconButtonDefaults.iconButtonColors() + ) { + ButtonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = disabledContainerColor, + disabledContentColor = disabledContentColor ) + }, + ) { + val icon = if (item.isHot) Icons.Outlined.LocalFireDepartment + else Icons.AutoMirrored.Outlined.Comment + CompositionLocalProvider(LocalContentColor provides primaryDarkMediumContrast) { + Icon(icon, "Comments") + if (item.numChildren > 0) { + Text( + item.numChildren.toString(), + modifier = Modifier.padding(start = 2.dp) + ) + } } } - } - if (!item.isDeleted && !item.isDead) { - IconButton(onClick = { eventSink(MainListEvent.AddComment) }) { - Icon(Icons.Outlined.AddComment, "Comment") + if (!item.isDeleted && !item.isDead) { + IconButton(onClick = { eventSink(MainListEvent.AddComment) }) { + Icon(Icons.Outlined.AddComment, "Comment") + } } } } } + + is HnScreenItem.CommentItem -> TODO("won't happen") } } @Composable private fun buildTitleText(item: HnScreenItem.StoryItem): String { val title = AnnotatedString.fromHtml(item.title) - val icon = when { - item.isDead -> "☠️" - item.isDeleted -> "🗑️" - else -> item.titleSuffix - } + val icon = item.titleSuffix.orEmpty() //normally wouldn't be this fussy but everything here is inside a list-loop - return buildString(title.length + 1 + (icon?.length ?: 0)) { + return buildString(title.length + 1 + icon.length) { append(title) append(" ") append(icon) } } +@[Preview Composable] +fun PreviewMainList() { + val state = remember { + val list = List(15) { + HnScreenItem.StoryItem( + id = ItemId(it), + title = "Archimedes, Vitruvius, and Leonardo: The Odometer Connection (2020)", + isHot = false, + rank = it + 1, + score = 950, + urlHost = "github.com", + numChildren = 121 + it, + time = "19h", + author = "JvmName", + isDead = false, + isDeleted = false, + titleSuffix = "💼", + ) + } + MainListScreen.MainListState(FetchMode.TOP, list, MutableStateFlow(-1)) {} + } + AcquisitiveTheme { + MainListContent(state) + } +} + @[Preview Composable] fun PreviewMainListItem() { AcquisitiveTheme { diff --git a/app/src/main/java/dev/jvmname/acquisitive/ui/types/HnScreenItem.kt b/app/src/main/java/dev/jvmname/acquisitive/ui/types/HnScreenItem.kt index 7b33455..9101d5f 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/ui/types/HnScreenItem.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/ui/types/HnScreenItem.kt @@ -1,12 +1,15 @@ package dev.jvmname.acquisitive.ui.types -import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.runtime.Immutable import dev.drewhamilton.poko.Poko import dev.jvmname.acquisitive.network.model.ItemId +@Immutable sealed interface HnScreenItem { + @[Poko Immutable] + class Shallow(val id: ItemId) : HnScreenItem - @Poko + @[Poko Immutable] class StoryItem( val id: ItemId, val title: String, @@ -22,7 +25,7 @@ sealed interface HnScreenItem { val titleSuffix: String?, ) : HnScreenItem - @Poko + @[Poko Immutable] class CommentItem( val text: String, val time: String, diff --git a/app/src/main/java/dev/jvmname/acquisitive/util/ItemIdArray.kt b/app/src/main/java/dev/jvmname/acquisitive/util/ItemIdArray.kt index 7555b66..3f46863 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/util/ItemIdArray.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/util/ItemIdArray.kt @@ -2,57 +2,51 @@ package dev.jvmname.acquisitive.util +import com.squareup.moshi.JsonClass import dev.jvmname.acquisitive.network.model.ItemId import kotlinx.serialization.Serializable -@[JvmInline Serializable(with = ItemIdArraySerializer::class)] +@[JvmInline Serializable(with = ItemIdArraySerializer::class) JsonClass(generateAdapter = false)] value class ItemIdArray @PublishedApi internal constructor(@PublishedApi internal val storage: IntArray) : Collection { - /** Creates a new array of the specified [size], with all elements initialized to zero. */ constructor(size: Int) : this(IntArray(size)) - - /** - * Returns the array element at the given [index]. This method can be called using the index operator. - * - * If the [index] is out of bounds of this array, throws an [IndexOutOfBoundsException] except in Kotlin/JS - * where the behavior is unspecified. - */ operator fun get(index: Int): ItemId = ItemId(storage[index]) - - /** - * Sets the element at the given [index] to the given [value]. This method can be called using the index operator. - * - * If the [index] is out of bounds of this array, throws an [IndexOutOfBoundsException] except in Kotlin/JS - * where the behavior is unspecified. - */ operator fun set(index: Int, value: ItemId) { storage[index] = value.id } - /** Returns the number of elements in the array. */ override val size: Int get() = storage.size - /** Creates an iterator over the elements of the array. */ - override operator fun iterator(): kotlin.collections.Iterator = Iterator(storage) + override operator fun iterator(): Iterator = IIAIterator(storage) - private class Iterator(private val array: IntArray) : kotlin.collections.Iterator { + private class IIAIterator(private val array: IntArray) : Iterator { private var index = 0 override fun hasNext() = index < array.size override fun next() = if (index < array.size) ItemId(array[index++]) else throw NoSuchElementException(index.toString()) } - override fun contains(element: ItemId): Boolean { - return storage.contains(element.id) - } + override fun contains(element: ItemId) = storage.contains(element.id) override fun containsAll(elements: Collection): Boolean { return (elements as Collection<*>).all { it is ItemId && storage.contains(it.id) } } override fun isEmpty(): Boolean = this.storage.isEmpty() + + fun take(n: Int): ItemIdArray { + require(n >= 0) { "Requested element count $n is less than zero." } + if (n == 0) return emptyItemIdArray() + if (n == 1) return itemIdArrayOf(first().id) + + var count = 0 + val storage = storage + return ItemIdArray(n) { i -> + ItemId(storage[i]) + } + } } /** @@ -66,7 +60,7 @@ inline fun ItemIdArray(size: Int, init: (Int) -> ItemId): ItemIdArray { return ItemIdArray(IntArray(size) { index -> init(index).id }) } -inline fun ItemIdArrayOf(vararg elements: Int): ItemIdArray = +inline fun itemIdArrayOf(vararg elements: Int): ItemIdArray = ItemIdArray(elements.size) { ItemId(elements[it]) } inline fun emptyItemIdArray() = ItemIdArray(size = 0) diff --git a/app/src/main/java/dev/jvmname/acquisitive/util/async.kt b/app/src/main/java/dev/jvmname/acquisitive/util/async.kt index 144502d..0864b51 100644 --- a/app/src/main/java/dev/jvmname/acquisitive/util/async.kt +++ b/app/src/main/java/dev/jvmname/acquisitive/util/async.kt @@ -1,10 +1,18 @@ package dev.jvmname.acquisitive.util +import android.util.Log import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import logcat.asLog +import logcat.logcat +import java.io.IOException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds //https://androidstudygroup.slack.com/archives/C03MHQ3NU/p1666367020309989 @@ -13,8 +21,29 @@ suspend fun Collection.fetchAsync( transform: suspend (T) -> R, ): List = coroutineScope { map { - async(dispatcher) { + async(dispatcher + CoroutineName("fetchAsync")) { transform(it) } }.awaitAll() } + +// https://stackoverflow.com/a/46890009 +suspend fun retry( + times: Int = Int.MAX_VALUE, + initialDelay: Duration = 0.1.seconds, + maxDelay: Duration = 1.seconds, + factor: Double = 2.0, + block: suspend () -> T, +): T { + var currentDelay = initialDelay + repeat(times - 1) { + try { + return block() + } catch (e: IOException) { + logcat(tag = "retry") { "Error while retrying: " + e.asLog() } + } + delay(currentDelay) + currentDelay = (currentDelay * factor).coerceAtMost(maxDelay) + } + return block() // last attempt +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 052d1c2..d9ac1d3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -66,12 +66,14 @@ sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", square-retrofit-bom = { module = "com.squareup.retrofit2:retrofit-bom", version = "2.11.0" } square-retrofit = { module = "com.squareup.retrofit2:retrofit" } +square-okhttpLogging = "com.squareup.okhttp3:logging-interceptor:4.12.0" square-retrofit-moshi = { module = "com.squareup.retrofit2:converter-moshi" } square-moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" } square-moshiKotlin = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "moshi" } square-moshiAdapters = { module = "dev.zacsweers.moshix:moshi-adapters", version.ref = "moshiX" } square-moshiSealed = { module = "dev.zacsweers.moshix:moshi-sealed-runtime", version.ref = "moshiX" } square-moshiSealedCodegen = { module = "dev.zacsweers.moshix:moshi-sealed-codegen", version.ref = "moshiX" } +square-logcat = "com.squareup.logcat:logcat:0.1" sqkon = "com.mercury.sqkon:library-android:1.0.0-alpha01" mnf-store = { module = "org.mobilenativefoundation.store:store5", version = "5.1.0-alpha05" }