Improve AddFeedDialog looking and behaviour

- Add error management for URL TextField
- Add account dropdown
This commit is contained in:
Shinokuni 2024-01-14 00:57:22 +01:00
parent 76cff80e68
commit 672de764de
6 changed files with 278 additions and 41 deletions

View File

@ -1,6 +1,5 @@
package com.readrops.api.localfeed
import android.accounts.NetworkErrorException
import androidx.annotation.WorkerThread
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.konsumeXml
@ -22,7 +21,6 @@ import okio.Buffer
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.IOException
import java.lang.Exception
import java.net.HttpURLConnection
class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
@ -75,7 +73,8 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
val rootKonsumer = nextElement(LocalRSSHelper.RSS_ROOT_NAMES)
rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) }
} catch (e: Exception) {
throw UnknownFormatException(e.message)
close()
return false
}
}

View File

@ -15,7 +15,7 @@ val composeAppModule = module {
viewModel { TimelineViewModel(get(), get()) }
viewModel { FeedViewModel(get(), get()) }
viewModel { FeedViewModel(get(), get(), get()) }
viewModel { AccountSelectionViewModel(get()) }

View File

@ -1,14 +1,25 @@
package com.readrops.app.compose.feeds
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
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.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -16,44 +27,144 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.spacing
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddFeedDialog(
viewModel: FeedViewModel,
onDismiss: () -> Unit,
onValidate: (String) -> Unit,
) {
var url by remember { mutableStateOf("") }
val state by viewModel.addFeedDialogState.collectAsStateWithLifecycle()
var isExpanded by remember { mutableStateOf(false) }
if (state.closeDialog) {
onDismiss()
}
Dialog(
onDismissRequest = onDismiss
) {
Column(
modifier = Modifier
.background(MaterialTheme.colorScheme.background)
.padding(16.dp)
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 = "Add new feed",
style = MaterialTheme.typography.headlineSmall
)
Spacer(modifier = Modifier.size(8.dp))
MediumSpacer()
TextField(
value = url,
onValueChange = { url = it }
OutlinedTextField(
value = state.url,
label = {
Text(text = "URL")
},
onValueChange = { viewModel.setAddFeedDialogURL(it) },
singleLine = true,
trailingIcon = {
if (state.url.isNotEmpty()) {
IconButton(
onClick = { viewModel.setAddFeedDialogURL("") }
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = null
)
}
}
},
isError = state.isError(),
supportingText = { Text(state.errorText) }
)
Spacer(modifier = Modifier.size(8.dp))
ShortSpacer()
Button(
onClick = { onValidate(url) },
modifier = Modifier.align(Alignment.CenterHorizontally)
ExposedDropdownMenuBox(
expanded = isExpanded,
onExpandedChange = { isExpanded = isExpanded.not() }
) {
ExposedDropdownMenu(
expanded = isExpanded,
onDismissRequest = { isExpanded = false }
) {
for (account in state.accounts) {
DropdownMenuItem(
text = { Text(text = account.accountName!!) },
onClick = {
isExpanded = false
viewModel.setAddFeedDialogSelectedAccount(account)
},
leadingIcon = {
Icon(
painter = painterResource(
id = if (state.selectedAccount.isLocal){
R.drawable.ic_rss_feed_grey}
else
state.selectedAccount.accountType!!.iconRes
),
contentDescription = null
)
}
)
}
}
OutlinedTextField(
value = state.selectedAccount.accountName!!,
readOnly = true,
onValueChange = {},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded)
},
leadingIcon = {
Icon(
painter = painterResource(
id = if (state.selectedAccount.isLocal){
R.drawable.ic_rss_feed_grey}
else
state.selectedAccount.accountType!!.iconRes
),
contentDescription = null
)
},
modifier = Modifier.menuAnchor()
)
}
LargeSpacer()
TextButton(
onClick = { viewModel.addFeedDialogValidate() },
) {
Text(text = "Validate")
}
}
}
}
}

View File

@ -73,11 +73,11 @@ object FeedTab : Tab {
if (showDialog) {
AddFeedDialog(
onDismiss = { showDialog = false },
onValidate = {
viewModel = viewModel,
onDismiss = {
showDialog = false
viewModel.insertFeed(it)
}
viewModel.resetAddFeedDialogState()
},
)
}

View File

@ -1,27 +1,38 @@
package com.readrops.app.compose.feeds
import android.util.Patterns
import androidx.lifecycle.viewModelScope
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.utils.HtmlParser
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
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class FeedViewModel(
database: Database,
private val getFoldersWithFeeds: GetFoldersWithFeeds
) : TabViewModel(database) {
private val getFoldersWithFeeds: GetFoldersWithFeeds,
private val localRSSDataSource: LocalRSSDataSource,
) : TabViewModel(database), KoinComponent {
private val _feedsState = MutableStateFlow<FeedsState>(FeedsState.InitialState)
val feedsState = _feedsState.asStateFlow()
private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState())
val addFeedDialogState = _addFeedDialogState.asStateFlow()
init {
viewModelScope.launch(context = Dispatchers.IO) {
accountEvent.consumeAsFlow()
@ -31,11 +42,95 @@ class FeedViewModel(
.catch { _feedsState.value = FeedsState.ErrorState(Exception(it)) }
.collect { _feedsState.value = FeedsState.LoadedState(it) }
}
viewModelScope.launch(context = Dispatchers.IO) {
database.newAccountDao()
.selectAllAccounts()
.collect { accounts ->
_addFeedDialogState.update { dialogState ->
dialogState.copy(
accounts = accounts,
selectedAccount = accounts.find { it.isCurrentAccount }!!
)
}
}
}
}
fun insertFeed(url: String) {
viewModelScope.launch(context = Dispatchers.IO) {
fun setAddFeedDialogURL(url: String) {
_addFeedDialogState.update {
it.copy(
url = url,
error = null,
)
}
}
fun setAddFeedDialogSelectedAccount(account: Account) {
_addFeedDialogState.update {
it.copy(
selectedAccount = account
)
}
}
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))
_addFeedDialogState.update {
it.copy(closeDialog = true)
}
} 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 })
_addFeedDialogState.update {
it.copy(closeDialog = true)
}
}
}
}
}
fun resetAddFeedDialogState() {
_addFeedDialogState.update {
it.copy(
url = "",
error = null,
closeDialog = false
)
}
}
}
@ -45,3 +140,32 @@ sealed class FeedsState {
data class ErrorState(val exception: Exception) : FeedsState()
data class LoadedState(val foldersAndFeeds: Map<Folder?, List<Feed>>) : FeedsState()
}
data class AddFeedDialogState(
val url: String = "",
val selectedAccount: Account = Account(accountName = ""),
val accounts: List<Account> = listOf(),
val error: AddFeedError? = null,
val closeDialog: Boolean = false,
) {
fun isError() = error != null
val errorText: String
get() = when (error) {
is AddFeedError.EmptyUrl -> "Field can't be empty"
AddFeedError.BadUrl -> "Input is not a valid URL"
AddFeedError.NoConnection -> ""
AddFeedError.NoRSSFeed -> "No RSS feed found"
AddFeedError.UnreachableUrl -> ""
else -> ""
}
sealed class AddFeedError {
object EmptyUrl : AddFeedError()
object BadUrl : AddFeedError()
object UnreachableUrl : AddFeedError()
object NoRSSFeed : AddFeedError()
object NoConnection : AddFeedError()
}
}

View File

@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface NewAccountDao : NewBaseDao<Account> {
@Query("Select * From Account")
fun selectAllAccounts(): Flow<List<Account>>
@Query("Select Count(*) From Account")
suspend fun selectAccountCount(): Int