Replace AddFeedDialog by a new screen

This commit is contained in:
Shinokuni 2024-11-10 22:25:31 +01:00
parent a5f16e089c
commit 7134772a5f
14 changed files with 902 additions and 357 deletions

View File

@ -15,6 +15,7 @@ import com.readrops.app.account.credentials.AccountCredentialsScreenMode
import com.readrops.app.account.credentials.AccountCredentialsScreenModel
import com.readrops.app.account.selection.AccountSelectionScreenModel
import com.readrops.app.feeds.FeedScreenModel
import com.readrops.app.feeds.newfeed.NewFeedScreenModel
import com.readrops.app.item.ItemScreenModel
import com.readrops.app.more.preferences.PreferencesScreenModel
import com.readrops.app.notifications.NotificationsScreenModel
@ -43,6 +44,8 @@ val appModule = module {
factory { FeedScreenModel(get(), get(), get(), androidContext()) }
factory { (url: String?) -> NewFeedScreenModel(get(), get(), androidContext(), url) }
factory { AccountSelectionScreenModel(get()) }
factory { AccountScreenModel(get()) }

View File

@ -1,23 +1,17 @@
package com.readrops.app.feeds
import android.content.Context
import android.content.SharedPreferences
import android.util.Patterns
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.Credentials
import com.readrops.api.utils.AuthInterceptor
import com.readrops.api.utils.HtmlParser
import com.readrops.app.R
import com.readrops.app.home.TabScreenModel
import com.readrops.app.repositories.BaseRepository
import com.readrops.app.repositories.GetFoldersWithFeeds
import com.readrops.app.util.components.TextFieldError
import com.readrops.app.util.components.dialog.TextFieldDialogState
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 com.readrops.db.filters.MainFilter
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@ -29,9 +23,6 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
import java.net.UnknownHostException
@OptIn(ExperimentalCoroutinesApi::class)
class FeedScreenModel(
@ -45,9 +36,6 @@ class FeedScreenModel(
private val _feedState = MutableStateFlow(FeedState())
val feedsState = _feedState.asStateFlow()
private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState())
val addFeedDialogState = _addFeedDialogState.asStateFlow()
private val _updateFeedDialogState = MutableStateFlow(UpdateFeedDialogState())
val updateFeedDialogState = _updateFeedDialogState.asStateFlow()
@ -85,22 +73,6 @@ class FeedScreenModel(
}
}
screenModelScope.launch(dispatcher) {
database.accountDao()
.selectAllAccounts()
.collect { accounts ->
if (accounts.isNotEmpty()) {
_addFeedDialogState.update { dialogState ->
dialogState.copy(
accounts = accounts,
selectedAccount = accounts.find { it.isCurrentAccount }
?: accounts.first()
)
}
}
}
}
screenModelScope.launch(dispatcher) {
accountEvent.flatMapLatest { account ->
_updateFeedDialogState.update {
@ -135,17 +107,6 @@ class FeedScreenModel(
fun closeDialog(dialog: DialogState? = null) {
when (dialog) {
is DialogState.AddFeed -> {
_addFeedDialogState.update {
it.copy(
url = "",
error = null,
exception = null,
isLoading = false
)
}
}
is DialogState.AddFolder, is DialogState.UpdateFolder -> {
_folderState.update {
it.copy(
@ -190,12 +151,6 @@ class FeedScreenModel(
}
}
is DialogState.AddFeed -> {
_addFeedDialogState.update {
it.copy(url = state.url.orEmpty())
}
}
else -> {}
}
@ -222,122 +177,6 @@ class FeedScreenModel(
}
}
//region Add Feed
fun setAddFeedDialogURL(url: String) {
_addFeedDialogState.update {
it.copy(
url = url,
error = null,
)
}
}
fun setAddFeedDialogSelectedAccount(account: Account) {
_addFeedDialogState.update {
it.copy(
selectedAccount = account,
isAccountDropDownExpanded = false
)
}
}
fun setAccountDropDownExpanded(isExpanded: Boolean) {
_addFeedDialogState.update { it.copy(isAccountDropDownExpanded = isExpanded) }
}
fun addFeedDialogValidate() {
val url = _addFeedDialogState.value.url
when {
url.isEmpty() -> {
_addFeedDialogState.update {
it.copy(error = TextFieldError.EmptyField)
}
return
}
!Patterns.WEB_URL.matcher(url).matches() -> {
_addFeedDialogState.update {
it.copy(error = TextFieldError.BadUrl)
}
return
}
else -> screenModelScope.launch(dispatcher) {
_addFeedDialogState.update { it.copy(exception = null, isLoading = true) }
try {
if (localRSSDataSource.isUrlRSSResource(url)) {
insertFeeds(listOf(Feed(url = url)))
} else {
val rssUrls = HtmlParser.getFeedLink(url, get())
if (rssUrls.isEmpty()) {
_addFeedDialogState.update {
it.copy(error = TextFieldError.NoRSSFeed, isLoading = false)
}
} else {
insertFeeds(rssUrls.map { Feed(url = it.url) })
}
}
} catch (e: Exception) {
when (e) {
is UnknownHostException -> _addFeedDialogState.update {
it.copy(
error = TextFieldError.UnreachableUrl,
isLoading = false
)
}
else -> _addFeedDialogState.update {
it.copy(
error = TextFieldError.NoRSSFeed,
isLoading = false
)
}
}
}
}
}
}
private suspend fun insertFeeds(feeds: List<Feed>) {
val selectedAccount = _addFeedDialogState.value.selectedAccount
if (!selectedAccount.isLocal) {
get<SharedPreferences>().apply {
selectedAccount.login = getString(selectedAccount.loginKey, null)
selectedAccount.password = getString(selectedAccount.passwordKey, null)
}
get<AuthInterceptor>().apply {
credentials = Credentials.toCredentials(selectedAccount)
}
}
val repository = get<BaseRepository> { parametersOf(selectedAccount) }
val errors = repository.insertNewFeeds(
newFeeds = feeds,
onUpdate = { /* TODO */ }
)
if (errors.isEmpty()) {
closeDialog(_feedState.value.dialog)
} else {
_addFeedDialogState.update {
it.copy(
exception = errors.values.first(),
isLoading = false
)
}
}
}
//endregion
//region Update feed
fun setFolderDropDownState(isExpanded: Boolean) {

View File

@ -3,7 +3,6 @@ package com.readrops.app.feeds
import com.readrops.app.util.components.TextFieldError
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.AccountConfig
data class FeedState(
@ -19,7 +18,6 @@ data class FeedState(
}
sealed interface DialogState {
data class AddFeed(val url: String? = null) : DialogState
data object AddFolder : DialogState
class DeleteFeed(val feed: Feed) : DialogState
class DeleteFolder(val folder: Folder) : DialogState
@ -39,18 +37,6 @@ sealed class FolderAndFeedsState {
data class LoadedState(val values: Map<Folder?, List<Feed>>) : FolderAndFeedsState()
}
data class AddFeedDialogState(
val url: String = "",
val selectedAccount: Account = Account(name = ""),
val accounts: List<Account> = listOf(),
val error: TextFieldError? = null,
val exception: Exception? = null,
val isLoading: Boolean = false,
val isAccountDropDownExpanded: Boolean = false
) {
val isError: Boolean get() = error != null
}
data class UpdateFeedDialogState(
val feedId: Int = 0,
val feedRemoteId: String? = null,

View File

@ -40,12 +40,14 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.readrops.app.R
import com.readrops.app.feeds.dialogs.AddFeedDialog
import com.readrops.app.feeds.dialogs.FeedModalBottomSheet
import com.readrops.app.feeds.dialogs.UpdateFeedDialog
import com.readrops.app.feeds.newfeed.NewFeedScreen
import com.readrops.app.util.ErrorMessage
import com.readrops.app.util.components.CenteredProgressIndicator
import com.readrops.app.util.components.ErrorMessage
@ -74,6 +76,7 @@ object FeedTab : Tab {
val haptic = LocalHapticFeedback.current
val uriHandler = LocalUriHandler.current
val context = LocalContext.current
val navigator = LocalNavigator.currentOrThrow
val screenModel = koinScreenModel<FeedScreenModel>()
val state by screenModel.feedsState.collectAsStateWithLifecycle()
@ -93,7 +96,7 @@ object FeedTab : Tab {
addFeedDialogChannel.receiveAsFlow()
.collect { url ->
if (Patterns.WEB_URL.matcher(url).matches()) {
screenModel.openDialog(DialogState.AddFeed(url))
navigator.push(NewFeedScreen(url))
}
}
}
@ -146,7 +149,7 @@ object FeedTab : Tab {
if (state.config?.canCreateFeed == true) {
FloatingActionButton(
onClick = { screenModel.openDialog(DialogState.AddFeed()) }
onClick = { navigator.push(NewFeedScreen()) }
) {
Icon(
imageVector = Icons.Default.Add,
@ -256,21 +259,9 @@ object FeedTab : Tab {
private fun FeedDialogs(state: FeedState, screenModel: FeedScreenModel) {
val uriHandler = LocalUriHandler.current
val addFeedDialogState by screenModel.addFeedDialogState.collectAsStateWithLifecycle()
val folderState by screenModel.folderState.collectAsStateWithLifecycle()
when (val dialog = state.dialog) {
is DialogState.AddFeed -> {
AddFeedDialog(
state = addFeedDialogState,
onValueChange = { screenModel.setAddFeedDialogURL(it) },
onExpandChange = { screenModel.setAccountDropDownExpanded(it) },
onAccountClick = { screenModel.setAddFeedDialogSelectedAccount(it) },
onValidate = { screenModel.addFeedDialogValidate() },
onDismiss = { screenModel.closeDialog(DialogState.AddFeed()) },
)
}
is DialogState.DeleteFeed -> {
TwoChoicesDialog(
title = stringResource(R.string.delete_feed),

View File

@ -1,138 +0,0 @@
package com.readrops.app.feeds.dialogs
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
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.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.readrops.app.R
import com.readrops.app.account.selection.adaptiveIconPainterResource
import com.readrops.app.feeds.AddFeedDialogState
import com.readrops.app.util.ErrorMessage
import com.readrops.app.util.components.LoadingTextButton
import com.readrops.app.util.components.dialog.BaseDialog
import com.readrops.app.util.theme.LargeSpacer
import com.readrops.app.util.theme.MediumSpacer
import com.readrops.app.util.theme.ShortSpacer
import com.readrops.db.entities.account.Account
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddFeedDialog(
state: AddFeedDialogState,
onValueChange: (String) -> Unit,
onExpandChange: (Boolean) -> Unit,
onAccountClick: (Account) -> Unit,
onValidate: () -> Unit,
onDismiss: () -> Unit
) {
BaseDialog(
title = stringResource(R.string.add_feed),
icon = painterResource(id = R.drawable.ic_rss_feed_grey),
onDismiss = { if (!state.isLoading) onDismiss() }
) {
OutlinedTextField(
value = state.url,
label = { Text(text = stringResource(id = R.string.url)) },
onValueChange = { onValueChange(it) },
singleLine = true,
trailingIcon = {
if (state.url.isNotEmpty()) {
IconButton(
onClick = { onValueChange("") }
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = null
)
}
}
},
isError = state.isError,
supportingText = { Text(state.error?.errorText().orEmpty()) }
)
ShortSpacer()
ExposedDropdownMenuBox(
expanded = state.isAccountDropDownExpanded,
onExpandedChange = { onExpandChange(!state.isAccountDropDownExpanded) }
) {
ExposedDropdownMenu(
expanded = state.isAccountDropDownExpanded,
onDismissRequest = { onExpandChange(false) }
) {
for (account in state.accounts) {
DropdownMenuItem(
text = { Text(text = account.name!!) },
onClick = {
onAccountClick(account)
},
leadingIcon = {
Image(
painter = adaptiveIconPainterResource(
id = account.type!!.iconRes
),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
)
}
}
OutlinedTextField(
value = state.selectedAccount.name!!,
readOnly = true,
onValueChange = {},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.isAccountDropDownExpanded)
},
leadingIcon = {
Image(
painter = adaptiveIconPainterResource(
id = state.selectedAccount.type!!.iconRes
),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
},
modifier = Modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
)
}
if (state.exception != null) {
MediumSpacer()
Text(
text = ErrorMessage.get(state.exception, LocalContext.current),
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center
)
}
LargeSpacer()
LoadingTextButton(
text = stringResource(id = R.string.validate),
isLoading = state.isLoading,
onClick = onValidate,
)
}
}

View File

@ -0,0 +1,230 @@
package com.readrops.app.feeds.newfeed
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.koinScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.R
import com.readrops.app.account.selection.adaptiveIconPainterResource
import com.readrops.app.util.ErrorMessage
import com.readrops.app.util.components.AndroidScreen
import com.readrops.app.util.components.DropdownBox
import com.readrops.app.util.components.DropdownBoxValue
import com.readrops.app.util.components.LoadingButton
import com.readrops.app.util.components.TextHorizontalDivider
import com.readrops.app.util.theme.LargeSpacer
import com.readrops.app.util.theme.MediumSpacer
import com.readrops.app.util.theme.ShortSpacer
import com.readrops.app.util.theme.spacing
import org.koin.core.parameter.parametersOf
class NewFeedScreen(val url: String? = null) : AndroidScreen() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val screenModel = koinScreenModel<NewFeedScreenModel> { parametersOf(url) }
val state by screenModel.state.collectAsStateWithLifecycle()
if (state.popScreen) {
navigator.pop()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = stringResource(R.string.add_feed))
},
navigationIcon = {
IconButton(
onClick = { navigator.pop() }
) {
Icon(
imageVector = Icons.AutoMirrored.Default.ArrowBack,
contentDescription = null
)
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
.verticalScroll(rememberScrollState())
.animateContentSize()
.fillMaxSize(),
) {
OutlinedTextField(
value = state.url,
label = { Text(text = stringResource(R.string.enter_url)) },
onValueChange = { screenModel.updateUrl(it) },
singleLine = true,
trailingIcon = {
if (state.url.isNotEmpty()) {
IconButton(
onClick = { screenModel.updateUrl("") }
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = null
)
}
}
},
isError = state.isURLError,
supportingText = { Text(state.urlError?.errorText().orEmpty()) },
modifier = Modifier.fillMaxWidth()
)
ShortSpacer()
TextHorizontalDivider(text = stringResource(R.string.account))
ShortSpacer()
DropdownBox(
expanded = state.isAccountDropdownExpanded,
text = state.selectedAccount?.name.orEmpty(),
label = stringResource(R.string.choose_account),
painter = if (state.selectedAccount != null) {
adaptiveIconPainterResource(state.selectedAccount!!.type!!.iconRes)
} else null,
values = state.accounts.map {
DropdownBoxValue(
id = it.id,
text = it.name.orEmpty(),
painter = adaptiveIconPainterResource(it.type!!.iconRes)
)
},
onExpandedChange = { screenModel.updateAccountDropDownExpandStatus(it) },
onValueClick = { id -> screenModel.updateSelectedAccount(state.accounts.first { it.id == id }) },
onDismiss = { screenModel.updateAccountDropDownExpandStatus(false) },
modifier = Modifier.fillMaxWidth()
)
ShortSpacer()
DropdownBox(
expanded = state.isFoldersDropdownExpanded,
text = state.selectedFolder?.name.orEmpty(),
label = stringResource(R.string.choose_folder),
painter = if (state.selectedFolder != null) {
painterResource(R.drawable.ic_folder_grey)
} else null,
enabled = state.folders.isNotEmpty(),
values = state.folders.map {
DropdownBoxValue(
id = it.id,
text = it.name.orEmpty(),
painter = painterResource(R.drawable.ic_folder_grey)
)
},
onExpandedChange = { screenModel.updateFolderDropdownExpandStatus(it) },
onValueClick = { id -> screenModel.updateSelectedFolder(state.folders.first { it.id == id }) },
onDismiss = { screenModel.updateFolderDropdownExpandStatus(false) },
modifier = Modifier.fillMaxWidth()
)
if (state.parsingResults.isNotEmpty()) {
LargeSpacer()
TextHorizontalDivider(
text = stringResource(R.string.feeds) + " " + stringResource(
R.string.selected,
state.selectedResultsCount
)
)
ShortSpacer()
for (parsingResult in state.parsingResults) {
ParsingResultItem(
parsingResult = parsingResult,
folders = state.folders,
onExpandedChange = {
screenModel.updateParsingResultExpandedState(
parsingResult = parsingResult,
isExpanded = it
)
},
onSelectFolder = { folder ->
screenModel.updateParsingResultFolder(
parsingResult = parsingResult,
folder = folder
)
},
onCheckedChange = {
screenModel.updateParsingResultCheckedState(
parsingResult
)
},
onDismiss = {
screenModel.updateParsingResultExpandedState(
parsingResult = parsingResult,
isExpanded = false
)
},
modifier = Modifier.fillMaxWidth()
)
ShortSpacer()
}
}
if (state.exception != null) {
MediumSpacer()
Text(
text = ErrorMessage.get(state.exception!!, LocalContext.current),
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
MediumSpacer()
LoadingButton(
text = if (state.selectedResultsCount > 0) {
stringResource(R.string.add_selected_feeds, state.selectedResultsCount)
} else {
stringResource(id = R.string.validate)
},
isLoading = state.isLoading,
onClick = { screenModel.validate() },
modifier = Modifier.fillMaxWidth()
)
}
}
}
}

View File

@ -0,0 +1,316 @@
package com.readrops.app.feeds.newfeed
import android.content.Context
import android.content.SharedPreferences
import android.util.Patterns
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.Credentials
import com.readrops.api.utils.AuthInterceptor
import com.readrops.api.utils.HtmlParser
import com.readrops.app.R
import com.readrops.app.repositories.BaseRepository
import com.readrops.app.util.components.TextFieldError
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.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
import java.net.UnknownHostException
class NewFeedScreenModel(
private val database: Database,
private val dataSource: LocalRSSDataSource,
private val context: Context,
url: String?,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<State>(State(url = url.orEmpty())), KoinComponent {
private val selectedAccountState = MutableStateFlow(state.value.selectedAccount)
init {
screenModelScope.launch(dispatcher) {
database.accountDao()
.selectAllAccounts()
.map { it.filter { account -> account.config.canCreateFeed } }
.collect { accounts ->
val selectedAccount = accounts.find { it.isCurrentAccount }
?: accounts.first()
selectedAccountState.update { selectedAccount }
mutableState.update { newFeedState ->
newFeedState.copy(
accounts = accounts,
selectedAccount = selectedAccount
)
}
}
}
screenModelScope.launch(dispatcher) {
selectedAccountState.collect { selectedAccount ->
if (selectedAccount != null) {
val folders = if (selectedAccount.config.addNoFolder) {
database.folderDao().selectFolders(selectedAccount.id).first() +
Folder(name = context.resources.getString(R.string.no_folder))
} else {
database.folderDao().selectFolders(selectedAccount.id).first()
}
val newParsingResults = mutableState.value.parsingResults.map {
it.copy(folder = folders.firstOrNull())
}
mutableState.update {
it.copy(
folders = folders,
selectedFolder = folders.firstOrNull(),
parsingResults = newParsingResults
)
}
}
}
}
}
fun validate() {
val url = mutableState.value.url
if (state.value.selectedResultsCount > 0) {
mutableState.update { it.copy(exception = null, isLoading = true) }
screenModelScope.launch(dispatcher) {
insertFeeds(state.value.parsingResults
.filter { it.isSelected }
.map { Feed(url = it.url, folderId = it.folderId) })
}
} else {
when {
url.isEmpty() -> {
mutableState.update {
it.copy(urlError = TextFieldError.EmptyField)
}
return
}
!Patterns.WEB_URL.matcher(url).matches() -> {
mutableState.update {
it.copy(urlError = TextFieldError.BadUrl)
}
return
}
else -> loadFeeds()
}
}
}
private fun loadFeeds() {
screenModelScope.launch(dispatcher) {
mutableState.update { it.copy(exception = null, isLoading = true) }
val url = state.value.url
try {
if (dataSource.isUrlRSSResource(url)) {
insertFeeds(listOf(Feed(url = url, folderId = state.value.folderId)))
} else {
val rssUrls = HtmlParser.getFeedLink(url, get())
when {
rssUrls.isEmpty() -> mutableState.update {
it.copy(urlError = TextFieldError.NoRSSFeed, isLoading = false)
}
rssUrls.size == 1 -> insertFeeds(
listOf(
Feed(
url = rssUrls.first().url,
folderId = state.value.folderId
)
)
)
else -> {
val parsingResults = rssUrls.map {
ParsingResultState(
url = it.url,
label = it.label,
isSelected = true,
folder = state.value.folders.firstOrNull(),
isExpanded = false
)
}
mutableState.update {
it.copy(
parsingResults = parsingResults,
isLoading = false
)
}
}
}
}
} catch (e: Exception) {
// TODO improve error handling for all accounts
when (e) {
is UnknownHostException -> mutableState.update {
it.copy(
urlError = TextFieldError.UnreachableUrl,
isLoading = false
)
}
else -> mutableState.update {
it.copy(
urlError = TextFieldError.NoRSSFeed,
isLoading = false
)
}
}
}
}
}
private suspend fun insertFeeds(feeds: List<Feed>) {
val selectedAccount = mutableState.value.selectedAccount
if (selectedAccount != null && !selectedAccount.isLocal) {
get<SharedPreferences>().apply {
selectedAccount.login = getString(selectedAccount.loginKey, null)
selectedAccount.password = getString(selectedAccount.passwordKey, null)
}
get<AuthInterceptor>().apply {
credentials = Credentials.toCredentials(selectedAccount)
}
}
val repository = get<BaseRepository> { parametersOf(selectedAccount) }
val errors = repository.insertNewFeeds(
newFeeds = feeds,
onUpdate = { /* TODO */ }
)
if (errors.isEmpty()) {
mutableState.update { it.copy(popScreen = true) }
} else {
mutableState.update {
it.copy(
exception = errors.values.first(),
isLoading = false
)
}
}
}
fun updateUrl(url: String) = mutableState.update { it.copy(url = url, urlError = null) }
fun updateAccountDropDownExpandStatus(isExpanded: Boolean) =
mutableState.update { it.copy(isAccountDropdownExpanded = isExpanded) }
fun updateSelectedAccount(account: Account) {
mutableState.update {
it.copy(
selectedAccount = account,
isAccountDropdownExpanded = false
)
}
selectedAccountState.update { account }
}
fun updateFolderDropdownExpandStatus(isExpanded: Boolean) =
mutableState.update { it.copy(isFoldersDropdownExpanded = isExpanded) }
fun updateSelectedFolder(folder: Folder) {
val newParsingResults = mutableState.value.parsingResults.map {
it.copy(folder = folder)
}
mutableState.update {
it.copy(
selectedFolder = folder,
isFoldersDropdownExpanded = false,
parsingResults = newParsingResults
)
}
}
fun updateParsingResultCheckedState(parsingResult: ParsingResultState) {
val newList = mutableState.value.parsingResults.map {
if (it == parsingResult) {
parsingResult.copy(isSelected = !parsingResult.isSelected)
} else {
it
}
}
mutableState.update { it.copy(parsingResults = newList) }
}
fun updateParsingResultExpandedState(parsingResult: ParsingResultState, isExpanded: Boolean) {
val newList = mutableState.value.parsingResults.map {
if (it == parsingResult) {
parsingResult.copy(isExpanded = isExpanded)
} else {
it
}
}
mutableState.update { it.copy(parsingResults = newList) }
}
fun updateParsingResultFolder(parsingResult: ParsingResultState, folder: Folder) {
val newList = mutableState.value.parsingResults.map {
if (it == parsingResult) {
parsingResult.copy(folder = folder, isExpanded = false)
} else {
it
}
}
mutableState.update { it.copy(parsingResults = newList) }
}
}
data class State(
val url: String = "https://blog.broulik.de/",
val selectedAccount: Account? = null,
val selectedFolder: Folder? = null,
val accounts: List<Account> = listOf(),
val folders: List<Folder> = listOf(),
val isAccountDropdownExpanded: Boolean = false,
val isFoldersDropdownExpanded: Boolean = false,
val urlError: TextFieldError? = null,
val exception: Exception? = null,
val isLoading: Boolean = false,
val popScreen: Boolean = false,
val parsingResults: List<ParsingResultState> = listOf()
) {
val isURLError: Boolean get() = urlError != null
val selectedResultsCount: Int get() = parsingResults.count { it.isSelected }
val folderId: Int? get() = selectedFolder?.id.takeUnless { it == 0 }
}
data class ParsingResultState(
val url: String,
val label: String?,
val isSelected: Boolean,
val folder: Folder?,
val isExpanded: Boolean
) {
val folderId: Int? get() = folder?.id.takeUnless { it == 0 }
}

View File

@ -0,0 +1,116 @@
package com.readrops.app.feeds.newfeed
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ShapeDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import com.readrops.app.R
import com.readrops.app.util.components.CompactDropdownBox
import com.readrops.app.util.components.DropdownBoxValue
import com.readrops.app.util.theme.ShortSpacer
import com.readrops.app.util.theme.VeryShortSpacer
import com.readrops.app.util.theme.spacing
import com.readrops.db.entities.Folder
@Composable
fun ParsingResultItem(
parsingResult: ParsingResultState,
folders: List<Folder>,
onCheckedChange: (Boolean) -> Unit,
onExpandedChange: (Boolean) -> Unit,
onSelectFolder: (Folder) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
color = animateColorAsState(
targetValue = if (parsingResult.isSelected) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surface
},
animationSpec = spring(stiffness = Spring.StiffnessHigh),
label = "ParsingResult item color animation"
).value,
shape = ShapeDefaults.Medium,
onClick = { onCheckedChange(!parsingResult.isSelected) },
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
ShortSpacer()
Icon(
painter = painterResource(R.drawable.ic_rss_feed_grey),
tint = MaterialTheme.colorScheme.primary,
contentDescription = null
)
Column(
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
) {
Text(
text = parsingResult.label ?: parsingResult.url,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (parsingResult.label != null) {
VeryShortSpacer()
Text(
text = parsingResult.url,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (folders.isNotEmpty()) {
ShortSpacer()
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_folder_grey),
contentDescription = null,
)
ShortSpacer()
CompactDropdownBox(
expanded = parsingResult.isExpanded,
text = parsingResult.folder?.name.orEmpty(),
values = folders.map {
DropdownBoxValue(
id = it.id,
text = it.name.orEmpty(),
painter = painterResource(R.drawable.ic_folder_grey)
)
},
onExpandedChange = onExpandedChange,
onValueClick = { id -> onSelectFolder(folders.first { it.id == id }) },
onDismiss = onDismiss,
)
}
}
}
}
}
}

View File

@ -0,0 +1,139 @@
package com.readrops.app.util.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.readrops.app.util.theme.ShortSpacer
data class DropdownBoxValue(
val id: Int,
val text: String,
val painter: Painter,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DropdownBox(
expanded: Boolean,
text: String,
label: String,
painter: Painter?,
values: List<DropdownBoxValue>,
onExpandedChange: (Boolean) -> Unit,
onValueClick: (Int) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = onExpandedChange,
) {
if (values.isNotEmpty()) {
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
) {
for (value in values) {
DropdownMenuItem(
text = { Text(text = value.text) },
onClick = { onValueClick(value.id) },
leadingIcon = {
Image(
painter = value.painter,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
)
}
}
}
OutlinedTextField(
value = text,
label = { Text(text = label) },
enabled = enabled,
readOnly = true,
onValueChange = {},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded && enabled)
},
leadingIcon = {
if (painter != null) {
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
}
},
modifier = modifier.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CompactDropdownBox(
expanded: Boolean,
text: String,
values: List<DropdownBoxValue>,
onExpandedChange: (Boolean) -> Unit,
onValueClick: (Int) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = onExpandedChange
) {
if (values.isNotEmpty()) {
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = onDismiss,
matchTextFieldWidth = false
) {
for (value in values) {
DropdownMenuItem(
text = { Text(text = value.text) },
onClick = { onValueClick(value.id) },
)
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
.menuAnchor(type = MenuAnchorType.PrimaryNotEditable)
.clickable { onExpandedChange(!expanded) }
) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
ShortSpacer()
ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded)
}
}
}

View File

@ -0,0 +1,57 @@
package com.readrops.app.util.components
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoadingButton(
text: String,
isLoading: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Button(
onClick = onClick,
modifier = modifier
) {
if (isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(16.dp)
)
} else {
Text(text = text)
}
}
}
@Composable
fun LoadingTextButton(
text: String,
isLoading: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
TextButton(
onClick = onClick,
modifier = modifier
) {
if (isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp)
)
} else {
Text(text = text)
}
}
}

View File

@ -1,29 +0,0 @@
package com.readrops.app.util.components
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoadingTextButton(
text: String,
isLoading: Boolean,
onClick: () -> Unit
) {
TextButton(
onClick = onClick
) {
if (isLoading) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(16.dp)
)
} else {
Text(text = text)
}
}
}

View File

@ -0,0 +1,27 @@
package com.readrops.app.util.components
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import com.readrops.app.util.theme.ShortSpacer
@Composable
fun TextHorizontalDivider(
text: String,
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall
)
ShortSpacer()
HorizontalDivider()
}
}

View File

@ -178,4 +178,8 @@
<string name="descending">Descendant</string>
<string name="order_by">Ordonner par</string>
<string name="with_direction">Avec comme direction</string>
<string name="choose_folder">Choisir un dossier</string>
<string name="selected">(%1$s sélectionnés)</string>
<string name="add_selected_feeds">Ajouter %1$s flux sélectionnés</string>
<string name="enter_url">Entrer une URL</string>
</resources>

View File

@ -187,4 +187,8 @@
<string name="descending">Descending</string>
<string name="order_by">Order by</string>
<string name="with_direction">With direction</string>
<string name="choose_folder">Choose a folder</string>
<string name="selected">(%1$s selected)</string>
<string name="add_selected_feeds">Add %1$s selected feeds</string>
<string name="enter_url">Enter an URL</string>
</resources>