diff --git a/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt b/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt index 867f17bc..cf2a4bc6 100644 --- a/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt +++ b/api/src/main/java/com/readrops/api/localfeed/LocalRSSDataSource.kt @@ -1,6 +1,5 @@ package com.readrops.api.localfeed -import android.accounts.NetworkErrorException import androidx.annotation.WorkerThread import com.gitlab.mvysny.konsumexml.Konsumer import com.gitlab.mvysny.konsumexml.konsumeXml @@ -22,7 +21,6 @@ import okio.Buffer import org.koin.core.component.KoinComponent import org.koin.core.component.get import java.io.IOException -import java.lang.Exception import java.net.HttpURLConnection class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent { @@ -75,7 +73,8 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent { val rootKonsumer = nextElement(LocalRSSHelper.RSS_ROOT_NAMES) rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) } } catch (e: Exception) { - throw UnknownFormatException(e.message) + close() + return false } } diff --git a/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt b/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt index 25c84ac1..67de2c86 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt @@ -15,7 +15,7 @@ val composeAppModule = module { viewModel { TimelineViewModel(get(), get()) } - viewModel { FeedViewModel(get(), get()) } + viewModel { FeedViewModel(get(), get(), get()) } viewModel { AccountSelectionViewModel(get()) } diff --git a/appcompose/src/main/java/com/readrops/app/compose/feeds/AddFeedDialog.kt b/appcompose/src/main/java/com/readrops/app/compose/feeds/AddFeedDialog.kt index d1bf7b85..a46d6049 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/feeds/AddFeedDialog.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/feeds/AddFeedDialog.kt @@ -1,14 +1,25 @@ package com.readrops.app.compose.feeds import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text -import androidx.compose.material3.TextField +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -16,44 +27,144 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.readrops.app.compose.R +import com.readrops.app.compose.util.theme.LargeSpacer +import com.readrops.app.compose.util.theme.MediumSpacer +import com.readrops.app.compose.util.theme.ShortSpacer +import com.readrops.app.compose.util.theme.spacing +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AddFeedDialog( + viewModel: FeedViewModel, onDismiss: () -> Unit, - onValidate: (String) -> Unit, ) { - var url by remember { mutableStateOf("") } + val state by viewModel.addFeedDialogState.collectAsStateWithLifecycle() + + var isExpanded by remember { mutableStateOf(false) } + + if (state.closeDialog) { + onDismiss() + } Dialog( onDismissRequest = onDismiss ) { - Column( - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .padding(16.dp) + Card( + shape = RoundedCornerShape(16.dp), ) { - Text( - text = "Add new feed", - style = MaterialTheme.typography.headlineSmall - ) - - Spacer(modifier = Modifier.size(8.dp)) - - TextField( - value = url, - onValueChange = { url = it } - ) - - Spacer(modifier = Modifier.size(8.dp)) - - Button( - onClick = { onValidate(url) }, - modifier = Modifier.align(Alignment.CenterHorizontally) + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(MaterialTheme.spacing.largeSpacing) ) { - 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") + } } } } -} \ No newline at end of file +} diff --git a/appcompose/src/main/java/com/readrops/app/compose/feeds/FeedTab.kt b/appcompose/src/main/java/com/readrops/app/compose/feeds/FeedTab.kt index fcf06e9c..0f21a765 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/feeds/FeedTab.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/feeds/FeedTab.kt @@ -73,11 +73,11 @@ object FeedTab : Tab { if (showDialog) { AddFeedDialog( - onDismiss = { showDialog = false }, - onValidate = { + viewModel = viewModel, + onDismiss = { showDialog = false - viewModel.insertFeed(it) - } + viewModel.resetAddFeedDialogState() + }, ) } diff --git a/appcompose/src/main/java/com/readrops/app/compose/feeds/FeedViewModel.kt b/appcompose/src/main/java/com/readrops/app/compose/feeds/FeedViewModel.kt index c1733491..e42a4877 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/feeds/FeedViewModel.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/feeds/FeedViewModel.kt @@ -1,27 +1,38 @@ package com.readrops.app.compose.feeds +import android.util.Patterns import androidx.lifecycle.viewModelScope +import com.readrops.api.localfeed.LocalRSSDataSource +import com.readrops.api.utils.HtmlParser import com.readrops.app.compose.base.TabViewModel import com.readrops.app.compose.repositories.GetFoldersWithFeeds import com.readrops.db.Database import com.readrops.db.entities.Feed import com.readrops.db.entities.Folder +import com.readrops.db.entities.account.Account import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.koin.core.component.KoinComponent +import org.koin.core.component.get class FeedViewModel( database: Database, - private val getFoldersWithFeeds: GetFoldersWithFeeds -) : TabViewModel(database) { + private val getFoldersWithFeeds: GetFoldersWithFeeds, + private val localRSSDataSource: LocalRSSDataSource, +) : TabViewModel(database), KoinComponent { private val _feedsState = MutableStateFlow(FeedsState.InitialState) val feedsState = _feedsState.asStateFlow() + private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState()) + val addFeedDialogState = _addFeedDialogState.asStateFlow() + init { viewModelScope.launch(context = Dispatchers.IO) { accountEvent.consumeAsFlow() @@ -31,11 +42,95 @@ class FeedViewModel( .catch { _feedsState.value = FeedsState.ErrorState(Exception(it)) } .collect { _feedsState.value = FeedsState.LoadedState(it) } } - } - fun insertFeed(url: String) { viewModelScope.launch(context = Dispatchers.IO) { - repository?.insertNewFeeds(listOf(url)) + database.newAccountDao() + .selectAllAccounts() + .collect { accounts -> + _addFeedDialogState.update { dialogState -> + dialogState.copy( + accounts = accounts, + selectedAccount = accounts.find { it.isCurrentAccount }!! + ) + } + } + } + } + + fun setAddFeedDialogURL(url: String) { + _addFeedDialogState.update { + it.copy( + url = url, + error = null, + ) + } + } + + fun setAddFeedDialogSelectedAccount(account: Account) { + _addFeedDialogState.update { + it.copy( + selectedAccount = account + ) + } + } + + fun addFeedDialogValidate() { + val url = _addFeedDialogState.value.url + + if (url.isEmpty()) { + _addFeedDialogState.update { + it.copy( + error = AddFeedDialogState.AddFeedError.EmptyUrl + ) + } + + return + } else if (!Patterns.WEB_URL.matcher(url).matches()) { + _addFeedDialogState.update { + it.copy( + error = AddFeedDialogState.AddFeedError.BadUrl + ) + } + + return + } + + viewModelScope.launch(Dispatchers.IO) { + if (localRSSDataSource.isUrlRSSResource(url)) { + // TODO add support for all account types + repository?.insertNewFeeds(listOf(url)) + + _addFeedDialogState.update { + it.copy(closeDialog = true) + } + } else { + val rssUrls = HtmlParser.getFeedLink(url, get()) + + if (rssUrls.isEmpty()) { + _addFeedDialogState.update { + it.copy( + error = AddFeedDialogState.AddFeedError.NoRSSFeed + ) + } + } else { + // TODO add support for all account types + repository?.insertNewFeeds(rssUrls.map { it.url }) + + _addFeedDialogState.update { + it.copy(closeDialog = true) + } + } + } + } + } + + fun resetAddFeedDialogState() { + _addFeedDialogState.update { + it.copy( + url = "", + error = null, + closeDialog = false + ) } } } @@ -44,4 +139,33 @@ sealed class FeedsState { object InitialState : FeedsState() data class ErrorState(val exception: Exception) : FeedsState() data class LoadedState(val foldersAndFeeds: Map>) : FeedsState() +} + + +data class AddFeedDialogState( + val url: String = "", + val selectedAccount: Account = Account(accountName = ""), + val accounts: List = 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() + } } \ No newline at end of file diff --git a/db/src/main/java/com/readrops/db/dao/newdao/NewAccountDao.kt b/db/src/main/java/com/readrops/db/dao/newdao/NewAccountDao.kt index b62cef67..e5390ffe 100644 --- a/db/src/main/java/com/readrops/db/dao/newdao/NewAccountDao.kt +++ b/db/src/main/java/com/readrops/db/dao/newdao/NewAccountDao.kt @@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.Flow @Dao interface NewAccountDao : NewBaseDao { + @Query("Select * From Account") + fun selectAllAccounts(): Flow> + @Query("Select Count(*) From Account") suspend fun selectAccountCount(): Int