refactor: Use Compose for EmptyView

This commit is contained in:
Ahmad Ansori Palembani 2024-12-23 20:58:30 +07:00
parent b1665eaedf
commit 5824ac81c2
Signed by: null2264
GPG key ID: BA64F8B60AF3EFB6
13 changed files with 228 additions and 95 deletions

View file

@ -5,6 +5,8 @@ import android.content.Context
import android.util.AttributeSet
import android.view.MenuItem
import android.widget.LinearLayout
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileDownloadOff
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isInvisible
import androidx.core.view.updateLayoutParams
@ -212,7 +214,7 @@ class DownloadBottomSheet @JvmOverloads constructor(
setBottomSheet()
if (presenter.downloadQueueState.value.isEmpty()) {
binding.emptyView.show(
R.drawable.ic_download_off_24dp,
Icons.Filled.FileDownloadOff,
MR.strings.nothing_is_downloading,
)
} else {

View file

@ -28,6 +28,8 @@ import android.widget.ImageView
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HeartBroken
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.animation.doOnEnd
import androidx.core.view.ViewCompat
@ -1135,7 +1137,7 @@ open class LibraryController(
binding.emptyView.hide()
} else {
binding.emptyView.show(
R.drawable.ic_heart_off_24dp,
Icons.Filled.HeartBroken,
if (hasActiveFilters) {
MR.strings.no_matches_for_filters
} else {

View file

@ -18,6 +18,8 @@ import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.PopupMenu
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SearchOff
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.net.toUri
import androidx.core.view.WindowInsetsCompat.Type.systemBars
@ -31,7 +33,6 @@ import com.google.android.material.datepicker.MaterialDatePicker
import com.mikepenz.fastadapter.FastAdapter
import com.mikepenz.fastadapter.adapters.ItemAdapter
import com.mikepenz.fastadapter.listeners.addClickListener
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.model.TrackSearch
@ -317,7 +318,7 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) :
if (results.isEmpty()) {
setMiddleTrackView(binding.searchEmptyView.id)
binding.searchEmptyView.show(
R.drawable.ic_search_off_24dp,
Icons.Filled.SearchOff,
MR.strings.no_results_found,
)
} else {
@ -338,7 +339,7 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) :
binding.trackSearchRecycler.isVisible = false
searchItemAdapter.clear()
binding.searchEmptyView.show(
R.drawable.ic_search_off_24dp,
Icons.Filled.SearchOff,
error.message ?: "",
)
}

View file

@ -12,6 +12,8 @@ import android.view.View
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.SearchView
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HeartBroken
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.graphics.ColorUtils
import androidx.core.util.Pair
@ -458,7 +460,10 @@ class StatsDetailsController :
with(binding ?: headerBinding) {
val hasNoData = currentStats.isNullOrEmpty() || currentStats.all { it.count == 0 }
if (hasNoData) {
this@StatsDetailsController.binding.noChartData.show(R.drawable.ic_heart_off_24dp, MR.strings.no_data_for_filters)
this@StatsDetailsController.binding.noChartData.show(
Icons.Filled.HeartBroken,
MR.strings.no_data_for_filters,
)
presenter.currentStats?.removeAll { it.count == 0 }
handleNoChartLayout()
this?.statsPieChart?.isVisible = false

View file

@ -12,6 +12,9 @@ import android.view.RoundedCorner
import android.view.View
import android.view.ViewGroup
import androidx.activity.BackEventCompat
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.HistoryToggleOff
import androidx.compose.material.icons.filled.SearchOff
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
@ -596,9 +599,9 @@ class RecentsController(bundle: Bundle? = null) :
if (recents.isEmpty()) {
binding.recentsEmptyView.show(
if (!isSearching()) {
R.drawable.ic_history_off_24dp
Icons.Filled.HistoryToggleOff
} else {
R.drawable.ic_search_off_24dp
Icons.Filled.SearchOff
},
if (isSearching()) {
MR.strings.no_results_found

View file

@ -75,8 +75,8 @@ class RecentsPresenter(
}
private val newAdditionsHeader = RecentMangaHeaderItem(RecentMangaHeaderItem.NEWLY_ADDED)
private val newChaptersHeader = RecentMangaHeaderItem(RecentMangaHeaderItem.NEW_CHAPTERS)
private val continueReadingHeader =
RecentMangaHeaderItem(RecentMangaHeaderItem.CONTINUE_READING)
private val continueReadingHeader = RecentMangaHeaderItem(RecentMangaHeaderItem.CONTINUE_READING)
var finished = false
private var shouldMoveToTop = false
var viewType: RecentsViewType = RecentsViewType.valueOf(uiPreferences.recentsViewType().get())

View file

@ -7,6 +7,8 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Book
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.forEach
import androidx.core.view.isInvisible
@ -193,7 +195,7 @@ class ClearDatabaseController :
binding.emptyView.hide()
} else {
binding.emptyView.show(
R.drawable.ic_book_24dp,
Icons.Filled.Book,
MR.strings.database_clean,
)
}

View file

@ -8,6 +8,8 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExploreOff
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible
@ -62,7 +64,6 @@ import eu.kanade.tachiyomi.widget.EmptyView
import eu.kanade.tachiyomi.widget.LinearLayoutManagerAccurateOffset
import kotlin.math.roundToInt
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import uy.kohesive.injekt.injectLazy
@ -577,7 +578,7 @@ open class BrowseSourceController(bundle: Bundle) :
snack?.dismiss()
val message = getErrorMessage(error)
val retryAction = View.OnClickListener {
val retryAction = {
// If not the first page, show bottom binding.progress bar.
if (adapter.mainItemCount > 0 && progressItem != null) {
adapter.addScrollableFooterWithDelay(progressItem!!, 0, true)
@ -606,16 +607,16 @@ open class BrowseSourceController(bundle: Bundle) :
binding.emptyView.show(
if (presenter.source is HttpSource) {
R.drawable.ic_browse_off_24dp
EmptyView.Image.Vector(Icons.Filled.ExploreOff)
} else {
R.drawable.ic_local_library_24dp
EmptyView.Image.ResourceVector(R.drawable.ic_local_library_24dp)
},
message,
actions,
)
} else {
snack = binding.sourceLayout.snack(message, Snackbar.LENGTH_INDEFINITE) {
setAction(MR.strings.retry, retryAction)
setAction(MR.strings.retry) { retryAction() }
}
}
if (isControllerVisible) {

View file

@ -0,0 +1,12 @@
package eu.kanade.tachiyomi.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.platform.LocalConfiguration
import eu.kanade.tachiyomi.util.system.isTablet
@Composable
@ReadOnlyComposable
fun isTablet(): Boolean {
return LocalConfiguration.current.isTablet()
}

View file

@ -54,6 +54,8 @@ import yokai.i18n.MR
private const val TABLET_UI_MIN_SCREEN_WIDTH_DP = 720
private const val TABLET_UI_MIN_SCREEN_WIDTH_LANDSCAPE_DP = 600
/**
* Helper method to create a notification.
*
@ -113,7 +115,8 @@ fun Float.dpToPxEnd(resources: Resources): Float {
val Resources.isLTR
get() = configuration.layoutDirection == View.LAYOUT_DIRECTION_LTR
fun Context.isTablet() = resources.configuration.smallestScreenWidthDp >= 600
fun Configuration.isTablet() = smallestScreenWidthDp >= TABLET_UI_MIN_SCREEN_WIDTH_LANDSCAPE_DP
fun Context.isTablet() = resources.configuration.isTablet()
val displayMaxHeightInPx: Int
get() = Resources.getSystem().displayMetrics.let { max(it.heightPixels, it.widthPixels) }

View file

@ -2,28 +2,60 @@ package eu.kanade.tachiyomi.widget
import android.content.Context
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.RelativeLayout
import androidx.annotation.DrawableRes
import android.view.Gravity
import android.widget.FrameLayout
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.AbstractComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.vectorResource
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import com.google.android.material.button.MaterialButton
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.CommonViewEmptyBinding
import eu.kanade.tachiyomi.util.view.setText
import eu.kanade.tachiyomi.util.view.setVectorCompat
import eu.kanade.tachiyomi.util.isTablet
import yokai.presentation.component.EmptyScreen
import yokai.presentation.theme.YokaiTheme
import yokai.util.lang.getString
import android.R as AR
class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) :
RelativeLayout(context, attrs) {
class EmptyView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : AbstractComposeView(context, attrs, defStyleAttr) {
private val binding: CommonViewEmptyBinding =
CommonViewEmptyBinding.inflate(LayoutInflater.from(context), this, true)
private var image by mutableStateOf<Image>(Image.Vector(Icons.Filled.Download))
private var message by mutableStateOf("")
private var actions by mutableStateOf(emptyList<Action>())
init {
layoutParams = FrameLayout.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT, Gravity.CENTER)
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool)
}
@Composable
fun image(): ImageVector {
return when (image) {
is Image.Vector -> (image as Image.Vector).image
is Image.ResourceVector -> ImageVector.vectorResource((image as Image.ResourceVector).id)
}
}
@Composable
override fun Content() {
YokaiTheme {
EmptyScreen(
image = image(),
message = message,
isTablet = isTablet(),
actions = actions,
)
}
}
/**
* Hide the information view
@ -36,16 +68,21 @@ class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
* Show the information view
* @param textResource text of information view
*/
fun show(@DrawableRes drawable: Int, textResource: StringResource, actions: List<Action>? = null) {
show(drawable, context.getString(textResource), actions)
fun show(image: ImageVector, textResource: StringResource, actions: List<Action> = emptyList()) {
show(Image.Vector(image), context.getString(textResource), actions)
}
/**
* Show the information view
* @param textResource text of information view
*/
fun show(@DrawableRes drawable: Int, @StringRes textResource: Int, actions: List<Action>? = null) {
show(drawable, context.getString(textResource), actions)
fun show(image: ImageVector, @StringRes textResource: Int, actions: List<Action> = emptyList()) {
show(Image.Vector(image), context.getString(textResource), actions)
}
@Deprecated("Use EmptyView.Image instead of passing ImageVector directly")
fun show(image: ImageVector, message: String, actions: List<Action> = emptyList()) {
show(Image.Vector(image), message, actions)
}
/**
@ -53,36 +90,20 @@ class EmptyView @JvmOverloads constructor(context: Context, attrs: AttributeSet?
* @param drawable icon of information view
* @param textResource text of information view
*/
fun show(@DrawableRes drawable: Int, message: String, actions: List<Action>? = null) {
binding.imageView.setVectorCompat(drawable, AR.attr.textColorHint)
binding.textLabel.text = message
binding.actionsContainer.removeAllViews()
binding.actionsContainer.isVisible = !actions.isNullOrEmpty()
if (!actions.isNullOrEmpty()) {
actions.forEach {
val button =
(inflate(context, R.layout.material_text_button, null) as MaterialButton)
.apply {
setText(it.resId)
setOnClickListener(it.listener)
}
binding.actionsContainer.addView(button)
if (context.resources.configuration.screenHeightDp < 600) {
button.textAlignment = View.TEXT_ALIGNMENT_TEXT_START
button.updateLayoutParams<MarginLayoutParams> {
width = ViewGroup.LayoutParams.WRAP_CONTENT
height = ViewGroup.LayoutParams.WRAP_CONTENT
}
}
}
}
fun show(image: Image, message: String, actions: List<Action> = emptyList()) {
this.image = image
this.message = message
this.actions = actions
this.isVisible = true
}
data class Action(
val resId: StringResource,
val listener: OnClickListener,
val listener: () -> Unit,
)
sealed class Image {
data class Vector(val image: ImageVector) : Image()
data class ResourceVector(val id: Int) : Image()
}
}

View file

@ -4,6 +4,8 @@ import android.content.res.Configuration
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@ -13,6 +15,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -21,7 +24,11 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.icerock.moko.resources.compose.stringResource
import eu.kanade.tachiyomi.util.compose.textHint
import eu.kanade.tachiyomi.widget.EmptyView
import yokai.i18n.MR
private val defaultIconModifier =
Modifier.size(128.dp)
@ -34,8 +41,9 @@ fun EmptyScreen(
modifier: Modifier = Modifier,
image: ImageVector,
message: String,
actions: @Composable () -> Unit = {},
) = EmptyScreen(
isTablet: Boolean,
actions: List<EmptyView.Action> = emptyList(),
) = EmptyScreenImpl(
modifier = modifier,
image = {
Image(
@ -46,7 +54,8 @@ fun EmptyScreen(
)
},
message = message,
actions = actions,
actions = { EmptyScreenActions(actions, isTablet) },
isTablet = isTablet,
)
@Composable
@ -54,8 +63,9 @@ fun EmptyScreen(
modifier: Modifier = Modifier,
image: ImageBitmap,
message: String,
actions: @Composable () -> Unit = {},
) = EmptyScreen(
isTablet: Boolean,
actions: List<EmptyView.Action> = emptyList(),
) = EmptyScreenImpl(
modifier = modifier,
image = {
Image(
@ -65,35 +75,104 @@ fun EmptyScreen(
)
},
message = message,
actions = actions,
actions = { EmptyScreenActions(actions, isTablet) },
isTablet = isTablet,
)
@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Composable
private fun EmptyScreen(
modifier: Modifier = Modifier,
image: @Composable () -> Unit = {
Image(modifier = defaultIconModifier, imageVector = Icons.Filled.Download, contentDescription = null)
},
message: String = "Something went wrong",
actions: @Composable () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
image()
Text(
modifier = Modifier
.padding(top = 16.dp),
text = message,
color = MaterialTheme.colorScheme.textHint,
style = MaterialTheme.typography.labelMedium,
)
actions()
private fun EmptyScreenActions(actions: List<EmptyView.Action>, isTablet: Boolean) {
if (isTablet) {
FlowRow {
actions.forEach { action ->
TextButton(onClick = { action.listener() }) {
Text(
text = stringResource(action.resId),
fontSize = 14.sp,
)
}
}
}
} else {
Column(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
actions.forEach { action ->
TextButton(onClick = { action.listener() }) {
Text(
text = stringResource(action.resId),
fontSize = 14.sp,
)
}
}
}
}
}
@Composable
private fun EmptyScreenImpl(
modifier: Modifier = Modifier,
image: @Composable () -> Unit,
message: String,
actions: @Composable () -> Unit,
isTablet: Boolean,
) {
if (isTablet) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Row {
image()
Text(
modifier = Modifier
.padding(vertical = 4.dp),
text = message,
color = MaterialTheme.colorScheme.textHint,
style = MaterialTheme.typography.labelMedium,
)
}
actions()
}
} else {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
image()
Text(
modifier = Modifier
.padding(vertical = 16.dp),
text = message,
color = MaterialTheme.colorScheme.textHint,
style = MaterialTheme.typography.labelMedium,
)
actions()
}
}
}
@Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Preview(name = "Light", uiMode = Configuration.UI_MODE_NIGHT_NO, showBackground = true)
@Composable
private fun EmptyScreenPreview() {
EmptyScreen(
image = Icons.Filled.Download,
message = "Something went wrong",
actions = listOf(
EmptyView.Action(MR.strings.download) {},
EmptyView.Action(MR.strings.download) {},
EmptyView.Action(MR.strings.download) {},
EmptyView.Action(MR.strings.download) {},
EmptyView.Action(MR.strings.download) {},
),
isTablet = false,
)
}

View file

@ -29,6 +29,7 @@ import dev.icerock.moko.resources.compose.stringResource
import eu.kanade.tachiyomi.util.compose.LocalAlertDialog
import eu.kanade.tachiyomi.util.compose.LocalBackPress
import eu.kanade.tachiyomi.util.compose.currentOrThrow
import eu.kanade.tachiyomi.util.isTablet
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.collectLatest
import yokai.domain.ComposableAlertDialog
@ -101,6 +102,7 @@ fun ExtensionRepoScreen(
modifier = Modifier.fillParentMaxSize(),
image = Icons.Filled.ExtensionOff,
message = stringResource(MR.strings.information_empty_repos),
isTablet = isTablet(),
)
}
return@LazyColumn