common: add TagList

This commit is contained in:
Harsh Shandilya 2025-06-06 00:10:03 +05:30
parent a0f044786f
commit a746734ad8
10 changed files with 248 additions and 0 deletions

View file

@ -36,6 +36,8 @@ import kotlinx.serialization.Serializable
@Parcelize @Serializable data object AboutLibraries : NavKey, Parcelable
@Parcelize @Serializable data object TagFiltering : NavKey, Parcelable
enum class AppDestinations(
val icon: ImageVector,
val label: String,

View file

@ -64,9 +64,11 @@ import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.Saved
import dev.msfjarvis.claw.android.ui.navigation.Search
import dev.msfjarvis.claw.android.ui.navigation.Settings
import dev.msfjarvis.claw.android.ui.navigation.TagFiltering
import dev.msfjarvis.claw.android.ui.navigation.User
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.tags.TagList
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
import dev.msfjarvis.claw.common.user.UserProfile
import kotlinx.collections.immutable.persistentListOf
@ -266,6 +268,7 @@ fun LobstersPostsScreen(
modifier = Modifier.fillMaxSize(),
)
}
entry<TagFiltering> { TagList(contentPadding = contentPadding) }
},
)
}

View file

@ -54,6 +54,7 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.runtime.saveable)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.core)
implementation(libs.coil3.compose)

View file

@ -43,6 +43,7 @@ constructor(
@ForScope(ApplicationScope::class) context: Context,
) : AndroidViewModel(context as Application) {
var postDetails by mutableStateOf<NetworkState>(NetworkState.Loading)
private set
suspend fun loadPostDetails(postId: String) {
if (postDetails is NetworkState.Error) {

View file

@ -0,0 +1,27 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.tags
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class TagFilterRepository @Inject constructor(private val preferences: DataStore<Preferences>) {
private val tagsKey = stringSetPreferencesKey("tags")
fun getSavedTags(): Flow<Set<String>> {
return preferences.data.map { prefs -> prefs[tagsKey] ?: emptySet() }
}
suspend fun saveTags(tags: Set<String>) {
preferences.edit { prefs -> prefs[tagsKey] = tags }
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.tags
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.deliveryhero.whetstone.viewmodel.ContributesViewModel
import com.github.michaelbull.result.coroutines.runSuspendCatching
import com.github.michaelbull.result.fold
import com.slack.eithernet.ApiResult.Failure
import com.slack.eithernet.ApiResult.Success
import dev.msfjarvis.claw.api.LobstersApi
import dev.msfjarvis.claw.api.toError
import dev.msfjarvis.claw.common.NetworkState
import dev.msfjarvis.claw.core.coroutines.IODispatcher
import dev.msfjarvis.claw.model.Tag
import java.io.IOException
import javax.inject.Inject
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ContributesViewModel
class TagFilterViewModel
@Inject
constructor(
private val api: LobstersApi,
private val tagFilterRepository: TagFilterRepository,
@IODispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
var filteredTags by mutableStateOf<ImmutableList<String>>(persistentListOf())
private set
var allTags by mutableStateOf<NetworkState>(NetworkState.Loading)
private set
init {
viewModelScope.launch {
tagFilterRepository.getSavedTags().collectLatest { filteredTags = it.toImmutableList() }
}
viewModelScope.launch {
allTags =
runSuspendCatching<ImmutableList<Tag>> {
withContext(ioDispatcher) {
when (val result = api.getTags()) {
is Success -> result.value.toImmutableList()
is Failure.NetworkFailure -> throw result.error
is Failure.UnknownFailure -> throw result.error
is Failure.HttpFailure -> throw result.toError()
is Failure.ApiFailure -> throw IOException("API returned an invalid response")
}
}
}
.fold(
success = { details -> NetworkState.Success(details) },
failure = { NetworkState.Error(error = it, description = "Failed to load comments") },
)
}
}
fun saveTags(tags: Set<String>) {
viewModelScope.launch { tagFilterRepository.saveTags(tags) }
}
}

View file

@ -0,0 +1,76 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.common.tags
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.deliveryhero.whetstone.compose.injectedViewModel
import dev.msfjarvis.claw.common.NetworkState
import dev.msfjarvis.claw.model.Tag
import kotlinx.collections.immutable.ImmutableList
@Composable
fun TagList(
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
viewModel: TagFilterViewModel = injectedViewModel(key = "tag_filter"),
) {
val allTagsState = viewModel.allTags
val filteredTags = viewModel.filteredTags
LazyColumn(modifier = modifier.fillMaxWidth().padding(contentPadding)) {
when (allTagsState) {
is NetworkState.Loading -> {
item { Text("Loading tags...") }
}
is NetworkState.Error -> {
item { Text("Failed to load tags") }
}
is NetworkState.Success<*> -> {
@Suppress("UNCHECKED_CAST")
val allTags = (allTagsState as NetworkState.Success<ImmutableList<Tag>>).data
items(allTags) { tag ->
val isSelected = filteredTags.contains(tag.tag)
ListItem(
headlineContent = { Text(tag.tag) },
supportingContent = { Text(tag.description) },
trailingContent = {
Button(
onClick = {
val updatedTags =
if (isSelected) {
filteredTags - tag.tag
} else {
filteredTags + tag.tag
}
viewModel.saveTags(updatedTags.toSet())
}
) {
Icon(
imageVector = if (isSelected) Icons.Default.Remove else Icons.Default.Add,
contentDescription = if (isSelected) "Remove" else "Add",
)
}
},
)
}
}
}
}
}

View file

@ -25,6 +25,7 @@ dependencies {
api(libs.napier)
api(libs.okhttp.core)
api(libs.retrofit)
api(libs.androidx.datastore)
implementation(platform(libs.okhttp.bom))
implementation(libs.kotlinx.serialization.core)

View file

@ -0,0 +1,58 @@
/*
* Copyright © Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.core.persistence
import android.content.Context
import androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferencesFileSerializer
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import com.deliveryhero.whetstone.app.ApplicationScope
import com.squareup.anvil.annotations.ContributesTo
import com.squareup.anvil.annotations.optional.ForScope
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoSet
import io.github.aakira.napier.Napier
@Module
@ContributesTo(ApplicationScope::class)
object PreferencesStoreModule {
@Provides
fun providePreferencesDataStore(
@ForScope(ApplicationScope::class) context: Context,
migrations: Set<@JvmSuppressWildcards DataMigration<@JvmSuppressWildcards Preferences>>,
): DataStore<@JvmSuppressWildcards Preferences> {
return DataStoreFactory.create(
corruptionHandler =
ReplaceFileCorruptionHandler {
Napier.d(throwable = it) {
"Preferences data store corruption detected, returning empty preferences."
}
emptyPreferences()
},
migrations = migrations.toList(),
produceFile = { context.preferencesDataStoreFile("claw_preferences") },
serializer = PreferencesFileSerializer,
)
}
@Provides
@IntoSet
fun firstMigration(): DataMigration<Preferences> =
object : DataMigration<Preferences> {
override suspend fun shouldMigrate(currentData: Preferences): Boolean = false
override suspend fun migrate(currentData: Preferences): Preferences = currentData
override suspend fun cleanUp() {}
}
}

View file

@ -7,6 +7,7 @@ benchmark = "1.4.0-beta02"
coil3 = "3.2.0"
coroutines = "1.10.2"
dagger = "2.56.2"
datastore = "1.2.0-alpha02"
eithernet = "2.0.0"
glance = "1.0.0"
haze = "1.6.3"
@ -42,6 +43,7 @@ androidx-compose-material-icons-extended = { module = "androidx.compose.material
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-window-size = { module = "androidx.compose.material3:material3-window-size-class" }
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
androidx-compose-runtime-saveable = { module = "androidx.compose.runtime:runtime-saveable" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" }
androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
@ -50,6 +52,7 @@ androidx-compose-ui-unit = { module = "androidx.compose.ui:ui-unit" }
androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util" }
androidx-core = "androidx.core:core:1.16.0"
androidx-core-splashscreen = "androidx.core:core-splashscreen:1.2.0-beta02"
androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lint-gradle = "androidx.lint:lint-gradle:1.0.0-alpha05"
androidx-material3-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "navigation3-material" }