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.graphics.drawable.AdaptiveIconDrawable
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement 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.foundation.layout.size
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme 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.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.asImageBitmap 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.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.api.utils.ApiUtils
import com.readrops.app.BuildConfig import com.readrops.app.BuildConfig
import com.readrops.app.R import com.readrops.app.R
import com.readrops.app.account.OPMLImportProgressDialog
import com.readrops.app.account.credentials.AccountCredentialsScreen import com.readrops.app.account.credentials.AccountCredentialsScreen
import com.readrops.app.account.credentials.AccountCredentialsScreenMode import com.readrops.app.account.credentials.AccountCredentialsScreenMode
import com.readrops.app.home.HomeScreen 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.AndroidScreen
import com.readrops.app.util.components.SelectableImageText import com.readrops.app.util.components.SelectableImageText
import com.readrops.app.util.theme.LargeSpacer import com.readrops.app.util.theme.LargeSpacer
@ -48,28 +58,49 @@ class AccountSelectionScreen : AndroidScreen() {
@Composable @Composable
override fun Content() { override fun Content() {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val screenModel = getScreenModel<AccountSelectionScreenModel>() val screenModel = getScreenModel<AccountSelectionScreenModel>()
val state by screenModel.state.collectAsStateWithLifecycle() 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 -> { is NavState.GoToHomeScreen -> {
// using replace makes the app crash due to a screen key conflict // using replace makes the app crash due to a screen key conflict
navigator.replaceAll(HomeScreen()) navigator.replaceAll(HomeScreen())
} }
is NavState.GoToAccountCredentialsScreen -> { is NavState.GoToAccountCredentialsScreen -> {
val accountType = (state as NavState.GoToAccountCredentialsScreen).accountType val accountType =
(state.navState as NavState.GoToAccountCredentialsScreen).accountType
val account = Account( val account = Account(
accountType = accountType, accountType = accountType,
accountName = stringResource(id = accountType.typeName) accountName = stringResource(id = accountType.typeName)
) )
navigator.push( navigator.push(
AccountCredentialsScreen( AccountCredentialsScreen(account, AccountCredentialsScreenMode.NEW_CREDENTIALS)
account,
AccountCredentialsScreenMode.NEW_CREDENTIALS
)
) )
screenModel.resetNavState() screenModel.resetNavState()
} }
@ -77,105 +108,112 @@ class AccountSelectionScreen : AndroidScreen() {
else -> {} else -> {}
} }
Column( Scaffold(
modifier = Modifier.fillMaxSize() snackbarHost = { SnackbarHost(snackbarHostState) }
) { ) { paddingValues ->
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.weight(1f) .padding(paddingValues)
.padding(MaterialTheme.spacing.mediumSpacing)
) { ) {
Image( Column(
painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), horizontalAlignment = Alignment.CenterHorizontally,
contentDescription = null, verticalArrangement = Arrangement.Center,
modifier = Modifier.size(64.dp) 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(
text = stringResource(id = R.string.app_name), text = stringResource(id = R.string.app_name),
style = MaterialTheme.typography.headlineLarge style = MaterialTheme.typography.headlineLarge,
) )
LargeSpacer() LargeSpacer()
Card { Card {
Column( Column {
modifier = Modifier.padding(MaterialTheme.spacing.largeSpacing) Text(
) { text = stringResource(id = R.string.choose_account),
Text( style = MaterialTheme.typography.titleMedium,
text = stringResource(id = R.string.choose_account), modifier = Modifier.align(Alignment.CenterHorizontally)
style = MaterialTheme.typography.titleMedium, .padding(top = MaterialTheme.spacing.mediumSpacing)
modifier = Modifier.align(Alignment.CenterHorizontally) )
)
MediumSpacer() MediumSpacer()
Text( Text(
text = stringResource(id = R.string.local), text = stringResource(id = R.string.local),
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
) modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
)
SelectableImageText( SelectableImageText(
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
text = stringResource(id = AccountType.LOCAL.typeName), text = stringResource(id = AccountType.LOCAL.typeName),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
imageSize = 24.dp, imageSize = 24.dp,
onClick = { screenModel.createAccount(AccountType.LOCAL) } onClick = { screenModel.createAccount(AccountType.LOCAL) }
) )
SelectableImageText( SelectableImageText(
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher), image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
text = stringResource(id = R.string.opml_import), text = stringResource(id = R.string.opml_import),
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
spacing = MaterialTheme.spacing.mediumSpacing, spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing, padding = MaterialTheme.spacing.mediumSpacing,
imageSize = 24.dp, imageSize = 24.dp,
onClick = { } onClick = { opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) }
) )
MediumSpacer() MediumSpacer()
Text( Text(
text = "External", text = "External",
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
) modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
)
ShortSpacer() AccountType.entries.filter { it != AccountType.LOCAL }
.forEach { accountType ->
AccountType.entries.filter { it != AccountType.LOCAL } SelectableImageText(
.forEach { accountType -> image = adaptiveIconPainterResource(id = accountType.iconRes),
SelectableImageText( text = stringResource(id = accountType.typeName),
image = adaptiveIconPainterResource(id = accountType.iconRes), style = MaterialTheme.typography.bodyLarge,
text = stringResource(id = accountType.typeName), imageSize = 24.dp,
style = MaterialTheme.typography.bodyLarge, spacing = MaterialTheme.spacing.mediumSpacing,
imageSize = 24.dp, padding = MaterialTheme.spacing.mediumSpacing,
spacing = MaterialTheme.spacing.mediumSpacing, onClick = { screenModel.createAccount(accountType) }
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 package com.readrops.app.account.selection
import android.content.Context 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.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope 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.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.Account
import com.readrops.db.entities.account.AccountType import com.readrops.db.entities.account.AccountType
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
@ -13,11 +19,12 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.get import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
class AccountSelectionScreenModel( class AccountSelectionScreenModel(
private val database: Database, private val database: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : StateScreenModel<NavState>(NavState.Idle), KoinComponent { ) : StateScreenModel<AccountSelectionState>(AccountSelectionState()), KoinComponent {
fun accountExists(): Boolean { fun accountExists(): Boolean {
val accountCount = runBlocking { val accountCount = runBlocking {
@ -29,35 +36,98 @@ class AccountSelectionScreenModel(
fun createAccount(accountType: AccountType) { fun createAccount(accountType: AccountType) {
if (accountType == AccountType.LOCAL) { if (accountType == AccountType.LOCAL) {
createLocalAccount() screenModelScope.launch(dispatcher) {
createLocalAccount()
mutableState.update { it.copy(navState = NavState.GoToHomeScreen) }
}
} else { } else {
mutableState.update { NavState.GoToAccountCredentialsScreen(accountType) } mutableState.update {
it.copy(navState = NavState.GoToAccountCredentialsScreen(accountType))
}
} }
} }
fun resetNavState() { 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 context = get<Context>()
val account = Account( val account = Account(
url = null, url = null,
accountName = context.getString(AccountType.LOCAL.typeName), accountName = context.getString(AccountType.LOCAL.typeName),
accountType = AccountType.LOCAL, accountType = AccountType.LOCAL,
isCurrentAccount = true isCurrentAccount = true
) )
screenModelScope.launch(dispatcher) { account.id = database.accountDao().insert(account).toInt()
database.accountDao().insert(account) 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 { sealed class NavState {
object Idle : NavState() data object Idle : NavState()
object GoToHomeScreen : NavState() data object GoToHomeScreen : NavState()
class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState() class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
} }