mirror of https://github.com/readrops/Readrops.git
Improve AddFeedDialog looking and behaviour
- Add error management for URL TextField - Add account dropdown
This commit is contained in:
parent
76cff80e68
commit
672de764de
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()) }
|
||||||
|
|
||||||
|
|
|
@ -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,44 +27,144 @@ 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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,4 +139,33 @@ sealed class FeedsState {
|
||||||
object InitialState : FeedsState()
|
object InitialState : 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()
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue