Convert Batch Add to Compose + Voyager

This commit is contained in:
Jobobby04 2022-11-30 13:59:58 -05:00
parent 0142e0f771
commit c14b7879a4
5 changed files with 322 additions and 367 deletions

View File

@ -1,168 +1,16 @@
package exh.ui.batchadd
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import androidx.core.view.isVisible
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dev.chrisbanes.insetter.applyInsetter
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.EhFragmentBatchAddBinding
import eu.kanade.tachiyomi.ui.base.controller.NucleusController
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import reactivecircus.flowbinding.android.view.clicks
import kotlin.time.Duration.Companion.seconds
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.navigator.Navigator
import eu.kanade.tachiyomi.ui.base.controller.BasicFullComposeController
/**
* Batch add screen
*/
class BatchAddController : NucleusController<EhFragmentBatchAddBinding, BatchAddPresenter>() {
override fun getTitle() = activity!!.getString(R.string.batch_add)
class BatchAddController : BasicFullComposeController() {
override fun createPresenter() = BatchAddPresenter()
override fun createBinding(inflater: LayoutInflater) = EhFragmentBatchAddBinding.inflate(inflater)
override fun onViewCreated(view: View) {
super.onViewCreated(view)
binding.btnAddGalleries.clicks()
.onEach {
addGalleries(binding.galleriesBox.text.toString())
}
.launchIn(viewScope)
binding.progressDismissBtn.clicks()
.onEach {
presenter.currentlyAddingFlow.value = BatchAddPresenter.STATE_PROGRESS_TO_INPUT
}
.launchIn(viewScope)
binding.scrollView.applyInsetter {
type(navigationBars = true) {
padding()
}
}
presenter.currentlyAddingFlow
.buffer(capacity = 0, onBufferOverflow = BufferOverflow.SUSPEND)
.mapLatest { event ->
when (event) {
BatchAddPresenter.STATE_INPUT_TO_PROGRESS -> coroutineScope {
showProgress(binding)
presenter.progressFlow
.buffer(capacity = Channel.RENDEZVOUS)
.combine(presenter.progressTotalFlow) { progress, total ->
// Show hide dismiss button
binding.progressDismissBtn.isVisible = progress == total
formatProgress(progress, total)
}
.onEach {
binding.progressText.text = it
}
.launchIn(this)
presenter.progressTotalFlow
.buffer(capacity = Channel.RENDEZVOUS)
.onEach {
binding.progressBar.max = it
}
.launchIn(this)
presenter.progressFlow
.buffer(capacity = Channel.RENDEZVOUS)
.onEach {
binding.progressBar.progress = it
}
.launchIn(this)
presenter.eventFlow
?.buffer(capacity = Channel.RENDEZVOUS)
?.onEach {
binding.progressLog.append("$it\n")
}
?.launchIn(this)
Unit
}
BatchAddPresenter.STATE_PROGRESS_TO_INPUT -> {
hideProgress(binding)
presenter.currentlyAddingFlow.value = BatchAddPresenter.STATE_IDLE
}
}
}
.launchIn(viewScope)
}
private val EhFragmentBatchAddBinding.progressViews
get() = listOf(
progressTitleView,
progressLog,
progressBar,
progressText,
)
private val EhFragmentBatchAddBinding.inputViews
get() = listOf(
inputTitleView,
galleriesBox,
btnAddGalleries,
)
private var List<View>.isVisible: Boolean
get() = throw UnsupportedOperationException()
set(v) {
forEach { it.isVisible = v }
}
private fun showProgress(target: EhFragmentBatchAddBinding = binding) {
target.apply {
viewScope.launch {
inputViews.isVisible = false
delay(0.5.seconds)
progressViews.isVisible = true
}
}.progressLog.text = ""
}
private fun hideProgress(target: EhFragmentBatchAddBinding = binding) {
target.apply {
viewScope.launch {
progressViews.isVisible = false
binding.progressDismissBtn.isVisible = false
delay(0.5.seconds)
inputViews.isVisible = true
}
}.galleriesBox.setText("", TextView.BufferType.EDITABLE)
}
private fun formatProgress(progress: Int, total: Int) = "$progress/$total"
private fun addGalleries(galleries: String) {
// Check text box has content
if (galleries.isBlank()) {
noGalleriesSpecified()
return
}
presenter.addGalleries(applicationContext!!, galleries)
}
private fun noGalleriesSpecified() {
activity?.let {
MaterialAlertDialogBuilder(it)
.setTitle(R.string.batch_add_no_valid_galleries)
.setMessage(R.string.batch_add_no_valid_galleries_message)
.setPositiveButton(android.R.string.ok, null)
.show()
}
@Composable
override fun ComposeContent() {
Navigator(screen = BatchAddScreen())
}
}

View File

@ -1,93 +0,0 @@
package exh.ui.batchadd
import android.content.Context
import eu.kanade.domain.UnsortedPreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.ui.base.presenter.BasePresenter
import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.GalleryAddEvent
import exh.GalleryAdder
import exh.log.xLogE
import exh.util.trimOrNull
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy
class BatchAddPresenter : BasePresenter<BatchAddController>() {
private val preferences: UnsortedPreferences by injectLazy()
private val galleryAdder by lazy { GalleryAdder() }
val progressTotalFlow = MutableStateFlow(0)
val progressFlow = MutableStateFlow(0)
var eventFlow: MutableSharedFlow<String>? = null
val currentlyAddingFlow = MutableStateFlow(STATE_IDLE)
fun addGalleries(context: Context, galleries: String) {
eventFlow = MutableSharedFlow(1)
val splitGalleries = if (ehVisitedRegex.containsMatchIn(galleries)) {
val url = if (preferences.enableExhentai().get()) {
"https://exhentai.org/g/"
} else {
"https://e-hentai.org/g/"
}
ehVisitedRegex.findAll(galleries).map { galleryKeys ->
val linkParts = galleryKeys.value.split(".")
url + linkParts[0] + "/" + linkParts[1].replace(":", "")
}.toList()
} else {
galleries.split("\n")
.mapNotNull(String::trimOrNull)
}
progressFlow.value = 0
progressTotalFlow.value = splitGalleries.size
currentlyAddingFlow.value = STATE_INPUT_TO_PROGRESS
val handler = CoroutineExceptionHandler { _, throwable ->
xLogE("Batch add error", throwable)
}
presenterScope.launch(Dispatchers.IO + handler) {
val succeeded = mutableListOf<String>()
val failed = mutableListOf<String>()
splitGalleries.forEachIndexed { i, s ->
ensureActive()
val result = withIOContext { galleryAdder.addGallery(context, s, true) }
if (result is GalleryAddEvent.Success) {
succeeded.add(s)
} else {
failed.add(s)
}
progressFlow.value = i + 1
eventFlow?.emit(
(
when (result) {
is GalleryAddEvent.Success -> context.getString(R.string.batch_add_ok)
is GalleryAddEvent.Fail -> context.getString(R.string.batch_add_error)
}
) + " " + result.logMessage,
)
}
// Show report
val summary = context.getString(R.string.batch_add_summary, succeeded.size, failed.size)
eventFlow?.emit(summary)
}
}
companion object {
const val STATE_IDLE = 0
const val STATE_INPUT_TO_PROGRESS = 1
const val STATE_PROGRESS_TO_INPUT = 2
val ehVisitedRegex = """[0-9]*?\.[a-z0-9]*?:""".toRegex()
}
}

View File

@ -0,0 +1,174 @@
package exh.ui.batchadd
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.core.model.rememberScreenModel
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import eu.kanade.presentation.components.AppBar
import eu.kanade.presentation.components.Button
import eu.kanade.presentation.components.LazyColumn
import eu.kanade.presentation.components.Scaffold
import eu.kanade.presentation.util.LocalRouter
import eu.kanade.presentation.util.padding
import eu.kanade.presentation.util.plus
import eu.kanade.tachiyomi.R
class BatchAddScreen : Screen {
@Composable
override fun Content() {
val screenModel = rememberScreenModel { BatchAddScreenModel() }
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
val router = LocalRouter.currentOrThrow
val context = LocalContext.current
Scaffold(
topBar = { scrollBehavior ->
AppBar(
title = stringResource(R.string.batch_add),
navigateUp = {
when {
navigator.canPop -> navigator.pop()
else -> router.popCurrentController()
}
},
scrollBehavior = scrollBehavior,
)
},
) { paddingValues ->
when (state.state) {
BatchAddScreenModel.State.INPUT -> {
Column(
Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(MaterialTheme.padding.medium),
) {
Text(text = stringResource(R.string.eh_batch_add_title), style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp))
TextField(
value = state.galleries,
onValueChange = screenModel::updateGalleries,
modifier = Modifier.fillMaxWidth(),
placeholder = {
Text(
text = stringResource(R.string.eh_batch_add_description),
)
},
keyboardOptions = KeyboardOptions(autoCorrect = false),
textStyle = MaterialTheme.typography.bodyLarge,
)
Spacer(Modifier.height(8.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { screenModel.addGalleries(context) },
) {
Text(text = stringResource(R.string.eh_batch_add_button))
}
}
}
BatchAddScreenModel.State.PROGRESS -> {
LazyColumn(
contentPadding = paddingValues + PaddingValues(MaterialTheme.padding.medium),
) {
item(key = "top") {
Column {
Text(text = stringResource(R.string.eh_batch_add_adding_galleries), style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(8.dp))
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
LinearProgressIndicator(
progress = state.progress.toFloat() / state.progressTotal,
Modifier
.padding(top = 2.dp)
.weight(1f),
)
Text(
text = state.progress.toString() + "/" + state.progressTotal,
modifier = Modifier.weight(0.15f),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(12.dp))
}
}
itemsIndexed(
state.events,
key = { index, text -> index + text.hashCode() },
) { _, text ->
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
}
if (state.progress == state.progressTotal) {
item(key = "finish") {
Column {
Spacer(modifier = Modifier.height(8.dp))
Button(
modifier = Modifier.fillMaxWidth(),
onClick = screenModel::finish,
) {
Text(text = stringResource(R.string.eh_batch_add_finish))
}
}
}
}
}
}
}
}
val onDismissRequest = screenModel::dismissDialog
when (state.dialog) {
BatchAddScreenModel.Dialog.NoGalleriesSpecified -> AlertDialog(
onDismissRequest = onDismissRequest,
confirmButton = {
TextButton(onClick = onDismissRequest) {
Text(text = stringResource(android.R.string.ok))
}
},
title = {
Text(text = stringResource(R.string.batch_add_no_valid_galleries))
},
text = {
Text(text = stringResource(R.string.batch_add_no_valid_galleries_message))
},
)
null -> Unit
}
}
}

View File

@ -0,0 +1,141 @@
package exh.ui.batchadd
import android.content.Context
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.coroutineScope
import eu.kanade.domain.UnsortedPreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.GalleryAddEvent
import exh.GalleryAdder
import exh.log.xLogE
import exh.util.trimOrNull
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
class BatchAddScreenModel(
private val unsortedPreferences: UnsortedPreferences = Injekt.get(),
) : StateScreenModel<BatchAddState>(BatchAddState()) {
private val galleryAdder by lazy { GalleryAdder() }
fun addGalleries(context: Context) {
val galleries = state.value.galleries
// Check text box has content
if (galleries.isBlank()) {
mutableState.update { it.copy(dialog = Dialog.NoGalleriesSpecified) }
return
}
addGalleries(context, galleries)
}
private fun addGalleries(context: Context, galleries: String) {
val splitGalleries = if (ehVisitedRegex.containsMatchIn(galleries)) {
val url = if (unsortedPreferences.enableExhentai().get()) {
"https://exhentai.org/g/"
} else {
"https://e-hentai.org/g/"
}
ehVisitedRegex.findAll(galleries).map { galleryKeys ->
val linkParts = galleryKeys.value.split(".")
url + linkParts[0] + "/" + linkParts[1].replace(":", "")
}.toList()
} else {
galleries.split("\n")
.mapNotNull(String::trimOrNull)
}
mutableState.update { state ->
state.copy(
progress = 0,
progressTotal = splitGalleries.size,
state = State.PROGRESS,
)
}
val handler = CoroutineExceptionHandler { _, throwable ->
xLogE("Batch add error", throwable)
}
coroutineScope.launch(Dispatchers.IO + handler) {
val succeeded = mutableListOf<String>()
val failed = mutableListOf<String>()
splitGalleries.forEachIndexed { i, s ->
ensureActive()
val result = withIOContext { galleryAdder.addGallery(context, s, true) }
if (result is GalleryAddEvent.Success) {
succeeded.add(s)
} else {
failed.add(s)
}
mutableState.update { state ->
state.copy(
progress = i + 1,
events = state.events.plus(
when (result) {
is GalleryAddEvent.Success -> context.getString(R.string.batch_add_ok)
is GalleryAddEvent.Fail -> context.getString(R.string.batch_add_error)
} + " " + result.logMessage,
),
)
}
}
// Show report
val summary = context.getString(R.string.batch_add_summary, succeeded.size, failed.size)
mutableState.update { state ->
state.copy(
events = state.events + summary,
)
}
}
}
fun finish() {
mutableState.update { state ->
state.copy(
progressTotal = 0,
progress = 0,
galleries = "",
state = State.INPUT,
events = emptyList(),
)
}
}
fun updateGalleries(galleries: String) {
mutableState.update { it.copy(galleries = galleries) }
}
fun dismissDialog() {
mutableState.update { it.copy(dialog = null) }
}
enum class State {
INPUT,
PROGRESS,
}
sealed class Dialog {
object NoGalleriesSpecified : Dialog()
}
companion object {
val ehVisitedRegex = """[0-9]*?\.[a-z0-9]*?:""".toRegex()
}
}
data class BatchAddState(
val progressTotal: Int = 0,
val progress: Int = 0,
val galleries: String = "",
val state: BatchAddScreenModel.State = BatchAddScreenModel.State.INPUT,
val events: List<String> = emptyList(),
val dialog: BatchAddScreenModel.Dialog? = null,
)

View File

@ -1,115 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.core.widget.NestedScrollView
android:id="@+id/scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true"
android:padding="16dp">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/input_title_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/eh_batch_add_title"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:visibility="visible"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<eu.kanade.tachiyomi.widget.TachiyomiTextInputEditText
android:id="@+id/galleries_box"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:ems="10"
android:gravity="top"
android:hint="@string/eh_batch_add_description"
android:inputType="textUri|textMultiLine|textNoSuggestions"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/input_title_view" />
<Button
android:id="@+id/btn_add_galleries"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/eh_batch_add_button"
android:visibility="visible"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/galleries_box" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/progress_title_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/eh_batch_add_adding_galleries"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/progress_bar_text"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="8dp"
android:orientation="horizontal"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progress_title_view">
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:paddingTop="2dp"
android:visibility="gone" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/progress_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="0.15"
android:background="#00000000"
android:scrollHorizontally="false"
android:textAlignment="center"
android:visibility="gone" />
</LinearLayout>
<com.google.android.material.textview.MaterialTextView
android:id="@+id/progress_log"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textAlignment="center"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progress_bar_text" />
<Button
android:id="@+id/progress_dismiss_btn"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/eh_batch_add_finish"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/progress_log" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>