Replace AddFeedDialog by a new screen
This commit is contained in:
parent
a5f16e089c
commit
7134772a5f
@ -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()) }
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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 }
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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>
|
@ -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>
|
Loading…
x
Reference in New Issue
Block a user