From bac80715e3a6673d59ce37b68e6af4228ead50cf Mon Sep 17 00:00:00 2001 From: Bnyro Date: Wed, 5 Mar 2025 13:58:50 +0100 Subject: [PATCH] feat: rebuild custom instances dialogs for better UX --- .../github/libretube/constants/IntentData.kt | 1 + .../libretube/constants/PreferenceKeys.kt | 1 - .../libretube/db/dao/CustomInstanceDao.kt | 13 +++- .../github/libretube/db/obj/CustomInstance.kt | 5 +- .../ui/adapters/CustomInstancesAdapter.kt | 38 +++++++++++ .../ui/dialogs/CreateCustomInstanceDialog.kt | 63 +++++++++++++++++++ .../ui/dialogs/CustomInstanceDialog.kt | 53 ---------------- .../ui/dialogs/CustomInstancesListDialog.kt | 57 +++++++++++++++++ .../libretube/ui/dialogs/ShareDialog.kt | 6 +- .../ui/models/CustomInstancesModel.kt | 38 +++++++++++ .../ui/preferences/InstanceSettings.kt | 16 ++--- .../viewholders/CustomInstancesViewHolder.kt | 8 +++ .../main/res/layout/custom_instance_row.xml | 31 +++++++++ .../res/layout/dialog_custom_instance.xml | 22 +++---- .../layout/dialog_custom_intances_list.xml | 10 +++ app/src/main/res/values/strings.xml | 2 +- app/src/main/res/xml/instance_settings.xml | 6 -- 17 files changed, 281 insertions(+), 89 deletions(-) create mode 100644 app/src/main/java/com/github/libretube/ui/adapters/CustomInstancesAdapter.kt create mode 100644 app/src/main/java/com/github/libretube/ui/dialogs/CreateCustomInstanceDialog.kt delete mode 100644 app/src/main/java/com/github/libretube/ui/dialogs/CustomInstanceDialog.kt create mode 100644 app/src/main/java/com/github/libretube/ui/dialogs/CustomInstancesListDialog.kt create mode 100644 app/src/main/java/com/github/libretube/ui/models/CustomInstancesModel.kt create mode 100644 app/src/main/java/com/github/libretube/ui/viewholders/CustomInstancesViewHolder.kt create mode 100644 app/src/main/res/layout/custom_instance_row.xml create mode 100644 app/src/main/res/layout/dialog_custom_intances_list.xml diff --git a/app/src/main/java/com/github/libretube/constants/IntentData.kt b/app/src/main/java/com/github/libretube/constants/IntentData.kt index 70a009dfd..c600654dc 100644 --- a/app/src/main/java/com/github/libretube/constants/IntentData.kt +++ b/app/src/main/java/com/github/libretube/constants/IntentData.kt @@ -59,4 +59,5 @@ object IntentData { const val segments = "segments" const val alreadyStarted = "alreadyStarted" const val showUpcoming = "showUpcoming" + const val customInstance = "customInstance" } diff --git a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt index 48be21253..dfd3251b7 100644 --- a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt @@ -40,7 +40,6 @@ object PreferenceKeys { const val AUTH_INSTANCE = "selectAuthInstance" const val AUTH_INSTANCE_TOGGLE = "auth_instance_toggle" const val CUSTOM_INSTANCE = "customInstance" - const val CLEAR_CUSTOM_INSTANCES = "clearCustomInstances" const val LOGIN_REGISTER = "login_register" const val LOGOUT = "logout" const val DELETE_ACCOUNT = "delete_account" diff --git a/app/src/main/java/com/github/libretube/db/dao/CustomInstanceDao.kt b/app/src/main/java/com/github/libretube/db/dao/CustomInstanceDao.kt index 312c1cf34..12b292457 100644 --- a/app/src/main/java/com/github/libretube/db/dao/CustomInstanceDao.kt +++ b/app/src/main/java/com/github/libretube/db/dao/CustomInstanceDao.kt @@ -1,22 +1,33 @@ package com.github.libretube.db.dao import androidx.room.Dao +import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.github.libretube.db.obj.CustomInstance +import kotlinx.coroutines.flow.Flow @Dao interface CustomInstanceDao { - @Query("SELECT * FROM customInstance") + @Query("SELECT * FROM customInstance ORDER BY name") suspend fun getAll(): List + @Query("SELECT * FROM customInstance ORDER BY name") + fun getAllFlow(): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(customInstance: CustomInstance) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(customInstances: List) + @Query("SELECT * FROM customInstance WHERE apiUrl = :apiUrl") + suspend fun getByApiUrl(apiUrl: String): CustomInstance? + + @Delete + suspend fun deleteCustomInstance(customInstance: CustomInstance) + @Query("DELETE FROM customInstance") suspend fun deleteAll() } diff --git a/app/src/main/java/com/github/libretube/db/obj/CustomInstance.kt b/app/src/main/java/com/github/libretube/db/obj/CustomInstance.kt index e2d9d9249..a5540225f 100644 --- a/app/src/main/java/com/github/libretube/db/obj/CustomInstance.kt +++ b/app/src/main/java/com/github/libretube/db/obj/CustomInstance.kt @@ -1,14 +1,17 @@ package com.github.libretube.db.obj +import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @Serializable @Entity(tableName = "customInstance") +@Parcelize class CustomInstance( @PrimaryKey var name: String = "", @ColumnInfo var apiUrl: String = "", @ColumnInfo var frontendUrl: String = "" -) +) : Parcelable diff --git a/app/src/main/java/com/github/libretube/ui/adapters/CustomInstancesAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/CustomInstancesAdapter.kt new file mode 100644 index 000000000..f669d9bbf --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/adapters/CustomInstancesAdapter.kt @@ -0,0 +1,38 @@ +package com.github.libretube.ui.adapters + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import com.github.libretube.databinding.CustomInstanceRowBinding +import com.github.libretube.db.obj.CustomInstance +import com.github.libretube.ui.adapters.callbacks.DiffUtilItemCallback +import com.github.libretube.ui.viewholders.CustomInstancesViewHolder + +class CustomInstancesAdapter( + private val onClickInstance: (CustomInstance) -> Unit, + private val onDeleteInstance: (CustomInstance) -> Unit +) : ListAdapter( + DiffUtilItemCallback() +) { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CustomInstancesViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = CustomInstanceRowBinding.inflate(layoutInflater, parent, false) + return CustomInstancesViewHolder(binding) + } + + override fun onBindViewHolder(holder: CustomInstancesViewHolder, position: Int) { + val instance = getItem(position)!! + + with (holder.binding) { + instanceName.text = instance.name + + root.setOnClickListener { + onClickInstance(instance) + } + + deleteInstance.setOnClickListener { + onDeleteInstance.invoke(instance) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/CreateCustomInstanceDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/CreateCustomInstanceDialog.kt new file mode 100644 index 000000000..e51189f87 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/dialogs/CreateCustomInstanceDialog.kt @@ -0,0 +1,63 @@ +package com.github.libretube.ui.dialogs + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import com.github.libretube.R +import com.github.libretube.constants.IntentData +import com.github.libretube.databinding.DialogCustomInstanceBinding +import com.github.libretube.db.obj.CustomInstance +import com.github.libretube.extensions.parcelable +import com.github.libretube.extensions.toastFromMainThread +import com.github.libretube.ui.models.CustomInstancesModel +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.net.MalformedURLException + +class CreateCustomInstanceDialog : DialogFragment() { + val viewModel: CustomInstancesModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogCustomInstanceBinding.inflate(layoutInflater) + arguments?.parcelable(IntentData.customInstance)?.let { initialInstance -> + binding.instanceName.setText(initialInstance.name) + binding.instanceApiUrl.setText(initialInstance.apiUrl) + binding.instanceFrontendUrl.setText(initialInstance.frontendUrl) + } + + binding.instanceApiUrl.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus || !binding.instanceName.text.isNullOrEmpty()) return@setOnFocusChangeListener + + // automatically set the api name + val apiUrl = binding.instanceApiUrl.text.toString().toHttpUrlOrNull() + if (apiUrl != null) { + binding.instanceName.setText(apiUrl.host) + } + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.customInstance) + .setView(binding.root) + .setPositiveButton(R.string.addInstance, null) + .setNegativeButton(R.string.cancel, null) + .show() + .apply { + getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener { + val instanceName = binding.instanceName.text.toString() + val apiUrl = binding.instanceApiUrl.text.toString() + val frontendUrl = binding.instanceFrontendUrl.text.toString() + + try { + viewModel.addCustomInstance(apiUrl, instanceName, frontendUrl) + requireDialog().dismiss() + } catch (e: IllegalArgumentException) { + context.toastFromMainThread(R.string.empty_instance) + } catch (e: MalformedURLException) { + context.toastFromMainThread(R.string.invalid_url) + } + } + } + } +} diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/CustomInstanceDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/CustomInstanceDialog.kt deleted file mode 100644 index ba339b657..000000000 --- a/app/src/main/java/com/github/libretube/ui/dialogs/CustomInstanceDialog.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.github.libretube.ui.dialogs - -import android.app.Dialog -import android.os.Bundle -import android.widget.Toast -import androidx.core.app.ActivityCompat -import androidx.fragment.app.DialogFragment -import androidx.lifecycle.lifecycleScope -import com.github.libretube.R -import com.github.libretube.databinding.DialogCustomInstanceBinding -import com.github.libretube.db.DatabaseHolder.Database -import com.github.libretube.db.obj.CustomInstance -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.launch -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull - -class CustomInstanceDialog : DialogFragment() { - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val binding = DialogCustomInstanceBinding.inflate(layoutInflater) - - return MaterialAlertDialogBuilder(requireContext()) - .setTitle(R.string.customInstance) - .setView(binding.root) - .setPositiveButton(R.string.addInstance) { _, _ -> - val instanceName = binding.instanceName.text.toString() - val apiUrl = binding.instanceApiUrl.text.toString() - val frontendUrl = binding.instanceFrontendUrl.text.toString() - - if (instanceName.isNotEmpty() && apiUrl.isNotEmpty() && frontendUrl.isNotEmpty()) { - if (apiUrl.toHttpUrlOrNull() != null && frontendUrl.toHttpUrlOrNull() != null) { - lifecycleScope.launch { - Database.customInstanceDao() - .insert(CustomInstance(instanceName, apiUrl, frontendUrl)) - ActivityCompat.recreate(requireActivity()) - dismiss() - } - } else { - Toast.makeText(requireContext(), R.string.invalid_url, Toast.LENGTH_SHORT) - .show() - } - } else { - // at least one empty input - Toast.makeText( - requireContext(), - R.string.empty_instance, - Toast.LENGTH_SHORT - ).show() - } - } - .setNegativeButton(R.string.cancel, null) - .show() - } -} diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/CustomInstancesListDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/CustomInstancesListDialog.kt new file mode 100644 index 000000000..b31180609 --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/dialogs/CustomInstancesListDialog.kt @@ -0,0 +1,57 @@ +package com.github.libretube.ui.dialogs + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.lifecycleScope +import com.github.libretube.R +import com.github.libretube.constants.IntentData +import com.github.libretube.databinding.DialogCustomIntancesListBinding +import com.github.libretube.ui.adapters.CustomInstancesAdapter +import com.github.libretube.ui.models.CustomInstancesModel +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class CustomInstancesListDialog: DialogFragment() { + val viewModel: CustomInstancesModel by activityViewModels() + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val binding = DialogCustomIntancesListBinding.inflate(layoutInflater) + val adapter = CustomInstancesAdapter( + onClickInstance = { + CreateCustomInstanceDialog() + .apply { + arguments = bundleOf(IntentData.customInstance to it) + } + .show(childFragmentManager, null) + }, + onDeleteInstance = { + viewModel.deleteCustomInstance(it) + } + ) + binding.customInstancesRecycler.adapter = adapter + + lifecycleScope.launch { + viewModel.instances.collectLatest { + adapter.submitList(it) + } + } + + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(getString(R.string.customInstance)) + .setView(binding.root) + .setPositiveButton(getString(R.string.okay), null) + .setNegativeButton(getString(R.string.addInstance), null) + .show() + .apply { + getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener { + CreateCustomInstanceDialog() + .show(childFragmentManager, null) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt index 10c630ac6..1b4fe6cbf 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/ShareDialog.kt @@ -118,12 +118,12 @@ class ShareDialog : DialogFragment() { ) // get the api urls of the other custom instances - val customInstances = runBlocking(Dispatchers.IO) { - Database.customInstanceDao().getAll() + val customInstance = runBlocking(Dispatchers.IO) { + Database.customInstanceDao().getByApiUrl(instancePref) } // return the custom instance frontend url if available - return customInstances.firstOrNull { it.apiUrl == instancePref }?.frontendUrl.orEmpty() + return customInstance?.frontendUrl.orEmpty() } private fun generateLinkText(binding: DialogShareBinding, customInstanceUrl: HttpUrl?): String { diff --git a/app/src/main/java/com/github/libretube/ui/models/CustomInstancesModel.kt b/app/src/main/java/com/github/libretube/ui/models/CustomInstancesModel.kt new file mode 100644 index 000000000..acc75a30b --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/models/CustomInstancesModel.kt @@ -0,0 +1,38 @@ +package com.github.libretube.ui.models + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.libretube.db.DatabaseHolder.Database +import com.github.libretube.db.obj.CustomInstance +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.net.MalformedURLException + +class CustomInstancesModel: ViewModel() { + val instances = Database.customInstanceDao().getAllFlow() + .flowOn(Dispatchers.IO) + + fun addCustomInstance(apiUrlInput: String, instanceNameInput: String?, frontendUrlInput: String?) { + if (apiUrlInput.isEmpty()) throw IllegalArgumentException() + + val apiUrl = apiUrlInput.toHttpUrlOrNull() ?: throw MalformedURLException() + val frontendUrl = if (!frontendUrlInput.isNullOrBlank()) { + frontendUrlInput.toHttpUrlOrNull() ?: throw MalformedURLException() + } else { + null + } + + viewModelScope.launch(Dispatchers.IO) { + val instanceName = instanceNameInput ?: apiUrl.host + + Database.customInstanceDao() + .insert(CustomInstance(instanceName, apiUrl.toString(), frontendUrl?.toString().orEmpty())) + } + } + + fun deleteCustomInstance(customInstance: CustomInstance) = viewModelScope.launch(Dispatchers.IO) { + Database.customInstanceDao().deleteCustomInstance(customInstance) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/preferences/InstanceSettings.kt b/app/src/main/java/com/github/libretube/ui/preferences/InstanceSettings.kt index 903311efd..c45444408 100644 --- a/app/src/main/java/com/github/libretube/ui/preferences/InstanceSettings.kt +++ b/app/src/main/java/com/github/libretube/ui/preferences/InstanceSettings.kt @@ -22,7 +22,8 @@ import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.ui.adapters.InstancesAdapter import com.github.libretube.ui.base.BasePreferenceFragment -import com.github.libretube.ui.dialogs.CustomInstanceDialog +import com.github.libretube.ui.dialogs.CreateCustomInstanceDialog +import com.github.libretube.ui.dialogs.CustomInstancesListDialog import com.github.libretube.ui.dialogs.DeleteAccountDialog import com.github.libretube.ui.dialogs.LoginDialog import com.github.libretube.ui.dialogs.LogoutDialog @@ -82,17 +83,8 @@ class InstanceSettings : BasePreferenceFragment() { val customInstance = findPreference(PreferenceKeys.CUSTOM_INSTANCE) customInstance?.setOnPreferenceClickListener { - CustomInstanceDialog() - .show(childFragmentManager, CustomInstanceDialog::class.java.name) - true - } - - val clearCustomInstances = findPreference(PreferenceKeys.CLEAR_CUSTOM_INSTANCES) - clearCustomInstances?.setOnPreferenceClickListener { - lifecycleScope.launch { - Database.customInstanceDao().deleteAll() - ActivityCompat.recreate(requireActivity()) - } + CustomInstancesListDialog() + .show(childFragmentManager, CreateCustomInstanceDialog::class.java.name) true } diff --git a/app/src/main/java/com/github/libretube/ui/viewholders/CustomInstancesViewHolder.kt b/app/src/main/java/com/github/libretube/ui/viewholders/CustomInstancesViewHolder.kt new file mode 100644 index 000000000..e431075ec --- /dev/null +++ b/app/src/main/java/com/github/libretube/ui/viewholders/CustomInstancesViewHolder.kt @@ -0,0 +1,8 @@ +package com.github.libretube.ui.viewholders + +import androidx.recyclerview.widget.RecyclerView +import com.github.libretube.databinding.CustomInstanceRowBinding + +class CustomInstancesViewHolder( + val binding: CustomInstanceRowBinding +): RecyclerView.ViewHolder(binding.root) \ No newline at end of file diff --git a/app/src/main/res/layout/custom_instance_row.xml b/app/src/main/res/layout/custom_instance_row.xml new file mode 100644 index 000000000..b5c1ac129 --- /dev/null +++ b/app/src/main/res/layout/custom_instance_row.xml @@ -0,0 +1,31 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_custom_instance.xml b/app/src/main/res/layout/dialog_custom_instance.xml index 73287e195..0741591c9 100644 --- a/app/src/main/res/layout/dialog_custom_instance.xml +++ b/app/src/main/res/layout/dialog_custom_instance.xml @@ -5,6 +5,17 @@ android:orientation="vertical" android:paddingTop="8dp"> + + + + + @@ -27,15 +38,4 @@ android:inputType="text" /> - - - - - diff --git a/app/src/main/res/layout/dialog_custom_intances_list.xml b/app/src/main/res/layout/dialog_custom_intances_list.xml new file mode 100644 index 000000000..ee4108ccd --- /dev/null +++ b/app/src/main/res/layout/dialog_custom_intances_list.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8821649c1..db4ab6e27 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -176,7 +176,7 @@ Resume Stop Autoplay - URL to instance frontend + URL to instance frontend (optional) Quality Behavior Defaults and behavior diff --git a/app/src/main/res/xml/instance_settings.xml b/app/src/main/res/xml/instance_settings.xml index fd70d0c5e..522f24e89 100644 --- a/app/src/main/res/xml/instance_settings.xml +++ b/app/src/main/res/xml/instance_settings.xml @@ -40,12 +40,6 @@ app:summary="@string/customInstance_summary" app:title="@string/customInstance" /> - -