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

View File

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

View File

@ -1,14 +1,25 @@
package com.readrops.app.compose.feeds package com.readrops.app.compose.feeds
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column 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.padding
import androidx.compose.foundation.layout.size 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.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -16,43 +27,143 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog 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 @Composable
fun AddFeedDialog( fun AddFeedDialog(
viewModel: FeedViewModel,
onDismiss: () -> Unit, 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( Dialog(
onDismissRequest = onDismiss onDismissRequest = onDismiss
) { ) {
Column( Card(
modifier = Modifier shape = RoundedCornerShape(16.dp),
.background(MaterialTheme.colorScheme.background)
.padding(16.dp)
) { ) {
Text( Column(
text = "Add new feed", verticalArrangement = Arrangement.Center,
style = MaterialTheme.typography.headlineSmall horizontalAlignment = Alignment.CenterHorizontally,
) modifier = Modifier
.fillMaxWidth()
Spacer(modifier = Modifier.size(8.dp)) .background(MaterialTheme.colorScheme.background)
.padding(MaterialTheme.spacing.largeSpacing)
TextField(
value = url,
onValueChange = { url = it }
)
Spacer(modifier = Modifier.size(8.dp))
Button(
onClick = { onValidate(url) },
modifier = Modifier.align(Alignment.CenterHorizontally)
) { ) {
Text(text = "Validate") 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
)
MediumSpacer()
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) }
)
ShortSpacer()
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) { if (showDialog) {
AddFeedDialog( AddFeedDialog(
onDismiss = { showDialog = false }, viewModel = viewModel,
onValidate = { onDismiss = {
showDialog = false showDialog = false
viewModel.insertFeed(it) viewModel.resetAddFeedDialogState()
} },
) )
} }

View File

@ -1,27 +1,38 @@
package com.readrops.app.compose.feeds package com.readrops.app.compose.feeds
import android.util.Patterns
import androidx.lifecycle.viewModelScope 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.base.TabViewModel
import com.readrops.app.compose.repositories.GetFoldersWithFeeds import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.Feed import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder import com.readrops.db.entities.Folder
import com.readrops.db.entities.account.Account
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.consumeAsFlow
import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class FeedViewModel( class FeedViewModel(
database: Database, database: Database,
private val getFoldersWithFeeds: GetFoldersWithFeeds private val getFoldersWithFeeds: GetFoldersWithFeeds,
) : TabViewModel(database) { private val localRSSDataSource: LocalRSSDataSource,
) : TabViewModel(database), KoinComponent {
private val _feedsState = MutableStateFlow<FeedsState>(FeedsState.InitialState) private val _feedsState = MutableStateFlow<FeedsState>(FeedsState.InitialState)
val feedsState = _feedsState.asStateFlow() val feedsState = _feedsState.asStateFlow()
private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState())
val addFeedDialogState = _addFeedDialogState.asStateFlow()
init { init {
viewModelScope.launch(context = Dispatchers.IO) { viewModelScope.launch(context = Dispatchers.IO) {
accountEvent.consumeAsFlow() accountEvent.consumeAsFlow()
@ -31,11 +42,95 @@ class FeedViewModel(
.catch { _feedsState.value = FeedsState.ErrorState(Exception(it)) } .catch { _feedsState.value = FeedsState.ErrorState(Exception(it)) }
.collect { _feedsState.value = FeedsState.LoadedState(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) { fun setAddFeedDialogURL(url: String) {
viewModelScope.launch(context = Dispatchers.IO) { _addFeedDialogState.update {
repository?.insertNewFeeds(listOf(url)) 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 ErrorState(val exception: Exception) : FeedsState()
data class LoadedState(val foldersAndFeeds: Map<Folder?, List<Feed>>) : 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 @Dao
interface NewAccountDao : NewBaseDao<Account> { interface NewAccountDao : NewBaseDao<Account> {
@Query("Select * From Account")
fun selectAllAccounts(): Flow<List<Account>>
@Query("Select Count(*) From Account") @Query("Select Count(*) From Account")
suspend fun selectAccountCount(): Int suspend fun selectAccountCount(): Int