feat: rebuild custom instances dialogs for better UX

This commit is contained in:
Bnyro 2025-03-05 13:58:50 +01:00
parent 71f0d48e24
commit bac80715e3
No known key found for this signature in database
17 changed files with 281 additions and 89 deletions

View File

@ -59,4 +59,5 @@ object IntentData {
const val segments = "segments" const val segments = "segments"
const val alreadyStarted = "alreadyStarted" const val alreadyStarted = "alreadyStarted"
const val showUpcoming = "showUpcoming" const val showUpcoming = "showUpcoming"
const val customInstance = "customInstance"
} }

View File

@ -40,7 +40,6 @@ object PreferenceKeys {
const val AUTH_INSTANCE = "selectAuthInstance" const val AUTH_INSTANCE = "selectAuthInstance"
const val AUTH_INSTANCE_TOGGLE = "auth_instance_toggle" const val AUTH_INSTANCE_TOGGLE = "auth_instance_toggle"
const val CUSTOM_INSTANCE = "customInstance" const val CUSTOM_INSTANCE = "customInstance"
const val CLEAR_CUSTOM_INSTANCES = "clearCustomInstances"
const val LOGIN_REGISTER = "login_register" const val LOGIN_REGISTER = "login_register"
const val LOGOUT = "logout" const val LOGOUT = "logout"
const val DELETE_ACCOUNT = "delete_account" const val DELETE_ACCOUNT = "delete_account"

View File

@ -1,22 +1,33 @@
package com.github.libretube.db.dao package com.github.libretube.db.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.github.libretube.db.obj.CustomInstance import com.github.libretube.db.obj.CustomInstance
import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface CustomInstanceDao { interface CustomInstanceDao {
@Query("SELECT * FROM customInstance") @Query("SELECT * FROM customInstance ORDER BY name")
suspend fun getAll(): List<CustomInstance> suspend fun getAll(): List<CustomInstance>
@Query("SELECT * FROM customInstance ORDER BY name")
fun getAllFlow(): Flow<List<CustomInstance>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(customInstance: CustomInstance) suspend fun insert(customInstance: CustomInstance)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(customInstances: List<CustomInstance>) suspend fun insertAll(customInstances: List<CustomInstance>)
@Query("SELECT * FROM customInstance WHERE apiUrl = :apiUrl")
suspend fun getByApiUrl(apiUrl: String): CustomInstance?
@Delete
suspend fun deleteCustomInstance(customInstance: CustomInstance)
@Query("DELETE FROM customInstance") @Query("DELETE FROM customInstance")
suspend fun deleteAll() suspend fun deleteAll()
} }

View File

@ -1,14 +1,17 @@
package com.github.libretube.db.obj package com.github.libretube.db.obj
import android.os.Parcelable
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@Entity(tableName = "customInstance") @Entity(tableName = "customInstance")
@Parcelize
class CustomInstance( class CustomInstance(
@PrimaryKey var name: String = "", @PrimaryKey var name: String = "",
@ColumnInfo var apiUrl: String = "", @ColumnInfo var apiUrl: String = "",
@ColumnInfo var frontendUrl: String = "" @ColumnInfo var frontendUrl: String = ""
) ) : Parcelable

View File

@ -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<CustomInstance, CustomInstancesViewHolder>(
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)
}
}
}
}

View File

@ -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<CustomInstance>(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)
}
}
}
}
}

View File

@ -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()
}
}

View File

@ -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)
}
}
}
}

View File

@ -118,12 +118,12 @@ class ShareDialog : DialogFragment() {
) )
// get the api urls of the other custom instances // get the api urls of the other custom instances
val customInstances = runBlocking(Dispatchers.IO) { val customInstance = runBlocking(Dispatchers.IO) {
Database.customInstanceDao().getAll() Database.customInstanceDao().getByApiUrl(instancePref)
} }
// return the custom instance frontend url if available // 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 { private fun generateLinkText(binding: DialogShareBinding, customInstanceUrl: HttpUrl?): String {

View File

@ -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)
}
}

View File

@ -22,7 +22,8 @@ import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.ui.adapters.InstancesAdapter import com.github.libretube.ui.adapters.InstancesAdapter
import com.github.libretube.ui.base.BasePreferenceFragment 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.DeleteAccountDialog
import com.github.libretube.ui.dialogs.LoginDialog import com.github.libretube.ui.dialogs.LoginDialog
import com.github.libretube.ui.dialogs.LogoutDialog import com.github.libretube.ui.dialogs.LogoutDialog
@ -82,17 +83,8 @@ class InstanceSettings : BasePreferenceFragment() {
val customInstance = findPreference<Preference>(PreferenceKeys.CUSTOM_INSTANCE) val customInstance = findPreference<Preference>(PreferenceKeys.CUSTOM_INSTANCE)
customInstance?.setOnPreferenceClickListener { customInstance?.setOnPreferenceClickListener {
CustomInstanceDialog() CustomInstancesListDialog()
.show(childFragmentManager, CustomInstanceDialog::class.java.name) .show(childFragmentManager, CreateCustomInstanceDialog::class.java.name)
true
}
val clearCustomInstances = findPreference<Preference>(PreferenceKeys.CLEAR_CUSTOM_INSTANCES)
clearCustomInstances?.setOnPreferenceClickListener {
lifecycleScope.launch {
Database.customInstanceDao().deleteAll()
ActivityCompat.recreate(requireActivity())
}
true true
} }

View File

@ -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)

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
tools:ignore="UseCompoundDrawables"
android:background="@drawable/rounded_ripple"
android:layout_marginHorizontal="16dp"
android:layout_marginVertical="2dp"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp">
<TextView
android:id="@+id/instanceName"
android:layout_height="wrap_content"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_gravity="center"
android:layout_marginEnd="6dp"
android:ellipsize="end"
android:maxLines="1" />
<ImageView
android:id="@+id/deleteInstance"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:src="@drawable/ic_delete" />
</LinearLayout>

View File

@ -5,6 +5,17 @@
android:orientation="vertical" android:orientation="vertical"
android:paddingTop="8dp"> android:paddingTop="8dp">
<com.google.android.material.textfield.TextInputLayout
style="@style/CustomDialogTextInputLayout"
android:hint="@string/instance_api_url">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/instanceApiUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
style="@style/CustomDialogTextInputLayout" style="@style/CustomDialogTextInputLayout"
android:hint="@string/instance_name"> android:hint="@string/instance_name">
@ -27,15 +38,4 @@
android:inputType="text" /> android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/CustomDialogTextInputLayout"
android:hint="@string/instance_api_url">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/instanceApiUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout> </LinearLayout>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:id="@+id/custom_instances_recycler"
android:paddingTop="10dp"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
android:layout_height="match_parent">
</androidx.recyclerview.widget.RecyclerView>

View File

@ -176,7 +176,7 @@
<string name="resume">Resume</string> <string name="resume">Resume</string>
<string name="stop">Stop</string> <string name="stop">Stop</string>
<string name="player_autoplay">Autoplay</string> <string name="player_autoplay">Autoplay</string>
<string name="instance_frontend_url">URL to instance frontend</string> <string name="instance_frontend_url">URL to instance frontend (optional)</string>
<string name="quality">Quality</string> <string name="quality">Quality</string>
<string name="behavior">Behavior</string> <string name="behavior">Behavior</string>
<string name="player_summary">Defaults and behavior</string> <string name="player_summary">Defaults and behavior</string>

View File

@ -40,12 +40,6 @@
app:summary="@string/customInstance_summary" app:summary="@string/customInstance_summary"
app:title="@string/customInstance" /> app:title="@string/customInstance" />
<Preference
android:icon="@drawable/ic_trash"
app:key="clearCustomInstances"
android:dependency="full_local_mode"
app:title="@string/clear_customInstances" />
<SwitchPreferenceCompat <SwitchPreferenceCompat
android:defaultValue="false" android:defaultValue="false"
android:icon="@drawable/ic_server" android:icon="@drawable/ic_server"