Rewrite loading UI

Completely rewrite the loading UI to be far more understandable.
This commit is contained in:
OxygenCobalt 2021-02-19 09:33:49 -07:00
parent 92e9e3282c
commit e3e0015237
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 150 additions and 230 deletions

View file

@ -11,6 +11,7 @@
<img alt="Minimum SDK" src="https://img.shields.io/badge/API-21%2B-32B5ED">
</p>
<h4 align="center"><a href="/info/FAQ.md">FAQ</a> / <a href="/info/FORMATS.md">Formats</a> / <a href="/info/LICENSES.md">Licenses</a> / <a href="/.github/CONTRIBUTING.md">Contributing</a></h4>
<p align="center"><a href="https://apt.izzysoft.de/fdroid/index/apk/org.oxycblt.auxio"><img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" width="170"></a></p>
## About

View file

@ -13,17 +13,9 @@ import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentLoadingBinding
import org.oxycblt.auxio.logD
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.processing.MusicLoader
/**
* An intermediary [Fragment] that asks for the READ_EXTERNAL_STORAGE permission and runs
* the music loading process in the background.
* @author OxygenCobalt
*/
class LoadingFragment : Fragment(R.layout.fragment_loading) {
// LoadingViewModel is scoped to this fragment only
class LoadingFragment : Fragment() {
private val loadingModel: LoadingViewModel by viewModels {
LoadingViewModel.Factory(requireActivity().application)
}
@ -35,115 +27,110 @@ class LoadingFragment : Fragment(R.layout.fragment_loading) {
): View {
val binding = FragmentLoadingBinding.inflate(inflater)
// Set up the permission launcher, as its disallowed outside of onCreate.
val permLauncher =
registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted: Boolean ->
// If its actually granted, restart the loading process again.
if (granted) {
returnToLoading(binding)
loadingModel.reload()
} else {
showError(binding)
binding.loadingGrantButton.visibility = View.VISIBLE
binding.loadingErrorText.text = getString(R.string.error_no_perms)
}
}
// Build the permission launcher here as you can only do it in onCreateView/onCreate
val permLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(), ::onPermResult
)
// --- UI SETUP ---
binding.lifecycleOwner = this
binding.loadingModel = loadingModel
// --- VIEWMODEL SETUP ---
loadingModel.response.observe(viewLifecycleOwner) {
if (it == MusicLoader.Response.SUCCESS) {
findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
} else {
// If the response wasn't a success, then show the specific error message
// depending on which error response was given, along with a retry button
binding.loadingErrorText.text =
if (it == MusicLoader.Response.NO_MUSIC)
getString(R.string.error_no_music)
else
getString(R.string.error_music_load_failed)
showError(binding)
binding.loadingRetryButton.visibility = View.VISIBLE
}
}
loadingModel.doReload.observe(viewLifecycleOwner) {
if (it) {
returnToLoading(binding)
loadingModel.doneWithReload()
}
}
loadingModel.doGrant.observe(viewLifecycleOwner) {
if (it) {
permLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
returnToLoading(binding)
loadingModel.doneWithGrant()
}
}
// Force an error screen if the permissions are denied or the prompt needs to be shown.
if (checkPerms()) {
showError(binding)
loadingModel.response.observe(viewLifecycleOwner) { response ->
when (response) {
// Success should lead to Auxio navigating away from the fragment
MusicStore.Response.SUCCESS -> findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
binding.loadingGrantButton.visibility = View.VISIBLE
binding.loadingErrorText.text = getString(R.string.error_no_perms)
} else {
loadingModel.go()
// Null means that the loading process is going on
null -> showLoading(binding)
// Anything else is an error
else -> {
showError(binding, response)
}
}
}
logD("Fragment created.")
if (noPermissions()) {
// MusicStore.Response.NO_PERMS isnt actually returned by MusicStore, its just
// a way to keep the current permission state on_hand
loadingModel.notifyNoPermissions()
}
if (loadingModel.response.value == null) {
loadingModel.load()
}
return binding.root
}
override fun onResume() {
super.onResume()
// --- PERMISSIONS ---
// If the music was already loaded, then don't do it again.
if (MusicStore.getInstance().loaded) {
findNavController().navigate(
LoadingFragmentDirections.actionToMain()
)
}
}
// Check for two things:
// - If Auxio needs to show the rationale for getting the READ_EXTERNAL_STORAGE permission.
// - If Auxio straight up doesn't have the READ_EXTERNAL_STORAGE permission.
private fun checkPerms(): Boolean {
return shouldShowRequestPermissionRationale(
private fun noPermissions(): Boolean {
val needRationale = shouldShowRequestPermissionRationale(
Manifest.permission.READ_EXTERNAL_STORAGE
) || ContextCompat.checkSelfPermission(
)
val notGranted = ContextCompat.checkSelfPermission(
requireContext(), Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_DENIED
return needRationale || notGranted
}
// Remove the loading indicator and show the error groups
private fun showError(binding: FragmentLoadingBinding) {
binding.loadingBar.visibility = View.GONE
private fun onPermResult(granted: Boolean) {
if (granted) {
// If granted, its now safe to load [Which will clear the NO_PERMS response we applied
// earlier]
loadingModel.load()
}
}
// --- UI DISPLAY ---
private fun showLoading(binding: FragmentLoadingBinding) {
binding.apply {
loadingCircle.visibility = View.VISIBLE
loadingErrorIcon.visibility = View.GONE
loadingErrorText.visibility = View.GONE
loadingRetryButton.visibility = View.GONE
loadingGrantButton.visibility = View.GONE
}
}
private fun showError(binding: FragmentLoadingBinding, error: MusicStore.Response) {
binding.loadingCircle.visibility = View.GONE
binding.loadingErrorIcon.visibility = View.VISIBLE
binding.loadingErrorText.visibility = View.VISIBLE
when (error) {
MusicStore.Response.NO_MUSIC -> {
binding.loadingRetryButton.visibility = View.VISIBLE
binding.loadingErrorText.text = getString(R.string.error_no_music)
}
// Wipe views and switch back to the plain ProgressBar
private fun returnToLoading(binding: FragmentLoadingBinding) {
binding.loadingBar.visibility = View.VISIBLE
binding.loadingErrorText.visibility = View.GONE
binding.loadingErrorIcon.visibility = View.GONE
binding.loadingRetryButton.visibility = View.GONE
binding.loadingGrantButton.visibility = View.GONE
MusicStore.Response.NO_PERMS -> {
binding.loadingGrantButton.visibility = View.VISIBLE
binding.loadingErrorText.text = getString(R.string.error_no_perms)
}
MusicStore.Response.FAILED -> {
binding.loadingRetryButton.visibility = View.VISIBLE
binding.loadingErrorText.text = getString(R.string.error_load_failed)
}
else -> {}
}
}
}

View file

@ -6,87 +6,47 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.processing.MusicLoader
/**
* A [ViewModel] responsible for getting the music loading process going and managing the response
* returned.
* @author OxygenCobalt
*/
class LoadingViewModel(private val app: Application) : ViewModel() {
private val mResponse = MutableLiveData<MusicLoader.Response>()
val response: LiveData<MusicLoader.Response> get() = mResponse
private val mResponse = MutableLiveData<MusicStore.Response?>(null)
val response: LiveData<MusicStore.Response?> = mResponse
private val mRedo = MutableLiveData<Boolean>()
val doReload: LiveData<Boolean> get() = mRedo
private val mDoGrant = MutableLiveData(false)
val doGrant: LiveData<Boolean> = mDoGrant
private val mDoGrant = MutableLiveData<Boolean>()
val doGrant: LiveData<Boolean> get() = mDoGrant
private var isBusy = false
private var started = false
private val musicStore = MusicStore.getInstance()
/**
* Start the music loading sequence.
* This should only be ran once, use reload() for all other loads.
*/
fun go() {
if (!started) {
started = true
doLoad()
}
}
fun load() {
// Dont start a new load if the last one hasnt finished
if (isBusy) return
isBusy = true
mResponse.value = null
private fun doLoad() {
viewModelScope.launch {
val musicStore = MusicStore.getInstance()
val response = musicStore.load(app)
withContext(Dispatchers.Main) {
mResponse.value = response
}
mResponse.value = musicStore.load(app)
isBusy = false
}
}
/**
* Reload the music
*/
fun reload() {
mRedo.value = true
doLoad()
}
/**
* Mark that the UI is done with the reload call
*/
fun doneWithReload() {
mRedo.value = false
}
/**
* Mark to start the grant process
*/
fun grant() {
mDoGrant.value = true
}
/**
* Mark that the UI is done with the grant process.
*/
fun doneWithGrant() {
mDoGrant.value = false
}
/**
* Factory for [LoadingViewModel] instances.
*/
class Factory(private val application: Application) : ViewModelProvider.Factory {
fun notifyNoPermissions() {
mResponse.value = MusicStore.Response.NO_PERMS
}
@Suppress("UNCHECKED_CAST")
class Factory(private val application: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
if (modelClass.isAssignableFrom(LoadingViewModel::class.java)) {
return LoadingViewModel(application) as T

View file

@ -41,7 +41,7 @@ class MusicStore private constructor() {
* ***THIS SHOULD ONLY BE RAN FROM AN IO THREAD.***
* @param app [Application] required to load the music.
*/
suspend fun load(app: Application): MusicLoader.Response {
suspend fun load(app: Application): Response {
return withContext(Dispatchers.IO) {
this@MusicStore.logD("Starting initial music load...")
@ -50,7 +50,7 @@ class MusicStore private constructor() {
val loader = MusicLoader(app)
val response = loader.loadMusic()
if (response == MusicLoader.Response.SUCCESS) {
if (response == Response.SUCCESS) {
// If the loading succeeds, then sort the songs and update the value
val sorter = MusicSorter(loader.songs, loader.albums)
@ -72,6 +72,10 @@ class MusicStore private constructor() {
}
}
enum class Response {
NO_MUSIC, NO_PERMS, FAILED, SUCCESS
}
companion object {
@Volatile
private var INSTANCE: MusicStore? = null

View file

@ -12,6 +12,7 @@ import org.oxycblt.auxio.logD
import org.oxycblt.auxio.logE
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.toAlbumArtURI
@ -26,23 +27,25 @@ class MusicLoader(private val app: Application) {
private val resolver = app.contentResolver
fun loadMusic(): Response {
fun loadMusic(): MusicStore.Response {
try {
loadGenres()
loadAlbums()
loadSongs()
} catch (error: Exception) {
logE("Something went horribly wrong.")
error.printStackTrace()
val trace = error.stackTraceToString()
return Response.FAILED
logE("Something went horribly wrong.")
logE(trace)
return MusicStore.Response.FAILED
}
if (songs.isEmpty()) {
return Response.NO_MUSIC
return MusicStore.Response.NO_MUSIC
}
return Response.SUCCESS
return MusicStore.Response.SUCCESS
}
private fun loadGenres() {
@ -89,7 +92,6 @@ class MusicLoader(private val app: Application) {
Albums._ID, // 0
Albums.ALBUM, // 1
Albums.ARTIST, // 2
Albums.FIRST_YEAR, // 3
),
null, null,
@ -207,8 +209,4 @@ class MusicLoader(private val app: Application) {
logD("Song search finished with ${songs.size} found")
}
enum class Response {
SUCCESS, FAILED, NO_MUSIC
}
}

View file

@ -7,7 +7,7 @@ import android.text.Spanned
import androidx.annotation.ColorRes
import androidx.annotation.StringRes
import androidx.annotation.StyleRes
import androidx.core.text.toSpanned
import androidx.core.text.HtmlCompat
import org.oxycblt.auxio.R
import org.oxycblt.auxio.settings.SettingsManager
import java.util.Locale
@ -59,10 +59,10 @@ data class Accent(@ColorRes val color: Int, @StyleRes val theme: Int, @StringRes
val name = context.getString(name)
val hex = context.getString(color).toUpperCase(Locale.getDefault())
return context.getString(
R.string.format_accent_summary,
name, hex
).toSpanned().render()
return HtmlCompat.fromHtml(
context.getString(R.string.format_accent_summary, name, hex),
HtmlCompat.FROM_HTML_MODE_COMPACT
)
}
companion object {
@ -70,8 +70,7 @@ data class Accent(@ColorRes val color: Int, @StyleRes val theme: Int, @StringRes
private var current: Accent? = null
/**
* Get the current accent, will default to whatever is stored in [SettingsManager]
* if there isnt one.
* Get the current accent.
* @return The current accent
*/
fun get(): Accent {

View file

@ -8,7 +8,6 @@ import android.content.res.Resources
import android.graphics.Point
import android.graphics.drawable.AnimatedVectorDrawable
import android.os.Build
import android.text.Spanned
import android.util.DisplayMetrics
import android.view.LayoutInflater
import android.view.View
@ -22,7 +21,6 @@ import androidx.annotation.DrawableRes
import androidx.annotation.PluralsRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.text.HtmlCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.button.MaterialButton
@ -109,16 +107,6 @@ fun Fragment.requireCompatActivity(): AppCompatActivity {
}
}
/**
* "Render" a [Spanned] using [HtmlCompat]. (As in making text bolded and whatnot).
* @return A [Spanned] that actually works.
*/
fun Spanned.render(): Spanned {
return HtmlCompat.fromHtml(
this.toString(), HtmlCompat.FROM_HTML_OPTION_USE_CSS_COLORS
)
}
/**
* Resolve a color.
* @param context [Context] required

View file

@ -39,7 +39,7 @@ class SlideLinearLayout @JvmOverloads constructor(
init {
if (disappearingChildrenField != null) {
// Create a junk view and add it, which makes all the magic happen [I think].
// Create a invisible junk view and add it, which makes all the magic happen [I think].
dumpView = View(context)
addView(dumpView, 0, 0)
}
@ -58,13 +58,18 @@ class SlideLinearLayout @JvmOverloads constructor(
val children = getDisappearingChildren()
if (doDrawingTrick && children != null) {
if (child == dumpView) { // Use the dump view as a marker for when to do the trick
var more = false
// Use the dump view as a marker for when to do the trick
if (child == dumpView) {
// I dont even know what this does.
var consumed = false
children.forEach {
more = more or super.drawChild(canvas, it, drawingTime) // What????
consumed = consumed or super.drawChild(canvas, it, drawingTime)
}
return more
} else if (children.contains(child)) { // Ignore the disappearing children
return consumed
} else if (children.contains(child)) {
// Ignore the disappearing children
return false
}
}

View file

@ -1,34 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".loading.LoadingFragment">
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="loadingModel"
type="org.oxycblt.auxio.loading.LoadingViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:animateLayoutChanges="true">
android:orientation="vertical"
android:animateLayoutChanges="true"
android:gravity="center">
<ProgressBar
android:id="@+id/loading_bar"
android:id="@+id/loading_circle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:indeterminateTint="?attr/colorPrimary"
android:indeterminateTintMode="src_in"
android:paddingBottom="@dimen/padding_tiny"
app:layout_constraintBottom_toTopOf="@+id/loading_error_icon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
android:layout_margin="@dimen/margin_small"
android:paddingBottom="@dimen/padding_tiny" />
<ImageView
android:id="@+id/loading_error_icon"
@ -38,28 +34,19 @@
android:indeterminateTint="?attr/colorPrimary"
android:indeterminateTintMode="src_in"
android:src="@drawable/ic_error"
android:layout_margin="@dimen/margin_small"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@+id/loading_error_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loading_bar"
app:srcCompat="@drawable/ic_error" />
tools:visibility="visible"/>
<TextView
android:id="@+id/loading_error_text"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_small"
android:fontFamily="@font/inter"
android:textAlignment="center"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?android:attr/textColorPrimary"
app:layout_constraintBottom_toTopOf="@+id/loading_retry_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loading_error_icon"
tools:text="Some kind of error." />
tools:text="Some kind of error" />
<Button
android:id="@+id/loading_retry_button"
@ -67,17 +54,10 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_semibold"
android:onClick="@{() -> loadingModel.reload()}"
android:text="@string/label_retry"
android:textColor="?attr/colorPrimary"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/loading_grant_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loading_error_text"
app:layout_constraintVertical_bias="0.673"
android:onClick="@{() -> loadingModel.load()}"
tools:visibility="visible" />
<Button
@ -86,15 +66,12 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="@font/inter_semibold"
android:onClick="@{() -> loadingModel.grant()}"
android:text="@string/label_grant"
android:textColor="?attr/colorPrimary"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/loading_retry_button"
android:onClick="@{() -> loadingModel.grant()}"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</layout>

View file

@ -89,7 +89,7 @@
<!-- Error Namespace | Error Labels -->
<string name="error_no_music">Keine Musik gefunden</string>
<string name="error_music_load_failed">Laden die Musik fehlgeschlagen</string>
<string name="error_load_failed">Laden die Musik fehlgeschlagen</string>
<string name="error_no_perms">Auxio braucht Berechtigung, zu lesen deine Musikbibliothek</string>
<string name="error_no_browser">Link könnte nicht geöffnet werden</string>

View file

@ -8,6 +8,7 @@
<!-- Label Namespace | Static Labels -->
<string name="label_retry">Retry</string>
<string name="label_grant">Grant</string>
<string name="label_view_trace">View Error</string>
<string name="label_library">Library</string>
<string name="label_genres">Genres</string>
@ -90,7 +91,7 @@
<!-- Error Namespace | Error Labels -->
<string name="error_no_music">No music found</string>
<string name="error_music_load_failed">Music loading failed</string>
<string name="error_load_failed">Music loading failed</string>
<string name="error_no_perms">Auxio needs permission to read your music library</string>
<string name="error_no_browser">Could not open link</string>

View file

@ -14,7 +14,7 @@ Currently, its available on the [IzzyOnDroid F-Droid repository](https://apt.izz
## Can I translate Auxio to my native language?
See the [Translations](https://github.com/OxygenCobalt/Auxio/issues/3) issue for guidance on how to create translations and submit them to the project. Any contributions are apprieciated.
See the [Translations](https://github.com/OxygenCobalt/Auxio/issues/3) issue for guidance on how to create translations and submit them to the project. Any contributions are appreciated.
## How can I contribute/report issues?