Add OPML import in AccountSelectionScreen

This commit is contained in:
Shinokuni 2024-07-11 12:26:10 +02:00
parent 36d9aef2bb
commit 7ad5e0bc54
2 changed files with 207 additions and 99 deletions

View File

@ -2,6 +2,8 @@ package com.readrops.app.account.selection
import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
@ -11,9 +13,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
@ -29,11 +36,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.api.utils.ApiUtils
import com.readrops.app.BuildConfig
import com.readrops.app.R
import com.readrops.app.account.OPMLImportProgressDialog
import com.readrops.app.account.credentials.AccountCredentialsScreen
import com.readrops.app.account.credentials.AccountCredentialsScreenMode
import com.readrops.app.home.HomeScreen
import com.readrops.app.util.ErrorMessage
import com.readrops.app.util.components.AndroidScreen
import com.readrops.app.util.components.SelectableImageText
import com.readrops.app.util.theme.LargeSpacer
@ -48,28 +58,49 @@ class AccountSelectionScreen : AndroidScreen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val screenModel = getScreenModel<AccountSelectionScreenModel>()
val state by screenModel.state.collectAsStateWithLifecycle()
when (state) {
val opmlImportLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { screenModel.parseOPMLFile(uri, context) }
}
val snackbarHostState = remember { SnackbarHostState() }
if (state.showOPMLImportDialog) {
OPMLImportProgressDialog(
currentFeed = state.currentFeed,
feedCount = state.feedCount,
feedMax = state.feedMax
)
}
LaunchedEffect(state.exception) {
if (state.exception != null) {
snackbarHostState.showSnackbar(ErrorMessage.get(state.exception!!, context))
screenModel.resetException()
}
}
when (state.navState) {
is NavState.GoToHomeScreen -> {
// using replace makes the app crash due to a screen key conflict
navigator.replaceAll(HomeScreen())
}
is NavState.GoToAccountCredentialsScreen -> {
val accountType = (state as NavState.GoToAccountCredentialsScreen).accountType
val accountType =
(state.navState as NavState.GoToAccountCredentialsScreen).accountType
val account = Account(
accountType = accountType,
accountName = stringResource(id = accountType.typeName)
)
navigator.push(
AccountCredentialsScreen(
account,
AccountCredentialsScreenMode.NEW_CREDENTIALS
)
AccountCredentialsScreen(account, AccountCredentialsScreenMode.NEW_CREDENTIALS)
)
screenModel.resetNavState()
}
@ -77,105 +108,112 @@ class AccountSelectionScreen : AndroidScreen() {
else -> {}
}
Column(
modifier = Modifier.fillMaxSize()
) {
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) }
) { paddingValues ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.weight(1f)
.padding(MaterialTheme.spacing.mediumSpacing)
.padding(paddingValues)
) {
Image(
painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.weight(1f)
.padding(MaterialTheme.spacing.mediumSpacing)
) {
Image(
painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
ShortSpacer()
ShortSpacer()
Text(
text = stringResource(id = R.string.app_name),
style = MaterialTheme.typography.headlineLarge
)
Text(
text = stringResource(id = R.string.app_name),
style = MaterialTheme.typography.headlineLarge,
)
LargeSpacer()
LargeSpacer()
Card {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.largeSpacing)
) {
Text(
text = stringResource(id = R.string.choose_account),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Card {
Column {
Text(
text = stringResource(id = R.string.choose_account),
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.align(Alignment.CenterHorizontally)
.padding(top = MaterialTheme.spacing.mediumSpacing)
)
MediumSpacer()
MediumSpacer()
Text(
text = stringResource(id = R.string.local),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = stringResource(id = R.string.local),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
)
SelectableImageText(
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
text = stringResource(id = AccountType.LOCAL.typeName),
style = MaterialTheme.typography.bodyLarge,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
imageSize = 24.dp,
onClick = { screenModel.createAccount(AccountType.LOCAL) }
)
SelectableImageText(
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
text = stringResource(id = AccountType.LOCAL.typeName),
style = MaterialTheme.typography.bodyLarge,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
imageSize = 24.dp,
onClick = { screenModel.createAccount(AccountType.LOCAL) }
)
SelectableImageText(
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
text = stringResource(id = R.string.opml_import),
style = MaterialTheme.typography.bodyLarge,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
imageSize = 24.dp,
onClick = { }
)
SelectableImageText(
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
text = stringResource(id = R.string.opml_import),
style = MaterialTheme.typography.bodyLarge,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
imageSize = 24.dp,
onClick = { opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) }
)
MediumSpacer()
MediumSpacer()
Text(
text = "External",
style = MaterialTheme.typography.bodyMedium
)
Text(
text = "External",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
)
ShortSpacer()
AccountType.entries.filter { it != AccountType.LOCAL }
.forEach { accountType ->
SelectableImageText(
image = adaptiveIconPainterResource(id = accountType.iconRes),
text = stringResource(id = accountType.typeName),
style = MaterialTheme.typography.bodyLarge,
imageSize = 24.dp,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
onClick = { screenModel.createAccount(accountType) }
)
}
AccountType.entries.filter { it != AccountType.LOCAL }
.forEach { accountType ->
SelectableImageText(
image = adaptiveIconPainterResource(id = accountType.iconRes),
text = stringResource(id = accountType.typeName),
style = MaterialTheme.typography.bodyLarge,
imageSize = 24.dp,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
onClick = { screenModel.createAccount(accountType) }
)
}
}
}
}
Text(
text = "v${BuildConfig.VERSION_NAME}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = MaterialTheme.spacing.veryShortSpacing)
)
}
Text(
text = "v${BuildConfig.VERSION_NAME}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = MaterialTheme.spacing.largeSpacing)
)
}
}
}

View File

@ -1,9 +1,15 @@
package com.readrops.app.account.selection
import android.content.Context
import android.net.Uri
import androidx.core.net.toFile
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import com.readrops.api.opml.OPMLParser
import com.readrops.app.repositories.BaseRepository
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.entities.account.AccountType
import kotlinx.coroutines.CoroutineDispatcher
@ -13,11 +19,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
class AccountSelectionScreenModel(
private val database: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<NavState>(NavState.Idle), KoinComponent {
private val database: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<AccountSelectionState>(AccountSelectionState()), KoinComponent {
fun accountExists(): Boolean {
val accountCount = runBlocking {
@ -29,35 +36,98 @@ class AccountSelectionScreenModel(
fun createAccount(accountType: AccountType) {
if (accountType == AccountType.LOCAL) {
createLocalAccount()
screenModelScope.launch(dispatcher) {
createLocalAccount()
mutableState.update { it.copy(navState = NavState.GoToHomeScreen) }
}
} else {
mutableState.update { NavState.GoToAccountCredentialsScreen(accountType) }
mutableState.update {
it.copy(navState = NavState.GoToAccountCredentialsScreen(accountType))
}
}
}
fun resetNavState() {
mutableState.update { NavState.Idle }
mutableState.update { it.copy(navState = NavState.Idle) }
}
private fun createLocalAccount() {
private suspend fun createLocalAccount(): Account {
val context = get<Context>()
val account = Account(
url = null,
accountName = context.getString(AccountType.LOCAL.typeName),
accountType = AccountType.LOCAL,
isCurrentAccount = true
url = null,
accountName = context.getString(AccountType.LOCAL.typeName),
accountType = AccountType.LOCAL,
isCurrentAccount = true
)
screenModelScope.launch(dispatcher) {
database.accountDao().insert(account)
account.id = database.accountDao().insert(account).toInt()
return account
}
mutableState.update { NavState.GoToHomeScreen }
fun parseOPMLFile(uri: Uri, context: Context) {
screenModelScope.launch(dispatcher) {
val foldersAndFeeds: Map<Folder?, List<Feed>>
try {
val stream = context.contentResolver.openInputStream(uri)
if (stream == null) {
mutableState.update { it.copy(exception = NoSuchFileException(uri.toFile())) }
return@launch
}
foldersAndFeeds = OPMLParser.read(stream)
} catch (e: Exception) {
mutableState.update { it.copy(exception = e) }
return@launch
}
mutableState.update {
it.copy(
showOPMLImportDialog = true,
currentFeed = foldersAndFeeds.values.first().first().name!!,
feedCount = 0,
feedMax = foldersAndFeeds.values.flatten().size
)
}
val account = createLocalAccount()
val repository = get<BaseRepository> { parametersOf(account) }
repository.insertOPMLFoldersAndFeeds(
foldersAndFeeds = foldersAndFeeds,
onUpdate = { feed ->
mutableState.update {
it.copy(
currentFeed = feed.name!!,
feedCount = it.feedCount + 1
)
}
}
)
mutableState.update {
it.copy(
showOPMLImportDialog = false,
navState = NavState.GoToHomeScreen
)
}
}
}
fun resetException() = mutableState.update { it.copy(exception = null) }
}
data class AccountSelectionState(
val showOPMLImportDialog: Boolean = false,
val navState: NavState = NavState.Idle,
val exception: Exception? = null,
val currentFeed: String = "",
val feedCount: Int = 0,
val feedMax: Int = 0
)
sealed class NavState {
object Idle : NavState()
object GoToHomeScreen : NavState()
data object Idle : NavState()
data object GoToHomeScreen : NavState()
class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
}