Author: Adam Evyčędo <git@apiote.xyz>
wip; getting favourites from sqlite
%!v(PANIC=String method: strings: negative Repeat count)
diff --git a/app/build.gradle b/app/build.gradle index 95ad937cd17c833225b63ce882c5893c84872f58..7ed02a6c02fd63bbd76f6bb7c31bd03912a1bba5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -12,6 +12,7 @@ id 'org.jetbrains.kotlin.android' id "org.jetbrains.kotlin.plugin.parcelize" id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.22' id 'com.mermake.locale-resource-generator' version '0.1' + id "com.google.protobuf" version "0.9.4" } android { @@ -49,6 +50,10 @@ viewBinding true } namespace 'xyz.apiote.bimba.czwek' buildToolsVersion = '34.0.0' + + sourceSets { + main.java.srcDirs += 'src/main/proto' + } } dependencies { diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt index 3ebf1676b286d06d1505a26a162af9843274a88a..76953700a4a76e06a41716bcdcc43d07e50e1008 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/api/Api.kt @@ -159,7 +159,8 @@ } fun mapHttpError(code: Int): Pair<Int, Int> { return when (code) { - 41 -> Pair(R.string.error_41, R.drawable.error_other) // TODO error_locality + 41 -> Pair(R.string.error_41, R.drawable.error_locality) + 44 -> Pair(R.string.error_44, R.drawable.error_departures) 400 -> Pair(R.string.error_400, R.drawable.error_app) 401 -> Pair(R.string.error_401, R.drawable.error_sec) 403 -> Pair(R.string.error_403, R.drawable.error_sec) @@ -205,15 +206,15 @@ private fun isNetworkAvailable(context: Context): Boolean { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - connectivityManager.activeNetwork?.let {network -> - connectivityManager.getNetworkCapabilities(network)?.let {capabilities -> + connectivityManager.activeNetwork?.let { network -> + connectivityManager.getNetworkCapabilities(network)?.let { capabilities -> capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || capabilities.hasTransport( NetworkCapabilities.TRANSPORT_CELLULAR ) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) } - }?: false + } ?: false } else { @Suppress("DEPRECATION") connectivityManager.activeNetworkInfo?.isConnected ?: false diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt index e984a11476a6821e66bd4cc898785c56136f6b80..b2a20c6ca37251d8b153b0789dbfa920fa015eb3 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/MainActivity.kt @@ -151,7 +151,7 @@ ) ) .setTitle(getString(R.string.no_location_access)) .setMessage(getString(R.string.no_location_message)) - .setPositiveButton(resources.getString(R.string.ok)) { _, _ -> } + .setPositiveButton(R.string.ok) { _, _ ->} .show() locationPermissionDialogShown = true } 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 new file mode 100644 index 0000000000000000000000000000000000000000..2990a44df86f9df529610830fcf50f7a8e3b5402 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/home/Favourites.kt @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Adam Evyčędo +// +// SPDX-License-Identifier: GPL-3.0-or-later + +package xyz.apiote.bimba.czwek.dashboard.ui.home + +import android.content.Context +import android.content.Intent +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +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.Optional + +// TODO deleting favourite +// TODO drag-drop sorting +// TODO diffUtil +class BimbaFavouritesAdapter( + private var favourites: List<Favourite>, + private var departures: List<Optional<Departure>?>, + private val inflater: LayoutInflater, + private val context: Context +) : + RecyclerView.Adapter<FavouriteViewHolder>() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FavouriteViewHolder { + val rowView = inflater.inflate(R.layout.favourite, parent, false) + return FavouriteViewHolder(rowView) + } + + override fun getItemCount() = favourites.size + + override fun onBindViewHolder(holder: FavouriteViewHolder, position: Int) { + FavouriteViewHolder.bind(favourites[position], holder, context, departures.getOrNull(position)) + } + + fun updateFavourites(favourites: List<Favourite>) { + this.favourites = favourites + departures = favourites.map { Optional.empty() } + notifyDataSetChanged() + } + + fun updateDepartures(departures: List<Departure?>) { + this.departures = departures.map { Optional.ofNullable(it) } + notifyDataSetChanged() + } +} + +class FavouriteViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + val root: View = itemView.findViewById(R.id.favourite) + val feedName: TextView = itemView.findViewById(R.id.feed_name) + val lineIcon: ImageView = itemView.findViewById(R.id.line_icon) + val departureTime: TextView = itemView.findViewById(R.id.departure_time) + val departureTimeFull: TextView = itemView.findViewById(R.id.departure_full_time) + val lineName: TextView = itemView.findViewById(R.id.departure_line) + val headsign: TextView = itemView.findViewById(R.id.departure_headsign) + val stopHeadline: TextView = itemView.findViewById(R.id.stop_name) + + companion object { + fun bind( + favourite: Favourite, + holder: FavouriteViewHolder, + context: Context, + departure: Optional<Departure>? + ) { + if (departure == null) { + holder.feedName.text = favourite.feedName + holder.stopHeadline.text = favourite.stopName + holder.lineIcon.setImageDrawable(null) + holder.lineName.text = context.getString(R.string.no_next_departures) + holder.departureTime.text = "" + holder.departureTimeFull.text = "" + holder.headsign.text = "" + } else if (departure.isEmpty) { + holder.feedName.text = favourite.feedName + holder.stopHeadline.text = favourite.stopName + holder.lineIcon.setImageDrawable(null) + holder.lineName.text = context.getString(R.string.loading) + holder.departureTime.text = "" + holder.departureTimeFull.text = "" + holder.headsign.text = "" + } else { + val vehicle = departure.get().vehicle + holder.feedName.text = favourite.feedName + holder.stopHeadline.text = favourite.stopName + holder.lineIcon.setImageDrawable(vehicle.Line.icon(context)) + holder.lineIcon.contentDescription = vehicle.Line.kind.name + holder.lineName.text = vehicle.Line.name + holder.departureTime.text = departure.get().statusText(context, false) + holder.departureTimeFull.text = departure.get().timeString(context) + holder.headsign.text = + context.getString(R.string.departure_headsign, vehicle.Headsign) + holder.headsign.contentDescription = + context.getString( + R.string.departure_headsign_content_description, + vehicle.Headsign + ) + } + + holder.root.setOnClickListener { + val intent = Intent(context, DeparturesActivity::class.java).apply { + putExtra("code", favourite.stopCode) + putExtra("name", favourite.stopName) + putExtra("feedID", favourite.feedID) + putExtra("linesFilter", favourite.lines.toTypedArray()) + } + context.startActivity(intent) + } + } + } +} \ No newline at end of file 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 6e29e6b47a85f9f90535ab1bce25b0fe086fb11d..85c205de059b0edc5e79eb81120daa5339261724 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 @@ -5,6 +5,7 @@ package xyz.apiote.bimba.czwek.dashboard.ui.home import android.os.Bundle +import android.os.CountDownTimer import android.view.KeyEvent import android.view.LayoutInflater import android.view.View @@ -23,12 +24,26 @@ 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.search.BimbaResultsAdapter +import xyz.apiote.bimba.czwek.units.Millisecond +import xyz.apiote.bimba.czwek.units.Second class HomeFragment : Fragment() { private var _binding: FragmentHomeBinding? = null private val binding get() = _binding!! private lateinit var adapter: BimbaResultsAdapter + private lateinit var favouritesAdapter: BimbaFavouritesAdapter + private lateinit var viewModel: HomeViewModel + + private val countdown = + object : CountDownTimer(Millisecond(Second(60)).millis, Millisecond(Second(10)).millis) { + override fun onTick(millisUntilFinished: Long) { + } + + override fun onFinish() { + refreshDepartures() + } + } override fun onCreateView( inflater: LayoutInflater, @@ -37,13 +52,23 @@ savedInstanceState: Bundle? ): View { _binding = FragmentHomeBinding.inflate(inflater, container, false) - val homeViewModel = + viewModel = ViewModelProvider(this)[HomeViewModel::class.java] - homeViewModel.queryables.observe(viewLifecycleOwner) { - adapter.feedsSettings = homeViewModel.feedsSettings - adapter.feeds = homeViewModel.feeds + viewModel.queryables.observe(viewLifecycleOwner) { + adapter.feedsSettings = viewModel.feedsSettings + adapter.feeds = viewModel.feeds adapter.update(it) } + viewModel.favourites.observe(viewLifecycleOwner) { + favouritesAdapter.updateFavourites(it) + } + viewModel.departures.observe(viewLifecycleOwner) { + favouritesAdapter.updateDepartures(it) + } + + countdown.start() + + binding.searchView.setupWithSearchBar(binding.searchBar) val onBackPressedCallback = object : OnBackPressedCallback(binding.searchView.currentTransitionState == TransitionState.SHOWN) { @@ -71,7 +96,7 @@ adapter = BimbaResultsAdapter(layoutInflater, activity, listOf()) binding.suggestionsRecycler.adapter = adapter binding.searchView.editText.addTextChangedListener( - homeViewModel.SearchBarWatcher(requireContext()) + viewModel.SearchBarWatcher(requireContext()) ) binding.searchView.editText.setOnKeyListener { v, keyCode, event -> when (keyCode) { @@ -111,11 +136,28 @@ } windowInsets } + binding.favourites.layoutManager = LinearLayoutManager(context) + favouritesAdapter = BimbaFavouritesAdapter(listOf(), listOf(), layoutInflater, requireContext()) + binding.favourites.adapter = favouritesAdapter + + viewModel.getFavourites(requireContext()) + return binding.root } + fun refreshDepartures() { + viewModel.getDepartures(requireContext()) + countdown.start() + } + + override fun onResume() { + super.onResume() + viewModel.getFavourites(requireContext()) + } + override fun onDestroyView() { super.onDestroyView() _binding = null + countdown.cancel() } } 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 d42be675e2f6c4b569a608c3c0e66e7598f1b4ab..4d483fbff8df25337bb52fbc93aa69c3045131e7 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 @@ -14,7 +14,14 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch +import xyz.apiote.bimba.czwek.api.Error +import xyz.apiote.bimba.czwek.api.mapHttpError +import xyz.apiote.bimba.czwek.repo.Departure +import xyz.apiote.bimba.czwek.repo.Favourite import xyz.apiote.bimba.czwek.repo.FeedInfo import xyz.apiote.bimba.czwek.repo.OfflineRepository import xyz.apiote.bimba.czwek.repo.OnlineRepository @@ -27,6 +34,10 @@ private val mutableQueryables = MutableLiveData>() val queryables: LiveData<List<Queryable>> = mutableQueryables var feeds: Map<String, FeedInfo>? = null var feedsSettings: FeedsSettings? = null + private val mutableFavourites = MutableLiveData<List<Favourite>>() + val favourites: LiveData<List<Favourite>> = mutableFavourites + private val mutableDepartures = MutableLiveData<List<Departure?>>() + val departures: LiveData<List<Departure?>> = mutableDepartures fun getQueryables(query: String, context: Context) { viewModelScope.launch { @@ -34,14 +45,65 @@ try { getFeeds(context) mutableQueryables.value = OnlineRepository().queryQueryables(query, context) ?: emptyList() } catch (e: TrafficResponseException) { - // xxx intentionally no error showing in suggestions + // XXX intentionally no error showing in suggestions Log.e("Suggestion", "$e") } } } + fun getFavourites(context: Context) { + viewModelScope.launch { + try { + getFeeds(context) + val repository = OfflineRepository(context) + mutableFavourites.value = repository.getFavourites(feedsSettings?.activeFeeds()?: emptySet()) + } catch (e: TrafficResponseException) { + Log.w("FavouritesForFavourite", "$e") + } + getDeparturesOnly(context) + } + } + + fun getDepartures(context: Context) { + viewModelScope.launch { + getDeparturesOnly(context) + } + } + + private suspend fun getDeparturesOnly(context: Context) { + coroutineScope { + if (favourites.value == null) + return@coroutineScope + mutableDepartures.value = favourites.value!!.map { favourite -> + async { + try { + val repository = OnlineRepository() + val stopDepartures = + repository.getDepartures( + favourite.feedID, + favourite.stopCode, + null, + context, + 12 // XXX heuristics + ) + stopDepartures?.let { sDs -> + 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) } + } + } catch (e: TrafficResponseException) { + Log.w("DeparturesForFavourite", "$e") + null + } + } + }.awaitAll() + } + } + private suspend fun getFeeds(context: Context) { - feeds = OfflineRepository().getFeeds(context) + feeds = OfflineRepository(context).getFeeds(context) feedsSettings = FeedsSettings.load(context) } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt index 5de90c71a88961a4d3bfaab1a44276da62cd189c..42dcc1a330de20dc80aa99bab9e5755931c00351 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/dashboard/ui/map/MapFragment.kt @@ -11,6 +11,7 @@ import android.content.res.Configuration.UI_MODE_NIGHT_MASK import android.content.res.Configuration.UI_MODE_NIGHT_UNDEFINED import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.graphics.Bitmap +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper @@ -83,7 +84,7 @@ val root: View = binding.root binding.map.setTileSource(TileSourceFactory.MAPNIK) if (((context?.resources?.configuration?.uiMode ?: UI_MODE_NIGHT_UNDEFINED) - and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES + and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES ) { binding.map.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS) } @@ -131,12 +132,15 @@ false } val attributionOverlay = TextView(context).apply { - // TODO deprecated in API 24 - text = + text = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Html.fromHtml(getString(R.string.map_attribution), 0) + } else { + @Suppress("DEPRECATION") Html.fromHtml(getString(R.string.map_attribution)) + } linksClickable = true movementMethod = LinkMovementMethod.getInstance() - setPadding(10,10,10,10) + setPadding(10, 10, 10, 10) } val layoutParams = CoordinatorLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT).apply { gravity = Gravity.END or Gravity.BOTTOM diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt index c4e0c76fc338134ce0c4aebe9abef6fbfb0a5b26..b9b2871dd953aab86b88c501729cc02801e8f705 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/Departures.kt @@ -206,23 +206,29 @@ fun update( departures: List<DepartureItem>, showAsTime: Boolean, - areNewObserved: Boolean = false + areNewObserved: Boolean = false, + leaveAlert: Boolean = false ) { + val newDepartures = if (leaveAlert && items.getOrNull(0)?.alert?.isNotEmpty() == true) { + listOf(items[0]) + departures + } else { + departures + } val newPositions: MutableMap<String, Int> = HashMap() - departures.forEachIndexed { i, departure -> + newDepartures.forEachIndexed { i, departure -> newPositions[departure.departure?.ID ?: "alert"] = i } val diff = DiffUtil.calculateDiff( DiffUtilCallback( this.items, - departures, + newDepartures, this.showAsTime != showAsTime ) ) this.showAsTime = showAsTime - this.items = departures + this.items = newDepartures departuresPositions = newPositions if (areNewObserved) { lastUpdate = ZonedDateTime.now() 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 a84d76aaee54c1de7f43a086a45b106a46c7ea7e..9d48f90b5f84b54f504c43afada054b7e34869ab 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 @@ -16,6 +16,7 @@ import android.os.CountDownTimer import android.text.format.DateUtils import android.text.format.DateUtils.MINUTE_IN_MILLIS import android.view.View +import android.widget.Toast import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources @@ -32,10 +33,16 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.android.material.timepicker.MaterialTimePicker import com.google.android.material.timepicker.TimeFormat +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import xyz.apiote.bimba.czwek.R import xyz.apiote.bimba.czwek.api.Error +import xyz.apiote.bimba.czwek.api.Server import xyz.apiote.bimba.czwek.databinding.ActivityDeparturesBinding import xyz.apiote.bimba.czwek.repo.DepartureItem +import xyz.apiote.bimba.czwek.repo.Favourite +import xyz.apiote.bimba.czwek.repo.OfflineRepository import xyz.apiote.bimba.czwek.repo.Stop import xyz.apiote.bimba.czwek.units.Millisecond import xyz.apiote.bimba.czwek.units.Second @@ -97,21 +104,45 @@ windowInsets } viewModel = ViewModelProvider(this)[DeparturesViewModel::class.java] + getLine()?.let { - viewModel.linesFilter[it] = true + viewModel.mutableLinesFilter.value = mapOf(Pair(it, true)) + } + + getLines()?.associate { Pair(it, true) }?.let { + if (it.isNotEmpty()) { + viewModel.mutableLinesFilter.value = it + } } linePicker = MaterialAlertDialogBuilder(this) .setTitle(resources.getString(R.string.title_select_line)) .setNegativeButton(R.string.clear_date_selection) { _, _ -> - viewModel.linesFilter.clear() - getDepartures() + viewModel.mutableLinesFilter.value = emptyMap() + //getDepartures() } .setPositiveButton(R.string.ok) { _, _ -> - linesFilterTemporary.forEach { viewModel.linesFilter[it.key] = it.value } - getDepartures() + viewModel.mutableLinesFilter.value = emptyMap() + viewModel.mutableLinesFilter.value = linesFilterTemporary + //getDepartures() } + viewModel.linesFilter.observe(this) { + // TODO if is before we got departures, do nothing + val departures = viewModel.departures.value?.departures ?: emptyList() + updateItems(departures + .filter { d -> + it.values.all { !it } or (it[d.vehicle.Line.name] ?: false) + } + .filter { d -> + val t = LocalTime.of(d.time.Hour.toInt(), d.time.Minute.toInt()) + t >= viewModel.startTime && t <= viewModel.endTime + }.map { DepartureItem(it) }, + null, + true + ) + } + viewModel.departures.observe(this) { stopDepartures -> val items = mutableListOf<DepartureItem>() if (stopDepartures.alerts.isNotEmpty()) { @@ -119,8 +150,9 @@ items.add(DepartureItem(stopDepartures.alerts)) } items.addAll(stopDepartures.departures .filter { d -> - viewModel.linesFilter.values.all { !it } or (viewModel.linesFilter[d.vehicle.Line.name] - ?: false) + viewModel.linesFilter.value?.let { filter -> + filter.values.all { !it } or (filter[d.vehicle.Line.name] ?: false) + } ?: true } .filter { d -> val t = LocalTime.of(d.time.Hour.toInt(), d.time.Minute.toInt()) @@ -130,6 +162,7 @@ updateItems(items, stopDepartures.stop) viewModel.openBottomSheet?.departureID()?.let { adapter.get(it) } ?.let { it.departure?.let { departure -> viewModel.openBottomSheet?.update(departure) } } + val lines = stopDepartures.departures.map { it.vehicle.Line.name }.sortedWith { s1, s2 -> val s1n = s1.toIntOrNull() val s2n = s2.toIntOrNull() @@ -139,7 +172,8 @@ } else { s1.compareTo(s2) } }.toSet().toTypedArray() - val selections = lines.map { viewModel.linesFilter.getOrDefault(it, false) }.toBooleanArray() + val selections = + lines.map { viewModel.linesFilter.value?.getOrDefault(it, false) ?: false }.toBooleanArray() linePicker?.setMultiChoiceItems(lines, selections) { _, which, checked -> linesFilterTemporary[lines[which]] = checked @@ -151,6 +185,14 @@ } binding.departuresAppBar.menu.findItem(R.id.departures_filter_bytime).setEnabled(false) + // TODO async esp. with Online + if (runBlocking { + OfflineRepository(this@DeparturesActivity).getFavourite( + getCode() ?: "" + ) + } != null) { + binding.departuresAppBar.menu.findItem(R.id.favourite).setIcon(R.drawable.favourite_full) + } datePicker.addOnNegativeButtonClickListener { viewModel.date = null @@ -176,8 +218,33 @@ val tf = ResourcesCompat.getFont(this@DeparturesActivity, R.font.yellowcircle8) setCollapsedTitleTypeface(tf) setExpandedTitleTypeface(tf) } + binding.departuresAppBar.setOnMenuItemClickListener { when (it.itemId) { + R.id.favourite -> { + if (!viewModel.linesFilter.value.isNullOrEmpty() && viewModel.linesFilter.value!!.any { filter -> filter.value }) { + MaterialAlertDialogBuilder(this).setIcon( + AppCompatResources.getDrawable( + this, + R.drawable.filter + ) + ) + .setTitle("Filtered departures") + .setMessage("Do you want to save a favourite filtered with selected lines?") + .setPositiveButton(R.string.filtered) { _, _ -> + saveFavourite(viewModel.linesFilter.value!!.keys) + } + .setNegativeButton(R.string.unfiltered) { _, _ -> + saveFavourite(setOf()) + } + .setNeutralButton(R.string.cancel) { _, _ -> } + .show() + } else { + saveFavourite(setOf()) + } + true + } + R.id.departures_calendar -> { datePicker.show(supportFragmentManager, "datePicker") true @@ -185,7 +252,7 @@ } R.id.departures_filter_byline -> { linesFilterTemporary.clear() - viewModel.linesFilter.forEach { filter -> + viewModel.linesFilter.value?.forEach { filter -> linesFilterTemporary[filter.key] = filter.value } linePicker?.show() @@ -247,10 +314,10 @@ } } ) adapter = BimbaDeparturesAdapter(layoutInflater, this, listOf()) { - DepartureBottomSheet(it).apply { - show(supportFragmentManager, DepartureBottomSheet.TAG) - viewModel.openBottomSheet = this - setOnCancel { viewModel.openBottomSheet = null } + DepartureBottomSheet(it).apply { + show(supportFragmentManager, DepartureBottomSheet.TAG) + viewModel.openBottomSheet = this + setOnCancel { viewModel.openBottomSheet = null } } } binding.departuresRecycler.adapter = adapter @@ -310,6 +377,15 @@ else -> null } } + private fun getLines(): List<String>? { + return when (intent?.action) { + null -> intent?.extras?.getStringArray("linesFilter")?.toList() + else -> null + } + } + + private fun getCode() = intent?.extras?.getString("code") + fun getDepartures(force: Boolean = false) { binding.departuresUpdatesProgress.isIndeterminate = true if (force) { @@ -363,8 +439,9 @@ binding.errorImage.visibility = View.GONE binding.errorText.visibility = View.GONE } - private fun updateItems(departures: List<DepartureItem>, stop: Stop) { + private fun updateItems(departures: List<DepartureItem>, stop: Stop?, leaveAlert: Boolean = false) { setupSnackbar() + binding.departuresRecycler.scrollToPosition(0) binding.departuresProgress.visibility = View.GONE // TODO [elizabeth] max, progress from header Cache-Control max-age binding.departuresUpdatesProgress.apply { @@ -375,9 +452,9 @@ progress = Tim(Second(30)).tims } countdown.cancel() countdown.start() - adapter.update(departures, viewModel.date != null, true) + adapter.update(departures, viewModel.date != null, true, leaveAlert) binding.collapsingLayout.apply { - title = stop.name + stop?.let { title = it.name } } if (departures.isEmpty()) { binding.errorImage.visibility = View.VISIBLE @@ -401,4 +478,31 @@ binding.departuresRecycler.visibility = View.VISIBLE } // todo [3.2; traffic] stop info } -} \ No newline at end of file + + private fun saveFavourite(linesFilter: Set<String>) { + val context = this + val feedID = intent.extras?.getString("feedID") + val code = intent?.extras?.getString("code") + if (feedID == null || code == null) { + Toast.makeText(this, R.string.cannot_save_favourite, Toast.LENGTH_LONG).show() + return + } + binding.departuresAppBar.menu.findItem(R.id.favourite).setIcon(R.drawable.favourite_full) + MainScope().launch { + val repo = OfflineRepository(context) + val feedName = repo.getFeeds(context, Server.get(context))?.get(feedID)?.name + if (feedName == null) { + Toast.makeText(context, R.string.cannot_save_favourite, Toast.LENGTH_LONG).show() + return@launch + } + val favourite = (repo.getFavourite(code) ?: Favourite( + feedID, + feedName, + code, + getName(), + linesFilter.toList() + )).copy(lines = linesFilter.toList()) + repo.saveFavourite(favourite) + } + } +} diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt index 6f2272afe8fe7a9dec1164df13fc8273aa438fb4..21950564af8d8c98ac0010be649143bcb7e3d6e6 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/departures/DeparturesViewModel.kt @@ -35,12 +35,12 @@ var allItemsRequested = false private var feed: FeedInfo? = null var openBottomSheet: DepartureBottomSheet? = null private lateinit var code: String - // TODO observe in activity, maybe refreshing and not getting departures is enough - val linesFilter = mutableMapOf<String, Boolean>() + val mutableLinesFilter = MutableLiveData<Map<String, Boolean>>() + val linesFilter: LiveData<Map<String, Boolean>> = mutableLinesFilter // TODO observe in activity, maybe refreshing and not getting departures is enough var startTime: LocalTime = LocalTime.MIN var endTime: LocalTime = LocalTime.MAX - // TODO observe in activity, maybe refreshing and not getting departures is enough + // TODO observe in activity, refreshing is not enough var date: LocalDate? = null fun getDepartures(context: Context, date: LocalDate?, force: Boolean) { @@ -61,9 +61,9 @@ context, requestedItemsNumber ) stopDepartures?.let { - if (stopDepartures.departures.isEmpty()) { // TODO other error for empty than not-found - val (string, image) = mapHttpError(404) - throw TrafficResponseException(404, "", Error(404, string, image)) + if (stopDepartures.departures.isEmpty()) { + val (string, image) = mapHttpError(44) + throw TrafficResponseException(44, "", Error(44, string, image)) } _departures.value = it } @@ -78,11 +78,11 @@ } private suspend fun getFeed(context: Context): FeedInfo { val intent = (context as Activity).intent - var feeds = OfflineRepository().getFeeds(context) + var feeds = OfflineRepository(context).getFeeds(context) if (feeds.isNullOrEmpty()) { feeds = OnlineRepository().getFeeds(context) if (feeds != null) { - OfflineRepository().saveFeedCache(context, feeds) + OfflineRepository(context).saveFeedCache(context, feeds) } } return when (intent.action) { diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt index f8be699529927e36494d50aa63eeeb58f94595f1..8f29cb95067119e2ee24b1688138d347ad55b74b 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/onboarding/FirstRunActivity.kt @@ -9,6 +9,7 @@ import android.os.Bundle import androidx.appcompat.app.AppCompatActivity import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import xyz.apiote.bimba.czwek.dashboard.MainActivity +import xyz.apiote.bimba.czwek.repo.migrateDB import xyz.apiote.bimba.czwek.settings.feeds.migrateFeedsSettings class FirstRunActivity : AppCompatActivity() { @@ -19,6 +20,7 @@ val preferences = getSharedPreferences("shp", MODE_PRIVATE) migrateFeedsSettings(this) + migrateDB(this) val intent = if (preferences.getBoolean("firstRun", true)) { Intent(this, OnboardingActivity::class.java) 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 new file mode 100644 index 0000000000000000000000000000000000000000..e77f790d1a24d664c9f08ec2dbdab4c6c902a2f5 --- /dev/null +++ b/app/src/main/java/xyz/apiote/bimba/czwek/repo/Favourite.kt @@ -0,0 +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 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 7d70dbc8aa3e2cedc903e6e19ea399841623f2a1..6db3f554e6eb6b110e5984f36e66f3584dfe1fba 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 @@ -17,6 +17,11 @@ fun id(): String } interface Repository { + suspend fun getFavourite(stopCode: String): Favourite? + suspend fun getFavourites(feedIDs: Set<String> = emptySet()): List<Favourite> + + suspend fun saveFavourite(favourite: Favourite) + suspend fun getFeeds( context: Context, server: Server = Server.get(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 6f9849cfc8b086ec4809afba0ad14e6218b4f58f..1f1f6b72c86d6e4b0d22cc8a8f1acb2c9c9c73eb 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 @@ -5,6 +5,7 @@ package xyz.apiote.bimba.czwek.repo import android.content.Context +import android.database.sqlite.SQLiteDatabase import androidx.core.content.edit import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -15,7 +16,10 @@ import java.io.File import java.net.URLEncoder import java.time.LocalDate -class OfflineRepository : Repository { +class OfflineRepository(context: Context) : Repository { + private val db = + SQLiteDatabase.openOrCreateDatabase(context.getDatabasePath("favourites").path, null) + fun saveFeedCache(context: Context, feedInfos: Map<String, FeedInfo>) { val file = File( context.filesDir, URLEncoder.encode(Server.get(context).apiPath, "utf-8") @@ -33,6 +37,69 @@ stream.flush() stream.close() } + override suspend fun getFavourite(stopCode: String): Favourite? { + val cursor = + db.rawQuery( + "select stop_name, feed_id, feed_name, lines from favourites where stop_code = ?", + listOf(stopCode).toTypedArray() + ) + if (cursor.count == 0) { + return null + } + cursor.moveToNext() + val f = Favourite( + cursor.getString(1), + cursor.getString(2), + stopCode, + cursor.getString(0), + cursor.getString(2).split("||").filter { it != "" } + ) + cursor.close() + return f + } + + override suspend fun getFavourites(feedIDs: Set<String>): List<Favourite> { + val whereClause = if (feedIDs.isNotEmpty()) { + feedIDs.indices.joinToString(prefix = "where feed_id in (", postfix = ")") { "?" } + } else { + "" + } + val cursor = + db.rawQuery( + "select stop_name, feed_id, feed_name, stop_code, lines from favourites $whereClause", + feedIDs.toTypedArray() + ) + val l = mutableListOf<Favourite>() + while (cursor.moveToNext()) { + l.add( + Favourite( + cursor.getString(1), + cursor.getString(2), + cursor.getString(3), + cursor.getString(0), + cursor.getString(4).split("||").filter { it != "" } + ) + ) + } + cursor.close() + return l + } + + override suspend fun saveFavourite(favourite: Favourite) { + 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 = ?", + arrayOf( + favourite.feedID, + favourite.feedName, + favourite.stopCode, + favourite.stopName, + favourite.lines.joinToString(separator = "||"), + favourite.stopName, + favourite.lines.joinToString(separator = "||") + ) + ) + } + @Suppress("RedundantNullableReturnType") override suspend fun getFeeds( context: Context, @@ -107,5 +174,10 @@ context: Context ): List<Queryable>? { TODO("Not yet implemented") } +} + +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))") } 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 8432f937e713904e92c77440c6d634607f8167fe..2e9221f818e5c8af264195212ffca2b425fdd68e 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 @@ -50,6 +50,18 @@ // todo [3.2] in Repository check if responses are BARE or HTML class OnlineRepository : Repository { + override suspend fun getFavourite(stopCode: String): Favourite? { + TODO("Not yet implemented; waits for ampelmänchen") + } + + override suspend fun getFavourites(feedIDs: Set<String>): List<Favourite> { + TODO("Not yet implemented; waits for ampelmänchen") + } + + override suspend fun saveFavourite(favourite: Favourite) { + TODO("Not yet implemented; waits for ampelmänchen") + } + override suspend fun getFeeds( context: Context, server: Server diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt index d18cb129731d39ea38fdf1878605754b52088caa..5575f8547ed6f8abfeacabc82767927c814a74ac 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/Results.kt @@ -89,7 +89,7 @@ setImageDrawable(stop.icon(context!!)) contentDescription = context.getString(R.string.stop_content_description) } holder?.title?.text = stop.name - if ((feedsSettings?.activeFeeds() ?: 0) > 1) { + if ((feedsSettings?.activeFeedsCount() ?: 0) > 1) { holder?.feedName?.visibility = View.VISIBLE holder?.feedName?.text = feeds?.get(stop.feedID)?.name ?: "" } @@ -115,7 +115,7 @@ setImageDrawable(line.icon(context!!)) contentDescription = line.type.name colorFilter = null } - if ((feedsSettings?.activeFeeds() ?: 0) > 1) { + if ((feedsSettings?.activeFeedsCount() ?: 0) > 1) { holder?.feedName?.visibility = View.VISIBLE holder?.feedName?.text = feeds?.get(line.feedID)?.name ?: "" } diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt index fe83369df4782fe86f711d835f11bc427d3d9bae..387f9b58407aecb312d1875780b0d24104c1d7b0 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/search/ResultsActivity.kt @@ -153,7 +153,7 @@ } private suspend fun getFeeds() { if (adapter.feeds.isNullOrEmpty()) { - adapter.feeds = OfflineRepository().getFeeds(this) + adapter.feeds = OfflineRepository(this).getFeeds(this) } if (adapter.feedsSettings == null) { adapter.feedsSettings = FeedsSettings.load(this) diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt index ce3a7df9ef1132e3a0770d837b341475da295535..223dfeee4be34ef5e501c4760e382eb3342bdf24 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/ServerChooserActivity.kt @@ -4,11 +4,9 @@ // SPDX-License-Identifier: GPL-3.0-or-later package xyz.apiote.bimba.czwek.settings -import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.graphics.Color -import android.net.ConnectivityManager import android.os.Bundle import android.util.Log import androidx.activity.enableEdgeToEdge @@ -134,9 +132,9 @@ title: Int, description: Int, icon: Int, onPositive: (() -> Unit)? ) { MaterialAlertDialogBuilder(this).setIcon(AppCompatResources.getDrawable(this, icon)) .setTitle(getString(title)).setMessage(getString(description)) - .setNegativeButton(resources.getString(R.string.cancel)) { _, _ -> }.apply { + .setNegativeButton(R.string.cancel) { _, _ -> }.apply { if (onPositive != null) { - setPositiveButton(resources.getString(R.string.cont)) { _, _ -> + setPositiveButton(R.string.cont) { _, _ -> onPositive() } } @@ -144,7 +142,6 @@ }.show() } private fun checkServer(isSimple: Boolean) { - val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager MainScope().launch { val result = getBimba(this@ServerChooserActivity, Server.get(this@ServerChooserActivity)) if (result.error != null) { diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt index 91921e1025e74c19ad73d9d75cd4e39d5668bfec..632af91e8e3286638d6614460593ed54b59dcc18 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedSettings.kt @@ -15,8 +15,9 @@ @Serializable @OptIn(ExperimentalStdlibApi::class) data class FeedsSettings(val settings: MutableMap<String, FeedSettings>) { - fun activeFeeds() = settings.count { it.value.enabled && it.value.useOnline } - fun getIDs() = settings.filter { it.value.enabled && it.value.useOnline }.keys.joinToString(",") + fun activeFeedsCount() = settings.count { it.value.enabled && it.value.useOnline } + fun activeFeeds() = settings.filter { it.value.enabled && it.value.useOnline }.keys + fun getIDs() = activeFeeds().joinToString(",") fun save(context: Context, server: Server) { val doc = KBson().dump(serializer(), this).toHexString() diff --git a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt index 6d5fb983e6baf603ce0237922088462d9450f328..95ea23d25bf9938f5d220360ba71245508a06c1e 100644 --- a/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt +++ b/app/src/main/java/xyz/apiote/bimba/czwek/settings/feeds/FeedsViewModel.kt @@ -52,7 +52,7 @@ MainScope().launch { withContext(coroutineContext) { launch { offlineFeeds = - OfflineRepository().getFeeds(context) + OfflineRepository(context).getFeeds(context) if (!offlineFeeds.isNullOrEmpty()) { _feeds.value = offlineFeeds!! } @@ -73,7 +73,7 @@ _error.value = error!! } else{ joinFeeds(offlineFeeds, onlineFeeds).let { joinedFeeds -> _feeds.value = joinedFeeds - OfflineRepository().saveFeedCache(context, joinedFeeds) + OfflineRepository(context).saveFeedCache(context, joinedFeeds) } } } diff --git a/app/src/main/res/drawable/error_departures.xml b/app/src/main/res/drawable/error_departures.xml new file mode 100644 index 0000000000000000000000000000000000000000..dfec75446a44e14d388b991b40bf57d6c7f6bbed --- /dev/null +++ b/app/src/main/res/drawable/error_departures.xml @@ -0,0 +1,16 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +SPDX-FileCopyrightText: Google + +SPDX-License-Identifier: Apache-2.0 +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorOnSurface"> + <path + android:fillColor="@android:color/white" + android:pathData="M21.19,21.19L2.81,2.81L1.39,4.22L4,6.83V16c0,0.88 0.39,1.67 1,2.22V20c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h8v1c0,0.55 0.45,1 1,1h1c0.05,0 0.09,-0.02 0.14,-0.03l1.64,1.64L21.19,21.19zM7.5,17C6.67,17 6,16.33 6,15.5C6,14.67 6.67,14 7.5,14S9,14.67 9,15.5C9,16.33 8.33,17 7.5,17zM6,11V8.83L8.17,11H6zM8.83,6L5.78,2.95C7.24,2.16 9.48,2 12,2c4.42,0 8,0.5 8,4v10c0,0.35 -0.08,0.67 -0.19,0.98L13.83,11H18V6H8.83z"/> +</vector> \ No newline at end of file diff --git a/app/src/main/res/drawable/error_locality.xml b/app/src/main/res/drawable/error_locality.xml new file mode 100644 index 0000000000000000000000000000000000000000..78add21082685570637e641f93757bae7c99c26e --- /dev/null +++ b/app/src/main/res/drawable/error_locality.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +SPDX-FileCopyrightText: Google + +SPDX-License-Identifier: Apache-2.0 +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorOnSurface"> + <path + android:fillColor="@android:color/white" + android:pathData="M14,10V3.26C13.35,3.09 12.68,3 12,3c-4.2,0 -8,3.22 -8,8.2c0,3.32 2.67,7.25 8,11.8c5.33,-4.55 8,-8.48 8,-11.8c0,-0.41 -0.04,-0.81 -0.09,-1.2H14zM12,13c-1.1,0 -2,-0.9 -2,-2c0,-1.1 0.9,-2 2,-2s2,0.9 2,2C14,12.1 13.1,13 12,13z"/> + <path + android:fillColor="@android:color/white" + android:pathData="M22.54,2.88l-1.42,-1.42l-2.12,2.13l-2.12,-2.13l-1.42,1.42l2.13,2.12l-2.13,2.12l1.42,1.42l2.12,-2.13l2.12,2.13l1.42,-1.42l-2.13,-2.12z"/> +</vector> \ No newline at end of file diff --git a/app/src/main/res/drawable/favourite_empty.xml b/app/src/main/res/drawable/favourite_empty.xml new file mode 100644 index 0000000000000000000000000000000000000000..94a481425f0379f97bd860b0886a16f648c2abcb --- /dev/null +++ b/app/src/main/res/drawable/favourite_empty.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?><!-- +SPDX-FileCopyrightText: Google + +SPDX-License-Identifier: Apache-2.0 +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24" + android:tint="?attr/colorOnSurface"> + <path + android:fillColor="@android:color/white" + android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/> +</vector> \ No newline at end of file diff --git a/app/src/main/res/drawable/favourite_full.xml b/app/src/main/res/drawable/favourite_full.xml new file mode 100644 index 0000000000000000000000000000000000000000..fc3f1e83de9cde90c2a6181e7c16bdb4c2310a33 --- /dev/null +++ b/app/src/main/res/drawable/favourite_full.xml @@ -0,0 +1,15 @@ +<?xml version="1.0" encoding="utf-8"?><!-- +SPDX-FileCopyrightText: Google + +SPDX-License-Identifier: Apache-2.0 +--> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:tint="?attr/colorOnSurface" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:fillColor="@android:color/white" + android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" /> +</vector> \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c4a4b92fc7e9ffa3068acb27897972c4c71ef6aa..8869f8b60d73a3890d711e643e79f2bec0ec2d2c 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -12,21 +12,6 @@ android:id="@+id/container" android:layout_width="match_parent" android:layout_height="match_parent"> - <androidx.coordinatorlayout.widget.CoordinatorLayout - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <com.google.android.material.appbar.AppBarLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <com.google.android.material.appbar.MaterialToolbar - android:id="@+id/top_app_bar" - android:layout_width="match_parent" - android:layout_height="?attr/actionBarSize" - app:title="" /> - </com.google.android.material.appbar.AppBarLayout> - <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> @@ -53,7 +38,6 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:menu="@menu/bottom_nav_menu" /> </androidx.constraintlayout.widget.ConstraintLayout> - </androidx.coordinatorlayout.widget.CoordinatorLayout> <com.google.android.material.navigation.NavigationView android:id="@+id/navigation_drawer" diff --git a/app/src/main/res/layout/favourite.xml b/app/src/main/res/layout/favourite.xml new file mode 100644 index 0000000000000000000000000000000000000000..7d47ac03dc672895ee185d4c471ffb19cf4847b7 --- /dev/null +++ b/app/src/main/res/layout/favourite.xml @@ -0,0 +1,94 @@ +<?xml version="1.0" encoding="utf-8"?><!-- +SPDX-FileCopyrightText: Adam Evyčędo + +SPDX-License-Identifier: GPL-3.0-or-later +--> +<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tool="http://schemas.android.com/tools" + android:id="@+id/favourite" + android:layout_marginTop="8dp" + android:layout_marginBottom="8dp" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:paddingBottom="16dp"> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/feed_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginTop="16dp" + android:textAppearance="@style/TextAppearance.Material3.LabelSmall" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tool:text="GB" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/stop_name" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/feed_name" + tool:text="Westminster" /> + + <ImageView + android:id="@+id/line_icon" + android:layout_width="24dp" + android:layout_height="24dp" + android:layout_marginStart="8dp" + app:layout_constraintBottom_toTopOf="@+id/departure_headsign" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="@+id/departure_time" + tool:ignore="ContentDescription" + tool:srcCompat="@drawable/bus_black" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/departure_time" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="8dp" + android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="@+id/departure_line" + tool:text="1hr" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/departure_full_time" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:textAppearance="@style/TextAppearance.Material3.LabelSmall" + app:layout_constraintEnd_toEndOf="@+id/departure_time" + app:layout_constraintTop_toBottomOf="@+id/departure_time" + tool:text="18:55" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/departure_line" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:text="@string/loading" + android:textAppearance="@style/TextAppearance.Material3.HeadlineMedium" + app:layout_constraintStart_toEndOf="@+id/line_icon" + app:layout_constraintTop_toBottomOf="@+id/stop_name" + tool:text="Metropolitan" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/departure_headsign" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Material3.BodySmall" + app:layout_constraintStart_toStartOf="@+id/departure_line" + app:layout_constraintTop_toBottomOf="@+id/departure_line" + tool:text="» Tower Hill" /> + + </androidx.constraintlayout.widget.ConstraintLayout> +</com.google.android.material.card.MaterialCardView> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index d2f52f4c6ae0429875e78dcf95f6498c2d8e7ed7..2f7c7394a3a1c5cbfd16861d7efc19b876056cfb 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -6,7 +6,7 @@ SPDX-License-Identifier: GPL-3.0-or-later --> -<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tool="http://schemas.android.com/tools" android:layout_width="match_parent" @@ -14,68 +14,73 @@ android:layout_height="match_parent" android:tag="@string/title_home" tool:context="xyz.apiote.bimba.czwek.dashboard.ui.home.HomeFragment"> - <com.google.android.material.appbar.AppBarLayout + <com.google.android.material.search.SearchBar + android:id="@+id/search_bar" android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <com.google.android.material.search.SearchBar - android:id="@+id/search_bar" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="8dp" - android:hint="@string/search_placeholder" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - app:navigationIcon="@drawable/menu" - app:useDrawerArrowDrawable="true" /> - </com.google.android.material.appbar.AppBarLayout> + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:hint="@string/search_placeholder" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:navigationIcon="@drawable/menu" + app:useDrawerArrowDrawable="true" /> <com.google.android.material.search.SearchView android:id="@+id/search_view" android:layout_width="match_parent" android:layout_height="match_parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" android:hint="@string/search_placeholder" app:layout_anchor="@id/search_bar"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/suggestions_recycler" android:layout_width="match_parent" - android:layout_height="match_parent" - app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + android:layout_height="match_parent" /> </com.google.android.material.search.SearchView> - <androidx.constraintlayout.widget.ConstraintLayout - android:layout_width="match_parent" - android:layout_height="match_parent"> - - <ImageView - android:id="@+id/inari" - android:layout_width="0dp" - android:layout_height="0dp" - android:layout_marginStart="16dp" - android:layout_marginTop="64dp" - android:layout_marginEnd="16dp" - android:layout_marginBottom="16dp" - android:alpha="0.25" - android:src="@drawable/inari" - app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toTopOf="parent" - tool:ignore="ContentDescription,ImageContrastCheck" /> + <ImageView + android:id="@+id/inari" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginStart="16dp" + android:layout_marginEnd="16dp" + android:layout_marginTop="16dp" + android:layout_marginBottom="16dp" + android:alpha="0.25" + android:src="@drawable/inari" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tool:ignore="ContentDescription,ImageContrastCheck" /> - </androidx.constraintlayout.widget.ConstraintLayout> + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/favourites" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginStart="16dp" + android:layout_marginTop="16dp" + android:layout_marginEnd="16dp" + app:layout_constraintBottom_toBottomOf="@id/inari" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/search_bar" /> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/floating_action_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="bottom|end" android:layout_margin="16dp" android:contentDescription="@string/home_fab_description" android:src="@drawable/gps_black" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" tool:ignore="ImageContrastCheck" /> -</androidx.coordinatorlayout.widget.CoordinatorLayout> \ No newline at end of file +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/app/src/main/res/menu/departures_menu.xml b/app/src/main/res/menu/departures_menu.xml index 2a4d18f7c839b628af7e21dcae639ed287fb05e5..0c3f27a935ef2b0bca568758f44f8d641d5d3c6c 100644 --- a/app/src/main/res/menu/departures_menu.xml +++ b/app/src/main/res/menu/departures_menu.xml @@ -9,6 +9,12 @@ <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item + android:id="@+id/favourite" + android:icon="@drawable/favourite_empty" + app:showAsAction="always" + android:contentDescription="@string/favourite_content_description" + android:title="@string/favourite" /> + <item android:id="@+id/departures_filter" android:icon="@drawable/filter" app:showAsAction="ifRoom" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd0d12a8a9e5c7a3b6851551306b5d4ef7820137..5394c875cd6eb6152241dd20bf5b03e618c6ae4f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -126,5 +126,13 @@
Select end time <string name="more">More</string> <string name="alert_header">Status updates</string> <string name="map_attribution"><![CDATA[© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors]]></string> + <string name="favourite_content_description">Save as favourite</string> + <string name="favourite">Favourite</string> + <string name="filtered">Filtered</string> + <string name="unfiltered">Unfiltered</string> + <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> </resources> diff --git a/metadata/en-US/images/phoneScreenshots/dash_w_fav.png b/metadata/en-US/images/phoneScreenshots/dash_w_fav.png new file mode 100644 index 0000000000000000000000000000000000000000..cb5c6df2d6613b95031e864a2e691de790eb9e84 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/dash_w_fav.png differ