Merge branch 'filter-favorites' into 'develop'
Filter favorites Closes #132 See merge request funkwhale/funkwhale-android!268
This commit is contained in:
commit
aa8e0ce1a6
|
@ -3,6 +3,7 @@
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
|
<permission android:name="android.permission.MEDIA_CONTENT_CONTROL" />
|
||||||
|
|
||||||
|
|
|
@ -37,6 +37,7 @@ class FavoritesAdapter(
|
||||||
private lateinit var binding: RowTrackBinding
|
private lateinit var binding: RowTrackBinding
|
||||||
|
|
||||||
var currentTrack: Track? = null
|
var currentTrack: Track? = null
|
||||||
|
var filter = ""
|
||||||
|
|
||||||
override fun getItemCount() = data.size
|
override fun getItemCount() = data.size
|
||||||
|
|
||||||
|
@ -44,6 +45,15 @@ class FavoritesAdapter(
|
||||||
return data[position].id.toLong()
|
return data[position].id.toLong()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun applyFilter() {
|
||||||
|
data.clear()
|
||||||
|
getUnfilteredData().map {
|
||||||
|
if (it.matchesFilter(filter)) {
|
||||||
|
data.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
|
||||||
binding = RowTrackBinding.inflate(layoutInflater, parent, false)
|
binding = RowTrackBinding.inflate(layoutInflater, parent, false)
|
||||||
|
|
|
@ -106,7 +106,7 @@ object AddToPlaylistDialog {
|
||||||
|
|
||||||
fetch().untilNetwork(lifecycleScope) { data, isCache, _, hasMore ->
|
fetch().untilNetwork(lifecycleScope) { data, isCache, _, hasMore ->
|
||||||
if (isCache) {
|
if (isCache) {
|
||||||
adapter.data = data.toMutableList()
|
adapter.setUnfilteredData(data.toMutableList())
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
|
|
||||||
return@untilNetwork
|
return@untilNetwork
|
||||||
|
|
|
@ -18,12 +18,26 @@ import com.google.gson.Gson
|
||||||
import kotlinx.coroutines.Dispatchers.IO
|
import kotlinx.coroutines.Dispatchers.IO
|
||||||
import kotlinx.coroutines.Dispatchers.Main
|
import kotlinx.coroutines.Dispatchers.Main
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.collect
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
abstract class FFAAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
abstract class FFAAdapter<D, VH : RecyclerView.ViewHolder> : RecyclerView.Adapter<VH>() {
|
||||||
var data: MutableList<D> = mutableListOf()
|
var data: MutableList<D> = mutableListOf()
|
||||||
|
private var unfilteredData: MutableList<D> = mutableListOf()
|
||||||
|
|
||||||
|
fun getUnfilteredData(): MutableList<D> {
|
||||||
|
return unfilteredData
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUnfilteredData(data: MutableList<D>) {
|
||||||
|
unfilteredData = data
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
open fun applyFilter() {
|
||||||
|
data.clear()
|
||||||
|
data.addAll(unfilteredData)
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
super.setHasStableIds(true)
|
super.setHasStableIds(true)
|
||||||
|
@ -130,19 +144,20 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
|
||||||
if (isCache) {
|
if (isCache) {
|
||||||
moreLoading = false
|
moreLoading = false
|
||||||
|
|
||||||
adapter.data = data.toMutableList()
|
adapter.setUnfilteredData(data.toMutableList())
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (first) {
|
if (first) {
|
||||||
adapter.data.clear()
|
adapter.getUnfilteredData().clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
onDataFetched(data)
|
onDataFetched(data)
|
||||||
|
|
||||||
adapter.data.addAll(data)
|
adapter.getUnfilteredData().addAll(data)
|
||||||
|
adapter.applyFilter()
|
||||||
|
|
||||||
withContext(IO) {
|
withContext(IO) {
|
||||||
try {
|
try {
|
||||||
|
@ -150,7 +165,7 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
|
||||||
FFACache.set(
|
FFACache.set(
|
||||||
context,
|
context,
|
||||||
cacheId,
|
cacheId,
|
||||||
Gson().toJson(repository.cache(adapter.data)).toString()
|
Gson().toJson(repository.cache(adapter.getUnfilteredData())).toString()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: ConcurrentModificationException) {
|
} catch (e: ConcurrentModificationException) {
|
||||||
|
@ -161,7 +176,7 @@ abstract class FFAFragment<D : Any, A : FFAAdapter<D, *>>() : Fragment() {
|
||||||
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
|
(repository.upstream as? HttpUpstream<*, *>)?.let { upstream ->
|
||||||
if (!isCache && upstream.behavior == HttpUpstream.Behavior.Progressive) {
|
if (!isCache && upstream.behavior == HttpUpstream.Behavior.Progressive) {
|
||||||
if (first || needsMoreOffscreenPages()) {
|
if (first || needsMoreOffscreenPages()) {
|
||||||
fetch(Repository.Origin.Network.origin, adapter.data.size)
|
fetch(Repository.Origin.Network.origin, adapter.getUnfilteredData().size)
|
||||||
} else {
|
} else {
|
||||||
moreLoading = false
|
moreLoading = false
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
package audio.funkwhale.ffa.fragments
|
package audio.funkwhale.ffa.fragments
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
@ -53,6 +55,20 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
||||||
): View {
|
): View {
|
||||||
_binding = FragmentFavoritesBinding.inflate(inflater)
|
_binding = FragmentFavoritesBinding.inflate(inflater)
|
||||||
swiper = binding.swiper
|
swiper = binding.swiper
|
||||||
|
binding.filterTracks.addTextChangedListener(object : TextWatcher {
|
||||||
|
|
||||||
|
override fun afterTextChanged(s: Editable) {
|
||||||
|
|
||||||
|
adapter.applyFilter()
|
||||||
|
adapter.notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { }
|
||||||
|
|
||||||
|
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
|
||||||
|
adapter.filter = s.toString()
|
||||||
|
}
|
||||||
|
})
|
||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,11 +121,13 @@ class FavoritesFragment : FFAFragment<Track, FavoritesAdapter>() {
|
||||||
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
|
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
|
||||||
|
|
||||||
withContext(Main) {
|
withContext(Main) {
|
||||||
adapter.data = adapter.data.map {
|
val data = adapter.data.map {
|
||||||
it.downloaded = downloaded.contains(it.id)
|
it.downloaded = downloaded.contains(it.id)
|
||||||
it
|
it
|
||||||
}.toMutableList()
|
}.toMutableList()
|
||||||
|
|
||||||
|
adapter.setUnfilteredData(data)
|
||||||
|
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -92,7 +92,7 @@ class LandscapeQueueFragment : Fragment() {
|
||||||
activity?.lifecycleScope?.launch(Main) {
|
activity?.lifecycleScope?.launch(Main) {
|
||||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||||
adapter?.let {
|
adapter?.let {
|
||||||
it.data = response.queue.toMutableList()
|
it.setUnfilteredData(response.queue.toMutableList())
|
||||||
it.notifyDataSetChanged()
|
it.notifyDataSetChanged()
|
||||||
|
|
||||||
if (it.data.isEmpty()) {
|
if (it.data.isEmpty()) {
|
||||||
|
|
|
@ -108,7 +108,7 @@ class QueueFragment : BottomSheetDialogFragment() {
|
||||||
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
RequestBus.send(Request.GetQueue).wait<Response.Queue>()?.let { response ->
|
||||||
binding.included.let { included ->
|
binding.included.let { included ->
|
||||||
adapter?.let {
|
adapter?.let {
|
||||||
it.data = response.queue.toMutableList()
|
it.setUnfilteredData(response.queue.toMutableList())
|
||||||
it.notifyDataSetChanged()
|
it.notifyDataSetChanged()
|
||||||
|
|
||||||
if (it.data.isEmpty()) {
|
if (it.data.isEmpty()) {
|
||||||
|
|
|
@ -269,10 +269,12 @@ class TracksFragment : FFAFragment<Track, TracksAdapter>() {
|
||||||
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
|
val downloaded = TracksRepository.getDownloadedIds(exoDownloadManager) ?: listOf()
|
||||||
|
|
||||||
withContext(Main) {
|
withContext(Main) {
|
||||||
adapter.data = adapter.data.map {
|
adapter.setUnfilteredData(
|
||||||
|
adapter.data.map {
|
||||||
it.downloaded = downloaded.contains(it.id)
|
it.downloaded = downloaded.contains(it.id)
|
||||||
it
|
it
|
||||||
}.toMutableList()
|
}.toMutableList()
|
||||||
|
)
|
||||||
|
|
||||||
adapter.notifyDataSetChanged()
|
adapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package audio.funkwhale.ffa.model
|
package audio.funkwhale.ffa.model
|
||||||
|
|
||||||
|
import audio.funkwhale.ffa.utils.containsIgnoringCase
|
||||||
import com.preference.PowerPreference
|
import com.preference.PowerPreference
|
||||||
|
|
||||||
data class Track(
|
data class Track(
|
||||||
|
@ -29,11 +30,13 @@ data class Track(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Upload(
|
data class Upload(val listen_url: String, val duration: Int, val bitrate: Int)
|
||||||
val listen_url: String,
|
|
||||||
val duration: Int,
|
fun matchesFilter(filter: String): Boolean {
|
||||||
val bitrate: Int
|
return title.containsIgnoringCase(filter) ||
|
||||||
)
|
artist.name.containsIgnoringCase(filter) ||
|
||||||
|
album?.title.containsIgnoringCase(filter)
|
||||||
|
}
|
||||||
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
return when (other) {
|
return when (other) {
|
||||||
|
|
|
@ -114,3 +114,6 @@ val ISO_8601_DATE_TIME_FORMAT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ")
|
||||||
fun Date.format(): String {
|
fun Date.format(): String {
|
||||||
return ISO_8601_DATE_TIME_FORMAT.format(this)
|
return ISO_8601_DATE_TIME_FORMAT.format(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String?.containsIgnoringCase(candidate: String): Boolean =
|
||||||
|
this != null && this.lowercase().contains(candidate.lowercase())
|
||||||
|
|
|
@ -39,14 +39,27 @@
|
||||||
android:clipChildren="false"
|
android:clipChildren="false"
|
||||||
app:layout_collapseMode="parallax">
|
app:layout_collapseMode="parallax">
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/filter_tracks"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_below="@id/favorites_title"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:ems="10"
|
||||||
|
android:inputType="text"
|
||||||
|
android:hint="@string/filters" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
style="@style/AppTheme.Title"
|
style="@style/AppTheme.Title"
|
||||||
|
android:id="@+id/favorites_title"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginTop="64dp"
|
android:layout_marginTop="64dp"
|
||||||
android:layout_marginEnd="16dp"
|
android:layout_marginEnd="16dp"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="6dp"
|
||||||
android:text="@string/favorites" />
|
android:text="@string/favorites" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
package audio.funkwhale.ffa.model
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import strikt.api.expectThat
|
||||||
|
import strikt.assertions.isFalse
|
||||||
|
import strikt.assertions.isTrue
|
||||||
|
|
||||||
|
class TrackTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackMatchesTitle() {
|
||||||
|
expectThat(createTrackObject(trackTitle = "track").matchesFilter("track")).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackDoesntMatchTitle() {
|
||||||
|
expectThat(createTrackObject(trackTitle = "xxxx").matchesFilter("track")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackMatchesArtist() {
|
||||||
|
expectThat(createTrackObject(artistName = "artist").matchesFilter("artist")).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackDoesntMatchArtist() {
|
||||||
|
expectThat(createTrackObject(artistName = "xxxx").matchesFilter("artist")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackMatchesAlbum() {
|
||||||
|
expectThat(createTrackObject(albumTitle = "album").matchesFilter("album")).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackDoesntMatchAlbum() {
|
||||||
|
expectThat(createTrackObject(albumTitle = "xxxx").matchesFilter("album")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun trackDoesntMatchNullAlbum() {
|
||||||
|
expectThat(createTrackObject(albumTitle = null).matchesFilter("album")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createTrackObject(
|
||||||
|
trackTitle: String = "trackTitle",
|
||||||
|
artistName: String = "artistName",
|
||||||
|
albumTitle: String? = "albumTitle"
|
||||||
|
) = Track(
|
||||||
|
id = 0,
|
||||||
|
title = trackTitle,
|
||||||
|
artist = Artist(id = 0, name = artistName, albums = listOf()),
|
||||||
|
album =
|
||||||
|
if (albumTitle == null)
|
||||||
|
null
|
||||||
|
else Album(
|
||||||
|
id = 0,
|
||||||
|
title = albumTitle,
|
||||||
|
artist = Album.Artist("albumArtist"),
|
||||||
|
cover = null,
|
||||||
|
release_date = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
package audio.funkwhale.ffa.utils
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import strikt.api.expectThat
|
||||||
|
import strikt.assertions.isFalse
|
||||||
|
import strikt.assertions.isTrue
|
||||||
|
|
||||||
|
internal class ExtensionsKtTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun nullStringDoesntContainCandidate() {
|
||||||
|
val s: String? = null
|
||||||
|
expectThat(s.containsIgnoringCase("candidate")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stringDoesntContainCandidate() {
|
||||||
|
expectThat("string".containsIgnoringCase("candidate")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sameStringWithDifferentCasingContainsCandidate() {
|
||||||
|
expectThat("CANDIDATE".containsIgnoringCase("candidate")).isTrue()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sameStringWithMatchingCasingContainsCandidate() {
|
||||||
|
expectThat("candidate".containsIgnoringCase("candidate")).isTrue()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
Add filtering functionality to favorites view (thanks @PhieF)
|
Loading…
Reference in New Issue