diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt index e4d5b103b..2698790ca 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/FeedParser.kt @@ -81,6 +81,7 @@ class FeedParser(private val dispatchersProvider: DispatchersProvider) { internal const val TAG_TITLE = "title" internal const val TAG_LINK = "link" + internal const val TAG_URL = "url" internal const val TAG_DESCRIPTION = "description" internal const val TAG_ENCLOSURE = "enclosure" internal const val TAG_CONTENT_ENCODED = "content:encoded" diff --git a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RssContentParser.kt b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RssContentParser.kt index 35a8ea88e..81b6cf91d 100644 --- a/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RssContentParser.kt +++ b/core/network/src/commonMain/kotlin/dev/sasikanth/rss/reader/core/network/parser/RssContentParser.kt @@ -31,6 +31,7 @@ import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_PUB import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_CHANNEL import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_RSS_ITEM import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_TITLE +import dev.sasikanth.rss.reader.core.network.parser.FeedParser.Companion.TAG_URL import dev.sasikanth.rss.reader.util.dateStringToEpochMillis import io.ktor.http.Url import kotlinx.datetime.Clock @@ -114,7 +115,7 @@ internal object RssContentParser : ContentParser() { name == TAG_TITLE -> { title = readTagText(name, parser) } - name == TAG_LINK -> { + link.isNullOrBlank() && (name == TAG_LINK || name == TAG_URL) -> { link = readTagText(name, parser) } name == TAG_ENCLOSURE && link.isNullOrBlank() -> { diff --git a/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/NewTag.kt b/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/NewTag.kt new file mode 100644 index 000000000..744fb1aef --- /dev/null +++ b/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/NewTag.kt @@ -0,0 +1,106 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.resources.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.EvenOdd +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val TwineIcons.NewTag: ImageVector + get() { + if (newTag != null) { + return newTag!! + } + newTag = + Builder( + name = "NewTag", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f + ) + .apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = EvenOdd + ) { + moveTo(5.0f, 6.0f) + curveTo(5.0f, 5.4477f, 5.4477f, 5.0f, 6.0f, 5.0f) + horizontalLineTo(11.3551f) + curveTo(11.7529f, 5.0f, 12.1344f, 5.158f, 12.4157f, 5.4393f) + lineTo(19.7087f, 12.7323f) + curveTo(20.0992f, 13.1228f, 20.0992f, 13.756f, 19.7087f, 14.1465f) + lineTo(18.9734f, 14.8818f) + curveTo(18.5829f, 15.2723f, 18.5829f, 15.9055f, 18.9734f, 16.296f) + curveTo(19.364f, 16.6865f, 19.9971f, 16.6865f, 20.3876f, 16.296f) + lineTo(21.1229f, 15.5607f) + curveTo(22.2945f, 14.3892f, 22.2945f, 12.4897f, 21.1229f, 11.3181f) + lineTo(13.8299f, 4.0251f) + curveTo(13.1736f, 3.3688f, 12.2833f, 3.0f, 11.3551f, 3.0f) + horizontalLineTo(6.0f) + curveTo(4.3431f, 3.0f, 3.0f, 4.3432f, 3.0f, 6.0f) + verticalLineTo(11.3551f) + curveTo(3.0f, 12.2833f, 3.3688f, 13.1736f, 4.0251f, 13.8299f) + lineTo(5.8905f, 15.6953f) + curveTo(6.281f, 16.0858f, 6.9142f, 16.0858f, 7.3047f, 15.6953f) + curveTo(7.6952f, 15.3048f, 7.6952f, 14.6716f, 7.3047f, 14.2811f) + lineTo(5.4393f, 12.4157f) + curveTo(5.158f, 12.1344f, 5.0f, 11.7529f, 5.0f, 11.3551f) + verticalLineTo(6.0f) + close() + moveTo(9.3f, 8.0f) + curveTo(9.3f, 8.718f, 8.718f, 9.3f, 8.0f, 9.3f) + curveTo(7.282f, 9.3f, 6.7f, 8.718f, 6.7f, 8.0f) + curveTo(6.7f, 7.282f, 7.282f, 6.7f, 8.0f, 6.7f) + curveTo(8.718f, 6.7f, 9.3f, 7.282f, 9.3f, 8.0f) + close() + moveTo(13.0f, 14.0f) + curveTo(13.5523f, 14.0f, 14.0f, 14.4477f, 14.0f, 15.0f) + verticalLineTo(18.0f) + horizontalLineTo(17.0f) + curveTo(17.5523f, 18.0f, 18.0f, 18.4477f, 18.0f, 19.0f) + curveTo(18.0f, 19.5523f, 17.5523f, 20.0f, 17.0f, 20.0f) + horizontalLineTo(14.0f) + verticalLineTo(23.0f) + curveTo(14.0f, 23.5523f, 13.5523f, 24.0f, 13.0f, 24.0f) + curveTo(12.4477f, 24.0f, 12.0f, 23.5523f, 12.0f, 23.0f) + verticalLineTo(20.0f) + horizontalLineTo(9.0f) + curveTo(8.4477f, 20.0f, 8.0f, 19.5523f, 8.0f, 19.0f) + curveTo(8.0f, 18.4477f, 8.4477f, 18.0f, 9.0f, 18.0f) + horizontalLineTo(12.0f) + verticalLineTo(15.0f) + curveTo(12.0f, 14.4477f, 12.4477f, 14.0f, 13.0f, 14.0f) + close() + } + } + .build() + return newTag!! + } + +private var newTag: ImageVector? = null diff --git a/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/Tag.kt b/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/Tag.kt new file mode 100644 index 000000000..b7bdcf913 --- /dev/null +++ b/resources/icons/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/icons/Tag.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.resources.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.EvenOdd +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +val TwineIcons.Tag: ImageVector + get() { + if (tag != null) { + return tag!! + } + tag = + Builder( + name = "Tag", + defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, + viewportWidth = 24.0f, + viewportHeight = 24.0f + ) + .apply { + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = EvenOdd + ) { + moveTo(6.0f, 5.0f) + curveTo(5.4477f, 5.0f, 5.0f, 5.4477f, 5.0f, 6.0f) + verticalLineTo(11.3551f) + curveTo(5.0f, 11.7529f, 5.158f, 12.1344f, 5.4393f, 12.4157f) + lineTo(12.7323f, 19.7087f) + curveTo(13.1228f, 20.0992f, 13.756f, 20.0992f, 14.1465f, 19.7087f) + lineTo(19.7087f, 14.1465f) + curveTo(20.0992f, 13.756f, 20.0992f, 13.1228f, 19.7087f, 12.7323f) + lineTo(12.4157f, 5.4393f) + curveTo(12.1344f, 5.158f, 11.7529f, 5.0f, 11.3551f, 5.0f) + horizontalLineTo(6.0f) + close() + moveTo(3.0f, 6.0f) + curveTo(3.0f, 4.3432f, 4.3431f, 3.0f, 6.0f, 3.0f) + horizontalLineTo(11.3551f) + curveTo(12.2833f, 3.0f, 13.1736f, 3.3688f, 13.8299f, 4.0251f) + lineTo(21.1229f, 11.3181f) + curveTo(22.2945f, 12.4897f, 22.2945f, 14.3892f, 21.1229f, 15.5607f) + lineTo(15.5607f, 21.1229f) + curveTo(14.3892f, 22.2945f, 12.4897f, 22.2945f, 11.3181f, 21.1229f) + lineTo(4.0251f, 13.8299f) + curveTo(3.3688f, 13.1736f, 3.0f, 12.2833f, 3.0f, 11.3551f) + verticalLineTo(6.0f) + close() + } + path( + fill = SolidColor(Color(0xFF000000)), + stroke = null, + strokeLineWidth = 0.0f, + strokeLineCap = Butt, + strokeLineJoin = Miter, + strokeLineMiter = 4.0f, + pathFillType = NonZero + ) { + moveTo(9.3f, 7.9999f) + curveTo(9.3f, 8.7179f, 8.718f, 9.2999f, 8.0f, 9.2999f) + curveTo(7.282f, 9.2999f, 6.7f, 8.7179f, 6.7f, 7.9999f) + curveTo(6.7f, 7.282f, 7.282f, 6.7f, 8.0f, 6.7f) + curveTo(8.718f, 6.7f, 9.3f, 7.282f, 9.3f, 7.9999f) + close() + } + } + .build() + return tag!! + } + +private var tag: ImageVector? = null diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt index 89703a5f9..b8486360d 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/EnTwineStrings.kt @@ -121,5 +121,13 @@ val EnTwineStrings = delete = "Delete", removeFeedDesc = { "Do you want to remove \"${it}\"?" }, alwaysFetchSourceArticle = "Always fetch source article in Reading View", - getFeedInfo = "Get Info" + getFeedInfo = "Get Info", + newTag = "New tag", + tags = "Tags", + addTagTitle = "Add tag", + tagNameHint = "Name", + tagSaveButton = "Save", + deleteTagTitle = "Delete tag?", + deleteTagDesc = + "Tag will be deleted and removed from all the assigned feeds. Your feeds won't be deleted" ) diff --git a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt index 0325c9331..ea9305adc 100644 --- a/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt +++ b/resources/strings/src/commonMain/kotlin/dev/sasikanth/rss/reader/resources/strings/TwineStrings.kt @@ -109,7 +109,14 @@ data class TwineStrings( val delete: String, val removeFeedDesc: (String) -> String, val alwaysFetchSourceArticle: String, - val getFeedInfo: String + val getFeedInfo: String, + val newTag: String, + val tags: String, + val addTagTitle: String, + val tagNameHint: String, + val tagSaveButton: String, + val deleteTagTitle: String, + val deleteTagDesc: String, ) object Locales { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt index a31c3508e..21d027324 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/App.kt @@ -45,6 +45,7 @@ import dev.sasikanth.rss.reader.search.ui.SearchScreen import dev.sasikanth.rss.reader.settings.ui.SettingsScreen import dev.sasikanth.rss.reader.share.LocalShareHandler import dev.sasikanth.rss.reader.share.ShareHandler +import dev.sasikanth.rss.reader.tags.ui.TagsScreen import dev.sasikanth.rss.reader.utils.LocalWindowSizeClass import me.tatarka.inject.annotations.Inject @@ -104,6 +105,9 @@ fun App( is Screen.Reader -> { ReaderScreen(presenter = screen.presenter, modifier = fillMaxSizeModifier) } + is Screen.Tags -> { + TagsScreen(tagsPresenter = screen.presenter, modifier = fillMaxSizeModifier) + } } } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt index c3420ca77..8ef554959 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/AppPresenter.kt @@ -43,6 +43,7 @@ import dev.sasikanth.rss.reader.repository.RssRepository import dev.sasikanth.rss.reader.repository.SettingsRepository import dev.sasikanth.rss.reader.search.SearchPresentFactory import dev.sasikanth.rss.reader.settings.SettingsPresenterFactory +import dev.sasikanth.rss.reader.tags.TagsPresenterFactory import dev.sasikanth.rss.reader.util.DispatchersProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -65,6 +66,7 @@ class AppPresenter( private val aboutPresenter: AboutPresenterFactory, private val readerPresenter: ReaderPresenterFactory, private val feedPresenter: FeedPresenterFactory, + private val tagsPresenter: TagsPresenterFactory, private val lastUpdatedAt: LastUpdatedAt, private val rssRepository: RssRepository, private val settingsRepository: SettingsRepository, @@ -163,6 +165,16 @@ class AppPresenter( presenter = readerPresenter(config.postLink, componentContext) { navigation.pop() } ) } + is Config.Tags -> { + Screen.Tags( + presenter = + tagsPresenter( + componentContext, + ) { + navigation.pop() + } + ) + } } private fun openPost(postLink: String) { @@ -216,6 +228,8 @@ class AppPresenter( @Serializable data object About : Config @Serializable data class Reader(val postLink: String) : Config + + @Serializable data object Tags : Config } @Serializable diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/Screens.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/Screens.kt index 33e6fcd4e..ed675b4e0 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/Screens.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/app/Screens.kt @@ -21,6 +21,7 @@ import dev.sasikanth.rss.reader.home.HomePresenter import dev.sasikanth.rss.reader.reader.ReaderPresenter import dev.sasikanth.rss.reader.search.SearchPresenter import dev.sasikanth.rss.reader.settings.SettingsPresenter +import dev.sasikanth.rss.reader.tags.TagsPresenter internal sealed interface Screen { class Home(val presenter: HomePresenter) : Screen @@ -34,4 +35,6 @@ internal sealed interface Screen { class About(val presenter: AboutPresenter) : Screen class Reader(val presenter: ReaderPresenter) : Screen + + class Tags(val presenter: TagsPresenter) : Screen } diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/TagRepository.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/TagRepository.kt index 4a869d947..391fd21eb 100644 --- a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/TagRepository.kt +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/repository/TagRepository.kt @@ -48,6 +48,7 @@ class TagRepository( } } + // TODO: Remove assigned tags from feeds as well suspend fun deleteTag(id: Uuid) = withContext(dispatchersProvider.io) { tagQueries.deleteTag(id) } suspend fun updatedTag(label: String, id: Uuid) { diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsEvent.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsEvent.kt new file mode 100644 index 000000000..bb1875e00 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsEvent.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.tags + +import com.benasher44.uuid.Uuid + +sealed interface TagsEvent { + + data object Init : TagsEvent + + data class CreateTag(val label: String) : TagsEvent + + data object BackClicked : TagsEvent + + data class OnTagNameChanged(val tagId: Uuid, val label: String) : TagsEvent + + data class OnDeleteTag(val tagId: Uuid) : TagsEvent +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsPresenter.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsPresenter.kt new file mode 100644 index 000000000..1f4975304 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsPresenter.kt @@ -0,0 +1,130 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.tags + +import app.cash.paging.cachedIn +import app.cash.paging.createPager +import app.cash.paging.createPagingConfig +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.essenty.instancekeeper.InstanceKeeper +import com.arkivanov.essenty.instancekeeper.getOrCreate +import com.benasher44.uuid.Uuid +import dev.sasikanth.rss.reader.repository.TagRepository +import dev.sasikanth.rss.reader.util.DispatchersProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import me.tatarka.inject.annotations.Assisted +import me.tatarka.inject.annotations.Inject + +internal typealias TagsPresenterFactory = + ( + ComponentContext, + goBack: () -> Unit, + ) -> TagsPresenter + +@Inject +class TagsPresenter( + dispatchersProvider: DispatchersProvider, + private val tagRepository: TagRepository, + @Assisted componentContext: ComponentContext, + @Assisted private val goBack: () -> Unit, +) : ComponentContext by componentContext { + + private val presenterInstance = + instanceKeeper.getOrCreate { + PresenterInstance(dispatchersProvider = dispatchersProvider, tagRepository = tagRepository) + } + + internal val state = presenterInstance.state + + fun dispatch(event: TagsEvent) { + when (event) { + is TagsEvent.BackClicked -> goBack() + else -> { + // no-op + } + } + + presenterInstance.dispatch(event) + } + + private class PresenterInstance( + dispatchersProvider: DispatchersProvider, + private val tagRepository: TagRepository + ) : InstanceKeeper.Instance { + + private val coroutineScope = CoroutineScope(SupervisorJob() + dispatchersProvider.main) + + private val _state = MutableStateFlow(TagsState.DEFAULT) + val state: StateFlow = + _state.stateIn( + scope = coroutineScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = TagsState.DEFAULT + ) + + init { + dispatch(TagsEvent.Init) + } + + fun dispatch(event: TagsEvent) { + when (event) { + TagsEvent.Init -> init() + is TagsEvent.CreateTag -> createTag(event.label) + TagsEvent.BackClicked -> { + // no-op + } + is TagsEvent.OnTagNameChanged -> onTagNameChanged(event.tagId, event.label) + is TagsEvent.OnDeleteTag -> onDeleteTag(event.tagId) + } + } + + private fun onDeleteTag(tagId: Uuid) { + coroutineScope.launch { tagRepository.deleteTag(tagId) } + } + + private fun onTagNameChanged(tagId: Uuid, label: String) { + coroutineScope.launch { tagRepository.updatedTag(label, tagId) } + } + + private fun createTag(label: String) { + coroutineScope.launch { tagRepository.createTag(label = label) } + } + + private fun init() { + val tags = + createPager(config = createPagingConfig(pageSize = 20, enablePlaceholders = false)) { + tagRepository.tags() + } + .flow + .cachedIn(coroutineScope) + + _state.update { it.copy(tags = tags) } + } + + override fun onDestroy() { + coroutineScope.cancel() + } + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsState.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsState.kt new file mode 100644 index 000000000..9a947ce95 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/TagsState.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.tags + +import androidx.paging.PagingData +import dev.sasikanth.rss.reader.core.model.local.Tag +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow + +data class TagsState(val tags: Flow>) { + + companion object { + + val DEFAULT = TagsState(tags = emptyFlow()) + } +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/ui/TagItem.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/ui/TagItem.kt new file mode 100644 index 000000000..22392f28a --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/ui/TagItem.kt @@ -0,0 +1,178 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.tags.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.AlertDialog +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.benasher44.uuid.Uuid +import dev.sasikanth.rss.reader.core.model.local.Tag +import dev.sasikanth.rss.reader.resources.icons.Delete +import dev.sasikanth.rss.reader.resources.icons.Tag +import dev.sasikanth.rss.reader.resources.icons.TwineIcons +import dev.sasikanth.rss.reader.resources.strings.LocalStrings +import dev.sasikanth.rss.reader.ui.AppTheme +import dev.sasikanth.rss.reader.utils.KeyboardState +import dev.sasikanth.rss.reader.utils.keyboardVisibilityAsState + +@Composable +fun TagItem( + tag: Tag, + modifier: Modifier = Modifier, + onTagNameChanged: (id: Uuid, newName: String) -> Unit, + onDeleteTagClick: (id: Uuid) -> Unit, +) { + var showDeleteConfirmationDialog by remember { mutableStateOf(false) } + + if (showDeleteConfirmationDialog) { + ConfirmTagDeleteDialog( + tag = tag, + onDeleteTagClick = onDeleteTagClick, + onDismiss = { showDeleteConfirmationDialog = false } + ) + } + + Row( + Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp).then(modifier), + verticalAlignment = Alignment.CenterVertically + ) { + var input by remember(tag.label) { mutableStateOf(TextFieldValue(text = tag.label)) } + val focusManager = LocalFocusManager.current + val keyboardState by keyboardVisibilityAsState() + + LaunchedEffect(keyboardState) { + if (keyboardState == KeyboardState.Closed) { + focusManager.clearFocus() + } + } + + TextField( + modifier = Modifier.fillMaxWidth(), + value = input, + onValueChange = { input = it }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, autoCorrect = false), + keyboardActions = KeyboardActions(onDone = { onTagNameChanged(tag.id, input.text) }), + singleLine = true, + shape = RoundedCornerShape(8.dp), + leadingIcon = { + Icon( + imageVector = TwineIcons.Tag, + contentDescription = null, + tint = AppTheme.colorScheme.textEmphasisHigh + ) + }, + trailingIcon = { + IconButton(onClick = { showDeleteConfirmationDialog = true }) { + Icon( + imageVector = TwineIcons.Delete, + contentDescription = null, + tint = AppTheme.colorScheme.textEmphasisHigh + ) + } + }, + colors = + TextFieldDefaults.colors( + focusedContainerColor = AppTheme.colorScheme.surfaceContainer, + unfocusedContainerColor = AppTheme.colorScheme.surfaceContainerLowest, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + focusedTextColor = AppTheme.colorScheme.textEmphasisHigh, + unfocusedTextColor = AppTheme.colorScheme.textEmphasisHigh, + ), + placeholder = { + Text( + text = LocalStrings.current.tagNameHint, + style = MaterialTheme.typography.labelLarge, + color = AppTheme.colorScheme.textEmphasisMed + ) + }, + ) + } +} + +@Composable +private fun ConfirmTagDeleteDialog( + tag: Tag, + onDeleteTagClick: (id: Uuid) -> Unit, + modifier: Modifier = Modifier, + onDismiss: () -> Unit, +) { + AlertDialog( + modifier = modifier, + onDismissRequest = onDismiss, + confirmButton = { + TextButton( + onClick = { + onDeleteTagClick(tag.id) + onDismiss() + }, + shape = MaterialTheme.shapes.large + ) { + Text( + text = LocalStrings.current.delete, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss, shape = MaterialTheme.shapes.large) { + Text( + text = LocalStrings.current.buttonCancel, + style = MaterialTheme.typography.labelLarge, + color = AppTheme.colorScheme.textEmphasisMed + ) + } + }, + title = { + Text(text = LocalStrings.current.deleteTagTitle, color = AppTheme.colorScheme.textEmphasisMed) + }, + text = { + Text(text = LocalStrings.current.deleteTagDesc, color = AppTheme.colorScheme.textEmphasisMed) + }, + containerColor = AppTheme.colorScheme.surfaceContainer, + titleContentColor = AppTheme.colorScheme.onSurface, + textContentColor = AppTheme.colorScheme.onSurface, + ) +} diff --git a/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/ui/TagsScreen.kt b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/ui/TagsScreen.kt new file mode 100644 index 000000000..f03fc50a1 --- /dev/null +++ b/shared/src/commonMain/kotlin/dev/sasikanth/rss/reader/tags/ui/TagsScreen.kt @@ -0,0 +1,239 @@ +/* + * Copyright 2024 Sasikanth Miriyampalli + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.sasikanth.rss.reader.tags.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.paging.LoadState +import app.cash.paging.compose.collectAsLazyPagingItems +import dev.sasikanth.rss.reader.resources.icons.ArrowBack +import dev.sasikanth.rss.reader.resources.icons.NewTag +import dev.sasikanth.rss.reader.resources.icons.TwineIcons +import dev.sasikanth.rss.reader.resources.strings.LocalStrings +import dev.sasikanth.rss.reader.tags.TagsEvent +import dev.sasikanth.rss.reader.tags.TagsPresenter +import dev.sasikanth.rss.reader.ui.AppTheme +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Composable +fun TagsScreen(tagsPresenter: TagsPresenter, modifier: Modifier = Modifier) { + + val state by tagsPresenter.state.collectAsState() + val tags = state.tags.collectAsLazyPagingItems() + + val listState = rememberLazyListState() + var showCreateTagDialog by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier, + topBar = { + Box { + CenterAlignedTopAppBar( + title = { Text(LocalStrings.current.tags) }, + navigationIcon = { + IconButton(onClick = { tagsPresenter.dispatch(TagsEvent.BackClicked) }) { + Icon(TwineIcons.ArrowBack, contentDescription = null) + } + }, + actions = { + IconButton(onClick = { showCreateTagDialog = true }) { + Icon(TwineIcons.NewTag, contentDescription = LocalStrings.current.newTag) + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = AppTheme.colorScheme.surface, + navigationIconContentColor = AppTheme.colorScheme.onSurface, + titleContentColor = AppTheme.colorScheme.onSurface, + actionIconContentColor = AppTheme.colorScheme.onSurface + ), + ) + + HorizontalDivider( + modifier = Modifier.fillMaxWidth().align(Alignment.BottomStart), + color = AppTheme.colorScheme.surfaceContainer + ) + } + }, + content = { padding -> + if (tags.loadState.refresh == LoadState.Loading) { + Box(modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = AppTheme.colorScheme.tintedForeground) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = + PaddingValues( + bottom = padding.calculateBottomPadding(), + top = padding.calculateTopPadding() + ), + state = listState + ) { + items(tags.itemCount) { index -> + val tag = tags[index] + if (tag != null) { + Column { + TagItem( + tag = tag, + onTagNameChanged = { id, label -> + tagsPresenter.dispatch(TagsEvent.OnTagNameChanged(id, label)) + }, + onDeleteTagClick = { id -> tagsPresenter.dispatch(TagsEvent.OnDeleteTag(id)) } + ) + + if (index < tags.itemCount) { + HorizontalDivider(color = AppTheme.colorScheme.surfaceContainer) + } + } + } + } + } + } + }, + containerColor = AppTheme.colorScheme.surfaceContainerLowest, + contentColor = Color.Unspecified + ) + + if (showCreateTagDialog) { + val coroutineScope = rememberCoroutineScope() + val sheetState = rememberModalBottomSheetState() + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { focusRequester.requestFocus() } + + ModalBottomSheet( + onDismissRequest = { + showCreateTagDialog = false + focusManager.clearFocus() + }, + containerColor = AppTheme.colorScheme.surfaceContainerLowest, + contentColor = AppTheme.colorScheme.textEmphasisHigh, + sheetState = sheetState + ) { + Column( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + var tagName by remember { mutableStateOf(TextFieldValue()) } + + TextField( + modifier = Modifier.requiredHeight(56.dp).fillMaxWidth().focusRequester(focusRequester), + value = tagName, + onValueChange = { tagName = it }, + keyboardOptions = + KeyboardOptions( + imeAction = ImeAction.Done, + capitalization = KeyboardCapitalization.Words + ), + keyboardActions = + KeyboardActions(onDone = { tagsPresenter.dispatch(TagsEvent.CreateTag(tagName.text)) }), + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge, + shape = RoundedCornerShape(16.dp), + colors = + TextFieldDefaults.colors( + unfocusedIndicatorColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ), + placeholder = { + Text( + text = LocalStrings.current.tagNameHint, + style = MaterialTheme.typography.bodyLarge, + color = AppTheme.colorScheme.textEmphasisMed + ) + } + ) + + Button( + modifier = Modifier.requiredHeight(52.dp).fillMaxWidth(), + onClick = { createTag(coroutineScope, sheetState, tagsPresenter, tagName) }, + shape = RoundedCornerShape(16.dp), + enabled = tagName.text.isNotBlank(), + colors = + ButtonDefaults.elevatedButtonColors( + containerColor = AppTheme.colorScheme.tintedSurface, + contentColor = AppTheme.colorScheme.tintedForeground + ) + ) { + Text(LocalStrings.current.tagSaveButton, style = MaterialTheme.typography.labelLarge) + } + } + } + } +} + +private fun createTag( + coroutineScope: CoroutineScope, + sheetState: SheetState, + tagsPresenter: TagsPresenter, + tagName: TextFieldValue +) { + coroutineScope + .launch { sheetState.hide() } + .invokeOnCompletion { tagsPresenter.dispatch(TagsEvent.CreateTag(tagName.text)) } +}