Add OPML import in AccountTab

This commit is contained in:
Shinokuni 2024-03-31 19:48:29 +02:00
parent 1a92684c18
commit e0874f2297
16 changed files with 262 additions and 104 deletions

View File

@ -78,4 +78,6 @@ dependencies {
debugApi 'com.chimerapps.niddler:niddler:1.5.5' debugApi 'com.chimerapps.niddler:niddler:1.5.5'
releaseApi 'com.chimerapps.niddler:niddler-noop:1.5.5' releaseApi 'com.chimerapps.niddler:niddler-noop:1.5.5'
testImplementation(libs.coroutines.test)
} }

View File

@ -1,10 +1,9 @@
package com.readrops.api.opml package com.readrops.api.opml
import com.gitlab.mvysny.konsumexml.konsumeXml import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder import com.readrops.db.entities.Folder
import io.reactivex.Completable
import io.reactivex.Single
import org.redundent.kotlin.xml.xml import org.redundent.kotlin.xml.xml
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
@ -12,48 +11,37 @@ import java.io.OutputStream
object OPMLParser { object OPMLParser {
@JvmStatic @JvmStatic
fun read(stream: InputStream): Single<Map<Folder?, List<Feed>>> { suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> {
return Single.create { emitter -> try {
try { val adapter = OPMLAdapter()
val adapter = OPMLAdapter() val opml = adapter.fromXml(stream.konsumeXml())
val opml = adapter.fromXml(stream.konsumeXml())
emitter.onSuccess(opml) stream.close()
} catch (e: Exception) { return opml
emitter.onError(e) } catch (e: Exception) {
} throw ParseException(e.message)
} }
} }
@JvmStatic @JvmStatic
fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream): Completable { suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) {
return Completable.create { emitter -> val opml = xml("opml") {
val opml = xml("opml") { attribute("version", "2.0")
attribute("version", "2.0")
"head" { "head" {
-"Subscriptions" -"Subscriptions"
} }
"body" { "body" {
for (folderAndFeeds in foldersAndFeeds) { for (folderAndFeeds in foldersAndFeeds) {
if (folderAndFeeds.key != null) { // feeds with folder if (folderAndFeeds.key != null) { // feeds with folder
"outline" { "outline" {
folderAndFeeds.key?.name?.let { folderAndFeeds.key?.name?.let {
attribute("title", it) attribute("title", it)
attribute("text", it) attribute("text", it)
}
for (feed in folderAndFeeds.value) {
"outline" {
feed.name?.let { attribute("title", it) }
attribute("xmlUrl", feed.url!!)
feed.siteUrl?.let { attribute("htmlUrl", it) }
}
}
} }
} else {
for (feed in folderAndFeeds.value) { // feeds without folder for (feed in folderAndFeeds.value) {
"outline" { "outline" {
feed.name?.let { attribute("title", it) } feed.name?.let { attribute("title", it) }
attribute("xmlUrl", feed.url!!) attribute("xmlUrl", feed.url!!)
@ -61,14 +49,20 @@ object OPMLParser {
} }
} }
} }
} else {
for (feed in folderAndFeeds.value) { // feeds without folder
"outline" {
feed.name?.let { attribute("title", it) }
attribute("xmlUrl", feed.url!!)
feed.siteUrl?.let { attribute("htmlUrl", it) }
}
}
} }
} }
} }
outputStream.write(opml.toString().toByteArray())
outputStream.flush()
emitter.onComplete()
} }
outputStream.write(opml.toString().toByteArray())
outputStream.flush()
} }
} }

View File

@ -16,6 +16,8 @@ object ApiUtils {
const val HTTP_NOT_FOUND = 404 const val HTTP_NOT_FOUND = 404
const val HTTP_CONFLICT = 409 const val HTTP_CONFLICT = 409
val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml")
private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)" private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)"
fun isMimeImage(type: String): Boolean = fun isMimeImage(type: String): Boolean =

View File

@ -4,8 +4,8 @@ import com.readrops.api.TestUtils
import com.readrops.api.utils.exceptions.ParseException import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder import com.readrops.db.entities.Folder
import io.reactivex.schedulers.Schedulers
import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@ -13,83 +13,85 @@ import java.io.FileOutputStream
class OPMLParserTest { class OPMLParserTest {
@Test @Test
fun readOpmlTest() { fun readOpmlTest() = runTest {
val stream = TestUtils.loadResource("opml/subscriptions.opml") val stream = TestUtils.loadResource("opml/subscriptions.opml")
val foldersAndFeeds = OPMLParser.read(stream)
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null assertEquals(foldersAndFeeds.size, 6)
OPMLParser.read(stream) assertEquals(foldersAndFeeds[Folder(name = "Folder 1")]?.size, 2)
.observeOn(Schedulers.trampoline()) assertEquals(foldersAndFeeds[Folder(name = "Subfolder 1")]?.size, 4)
.subscribeOn(Schedulers.trampoline()) assertEquals(foldersAndFeeds[Folder(name = "Subfolder 2")]?.size, 1)
.subscribe { result -> foldersAndFeeds = result } assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 1")]?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 2")]?.size, 0)
assertEquals(foldersAndFeeds?.size, 6) assertEquals(foldersAndFeeds[null]?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Folder 1"))?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 1"))?.size, 4)
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 2"))?.size, 1)
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 1"))?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 2"))?.size, 0)
assertEquals(foldersAndFeeds?.get(null)?.size, 2)
stream.close() stream.close()
} }
@Test @Test
fun readLiteSubscriptionsTest() { fun readLiteSubscriptionsTest() = runTest {
val stream = TestUtils.loadResource("opml/lite_subscriptions.opml") val stream = TestUtils.loadResource("opml/lite_subscriptions.opml")
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null val foldersAndFeeds = OPMLParser.read(stream)
OPMLParser.read(stream) assertEquals(foldersAndFeeds.values.first().size, 2)
.subscribe { result -> foldersAndFeeds = result } assertEquals(
foldersAndFeeds.values.first().first().url,
assertEquals(foldersAndFeeds?.values?.first()?.size, 2) "http://www.theverge.com/rss/index.xml"
assertEquals(foldersAndFeeds?.values?.first()?.first()?.url, "http://www.theverge.com/rss/index.xml") )
assertEquals(foldersAndFeeds?.values?.first()?.get(1)?.url, "https://techcrunch.com/feed/") assertEquals(foldersAndFeeds.values.first()[1].url, "https://techcrunch.com/feed/")
stream.close() stream.close()
} }
@Test @Test(expected = ParseException::class)
fun opmlVersionTest() { fun opmlVersionTest() = runTest {
val stream = TestUtils.loadResource("opml/wrong_version.opml") val stream = TestUtils.loadResource("opml/wrong_version.opml")
OPMLParser.read(stream) OPMLParser.read(stream)
.test()
.assertError(ParseException::class.java)
stream.close() stream.close()
} }
@Test @Test
fun writeOpmlTest() { fun writeOpmlTest() = runTest {
val file = File("subscriptions.opml") val file = File("subscriptions.opml")
val outputStream = FileOutputStream(file) val outputStream = FileOutputStream(file)
val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply { val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply {
put(null, listOf(Feed(name = "Feed1", url = "https://feed1.com"), put(
Feed(name = "Feed2", url = "https://feed2.com"))) null, listOf(
Feed(name = "Feed1", url = "https://feed1.com"),
Feed(name = "Feed2", url = "https://feed2.com")
)
)
put(Folder(name = "Folder1"), listOf()) put(Folder(name = "Folder1"), listOf())
put(Folder(name = "Folder2"), listOf(Feed(name = "Feed3", url = "https://feed3.com"), put(
Feed(name = "Feed4", url ="https://feed4.com"))) Folder(name = "Folder2"), listOf(
Feed(name = "Feed3", url = "https://feed3.com"),
Feed(name = "Feed4", url = "https://feed4.com")
)
)
} }
OPMLParser.write(foldersAndFeeds, outputStream) OPMLParser.write(foldersAndFeeds, outputStream)
.subscribeOn(Schedulers.trampoline())
.subscribe()
outputStream.flush() outputStream.flush()
outputStream.close() outputStream.close()
val inputStream = file.inputStream() val inputStream = file.inputStream()
var foldersAndFeeds2: Map<Folder?, List<Feed>>? = null val foldersAndFeeds2 = OPMLParser.read(inputStream)
OPMLParser.read(inputStream).subscribe { result -> foldersAndFeeds2 = result }
assertEquals(foldersAndFeeds.size, foldersAndFeeds2?.size) assertEquals(foldersAndFeeds.size, foldersAndFeeds2.size)
assertEquals(foldersAndFeeds[Folder(name = "Folder1")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder1"))?.size) assertEquals(
assertEquals(foldersAndFeeds[Folder(name = "Folder2")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder2"))?.size) foldersAndFeeds[Folder(name = "Folder1")]?.size,
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2?.get(null)?.size) foldersAndFeeds2[Folder(name = "Folder1")]?.size
)
assertEquals(
foldersAndFeeds[Folder(name = "Folder2")]?.size,
foldersAndFeeds2[Folder(name = "Folder2")]?.size
)
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2[null]?.size)
inputStream.close() inputStream.close()
} }

View File

@ -64,7 +64,8 @@ public class AccountViewModel extends ViewModel {
} }
public Completable parseOPMLFile(Uri uri, Context context) throws FileNotFoundException { public Completable parseOPMLFile(Uri uri, Context context) throws FileNotFoundException {
return OPMLParser.read(context.getContentResolver().openInputStream(uri)) /*return OPMLParser.read(context.getContentResolver().openInputStream(uri))
.flatMapCompletable(foldersAndFeeds -> repository.insertOPMLFoldersAndFeeds(foldersAndFeeds)); .flatMapCompletable(foldersAndFeeds -> repository.insertOPMLFoldersAndFeeds(foldersAndFeeds));*/
return Completable.complete();
} }
} }

View File

@ -257,8 +257,8 @@ public class AccountSettingsFragment extends PreferenceFragmentCompat {
.blockingGet(); .blockingGet();
OPMLParser.write(folderListMap, outputStream) /*OPMLParser.write(folderListMap, outputStream)
.blockingAwait(); .blockingAwait();*/
return Unit.INSTANCE; return Unit.INSTANCE;
}); });

View File

@ -1,6 +1,9 @@
package com.readrops.app.compose.account package com.readrops.app.compose.account
import android.content.Context
import android.net.Uri
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.opml.OPMLParser
import com.readrops.app.compose.base.TabScreenModel import com.readrops.app.compose.base.TabScreenModel
import com.readrops.db.Database import com.readrops.db.Database
import com.readrops.db.entities.account.Account import com.readrops.db.entities.account.Account
@ -45,6 +48,39 @@ class AccountScreenModel(
_closeHome.update { true } _closeHome.update { true }
} }
} }
fun parseOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch(Dispatchers.IO) {
val stream = context.contentResolver.openInputStream(uri)!!
val foldersAndFeeds = OPMLParser.read(stream)
openDialog(
DialogState.OPMLImport(
currentFeed = foldersAndFeeds.values.first().first().name!!,
feedCount = 0,
feedMax = foldersAndFeeds.values.flatten().size
)
)
repository?.insertOPMLFoldersAndFeeds(
foldersAndFeeds = foldersAndFeeds,
onUpdate = { feed ->
_accountState.update {
val dialog = (it.dialog as DialogState.OPMLImport)
it.copy(
dialog = dialog.copy(
currentFeed = feed.name!!,
feedCount = dialog.feedCount + 1
)
)
}
}
)
closeDialog()
}
}
} }
data class AccountState( data class AccountState(
@ -55,4 +91,6 @@ data class AccountState(
sealed interface DialogState { sealed interface DialogState {
object DeleteAccount : DialogState object DeleteAccount : DialogState
object NewAccount : DialogState object NewAccount : DialogState
data class OPMLImport(val currentFeed: String, val feedCount: Int, val feedMax: Int) :
DialogState
} }

View File

@ -1,5 +1,7 @@
package com.readrops.app.compose.account package com.readrops.app.compose.account
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -24,6 +26,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -33,6 +36,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.Tab import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions import cafe.adriel.voyager.navigator.tab.TabOptions
import com.readrops.api.utils.ApiUtils
import com.readrops.app.compose.R import com.readrops.app.compose.R
import com.readrops.app.compose.account.credentials.AccountCredentialsScreen import com.readrops.app.compose.account.credentials.AccountCredentialsScreen
import com.readrops.app.compose.account.selection.AccountSelectionDialog import com.readrops.app.compose.account.selection.AccountSelectionDialog
@ -56,6 +60,7 @@ object AccountTab : Tab {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val viewModel = getScreenModel<AccountScreenModel>() val viewModel = getScreenModel<AccountScreenModel>()
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle() val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
@ -65,8 +70,13 @@ object AccountTab : Tab {
navigator.replaceAll(AccountSelectionScreen()) navigator.replaceAll(AccountSelectionScreen())
} }
when (state.dialog) { val launcher =
DialogState.DeleteAccount -> { rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { viewModel.parseOPMLFile(uri, context) }
}
when (val dialog = state.dialog) {
is DialogState.DeleteAccount -> {
TwoChoicesDialog( TwoChoicesDialog(
title = stringResource(R.string.delete_account), title = stringResource(R.string.delete_account),
text = stringResource(R.string.delete_account_question), text = stringResource(R.string.delete_account_question),
@ -81,7 +91,7 @@ object AccountTab : Tab {
) )
} }
DialogState.NewAccount -> { is DialogState.NewAccount -> {
AccountSelectionDialog( AccountSelectionDialog(
onDismiss = { viewModel.closeDialog() }, onDismiss = { viewModel.closeDialog() },
onValidate = { accountType -> onValidate = { accountType ->
@ -91,6 +101,14 @@ object AccountTab : Tab {
) )
} }
is DialogState.OPMLImport -> {
OPMLImportProgressDialog(
currentFeed = dialog.currentFeed,
feedCount = dialog.feedCount,
feedMax = dialog.feedMax
)
}
else -> {} else -> {}
} }
@ -164,6 +182,15 @@ object AccountTab : Tab {
onClick = { } onClick = { }
) )
SelectableIconText(
icon = painterResource(id = R.drawable.ic_import_export),
text = stringResource(R.string.opml_import_export),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
onClick = { launcher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) }
)
SelectableIconText( SelectableIconText(
icon = rememberVectorPainter(image = Icons.Default.AccountCircle), icon = rememberVectorPainter(image = Icons.Default.AccountCircle),
text = stringResource(R.string.delete_account), text = stringResource(R.string.delete_account),

View File

@ -0,0 +1,27 @@
package com.readrops.app.compose.account
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.readrops.app.compose.R
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.components.RefreshIndicator
@Composable
fun OPMLImportProgressDialog(
currentFeed: String,
feedCount: Int,
feedMax: Int,
) {
BaseDialog(
title = stringResource(id = R.string.opml_import),
icon = painterResource(R.drawable.ic_import_export),
onDismiss = {}
) {
RefreshIndicator(
currentFeed = currentFeed,
feedCount = feedCount,
feedMax = feedMax
)
}
}

View File

@ -188,7 +188,9 @@ class FeedScreenModel(
try { try {
if (localRSSDataSource.isUrlRSSResource(url)) { if (localRSSDataSource.isUrlRSSResource(url)) {
// TODO add support for all account types // TODO add support for all account types
repository?.insertNewFeeds(listOf(url)) repository?.insertNewFeeds(
newFeeds = listOf(Feed(url = url))
) {}
closeDialog(DialogState.AddFeed) closeDialog(DialogState.AddFeed)
} else { } else {
@ -200,7 +202,9 @@ class FeedScreenModel(
} }
} else { } else {
// TODO add support for all account types // TODO add support for all account types
repository?.insertNewFeeds(rssUrls.map { it.url }) repository?.insertNewFeeds(
newFeeds = rssUrls.map { Feed(url = it.url) }
) {}
closeDialog(DialogState.AddFeed) closeDialog(DialogState.AddFeed)
} }

View File

@ -37,7 +37,7 @@ abstract class ARepository(
*/ */
abstract suspend fun synchronize(): SyncResult abstract suspend fun synchronize(): SyncResult
abstract suspend fun insertNewFeeds(urls: List<String>) abstract suspend fun insertNewFeeds(newFeeds: List<Feed>, onUpdate: (Feed) -> Unit)
} }
abstract class BaseRepository( abstract class BaseRepository(
@ -45,7 +45,8 @@ abstract class BaseRepository(
account: Account, account: Account,
) : ARepository(database, account) { ) : ARepository(database, account) {
open suspend fun updateFeed(feed: Feed) = database.newFeedDao().updateFeedFields(feed.id, feed.name!!, feed.url!!, feed.folderId) open suspend fun updateFeed(feed: Feed) =
database.newFeedDao().updateFeedFields(feed.id, feed.name!!, feed.url!!, feed.folderId)
open suspend fun deleteFeed(feed: Feed) = database.newFeedDao().delete(feed) open suspend fun deleteFeed(feed: Feed) = database.newFeedDao().delete(feed)
@ -82,4 +83,30 @@ abstract class BaseRepository(
open suspend fun setAllItemsReadByFolder(folderId: Int, accountId: Int) { open suspend fun setAllItemsReadByFolder(folderId: Int, accountId: Int) {
database.newItemDao().setAllItemsReadByFolder(folderId, accountId) database.newItemDao().setAllItemsReadByFolder(folderId, accountId)
} }
suspend fun insertOPMLFoldersAndFeeds(
foldersAndFeeds: Map<Folder?, List<Feed>>,
onUpdate: (Feed) -> Unit
) {
for ((folder, feeds) in foldersAndFeeds) {
if (folder != null) {
folder.accountId = account.id
val dbFolder = database.newFolderDao().selectFolderByName(folder.name!!, account.id)
if (dbFolder != null) {
folder.id = dbFolder.id
} else {
folder.id = database.newFolderDao().insert(folder).toInt()
}
}
feeds.forEach { it.folderId = folder?.id }
insertNewFeeds(
newFeeds = feeds,
onUpdate = onUpdate
)
}
}
} }

View File

@ -1,5 +1,6 @@
package com.readrops.app.compose.repositories package com.readrops.app.compose.repositories
import android.util.Log
import com.readrops.api.localfeed.LocalRSSDataSource import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.SyncResult import com.readrops.api.services.SyncResult
import com.readrops.api.utils.ApiUtils import com.readrops.api.utils.ApiUtils
@ -68,16 +69,19 @@ class LocalRSSRepository(
throw NotImplementedError("This method can't be called here") throw NotImplementedError("This method can't be called here")
override suspend fun insertNewFeeds(urls: List<String>) = withContext(Dispatchers.IO) { override suspend fun insertNewFeeds(newFeeds: List<Feed>, onUpdate: (Feed) -> Unit) = withContext(Dispatchers.IO) {
for (url in urls) { for (newFeed in newFeeds) {
try { onUpdate(newFeed)
val result = dataSource.queryRSSResource(url, null)!!
insertFeed(result.first) try {
} catch (e: Exception) { val result = dataSource.queryRSSResource(newFeed.url!!, null)!!
throw e insertFeed(result.first.also { it.folderId = newFeed.folderId })
} catch (e: Exception) {
Log.d("LocalRSSRepository", e.message.orEmpty())
//throw e
}
} }
} }
}
private suspend fun insertNewItems(items: List<Item>, feed: Feed) { private suspend fun insertNewItems(items: List<Item>, feed: Feed) {
items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation

View File

@ -1,8 +1,11 @@
package com.readrops.app.compose.util.components package com.readrops.app.compose.util.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import com.readrops.app.compose.util.theme.VeryShortSpacer import com.readrops.app.compose.util.theme.VeryShortSpacer
@Composable @Composable
@ -12,6 +15,24 @@ fun RefreshScreen(
feedMax: Int feedMax: Int
) { ) {
CenteredColumn { CenteredColumn {
RefreshIndicator(
currentFeed = currentFeed,
feedCount = feedCount,
feedMax = feedMax
)
}
}
@Composable
fun RefreshIndicator(
currentFeed: String,
feedCount: Int,
feedMax: Int
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
LinearProgressIndicator( LinearProgressIndicator(
progress = { feedCount.toFloat() / feedMax.toFloat() } progress = { feedCount.toFloat() / feedMax.toFloat() }
) )
@ -19,7 +40,8 @@ fun RefreshScreen(
VeryShortSpacer() VeryShortSpacer()
Text( Text(
text = "$currentFeed ($feedCount/$feedMax)" text = "$currentFeed ($feedCount/$feedMax)",
maxLines = 1
) )
} }
} }

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#727272"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M9,3L5,6.99h3L8,14h2L10,6.99h3L9,3zM16,17.01L16,10h-2v7.01h-3L15,21l4,-3.99h-3z"/>
</vector>

View File

@ -93,7 +93,7 @@
<string name="external_navigator">Navigateur externe</string> <string name="external_navigator">Navigateur externe</string>
<string name="actualize">Actualiser</string> <string name="actualize">Actualiser</string>
<string name="share_url">Partager le lien</string> <string name="share_url">Partager le lien</string>
<string name="opml_import_export">Importation/Exportation OPML</string> <string name="opml_import_export">Import/Export OPML</string>
<string name="opml_processing">Traitement du fichier OPML</string> <string name="opml_processing">Traitement du fichier OPML</string>
<string name="operation_takes_time">Cette opération peut prendre un certain temps car il faut interroger chaque flux.</string> <string name="operation_takes_time">Cette opération peut prendre un certain temps car il faut interroger chaque flux.</string>
<string name="processing_file_failed">Une erreur s\'est produite lors du traitement du fichier</string> <string name="processing_file_failed">Une erreur s\'est produite lors du traitement du fichier</string>

View File

@ -18,4 +18,7 @@ interface NewFolderDao : NewBaseDao<Folder> {
@Query("Select * From Folder Where account_id = :accountId") @Query("Select * From Folder Where account_id = :accountId")
fun selectFolders(accountId: Int): Flow<List<Folder>> fun selectFolders(accountId: Int): Flow<List<Folder>>
@Query("Select * From Folder Where name = :name And account_id = :accountId")
suspend fun selectFolderByName(name: String, accountId: Int): Folder?
} }