diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destination.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destination.kt index c151eab1..8c7fb27a 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destination.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/Destination.kt @@ -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, diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt index b7828587..cbc6d324 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/screens/LobstersPostsScreen.kt @@ -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 { TagList(contentPadding = contentPadding) } }, ) } diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 351998f0..6f92f565 100644 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -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) diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/comments/CommentsViewModel.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/comments/CommentsViewModel.kt index 839369ac..76961591 100644 --- a/common/src/main/kotlin/dev/msfjarvis/claw/common/comments/CommentsViewModel.kt +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/comments/CommentsViewModel.kt @@ -43,6 +43,7 @@ constructor( @ForScope(ApplicationScope::class) context: Context, ) : AndroidViewModel(context as Application) { var postDetails by mutableStateOf(NetworkState.Loading) + private set suspend fun loadPostDetails(postId: String) { if (postDetails is NetworkState.Error) { diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepository.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepository.kt new file mode 100644 index 00000000..b22fdb17 --- /dev/null +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterRepository.kt @@ -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) { + private val tagsKey = stringSetPreferencesKey("tags") + + fun getSavedTags(): Flow> { + return preferences.data.map { prefs -> prefs[tagsKey] ?: emptySet() } + } + + suspend fun saveTags(tags: Set) { + preferences.edit { prefs -> prefs[tagsKey] = tags } + } +} diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterViewModel.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterViewModel.kt new file mode 100644 index 00000000..80b26738 --- /dev/null +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagFilterViewModel.kt @@ -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>(persistentListOf()) + private set + + var allTags by mutableStateOf(NetworkState.Loading) + private set + + init { + viewModelScope.launch { + tagFilterRepository.getSavedTags().collectLatest { filteredTags = it.toImmutableList() } + } + viewModelScope.launch { + allTags = + runSuspendCatching> { + 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) { + viewModelScope.launch { tagFilterRepository.saveTags(tags) } + } +} diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagList.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagList.kt new file mode 100644 index 00000000..9353b24f --- /dev/null +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/tags/TagList.kt @@ -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>).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", + ) + } + }, + ) + } + } + } + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 45bf11de..24f6b45a 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -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) diff --git a/core/src/main/kotlin/dev/msfjarvis/claw/core/persistence/PreferencesStoreModule.kt b/core/src/main/kotlin/dev/msfjarvis/claw/core/persistence/PreferencesStoreModule.kt new file mode 100644 index 00000000..b2087283 --- /dev/null +++ b/core/src/main/kotlin/dev/msfjarvis/claw/core/persistence/PreferencesStoreModule.kt @@ -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 = + object : DataMigration { + override suspend fun shouldMigrate(currentData: Preferences): Boolean = false + + override suspend fun migrate(currentData: Preferences): Preferences = currentData + + override suspend fun cleanUp() {} + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f5d74e5c..49a3288c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }