Author: Adam <git@apiote.xyz>
show map with stops and vehicles
%!v(PANIC=String method: strings: negative Repeat count)
diff --git a/app/build.gradle b/app/build.gradle index a96bfaca838c91cd5bc02dd44734a70e07556140..f83613c9bb0a0fadee2f4a6202fe11327e3cae4d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -49,6 +49,7 @@ implementation 'com.github.mancj:MaterialSearchBar:0.8.5' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.core:core-splashscreen:1.0.0' implementation 'com.google.openlocationcode:openlocationcode:1.0.4' + implementation 'org.osmdroid:osmdroid-android:6.1.14' testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt index 06c49b9b35338ae17af9b98256b50a63ef00b4b6..4036f5b7617d2f894b516e53bb1026a8b256f92d 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/api/Api.kt @@ -55,6 +55,10 @@ suspend fun locateItems(cm: ConnectivityManager, server: Server, plusCode: String): Result { return request(server, "items", mapOf("near" to plusCode), cm) } +suspend fun getLocatablesIn(cm: ConnectivityManager, server: Server, bl: String, tr: String): Result { + return request(server, "locatables", mapOf("lb" to bl, "rt" to tr), cm) +} + suspend fun getDepartures( cm: ConnectivityManager, server: Server, diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt index 6ebdece24a398fb5511291697275703729ccc6bd..ab40e38e878d814f8f80c812ff731b812883f8d1 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/api/Responses.kt @@ -133,7 +133,51 @@ } } } -data class ErrorResponse(val field: String, val message: String) : ItemsResponse, DeparturesResponse, FeedsResponse { +interface LocatablesResponse { + companion object { + fun unmarshal(stream: InputStream): LocatablesResponse { + val reader = Reader(stream) + return when (reader.readUInt()) { + 0UL -> { + ErrorResponse.unmarshal(stream) + } + 1UL -> { + LocatablesSuccess.unmarshal(stream) + } + else -> { + TODO("throw unknown tag") + } + } + } + } +} + +data class LocatablesSuccess(val locatables: List<Locatable>) : LocatablesResponse { + companion object { + fun unmarshal(stream: InputStream): LocatablesSuccess { + val locatables = mutableListOf<Locatable>() + val reader = Reader(stream) + val itemsNum = reader.readUInt() + for (i in 0UL until itemsNum) { + when (reader.readUInt()) { + 0UL -> { + locatables.add(Stop.unmarshal(stream)) + } + 1UL -> { + locatables.add(Vehicle.unmarshal(stream)) + } + else -> { + TODO("throw unknown tag") + } + } + } + return LocatablesSuccess(locatables) + } + } +} + +data class ErrorResponse(val field: String, val message: String) : ItemsResponse, + DeparturesResponse, FeedsResponse, LocatablesResponse { companion object { fun unmarshal(stream: InputStream): ErrorResponse { val reader = Reader(stream) diff --git a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt b/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt index 8c13ba605ef96f1a907c5b9523a1971897a6d237..2f6d8ce34d7a26be330aaaae1a8ac95c965b65b0 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/api/Structs.kt @@ -2,13 +2,18 @@ package ml.adamsprogs.bimba.api import android.content.Context import android.graphics.* +import android.graphics.drawable.BitmapDrawable +import android.graphics.drawable.Drawable +import android.graphics.drawable.LayerDrawable import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.ColorUtils.HSLToColor import androidx.core.graphics.drawable.toBitmap import ml.adamsprogs.bimba.R import ml.adamsprogs.bimba.dpToPixel import ml.adamsprogs.bimba.dpToPixelI import xyz.apiote.fruchtfleisch.Reader import java.io.InputStream +import java.util.zip.Adler32 import kotlin.math.abs import kotlin.math.pow @@ -137,9 +142,11 @@ val ID: String, val Position: String, val Capabilities: UShort, val Speed: Float, + val Line: LineStub, + val Headsign: String, val CongestionLevel: UByte, val OccupancyStatus: UByte -) { +) : Locatable { enum class Capability(val bit: UShort) { RAMP(0b0001u), LOW_FLOOR(0b0010u), @@ -152,6 +159,14 @@ TICKET_DRIVER(0b0100_0000u), USB_CHARGING(0b1000_0000u) } + override fun id(): String = ID + + override fun icon(context: Context): Drawable { + return BitmapDrawable(context.resources, Line.icon(context, 1f)) + } + + override fun location(): String = Position + companion object { fun unmarshal(stream: InputStream): Vehicle { val reader = Reader(stream) @@ -160,6 +175,8 @@ reader.readString(), reader.readString(), reader.readU16(), reader.readFloat32(), + LineStub.unmarshal(stream), + reader.readString(), reader.readU8(), reader.readU8() ) @@ -186,8 +203,8 @@ return LineStub(name = name, colour = colour, type = LineType(type.toUInt())) } } - fun icon(context: Context): Bitmap { - return super.icon(context, type, colour) + fun icon(context: Context, scale: Float = 1f): Bitmap { + return super.icon(context, type, colour, scale) } } @@ -218,6 +235,11 @@ } } interface Item +interface Locatable { + fun icon(context: Context): Drawable + fun location(): String + fun id(): String +} data class Stop( val code: String, @@ -225,12 +247,43 @@ val zone: String, val position: String, val changeOptions: List<ChangeOption>, val name: String -) : Item { +) : Item, Locatable { + override fun icon(context: Context): Drawable { + val saturationArray = arrayOf(0.5f, 0.65f, 0.8f) + val sal = saturationArray.size + val lightnessArray = arrayOf(.5f) + val lal = lightnessArray.size + val md = Adler32().let { + it.update(name.toByteArray()) + it.value + } + val h = md % 359f + val s = saturationArray[(md / 360 % sal).toInt()] + val l = lightnessArray[(md / 360 / sal % lal).toInt()] + val fg = AppCompatResources.getDrawable(context, R.drawable.stop) + val bg = + AppCompatResources.getDrawable(context, R.drawable.stop_bg)!!.mutate() + .apply { + setTint(HSLToColor(arrayOf(h, s, l).toFloatArray())) + } + return LayerDrawable(arrayOf(bg, fg)) + } + + override fun id(): String = code + + override fun location(): String = position + override fun toString(): String { var result = "$name ($code) [$zone] $position\n" for (chOpt in changeOptions) result += "${chOpt.line} → ${chOpt.headsign}\n" return result + } + + fun changeOptions(): String { + return changeOptions.groupBy { it.line } + .map { Pair(it.key, it.value.joinToString { co -> co.headsign }) } + .joinToString { "${it.first} » ${it.second}" } } companion object { @@ -283,25 +336,25 @@ ((part + 0.055) / 1.055).pow(2.4) } } - fun icon(context: Context, type: LineType, colour: Colour): Bitmap { + fun icon(context: Context, type: LineType, colour: Colour, scale: Float): Bitmap { val drawingBitmap = Bitmap.createBitmap( - dpToPixelI(24f), - dpToPixelI(24f), + dpToPixelI(24f/scale), + dpToPixelI(24f/scale), Bitmap.Config.ARGB_8888 ) val canvas = Canvas(drawingBitmap) canvas.drawPath( getSquirclePath( - dpToPixel(.8f), - dpToPixel(.8f), - dpToPixelI(11.2f) + dpToPixel(.8f/scale), + dpToPixel(.8f/scale), + dpToPixelI(11.2f/scale) ), Paint().apply { color = textColour(colour) }) canvas.drawPath( getSquirclePath( - dpToPixel(1.6f), - dpToPixel(1.6f), - dpToPixelI(10.4f) + dpToPixel(1.6f/scale), + dpToPixel(1.6f/scale), + dpToPixelI(10.4f/scale) ), Paint().apply { color = colour.toInt() }) val iconID = when (type) { @@ -313,11 +366,11 @@ val icon = AppCompatResources.getDrawable(context, iconID)?.mutate() // todo(code) move context out ?.apply { setTint(textColour(colour)) - }?.toBitmap(dpToPixelI(19.2f), dpToPixelI(19.2f), Bitmap.Config.ARGB_8888) + }?.toBitmap(dpToPixelI(19.2f/scale), dpToPixelI(19.2f/scale), Bitmap.Config.ARGB_8888) canvas.drawBitmap( icon!!, - dpToPixel(2.4f), - dpToPixel(2.4f), + dpToPixel(2.4f/scale), + dpToPixel(2.4f/scale), Paint() ) return drawingBitmap @@ -360,8 +413,8 @@ override fun toString(): String { return "$name ($type) [${textColour()}/$colour]\n→ [${headsignsThere.joinToString()}]\n→ [${headsignsBack.joinToString()}]\n" } - fun icon(context: Context): Bitmap { - return super.icon(context, type, colour) + fun icon(context: Context, scale: Float = 1f): Bitmap { + return super.icon(context, type, colour, scale) } fun textColour(): Int { diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt index 96e5d3957337b05e928fb35b02ee4be41e56373e..fd757e49612e6dd6e73f627dc48f909d4535e94c 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/MainActivity.kt @@ -34,6 +34,8 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var locationPermissionRequest: ActivityResultLauncher<Array<String>> + private lateinit var permissionAsker: Fragment + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) @@ -63,7 +65,14 @@ ) { permissions -> when { permissions[Manifest.permission.ACCESS_FINE_LOCATION] ?: false || permissions[Manifest.permission.ACCESS_COARSE_LOCATION] ?: false -> { - showResults(ResultsActivity.Mode.MODE_LOCATION) + when (permissionAsker) { + is HomeFragment -> { + showResults(ResultsActivity.Mode.MODE_LOCATION) + } + is MapFragment -> { + (permissionAsker as MapFragment).showLocation() + } + } } else -> { // todo(ux,ui) dialog @@ -93,19 +102,23 @@ binding.container.openDrawer(binding.navigationDrawer) } } - fun onGpsClicked(fab: View) { + fun onGpsClicked(fab: View, fragment: Fragment) { when (PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission( this, Manifest.permission.ACCESS_COARSE_LOCATION ) -> { - /* todo(ux,low) animation - https://developer.android.com/guide/fragments/animate - https://github.com/raheemadamboev/fab-explosion-animation-app - */ - showResults(ResultsActivity.Mode.MODE_LOCATION) + when (fragment) { + is HomeFragment -> { + showResults(ResultsActivity.Mode.MODE_LOCATION) + } + is MapFragment -> { + fragment.showLocation() + } + } } else -> { + permissionAsker = fragment locationPermissionRequest.launch( arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt index 6d2bf76f6bfb9ceca255fa4a85fd0de301060031..2470cf45ad7dc2c49c7bb1a634f08da1178f5487 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/home/HomeFragment.kt @@ -78,7 +78,7 @@ }) binding.floatingActionButton.setOnClickListener { binding.searchBar.clearSuggestions() - (context as MainActivity).onGpsClicked(it) + (context as MainActivity).onGpsClicked(it, this) } /* todo(ux,low) on searchbar focus && if != '' -> populate suggestions binding.searchBar.searchEditText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus -> diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt index 5088bd52954aa7946746eca869c2ed21dc03647e..da970dc91871e1157c601ebcfb4d832cafe01c26 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapFragment.kt @@ -1,42 +1,252 @@ package ml.adamsprogs.bimba.dashboard.ui.map +import android.annotation.SuppressLint +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +import android.content.res.Configuration.* +import android.graphics.Bitmap +import android.net.ConnectivityManager import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.content.edit +import androidx.core.graphics.drawable.toBitmap import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider +import com.google.android.material.snackbar.Snackbar +import com.google.openlocationcode.OpenLocationCode +import ml.adamsprogs.bimba.R +import ml.adamsprogs.bimba.api.Server +import ml.adamsprogs.bimba.dashboard.MainActivity import ml.adamsprogs.bimba.databinding.FragmentMapBinding +import ml.adamsprogs.bimba.dpToPixelI +import org.osmdroid.config.Configuration +import org.osmdroid.events.MapListener +import org.osmdroid.events.ScrollEvent +import org.osmdroid.events.ZoomEvent +import org.osmdroid.tileprovider.tilesource.TileSourceFactory +import org.osmdroid.util.GeoPoint +import org.osmdroid.views.CustomZoomButtonsController +import org.osmdroid.views.overlay.Marker +import org.osmdroid.views.overlay.TilesOverlay +import org.osmdroid.views.overlay.gestures.RotationGestureOverlay +import org.osmdroid.views.overlay.mylocation.GpsMyLocationProvider +import org.osmdroid.views.overlay.mylocation.MyLocationNewOverlay +import java.io.File + + +// todo empty state on no network and long time on entry class MapFragment : Fragment() { - private var _binding: FragmentMapBinding? = null + private var maybeBinding: FragmentMapBinding? = null + private val binding get() = maybeBinding!! + + private lateinit var locationOverlay: MyLocationNewOverlay + private lateinit var mapViewModel: MapViewModel + + private val handler = Handler(Looper.getMainLooper()) + private var workRunnable = Runnable {} - // This property is only valid between onCreateView and - // onDestroyView. - private val binding get() = _binding!! + private var snackbar: Snackbar? = null + @SuppressLint("ClickableViewAccessibility") override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - val mapViewModel = - ViewModelProvider(this).get(MapViewModel::class.java) + mapViewModel = + ViewModelProvider(this)[MapViewModel::class.java] + + observeLocatables() + configureMap() - _binding = FragmentMapBinding.inflate(inflater, container, false) + maybeBinding = FragmentMapBinding.inflate(inflater, container, false) val root: View = binding.root - val textView: TextView = binding.textNotifications - mapViewModel.text.observe(viewLifecycleOwner) { - textView.text = it + binding.map.setTileSource(TileSourceFactory.MAPNIK) + if (((context?.resources?.configuration?.uiMode ?: UI_MODE_NIGHT_UNDEFINED) + and UI_MODE_NIGHT_MASK) == UI_MODE_NIGHT_YES + ) { + binding.map.overlayManager.tilesOverlay.setColorFilter(TilesOverlay.INVERT_COLORS) + } + binding.map.zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) + binding.map.setMultiTouchControls(true) + binding.map.overlays.add(RotationGestureOverlay(binding.map).apply { isEnabled = true }) + + locationOverlay = MyLocationNewOverlay(GpsMyLocationProvider(context), binding.map) + context?.let { + centreMap(it.getSharedPreferences("shp", MODE_PRIVATE)) + + locationOverlay.setDirectionIcon( + AppCompatResources.getDrawable(it, R.drawable.navigation_arrow)?.mutate() + ?.toBitmap(dpToPixelI(36f), dpToPixelI(36f), Bitmap.Config.ARGB_8888) + ) + locationOverlay.setPersonIcon( + AppCompatResources.getDrawable(it, R.drawable.navigation_circle)?.mutate() + ?.toBitmap(dpToPixelI(36f), dpToPixelI(36f), Bitmap.Config.ARGB_8888) + ) } + + binding.floatingActionButton.setOnClickListener { + (context as MainActivity).onGpsClicked(it, this) + } + + binding.map.addMapListener(object : MapListener { + override fun onScroll(event: ScrollEvent?): Boolean { + return onMapMove() + } + + override fun onZoom(event: ZoomEvent?): Boolean { + return onMapMove() + } + }) + + binding.map.setOnTouchListener { _, _ -> + binding.floatingActionButton.show() + false + } + return root } + private fun configureMap() { + Configuration.getInstance().let { config -> + context?.let { ctx -> + ctx.getSharedPreferences("shp", MODE_PRIVATE).let { + config.load(context, it) + } + config.osmdroidBasePath = File(ctx.cacheDir.absolutePath, "osmdroid") + } + config.osmdroidTileCache = File(config.osmdroidBasePath.absolutePath, "tile") + } + } + + private fun onMapMove(): Boolean { + snackbar?.dismiss() + return delayGetLocatables() + } + + private fun delayGetLocatables(delay: Long = 1000): Boolean { + handler.removeCallbacks(workRunnable) + workRunnable = Runnable { + getLocatables() + } + handler.postDelayed(workRunnable, delay) + return true + } + + private fun observeLocatables() { + mapViewModel.items.observe(viewLifecycleOwner) { + binding.map.overlays.removeAll { marker -> + marker is Marker + } + + it.forEach { locatable -> + val marker = Marker(binding.map) + marker.position = OpenLocationCode.decode(locatable.location()).let { area -> + GeoPoint(area.centerLatitude, area.centerLongitude) + } + marker.setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_CENTER) + marker.icon = context?.let { ctx -> locatable.icon(ctx) } + + context?.let { ctx -> + marker.setOnMarkerClickListener { _, _ -> + MapBottomSheet(locatable).apply { + (ctx as MainActivity?)?.supportFragmentManager?.let { fm -> + show(fm, MapBottomSheet.TAG) + } + } + true + } + } + binding.map.overlays.add(marker) + } + + binding.map.invalidate() + } + } + + fun showLocation() { + snackbar = Snackbar.make(binding.root, "waiting for position", Snackbar.LENGTH_INDEFINITE) + snackbar!!.show() + binding.floatingActionButton.hide() + binding.map.overlays.removeAll { + it is MyLocationNewOverlay + } + locationOverlay.enableFollowLocation() + binding.map.overlays.add(locationOverlay) + locationOverlay.runOnFirstFix { + snackbar?.dismiss() + } + } + + private fun getLocatables() { + maybeBinding?.let { binding -> + val (bl, tr) = binding.map.boundingBox.let { + Pair( + OpenLocationCode.encode(it.latSouth, it.lonWest), + OpenLocationCode.encode(it.latNorth, it.lonEast) + ) + } + context?.let { + val cm = it.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + mapViewModel.getLocatablesIn( + cm, + Server.get(it), bl, tr + ) + } + delayGetLocatables(30000) + } + } + + private fun centreMap(preferences: SharedPreferences) { + maybeBinding?.map?.controller?.apply { + setZoom(preferences.getFloat("mapZoom", 17.0f).toDouble()) + val startPoint = GeoPoint( + preferences.getFloat("mapCentreLat", 52.39511f).toDouble(), + preferences.getFloat("mapCentreLon", 16.89506f).toDouble() + ) + setCenter(startPoint) + } + } + + override fun onResume() { + super.onResume() + binding.map.onResume() + locationOverlay.enableMyLocation() + context?.let { ctx -> + ctx.getSharedPreferences("shp", MODE_PRIVATE).let { + Configuration.getInstance() + .load(ctx, it) + centreMap(it) + } + } + } + + override fun onPause() { + super.onPause() + binding.map.onPause() + locationOverlay.disableMyLocation() + val centre = binding.map.mapCenter + context?.let { ctx -> + ctx.getSharedPreferences("shp", MODE_PRIVATE).edit { + this.putFloat("mapCentreLat", centre.latitude.toFloat()) + this.putFloat("mapCentreLon", centre.longitude.toFloat()) + this.putFloat("mapZoom", binding.map.zoomLevelDouble.toFloat()) + } + } + handler.removeCallbacks(workRunnable) + } + override fun onDestroyView() { super.onDestroyView() - _binding = null + maybeBinding = null } } \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt index f811e9b19b6ffd84189117f0c2e1e04114a79f61..2f520a17e16ebfa05701fd737638632dcb4a0285 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/dashboard/ui/map/MapViewModel.kt @@ -1,13 +1,187 @@ package ml.adamsprogs.bimba.dashboard.ui.map -import androidx.lifecycle.LiveData +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Uri +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.openlocationcode.OpenLocationCode +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ml.adamsprogs.bimba.R +import ml.adamsprogs.bimba.api.* +import ml.adamsprogs.bimba.departures.DeparturesActivity +import java.io.InputStream class MapViewModel : ViewModel() { - private val _text = MutableLiveData<String>().apply { - value = "This is notifications Fragment" + private val _items = MutableLiveData<List<Locatable>>() + val items: MutableLiveData<List<Locatable>> = _items + + fun getLocatablesIn(cm: ConnectivityManager, server: Server, bl: String, tr: String) { + viewModelScope.launch { + val locatablesResult = ml.adamsprogs.bimba.api.getLocatablesIn(cm, server, bl, tr) + val response = if (locatablesResult.stream != null) { + unmarshallLocatablesResponse(locatablesResult.stream) + } else { + null + } + if (locatablesResult.error != null) { + Log.e("Results.location", "$locatablesResult") + Log.e("Results.location", "$response") + //todo showError(itemsResult.error) + } else { + _items.value = (response as LocatablesSuccess).locatables + } + } + } + + private suspend fun unmarshallLocatablesResponse(stream: InputStream): LocatablesResponse { + return withContext(Dispatchers.IO) { + LocatablesResponse.unmarshal(stream) + } } - val text: LiveData<String> = _text +} + +class MapBottomSheet(private val locatable: Locatable) : BottomSheetDialogFragment() { + companion object { + const val TAG = "MapBottomSheet" + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val content = inflater.inflate(R.layout.map_bottom_sheet, container, false) + content.apply { + when (locatable) { + is Vehicle -> { + findViewById<TextView>(R.id.title).apply { + text = "${locatable.Line.name} » ${locatable.Headsign}" + contentDescription = "${locatable.Line.name} towards ${locatable.Headsign}" + } + findViewById<TextView>(R.id.change_options).visibility = View.GONE + findViewById<Button>(R.id.departures_button).visibility = View.GONE + findViewById<Button>(R.id.navigation_button).visibility = View.GONE + findViewById<TextView>(R.id.speed_text).apply { + // todo units + val speed = locatable.Speed * 1.703 + text = "%.3f Vl".format(speed) + } + findViewById<TextView>(R.id.congestion_text).apply { + text = when (locatable.CongestionLevel.toUInt()) { + 0u -> "unknown" + 1u -> "smooth traffic" + 2u -> "stop and go" + 3u -> "congestion" + 4u -> "severe jams" + else -> TODO("throw invalid congestion") + } + } + findViewById<TextView>(R.id.occupancy_text).apply { + text = when (locatable.OccupancyStatus.toUInt()) { + 0u -> "unknown" + 1u -> "empty" + 2u -> "many seats" + 3u -> "few seats" + 4u -> "standing only" + 5u -> "crowded" + 6u -> "full" + 7u -> "won’t accept passengers" // todo shorten + else -> TODO("throw invalid congestion") + } + } + + findViewById<ImageView>(R.id.ac).visibility = + if (locatable.getCapability(Vehicle.Capability.AC)) { + View.VISIBLE + } else { + View.GONE + } + findViewById<ImageView>(R.id.bike).visibility = + if (locatable.getCapability(Vehicle.Capability.BIKE)) { + View.VISIBLE + } else { + View.GONE + } + findViewById<ImageView>(R.id.voice).visibility = + if (locatable.getCapability(Vehicle.Capability.VOICE)) { + View.VISIBLE + } else { + View.GONE + } + findViewById<ImageView>(R.id.ticket).visibility = + if (locatable.let { + it.getCapability(Vehicle.Capability.TICKET_DRIVER) || it.getCapability(Vehicle.Capability.TICKET_MACHINE) + }) { + View.VISIBLE + } else { + View.GONE + } + findViewById<ImageView>(R.id.usb).visibility = + if (locatable.getCapability(Vehicle.Capability.USB_CHARGING)) { + View.VISIBLE + } else { + View.GONE + } + } + is Stop -> { + findViewById<TextView>(R.id.title).text = "${locatable.name} [${locatable.code}]" + findViewById<Button>(R.id.departures_button).setOnClickListener { + val intent = Intent(context, DeparturesActivity::class.java).apply { + putExtra("code", locatable.code) + putExtra("name", locatable.name) + } + startActivity(intent) + } + findViewById<Button>(R.id.navigation_button).setOnClickListener { + OpenLocationCode.decode(locatable.position).let { position -> + try { + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("geo:${position.centerLatitude},${position.centerLongitude}") + ) + ) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, "No maps app installed", Toast.LENGTH_SHORT).show() + } + } + } + + findViewById<TextView>(R.id.change_options).text = locatable.changeOptions() + + findViewById<ImageView>(R.id.speed_icon).visibility = View.GONE + findViewById<TextView>(R.id.speed_text).visibility = View.GONE + findViewById<ImageView>(R.id.congestion_icon).visibility = View.GONE + findViewById<TextView>(R.id.congestion_text).visibility = View.GONE + findViewById<ImageView>(R.id.occupancy_icon).visibility = View.GONE + findViewById<TextView>(R.id.occupancy_text).visibility = View.GONE + + findViewById<ImageView>(R.id.ac).visibility = View.GONE + findViewById<ImageView>(R.id.bike).visibility = View.GONE + findViewById<ImageView>(R.id.voice).visibility = View.GONE + findViewById<ImageView>(R.id.ticket).visibility = View.GONE + findViewById<ImageView>(R.id.usb).visibility = View.GONE + } + } + } + //(dialog as BottomSheetDialog).behavior.peekHeight = dpToPixelI(90f) + + return content + } } \ No newline at end of file diff --git a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt b/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt index 51b733ef56b74df0d568d2fd0ac41d097c95f1bb..aa1d82958e0319167f150132141f746f20f529a5 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/departures/DeparturesActivity.kt @@ -23,6 +23,8 @@ import ml.adamsprogs.bimba.api.* import ml.adamsprogs.bimba.databinding.ActivityDeparturesBinding import java.io.InputStream +// todo show stop on map + class DeparturesActivity : AppCompatActivity() { private var _binding: ActivityDeparturesBinding? = null private val binding get() = _binding!! @@ -52,8 +54,16 @@ } } binding.departuresRecycler.adapter = adapter WindowCompat.setDecorFitsSystemWindows(window, false) + } + override fun onResume() { + super.onResume() getDepartures() + } + + override fun onPause() { + super.onPause() + handler.removeCallbacks(runnable) } private fun getName(): String { diff --git a/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt b/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt index 0b7b80f155331f96e61085904576486cd1762cbd..9ce76e9d21d575e48739745db6497a38996de56c 100644 --- a/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt +++ b/app/src/main/java/ml/adamsprogs/bimba/search/Results.kt @@ -42,10 +42,7 @@ @SuppressLint("SetTextI18n") fun bindStop(stop: Stop, holder: BimbaViewHolder?) { holder?.icon?.setImageResource(R.drawable.stop) holder?.title?.text = "${stop.name} [${stop.code}]" - holder?.description?.text = - stop.changeOptions.groupBy { it.line } - .map { Pair(it.key, it.value.joinToString { co -> co.headsign }) } - .joinToString { "${it.first} » ${it.second}" } + holder?.description?.text = stop.changeOptions() } @SuppressLint("SetTextI18n") diff --git a/app/src/main/res/drawable/departure.xml b/app/src/main/res/drawable/departure.xml new file mode 100644 index 0000000000000000000000000000000000000000..9bf1a5d515b626ebbfc9e9b6dcfc59240d614229 --- /dev/null +++ b/app/src/main/res/drawable/departure.xml @@ -0,0 +1,5 @@ +<vector android:height="24dp" android:tint="?attr/colorOnSurface" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M16,1c-2.4,0 -4.52,1.21 -5.78,3.05 0.01,-0.01 0.01,-0.02 0.02,-0.03C9.84,4 9.42,4 9,4c-4.42,0 -8,0.5 -8,4v10c0,0.88 0.39,1.67 1,2.22L2,22c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1h8v1c0,0.55 0.45,1 1,1h1c0.55,0 1,-0.45 1,-1v-1.78c0.61,-0.55 1,-1.34 1,-2.22v-3.08c3.39,-0.49 6,-3.39 6,-6.92 0,-3.87 -3.13,-7 -7,-7zM4.5,19c-0.83,0 -1.5,-0.67 -1.5,-1.5S3.67,16 4.5,16s1.5,0.67 1.5,1.5S5.33,19 4.5,19zM3,13L3,8h6c0,1.96 0.81,3.73 2.11,5L3,13zM13.5,19c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM16,13c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5 -2.24,5 -5,5zM16.5,4L15,4v5l3.62,2.16 0.75,-1.23 -2.87,-1.68z"/> +</vector> diff --git a/app/src/main/res/drawable/navigation_arrow.xml b/app/src/main/res/drawable/navigation_arrow.xml new file mode 100644 index 0000000000000000000000000000000000000000..c29f851f60d8cbd8b38d63085504c6cde84c2ffc --- /dev/null +++ b/app/src/main/res/drawable/navigation_arrow.xml @@ -0,0 +1,8 @@ +<vector android:height="24dp" android:tint="?colorPrimary" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillAlpha="0.3" + android:fillColor="@android:color/white" + android:pathData="M7.72,17.7l3.47,-1.53 0.81,-0.36 0.81,0.36 3.47,1.53L12,7.27z" android:strokeAlpha="0.3"/> + <path android:fillColor="@android:color/white" android:pathData="M4.5,20.29l0.71,0.71L12,18l6.79,3 0.71,-0.71L12,2 4.5,20.29zM12.81,16.17l-0.81,-0.36 -0.81,0.36 -3.47,1.53L12,7.27l4.28,10.43 -3.47,-1.53z"/> +</vector> diff --git a/app/src/main/res/drawable/navigation_circle.xml b/app/src/main/res/drawable/navigation_circle.xml new file mode 100644 index 0000000000000000000000000000000000000000..535e8520e6bde8d1784a1fb26a1ba701a26dfd74 --- /dev/null +++ b/app/src/main/res/drawable/navigation_circle.xml @@ -0,0 +1,8 @@ +<vector android:height="24dp" android:tint="?colorPrimary" + android:viewportHeight="24" android:viewportWidth="24" + android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillAlpha="0.3" + android:fillColor="@android:color/white" + android:pathData="M12,12m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0" android:strokeAlpha="0.3"/> + <path android:fillColor="@android:color/white" android:pathData="M12,2C6.47,2 2,6.47 2,12c0,5.53 4.47,10 10,10s10,-4.47 10,-10C22,6.47 17.53,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8c0,-4.42 3.58,-8 8,-8s8,3.58 8,8C20,16.42 16.42,20 12,20z"/> +</vector> diff --git a/app/src/main/res/drawable/open_outside.xml b/app/src/main/res/drawable/open_outside.xml new file mode 100644 index 0000000000000000000000000000000000000000..2a44988cc484c69541ae1cf9e3db3b1745c12626 --- /dev/null +++ b/app/src/main/res/drawable/open_outside.xml @@ -0,0 +1,5 @@ +<vector android:autoMirrored="true" android:height="24dp" + android:tint="?attr/colorOnSurface" android:viewportHeight="24" + android:viewportWidth="24" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> + <path android:fillColor="@android:color/white" android:pathData="M19,19H5V5h7V3H5c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2v-7h-2v7zM14,3v2h3.59l-9.83,9.83 1.41,1.41L19,6.41V10h2V3h-7z"/> +</vector> diff --git a/app/src/main/res/drawable/stop_bg.xml b/app/src/main/res/drawable/stop_bg.xml new file mode 100644 index 0000000000000000000000000000000000000000..2bbe61ebf84e1af6e37ab94e17428476dca002a0 --- /dev/null +++ b/app/src/main/res/drawable/stop_bg.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="24" + android:viewportHeight="24"> + <path + android:pathData="M2.807,12a9.193,9.193 0,1 0,18.386 0a9.193,9.193 0,1 0,-18.386 0z" + android:fillColor="@android:color/black"/> +</vector> diff --git a/app/src/main/res/layout/fragment_map.xml b/app/src/main/res/layout/fragment_map.xml index 619f8762a446d4a9644c88b56e08a217d00f7021..539858aa42b4041aea5db8e79b2f7c9f35b6aa96 100644 --- a/app/src/main/res/layout/fragment_map.xml +++ b/app/src/main/res/layout/fragment_map.xml @@ -1,22 +1,27 @@ <?xml version="1.0" encoding="utf-8"?> -<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" +<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".dashboard.ui.map.MapFragment"> - <TextView - android:id="@+id/text_notifications" + <org.osmdroid.views.MapView + android:id="@+id/map" android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="8dp" - android:textAlignment="center" - android:textSize="20sp" + android:layout_height="match_parent" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> -</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file + + <com.google.android.material.floatingactionbutton.FloatingActionButton + android:id="@+id/floating_action_button" + style="?attr/floatingActionButtonSmallStyle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="bottom|right" + android:layout_margin="16dp" + android:contentDescription="@string/home_fab_description" + android:src="@drawable/gps_black" /> +</androidx.coordinatorlayout.widget.CoordinatorLayout> \ No newline at end of file diff --git a/app/src/main/res/layout/map_bottom_sheet.xml b/app/src/main/res/layout/map_bottom_sheet.xml new file mode 100644 index 0000000000000000000000000000000000000000..d7ab738d5571271bc41a5417c7eab8c6b41e6282 --- /dev/null +++ b/app/src/main/res/layout/map_bottom_sheet.xml @@ -0,0 +1,201 @@ +<?xml version="1.0" encoding="utf-8"?> +<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" + android:layout_height="wrap_content" + android:paddingBottom="16dp"> + + <com.google.android.material.bottomsheet.BottomSheetDragHandleView + android:id="@+id/drag_handle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:layout_constraintTop_toTopOf="parent" /> + + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/title" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="48dp" + android:layout_marginEnd="8dp" + android:textAlignment="center" + android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tool:text="Aleje Marcinkowskiego [AMAR01]" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/change_options" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_margin="16dp" + android:textAppearance="@style/TextAppearance.Material3.BodyMedium" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/title" + tool:text="610 » Górczyn, 215 » Rondo Kaponiera, 392 » Słowiańska" /> + + <Button + android:id="@+id/departures_button" + style="@style/Widget.Material3.Button.TextButton.Icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="4dp" + android:text="Show departures" + app:icon="@drawable/departure" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/change_options" /> + + <Button + android:id="@+id/navigation_button" + style="@style/Widget.Material3.Button.TextButton.Icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_margin="4dp" + android:text="Open in maps app" + app:icon="@drawable/open_outside" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/departures_button" /> + + <!--suppress AndroidUnknownAttribute --> + <ImageView + android:id="@+id/speed_icon" + android:layout_width="16dp" + android:layout_height="16dp" + android:layout_marginEnd="8dp" + android:importantForAccessibility="no" + app:layout_constraintBottom_toBottomOf="@+id/speed_text" + app:layout_constraintEnd_toStartOf="@+id/speed_text" + app:layout_constraintTop_toTopOf="@+id/speed_text" + app:srcCompat="@drawable/speed" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/speed_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:textAppearance="@style/TextAppearance.Material3.BodyLarge" + app:layout_constraintEnd_toStartOf="@+id/middle" + app:layout_constraintTop_toBottomOf="@id/title" + tool:text="10 Vl" /> + + <!--suppress AndroidUnknownAttribute --> + <ImageView + android:id="@+id/congestion_icon" + android:layout_width="16dp" + android:layout_height="16dp" + android:layout_marginStart="8dp" + android:layout_marginEnd="8dp" + android:importantForAccessibility="no" + app:layout_constraintBottom_toBottomOf="@+id/congestion_text" + app:layout_constraintStart_toStartOf="@+id/middle" + app:layout_constraintTop_toTopOf="@+id/congestion_text" + app:srcCompat="@drawable/traffic" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/congestion_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:textAppearance="@style/TextAppearance.Material3.BodyLarge" + app:layout_constraintStart_toEndOf="@id/congestion_icon" + app:layout_constraintTop_toBottomOf="@id/title" + tool:text="smooth traffic" /> + + <!--suppress AndroidUnknownAttribute --> + <ImageView + android:id="@+id/occupancy_icon" + android:layout_width="16dp" + android:layout_height="16dp" + android:layout_marginStart="8dp" + android:layout_marginEnd="8dp" + android:importantForAccessibility="no" + app:layout_constraintBottom_toBottomOf="@+id/occupancy_text" + app:layout_constraintStart_toStartOf="@+id/middle" + app:layout_constraintTop_toTopOf="@+id/occupancy_text" + app:srcCompat="@drawable/crowd" /> + + <com.google.android.material.textview.MaterialTextView + android:id="@+id/occupancy_text" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="8dp" + android:layout_marginEnd="8dp" + android:textAppearance="@style/TextAppearance.Material3.BodyLarge" + app:layout_constraintStart_toEndOf="@id/occupancy_icon" + app:layout_constraintTop_toBottomOf="@id/congestion_text" + tool:text="empty vehicle" /> + + <androidx.constraintlayout.widget.Guideline + android:id="@+id/middle" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_constraintGuide_percent=".5" /> + + <androidx.constraintlayout.helper.widget.Flow + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginTop="16dp" + android:layout_marginEnd="8dp" + app:constraint_referenced_ids="ac,bike,voice,ticket,usb" + app:flow_horizontalGap="4dp" + app:flow_horizontalStyle="packed" + app:flow_verticalGap="4dp" + app:flow_wrapMode="chain" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/occupancy_text" /> + + <ImageView + android:id="@+id/ac" + android:layout_width="24dp" + android:layout_height="24dp" + android:contentDescription="air condition" + app:srcCompat="@drawable/ac" + tool:ignore="MissingConstraints" /> + + <ImageView + android:id="@+id/bike" + android:layout_width="24dp" + android:layout_height="24dp" + android:contentDescription="bicycles allowed" + app:srcCompat="@drawable/bike" + tool:ignore="MissingConstraints" /> + + <ImageView + android:id="@+id/voice" + android:layout_width="24dp" + android:layout_height="24dp" + android:contentDescription="voice announcements" + app:srcCompat="@drawable/voice" + tool:ignore="MissingConstraints" /> + + <ImageView + android:id="@+id/ticket" + android:layout_width="24dp" + android:layout_height="24dp" + android:contentDescription="tickets sold" + app:srcCompat="@drawable/ticket" + tool:ignore="MissingConstraints" /> + + <ImageView + android:id="@+id/usb" + android:layout_width="24dp" + android:layout_height="24dp" + android:contentDescription="USB charging" + app:srcCompat="@drawable/usb" + tool:ignore="MissingConstraints" /> + + +</androidx.constraintlayout.widget.ConstraintLayout>