Add UpdateFeedDialog in FeedTab

This commit is contained in:
Shinokuni 2024-01-18 18:45:34 +01:00
parent a6d753ef8a
commit caf55451d3
11 changed files with 363 additions and 60 deletions

View File

@ -5,7 +5,7 @@ import androidx.lifecycle.viewModelScope
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.db.Database
import com.readrops.db.entities.account.Account
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@ -26,7 +26,7 @@ abstract class TabViewModel(
protected var currentAccount: Account? = null
protected val accountEvent = Channel<Account>()
protected val accountEvent = MutableSharedFlow<Account>()
init {
viewModelScope.launch {
@ -38,7 +38,7 @@ abstract class TabViewModel(
currentAccount = account
repository = get(parameters = { parametersOf(account) })
accountEvent.send(account)
accountEvent.emit(account)
}
}
}

View File

@ -3,6 +3,7 @@ package com.readrops.app.compose.feeds
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
data class FeedState(
val foldersAndFeeds: FolderAndFeedsState = FolderAndFeedsState.InitialState,
@ -14,7 +15,7 @@ sealed interface DialogState {
object AddFolder : DialogState
class DeleteFeed(val feed: Feed) : DialogState
class DeleteFolder(val folder: Folder) : DialogState
class UpdateFeed(val feed: Feed) : DialogState
class UpdateFeed(val feed: Feed, val folder: Folder?) : DialogState
class UpdateFolder(val folder: Folder) : DialogState
class FeedSheet(val feed: Feed, val folder: Folder?) : DialogState
}
@ -30,9 +31,8 @@ data class AddFeedDialogState(
val selectedAccount: Account = Account(accountName = ""),
val accounts: List<Account> = listOf(),
val error: AddFeedError? = null,
val closeDialog: Boolean = false,
) {
fun isError() = error != null
val isError: Boolean get() = error != null
val errorText: String
get() = when (error) {
@ -51,4 +51,39 @@ data class AddFeedDialogState(
object NoRSSFeed : AddFeedError()
object NoConnection : AddFeedError()
}
}
data class UpdateFeedDialogState(
val feedName: String = "",
val feedNameError: Error? = null,
val feedUrl: String = "",
val feedUrlError: Error? = null,
val accountType: AccountType? = null,
val selectedFolder: Folder? = null,
val folders: List<Folder> = listOf(),
val isAccountDropDownExpanded: Boolean = false,
) {
sealed class Error {
object EmptyField : Error()
object BadUrl : Error()
object NoRSSUrl : Error()
}
val isFeedNameError
get() = feedNameError != null
val isFeedUrlError
get() = feedUrlError != null
fun errorText(error: Error?): String = when (error) {
Error.BadUrl -> "Input is not a valid URL"
Error.EmptyField -> "Field can't be empty"
Error.NoRSSUrl -> "The provided URL is not a valid RSS feed"
else -> ""
}
val isFeedUrlReadOnly: Boolean
get() = accountType != null && !accountType.accountConfig!!.isFeedUrlEditable
}

View File

@ -31,6 +31,7 @@ import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.dialogs.AddFeedDialog
import com.readrops.app.compose.feeds.dialogs.DeleteFeedDialog
import com.readrops.app.compose.feeds.dialogs.FeedModalBottomSheet
import com.readrops.app.compose.feeds.dialogs.UpdateFeedDialog
import com.readrops.app.compose.util.components.Placeholder
import com.readrops.db.entities.Feed
import org.koin.androidx.compose.getViewModel
@ -84,13 +85,19 @@ object FeedTab : Tab {
uriHandler.openUri(dialog.feed.siteUrl!!)
viewModel.closeDialog()
},
onModify = { },
onUpdate = { viewModel.openDialog(DialogState.UpdateFeed(dialog.feed, dialog.folder)) },
onUpdateColor = {},
onDelete = { viewModel.openDialog(DialogState.DeleteFeed(dialog.feed)) },
)
}
is DialogState.UpdateFeed -> {}
is DialogState.UpdateFeed -> {
UpdateFeedDialog(
viewModel = viewModel,
onDismissRequest = { viewModel.closeDialog() }
)
}
DialogState.AddFolder -> {}
is DialogState.DeleteFolder -> {}
is DialogState.UpdateFolder -> {}

View File

@ -8,12 +8,12 @@ import com.readrops.app.compose.base.TabViewModel
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -32,9 +32,12 @@ class FeedViewModel(
private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState())
val addFeedDialogState = _addFeedDialogState.asStateFlow()
private val _updateFeedDialogState = MutableStateFlow(UpdateFeedDialogState())
val updateFeedDialogState = _updateFeedDialogState.asStateFlow()
init {
viewModelScope.launch(context = Dispatchers.IO) {
accountEvent.consumeAsFlow()
accountEvent
.flatMapConcat { account ->
getFoldersWithFeeds.get(account.id)
}
@ -62,11 +65,39 @@ class FeedViewModel(
}
}
}
viewModelScope.launch(context = Dispatchers.IO) {
accountEvent
.flatMapConcat { account ->
database.newFolderDao()
.selectFolders(account.id)
}
.collect { folders ->
_updateFeedDialogState.update {
it.copy(
folders = folders,
accountType = currentAccount!!.accountType
)
}
}
}
}
fun closeDialog() = _feedState.update { it.copy(dialog = null) }
fun openDialog(state: DialogState) = _feedState.update { it.copy(dialog = state) }
fun openDialog(state: DialogState) {
if (state is DialogState.UpdateFeed) {
_updateFeedDialogState.update {
it.copy(
feedName = state.feed.name!!,
feedUrl = state.feed.url!!,
selectedFolder = state.folder
)
}
}
_feedState.update { it.copy(dialog = state) }
}
fun deleteFeed(feed: Feed) {
viewModelScope.launch(Dispatchers.IO) {
@ -74,6 +105,8 @@ class FeedViewModel(
}
}
// Add feed
fun setAddFeedDialogURL(url: String) {
_addFeedDialogState.update {
it.copy(
@ -94,47 +127,41 @@ class FeedViewModel(
fun addFeedDialogValidate() {
val url = _addFeedDialogState.value.url
if (url.isEmpty()) {
_addFeedDialogState.update {
it.copy(
error = AddFeedDialogState.AddFeedError.EmptyUrl
)
}
return
} else if (!Patterns.WEB_URL.matcher(url).matches()) {
_addFeedDialogState.update {
it.copy(
error = AddFeedDialogState.AddFeedError.BadUrl
)
}
return
}
viewModelScope.launch(Dispatchers.IO) {
if (localRSSDataSource.isUrlRSSResource(url)) {
// TODO add support for all account types
repository?.insertNewFeeds(listOf(url))
when {
url.isEmpty() -> {
_addFeedDialogState.update {
it.copy(closeDialog = true)
it.copy(error = AddFeedDialogState.AddFeedError.EmptyUrl)
}
} else {
val rssUrls = HtmlParser.getFeedLink(url, get())
if (rssUrls.isEmpty()) {
_addFeedDialogState.update {
it.copy(
error = AddFeedDialogState.AddFeedError.NoRSSFeed
)
}
} else {
return
}
!Patterns.WEB_URL.matcher(url).matches() -> {
_addFeedDialogState.update {
it.copy(error = AddFeedDialogState.AddFeedError.BadUrl)
}
return
}
else -> viewModelScope.launch(Dispatchers.IO) {
if (localRSSDataSource.isUrlRSSResource(url)) {
// TODO add support for all account types
repository?.insertNewFeeds(rssUrls.map { it.url })
repository?.insertNewFeeds(listOf(url))
_addFeedDialogState.update {
it.copy(closeDialog = true)
closeDialog()
} else {
val rssUrls = HtmlParser.getFeedLink(url, get())
if (rssUrls.isEmpty()) {
_addFeedDialogState.update {
it.copy(error = AddFeedDialogState.AddFeedError.NoRSSFeed)
}
} else {
// TODO add support for all account types
repository?.insertNewFeeds(rssUrls.map { it.url })
closeDialog()
}
}
}
@ -146,9 +173,82 @@ class FeedViewModel(
it.copy(
url = "",
error = null,
closeDialog = false
)
}
}
// add feed
// update feed
fun setAccountDropDownState(isExpanded: Boolean) {
_updateFeedDialogState.update {
it.copy(isAccountDropDownExpanded = isExpanded)
}
}
fun setSelectedFolder(folder: Folder) {
_updateFeedDialogState.update {
it.copy(selectedFolder = folder)
}
}
fun setUpdateFeedDialogStateFeedName(feedName: String) {
_updateFeedDialogState.update {
it.copy(
feedName = feedName,
feedNameError = null,
)
}
}
fun setUpdateFeedDialogFeedUrl(feedUrl: String) {
_updateFeedDialogState.update {
it.copy(
feedUrl = feedUrl,
feedUrlError = null,
)
}
}
fun updateFeedDialogValidate() {
val feedName = _updateFeedDialogState.value.feedName
val feedUrl = _updateFeedDialogState.value.feedUrl
when {
feedName.isEmpty() -> {
_updateFeedDialogState.update {
it.copy(feedNameError = UpdateFeedDialogState.Error.EmptyField)
}
return
}
feedUrl.isEmpty() -> {
_updateFeedDialogState.update {
it.copy(feedUrlError = UpdateFeedDialogState.Error.EmptyField)
}
return
}
!Patterns.WEB_URL.matcher(feedUrl).matches() -> {
_updateFeedDialogState.update {
it.copy(feedUrlError = UpdateFeedDialogState.Error.BadUrl)
}
return
}
else -> {
viewModelScope.launch(Dispatchers.IO) {
// TODO add logig to update feed
//repository?.updateFeed()
closeDialog()
}
}
}
}
// update feed
}

View File

@ -48,10 +48,6 @@ fun AddFeedDialog(
var isExpanded by remember { mutableStateOf(false) }
if (state.closeDialog) {
onDismiss()
}
Dialog(
onDismissRequest = onDismiss
) {
@ -100,7 +96,7 @@ fun AddFeedDialog(
}
}
},
isError = state.isError(),
isError = state.isError,
supportingText = { Text(state.errorText) }
)

View File

@ -39,7 +39,7 @@ fun FeedModalBottomSheet(
folder: Folder?,
onDismissRequest: () -> Unit,
onOpen: () -> Unit,
onModify: () -> Unit,
onUpdate: () -> Unit,
onUpdateColor: () -> Unit,
onDelete: () -> Unit,
) {
@ -98,9 +98,9 @@ fun FeedModalBottomSheet(
)
BottomSheetOption(
text = "Modify",
text = "Update",
icon = Icons.Default.Create,
onClick = onModify
onClick = onUpdate
)
BottomSheetOption(

View File

@ -0,0 +1,161 @@
package com.readrops.app.compose.feeds.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateFeedDialog(
viewModel: FeedViewModel,
onDismissRequest: () -> Unit
) {
val state by viewModel.updateFeedDialogState.collectAsStateWithLifecycle()
Dialog(
onDismissRequest = onDismissRequest
) {
Card(
shape = RoundedCornerShape(16.dp)
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(MaterialTheme.spacing.largeSpacing)
) {
Icon(
painter = painterResource(id = R.drawable.ic_rss_feed_grey),
contentDescription = null,
modifier = Modifier.size(MaterialTheme.spacing.largeSpacing)
)
MediumSpacer()
Text(
text = "Update feed",
style = MaterialTheme.typography.headlineSmall
)
MediumSpacer()
OutlinedTextField(
value = state.feedName,
onValueChange = { viewModel.setUpdateFeedDialogStateFeedName(it) },
label = { Text(text = "Feed name") },
singleLine = true,
isError = state.isFeedNameError,
supportingText = {
if (state.isFeedNameError) {
Text(
text = state.errorText(state.feedNameError)
)
}
}
)
MediumSpacer()
OutlinedTextField(
value = state.feedUrl,
onValueChange = { viewModel.setUpdateFeedDialogFeedUrl(it) },
label = { Text(text = "Feed URL") },
singleLine = true,
readOnly = state.isFeedUrlReadOnly,
isError = state.isFeedUrlError,
supportingText = {
if (state.isFeedUrlError) {
Text(
text = state.errorText(state.feedUrlError)
)
}
}
)
MediumSpacer()
ExposedDropdownMenuBox(
expanded = state.isAccountDropDownExpanded,
onExpandedChange = { viewModel.setAccountDropDownState(state.isAccountDropDownExpanded.not()) }
) {
ExposedDropdownMenu(
expanded = state.isAccountDropDownExpanded,
onDismissRequest = { viewModel.setAccountDropDownState(false) }
) {
for (folder in state.folders) {
DropdownMenuItem(
text = { Text(text = folder.name!!) },
onClick = {
viewModel.setSelectedFolder(folder)
viewModel.setAccountDropDownState(false)
},
leadingIcon = {
Icon(
painterResource(id = R.drawable.ic_folder_grey),
contentDescription = null,
)
}
)
}
}
OutlinedTextField(
value = state.selectedFolder?.name.orEmpty(),
readOnly = true,
onValueChange = {},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.isAccountDropDownExpanded)
},
leadingIcon = {
if (state.selectedFolder != null) {
Icon(
painterResource(id = R.drawable.ic_folder_grey),
contentDescription = null,
)
}
},
modifier = Modifier.menuAnchor()
)
}
LargeSpacer()
TextButton(
onClick = { viewModel.updateFeedDialogValidate() },
) {
Text(text = "Validate")
}
}
}
}
}

View File

@ -24,7 +24,8 @@ class GetFoldersWithFeeds(
id = it.feedId,
name = it.feedName,
iconUrl = it.feedIcon,
siteUrl = it.feedUrl,
url = it.feedUrl,
siteUrl = it.feedSiteUrl,
unreadCount = it.unreadCount
)
}

View File

@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -44,7 +43,7 @@ class TimelineViewModel(
init {
viewModelScope.launch(dispatcher) {
combine(
accountEvent.consumeAsFlow(),
accountEvent,
filters
) { account, filters ->
filters.accountId = account.id

View File

@ -10,9 +10,12 @@ import kotlinx.coroutines.flow.Flow
abstract class NewFolderDao : NewBaseDao<Folder> {
@Query("Select Folder.id As folderId, Folder.name as folderName, Feed.id As feedId, Feed.name AS feedName, " +
"Feed.icon_url As feedIcon, Feed.siteUrl as feedUrl, count(*) As unreadCount From Folder Left Join Feed " +
"Feed.icon_url As feedIcon, Feed.url as feedUrl, Feed.siteUrl as feedSiteUrl, count(*) As unreadCount From Folder Left Join Feed " +
"On Folder.id = Feed.folder_id Left Join Item On Item.feed_id = Feed.id " +
"Where Feed.folder_id is NULL OR Feed.folder_id is NOT NULL And Item.read = 0 " +
"And Feed.account_id = :accountId Group By Feed.id, Folder.id Order By Folder.id")
abstract fun selectFoldersAndFeeds(accountId: Int): Flow<List<FolderWithFeed>>
@Query("Select * From Folder Where account_id = :accountId")
abstract fun selectFolders(accountId: Int): Flow<List<Folder>>
}

View File

@ -19,6 +19,7 @@ data class FolderWithFeed(
val feedName: String? = null,
val feedIcon: String? = null,
val feedUrl: String? = null,
val feedSiteUrl: String? = null,
val unreadCount: Int = 0
)