Author: Adam Evyčędo <git@apiote.xyz>
move and delete favourites
%!v(PANIC=String method: strings: negative Repeat count)
diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt index 2990a44df86f9df529610830fcf50f7a8e3b5402..689434ea950b314a6b7faa16ffd2c6c6cdbb174c 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt @@ -16,10 +16,9 @@ import xyz.apiote.bimba.czwek.R import xyz.apiote.bimba.czwek.departures.DeparturesActivity import xyz.apiote.bimba.czwek.repo.Departure import xyz.apiote.bimba.czwek.repo.Favourite +import java.util.Collections import java.util.Optional -// TODO deleting favourite -// TODO drag-drop sorting // TODO diffUtil class BimbaFavouritesAdapter( private var favourites: List<Favourite>, @@ -48,6 +47,36 @@ fun updateDepartures(departures: List<Departure?>) { this.departures = departures.map { Optional.ofNullable(it) } notifyDataSetChanged() + } + + fun swap(from: Int, to: Int): List<Favourite> { + Collections.swap(favourites, from, to) + Collections.swap(departures, from, to) + favourites = favourites.mapIndexed { i, it -> + it.copy(sequence = i) + } + notifyItemMoved(from, to) + return favourites + } + + fun delete(position: Int): Pair<List<Favourite>, Favourite> { + val removedFavourite = favourites[position] + favourites = favourites.filterIndexed { i, _ -> + i != position + }.mapIndexed { i, it -> + it.copy(sequence = i) + } + notifyItemRemoved(position) + return Pair(favourites, removedFavourite) + } + + fun insert(removedFavourite: Favourite): List<Favourite> { + favourites = favourites.toMutableList().apply { + add(removedFavourite.sequence!!, removedFavourite) + }.mapIndexed { i, it -> + it.copy(sequence = i) + } + return favourites } } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt index 85c205de059b0edc5e79eb81120daa5339261724..31fe93c056ed02204bf6ad61d2a870931383f264 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeFragment.kt @@ -18,11 +18,16 @@ import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.search.SearchView.TransitionState +import com.google.android.material.snackbar.Snackbar +import xyz.apiote.bimba.czwek.R import xyz.apiote.bimba.czwek.dashboard.MainActivity import xyz.apiote.bimba.czwek.databinding.FragmentHomeBinding import xyz.apiote.bimba.czwek.dpToPixelI +import xyz.apiote.bimba.czwek.repo.Favourite import xyz.apiote.bimba.czwek.search.BimbaResultsAdapter import xyz.apiote.bimba.czwek.units.Millisecond import xyz.apiote.bimba.czwek.units.Second @@ -36,7 +41,7 @@ private lateinit var favouritesAdapter: BimbaFavouritesAdapter private lateinit var viewModel: HomeViewModel private val countdown = - object : CountDownTimer(Millisecond(Second(60)).millis, Millisecond(Second(10)).millis) { + object : CountDownTimer(Millisecond(Second(30)).millis, Millisecond(Second(10)).millis) { override fun onTick(millisUntilFinished: Long) { } @@ -61,6 +66,7 @@ adapter.update(it) } viewModel.favourites.observe(viewLifecycleOwner) { favouritesAdapter.updateFavourites(it) + refreshDepartures() } viewModel.departures.observe(viewLifecycleOwner) { favouritesAdapter.updateDepartures(it) @@ -141,6 +147,52 @@ favouritesAdapter = BimbaFavouritesAdapter(listOf(), listOf(), layoutInflater, requireContext()) binding.favourites.adapter = favouritesAdapter viewModel.getFavourites(requireContext()) + + val ith = ItemTouchHelper(object : ItemTouchHelper.Callback() { + var newFavourites = emptyList<Favourite>() + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + newFavourites = + favouritesAdapter.swap(viewHolder.absoluteAdapterPosition, target.absoluteAdapterPosition) + return true + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val (newFavourites, removedFavourite) = favouritesAdapter.delete(viewHolder.absoluteAdapterPosition) + this.newFavourites = newFavourites + // FIXME snackbar should appear above FAB (and be in CoordinatorLayout) + Snackbar.make(binding.fragmentContent, R.string.favourite_deleted, Snackbar.LENGTH_LONG) + .setAction(R.string.undo) { + this.newFavourites = favouritesAdapter.insert(removedFavourite) + viewModel.saveFavourites(this.newFavourites, requireContext()) + } + .show() + } + + override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) { + super.clearView(recyclerView, viewHolder) + + viewModel.saveFavourites(newFavourites, requireContext()) + } + + override fun getMovementFlags( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder + ): Int { + return makeFlag( + ItemTouchHelper.ACTION_STATE_DRAG, + ItemTouchHelper.DOWN or ItemTouchHelper.UP + ) or makeFlag( + ItemTouchHelper.ACTION_STATE_SWIPE, + ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT + ) + } + }) + ith.attachToRecyclerView(binding.favourites) + return binding.root } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt index 4d483fbff8df25337bb52fbc93aa69c3045131e7..1dd82ec49e908b7aa49a50923318ae7227620818 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/HomeViewModel.kt @@ -28,6 +28,7 @@ import xyz.apiote.bimba.czwek.repo.OnlineRepository import xyz.apiote.bimba.czwek.repo.Queryable import xyz.apiote.bimba.czwek.repo.TrafficResponseException import xyz.apiote.bimba.czwek.settings.feeds.FeedsSettings +import java.sql.SQLException class HomeViewModel : ViewModel() { private val mutableQueryables = MutableLiveData<List<Queryable>>() @@ -56,8 +57,9 @@ viewModelScope.launch { try { getFeeds(context) val repository = OfflineRepository(context) - mutableFavourites.value = repository.getFavourites(feedsSettings?.activeFeeds()?: emptySet()) - } catch (e: TrafficResponseException) { + mutableFavourites.value = + repository.getFavourites(feedsSettings?.activeFeeds() ?: emptySet()) + } catch (e: SQLException) { Log.w("FavouritesForFavourite", "$e") } getDeparturesOnly(context) @@ -91,7 +93,11 @@ if (sDs.departures.isEmpty()) { val (string, image) = mapHttpError(44) throw TrafficResponseException(44, "", Error(44, string, image)) } - sDs.departures.find {departure -> favourite.lines.isEmpty() or favourite.lines.contains(departure.vehicle.Line.name) } + sDs.departures.find { departure -> + favourite.lines.isEmpty() or favourite.lines.contains( + departure.vehicle.Line.name + ) + } } } catch (e: TrafficResponseException) { Log.w("DeparturesForFavourite", "$e") @@ -105,6 +111,18 @@ private suspend fun getFeeds(context: Context) { feeds = OfflineRepository(context).getFeeds(context) feedsSettings = FeedsSettings.load(context) + } + + fun saveFavourites(newFavourites: List<Favourite>, context: Context) { + viewModelScope.launch { + try { + val repository = OfflineRepository(context) + repository.saveFavourites(newFavourites.toSet()) + mutableFavourites.value = newFavourites + } catch (e: SQLException) { + Log.w("FavouritesForFavourite", "$e") + } + } } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt index 9d48f90b5f84b54f504c43afada054b7e34869ab..456340e38d76dcd0a0e63ea94a8bc7d8165c4670 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesActivity.kt @@ -496,6 +496,7 @@ Toast.makeText(context, R.string.cannot_save_favourite, Toast.LENGTH_LONG).show() return@launch } val favourite = (repo.getFavourite(code) ?: Favourite( + null, feedID, feedName, code, diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt index e77f790d1a24d664c9f08ec2dbdab4c6c902a2f5..a24d42ebccfb157547772a58006dcfcb0f561d05 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt @@ -1,3 +1,3 @@ package xyz.apiote.bimba.czwek.repo -data class Favourite(val feedID: String, val feedName: String, val stopCode: String, val stopName: String, val lines: List<String>) \ No newline at end of file +data class Favourite(val sequence: Int?, val feedID: String, val feedName: String, val stopCode: String, val stopName: String, val lines: List<String>) \ No newline at end of file diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt index 6db3f554e6eb6b110e5984f36e66f3584dfe1fba..95fa5fa0c75f0c3e765eefd64c22091021260eb8 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Interfaces.kt @@ -21,6 +21,7 @@ suspend fun getFavourite(stopCode: String): Favourite? suspend fun getFavourites(feedIDs: Set<String> = emptySet()): List<Favourite> suspend fun saveFavourite(favourite: Favourite) + suspend fun saveFavourites(favourites: Set<Favourite>) suspend fun getFeeds( context: Context, diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt index 1f1f6b72c86d6e4b0d22cc8a8f1acb2c9c9c73eb..f208f836c8d7c8cc4f48480df7a8f24e77846c14 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OfflineRepository.kt @@ -7,6 +7,7 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import androidx.core.content.edit +import androidx.core.database.sqlite.transaction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import xyz.apiote.bimba.czwek.api.Server @@ -40,7 +41,7 @@ override suspend fun getFavourite(stopCode: String): Favourite? { val cursor = db.rawQuery( - "select stop_name, feed_id, feed_name, lines from favourites where stop_code = ?", + "select sequence, stop_name, feed_id, feed_name, lines from favourites where stop_code = ?", listOf(stopCode).toTypedArray() ) if (cursor.count == 0) { @@ -48,11 +49,12 @@ return null } cursor.moveToNext() val f = Favourite( - cursor.getString(1), + cursor.getInt(0), cursor.getString(2), + cursor.getString(3), stopCode, - cursor.getString(0), - cursor.getString(2).split("||").filter { it != "" } + cursor.getString(1), + cursor.getString(4).split("||").filter { it != "" } ) cursor.close() return f @@ -66,18 +68,19 @@ "" } val cursor = db.rawQuery( - "select stop_name, feed_id, feed_name, stop_code, lines from favourites $whereClause", + "select sequence, stop_name, feed_id, feed_name, stop_code, lines from favourites $whereClause order by sequence", feedIDs.toTypedArray() ) val l = mutableListOf<Favourite>() while (cursor.moveToNext()) { l.add( Favourite( - cursor.getString(1), + cursor.getInt(0), cursor.getString(2), cursor.getString(3), - cursor.getString(0), - cursor.getString(4).split("||").filter { it != "" } + cursor.getString(4), + cursor.getString(1), + cursor.getString(5).split("||").filter { it != "" } ) ) } @@ -86,18 +89,41 @@ return l } override suspend fun saveFavourite(favourite: Favourite) { + val sequence = favourite.sequence ?: run { + val cursor = + db.rawQuery("select max(ROWID) from favourites", emptyArray<String?>()) + val s = if (cursor.count == 0) { + 0 + } else { + cursor.moveToNext() + cursor.getInt(0) + } + cursor.close() + s + } db.execSQL( - "insert into favourites(feed_id, feed_name, stop_code, stop_name, lines) values (?,?,?,?,?) on conflict(feed_id, stop_code) do update set stop_name = ?, lines = ?", + "insert into favourites(sequence, feed_id, feed_name, stop_code, stop_name, lines) values (?, ?,?,?,?,?) on conflict(feed_id, stop_code) do update set stop_name = ?, lines = ?, sequence = ?", arrayOf( + sequence, favourite.feedID, favourite.feedName, favourite.stopCode, favourite.stopName, favourite.lines.joinToString(separator = "||"), favourite.stopName, - favourite.lines.joinToString(separator = "||") + favourite.lines.joinToString(separator = "||"), + favourite.sequence ) ) + } + + override suspend fun saveFavourites(favourites: Set<Favourite>) { + db.execSQL("delete from favourites") + db.transaction { + favourites.forEach { + saveFavourite(it) + } + } } @Suppress("RedundantNullableReturnType") @@ -179,5 +205,5 @@ fun migrateDB(context: Context) { val db = SQLiteDatabase.openOrCreateDatabase(context.getDatabasePath("favourites").path, null) - db.execSQL("create table if not exists favourites(feed_id text, feed_name text, stop_code text, stop_name text, lines text, primary key(feed_id, stop_code))") + db.execSQL("create table if not exists favourites(sequence integer, feed_id text, feed_name text, stop_code text, stop_name text, lines text, primary key(feed_id, stop_code))") } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt index 2e9221f818e5c8af264195212ffca2b425fdd68e..1c3f36595fe1c6bc646a137d9475294d59f0f9d1 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/OnlineRepository.kt @@ -62,6 +62,10 @@ override suspend fun saveFavourite(favourite: Favourite) { TODO("Not yet implemented; waits for ampelmänchen") } + override suspend fun saveFavourites(favourites: Set<Favourite>) { + TODO("Not yet implemented; waits for ampelmänchen") + } + override suspend fun getFeeds( context: Context, server: Server diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 2f7c7394a3a1c5cbfd16861d7efc19b876056cfb..1cc4250cb72b7dc1b0fbc2ad9635620d6d48ffe1 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -11,6 +11,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" + android:id="@+id/fragment_content" android:tag="@string/title_home" tool:context="xyz.apiote.bimba.czwek.dashboard.ui.home.HomeFragment"> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5394c875cd6eb6152241dd20bf5b03e618c6ae4f..d95c7b6f7c90ae59047f1cf825bad6f84d4aa6c6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -133,6 +133,8 @@Unfiltered <string name="cannot_save_favourite">Couldn’t save the favourite</string> <string name="no_next_departures">no next departures</string> <string name="error_44">No more departures</string> - <string name="loading">Loading…</string> + <string name="loading">loading…</string> + <string name="favourite_deleted">Favourite deleted</string> + <string name="undo">Undo</string> </resources>