Bimba.git

commit 7f07f87017b732a4298642141fead0212b41fba4

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>