From 690e3f08268febe1d3003ae8d83cc8c167a45a3d Mon Sep 17 00:00:00 2001 From: json Date: Thu, 26 Dec 2024 04:28:10 +0800 Subject: [PATCH 1/7] [23125] Added support for real time vehicle location --- .../servicestop/ServiceStopMapViewModel.kt | 45 ++++++++++++++++++- .../ui/timetables/TimetableMapContributor.kt | 13 ++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/map/servicestop/ServiceStopMapViewModel.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/map/servicestop/ServiceStopMapViewModel.kt index 67b6749f..622fe773 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/map/servicestop/ServiceStopMapViewModel.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/map/servicestop/ServiceStopMapViewModel.kt @@ -1,8 +1,10 @@ package com.skedgo.tripkit.ui.map.servicestop +import android.annotation.SuppressLint import android.content.Context import com.google.android.gms.maps.model.MarkerOptions import com.jakewharton.rxrelay2.BehaviorRelay +import com.jakewharton.rxrelay2.PublishRelay import com.skedgo.tripkit.common.model.realtimealert.RealTimeStatus import com.skedgo.tripkit.common.model.region.Region import com.skedgo.tripkit.common.model.stop.ScheduledStop @@ -24,8 +26,10 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.functions.BiFunction import io.reactivex.rxkotlin.Observables import io.reactivex.schedulers.Schedulers +import timber.log.Timber import javax.inject.Inject +@SuppressLint("CheckResult") class ServiceStopMapViewModel @Inject constructor( val context: Context, val fetchAndLoadServices: FetchAndLoadServices, @@ -34,9 +38,10 @@ class ServiceStopMapViewModel @Inject constructor( ) : RxViewModel() { val service = BehaviorRelay.create() - val stop = BehaviorRelay.create() + private val stopRealtimeRelay = PublishRelay.create() // To stop real-time updates + private val serviceStop = Observable .combineLatest( stop.hide(), @@ -49,6 +54,43 @@ class ServiceStopMapViewModel @Inject constructor( lateinit var realtimeViewModel: RealTimeChoreographerViewModel lateinit var serviceStopMarkerCreator: ServiceStopMarkerCreator + init { + Observables.combineLatest( + service, + serviceStop.hide().flatMap { regionService.getRegionByLocationAsync(it) } + ) { service, region -> service to region } + .distinctUntilChanged() + .observeOn(Schedulers.io()) + .switchMap { (service, region) -> + if (service.realTimeStatus in listOf( + RealTimeStatus.IS_REAL_TIME, + RealTimeStatus.CAPABLE + ) + ) { + realtimeViewModel.getRealTimeVehicles(region, listOf(service)) + .takeUntil(stopRealtimeRelay) // Stop when stopRealtimeRelay emits + .doOnNext { vehicles -> + Timber.d("Fetched real-time vehicles: $vehicles") + } + .onErrorResumeNext { throwable: Throwable -> + Timber.e(throwable, "Error fetching real-time vehicles") + Observable.empty() // Emit nothing in case of an error + } + } else { + Timber.d("Service not real-time capable") + Observable.just(service to region) + } + } + .replay(1) + .refCount() + .subscribe() + .autoClear() + } + + fun stopRealtimeUpdates() { + stopRealtimeRelay.accept(Unit) + } + private val serviceStopsAndLines = Observable.combineLatest( service, @@ -138,4 +180,5 @@ class ServiceStopMapViewModel @Inject constructor( } return stop } + } diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt index 0af197df..3725a101 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt @@ -38,6 +38,7 @@ import com.skedgo.tripkit.ui.realtime.RealTimeViewModelFactory import com.skedgo.tripkit.ui.servicedetail.GetStopDisplayText import dagger.Lazy import io.reactivex.disposables.CompositeDisposable +import timber.log.Timber import java.util.Collections import javax.inject.Inject @@ -158,8 +159,20 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { stopCodesToMarkerMap.forEach { it.value.remove() } serviceLines.forEach { it.remove() } autoDisposable.clear() + cleanupServiceDetailVehicleUpdates() } + private fun cleanupServiceDetailVehicleUpdates() { + // Stop real-time updates + viewModel.stopRealtimeUpdates() + + // Safely remove the real-time vehicle marker if it exists + realTimeVehicleMarker?.let { marker -> + marker.remove() + realTimeVehicleMarker = null // Clear the reference to avoid memory leaks + Timber.d("Real-time vehicle marker removed") + } + } fun setService(service: TimetableEntry?) { viewModel.service.accept(service) From 5f7b692259683a315698407141313d5d1b13f2c8 Mon Sep 17 00:00:00 2001 From: json Date: Tue, 7 Jan 2025 20:45:55 +0800 Subject: [PATCH 2/7] [23125] Added animation whenever the bus marker updates --- .../ui/timetables/TimetableMapContributor.kt | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt index 3725a101..47fa9f87 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt @@ -1,5 +1,7 @@ package com.skedgo.tripkit.ui.timetables +import android.animation.TypeEvaluator +import android.animation.ValueAnimator import android.content.Context import android.graphics.Color import android.text.TextUtils @@ -205,23 +207,49 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } private fun setRealTimeVehicle(realTimeVehicle: RealTimeVehicle?) { - realTimeVehicleMarker?.remove() if (realTimeVehicle == null) { return } - googleMap?.let { - if (realTimeVehicle.hasLocationInformation()) { - if (service != null && TextUtils.equals( - realTimeVehicle.serviceTripId, - service!!.serviceTripId - )) { - service!!.realtimeVehicle = realTimeVehicle - createVehicleMarker(realTimeVehicle) - } + + realTimeVehicleMarker?.let { marker -> + // Animate existing marker if it already exists + if (realTimeVehicle != null && realTimeVehicle.hasLocationInformation()) { + animateMarkerToPosition(marker, LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon)) + marker.rotation = realTimeVehicle.location.bearing.toFloat() + } + return + } + + // Create a new marker if it doesn't exist + if (realTimeVehicle.hasLocationInformation()) { + if (service != null && TextUtils.equals( + realTimeVehicle.serviceTripId, + service!!.serviceTripId + )) { + service!!.realtimeVehicle = realTimeVehicle + createVehicleMarker(realTimeVehicle) } } } + private fun animateMarkerToPosition(marker: Marker, toPosition: LatLng) { + val startLatLng = marker.position + val latLngEvaluator = TypeEvaluator { fraction, startValue, endValue -> + LatLng( + startValue.latitude + fraction * (endValue.latitude - startValue.latitude), + startValue.longitude + fraction * (endValue.longitude - startValue.longitude) + ) + } + + val animator = ValueAnimator.ofObject(latLngEvaluator, startLatLng, toPosition) + animator.duration = 1000 // Animation duration in milliseconds + animator.addUpdateListener { animation -> + val animatedValue = animation.animatedValue as LatLng + marker.position = animatedValue + } + animator.start() + } + private fun createVehicleMarker(vehicle: RealTimeVehicle) { var title: String? = null if (TextUtils.isEmpty(service!!.serviceNumber)) { From 8395c0b057c5390c3584e7935ae179e2310853a8 Mon Sep 17 00:00:00 2001 From: json Date: Tue, 7 Jan 2025 21:36:58 +0800 Subject: [PATCH 3/7] [23125] Added pulsing animation: Resizing on zoom might be needed --- .../ui/timetables/TimetableMapContributor.kt | 88 ++++++++++--------- .../com/skedgo/tripkit/ui/utils/MapUtils.kt | 85 ++++++++++++++++++ TripKitAndroidUI/src/main/res/anim/pulse.xml | 18 ++++ .../src/main/res/drawable/pulse_circle.xml | 4 + 4 files changed, 155 insertions(+), 40 deletions(-) create mode 100644 TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt create mode 100644 TripKitAndroidUI/src/main/res/anim/pulse.xml create mode 100644 TripKitAndroidUI/src/main/res/drawable/pulse_circle.xml diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt index 47fa9f87..d4029533 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt @@ -3,16 +3,22 @@ package com.skedgo.tripkit.ui.timetables import android.animation.TypeEvaluator import android.animation.ValueAnimator import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas import android.graphics.Color import android.text.TextUtils +import android.util.Log import android.view.View import android.widget.TextView +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProviders import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.model.BitmapDescriptorFactory import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.GroundOverlayOptions import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.Marker @@ -38,6 +44,9 @@ import com.skedgo.tripkit.ui.model.TimetableEntry import com.skedgo.tripkit.ui.realtime.RealTimeChoreographerViewModel import com.skedgo.tripkit.ui.realtime.RealTimeViewModelFactory import com.skedgo.tripkit.ui.servicedetail.GetStopDisplayText +import com.skedgo.tripkit.ui.utils.MapUtils.animateMarkerToPosition +import com.skedgo.tripkit.ui.utils.MapUtils.animatePulseOverlay +import com.skedgo.tripkit.ui.utils.MapUtils.getBitmapFromDrawable import dagger.Lazy import io.reactivex.disposables.CompositeDisposable import timber.log.Timber @@ -74,6 +83,8 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { private var previousCameraPosition: CameraPosition? = null + private var pulseOverlay: GroundOverlay? = null + override fun initialize() { TripKitUI.getInstance() .serviceStopMapComponent() @@ -207,49 +218,30 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } private fun setRealTimeVehicle(realTimeVehicle: RealTimeVehicle?) { - if (realTimeVehicle == null) { - return - } - - realTimeVehicleMarker?.let { marker -> - // Animate existing marker if it already exists - if (realTimeVehicle != null && realTimeVehicle.hasLocationInformation()) { - animateMarkerToPosition(marker, LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon)) - marker.rotation = realTimeVehicle.location.bearing.toFloat() + googleMap?.let { map -> + realTimeVehicleMarker?.let { marker -> + // Animate existing marker if it already exists + if (realTimeVehicle != null && realTimeVehicle.hasLocationInformation()) { + animateMarkerToPosition(marker, LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon)) + marker.rotation = realTimeVehicle.location.bearing.toFloat() + pulseOverlay?.position = LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon) + } + return } - return - } - // Create a new marker if it doesn't exist - if (realTimeVehicle.hasLocationInformation()) { - if (service != null && TextUtils.equals( - realTimeVehicle.serviceTripId, - service!!.serviceTripId - )) { - service!!.realtimeVehicle = realTimeVehicle - createVehicleMarker(realTimeVehicle) + // Create a new marker and pulse overlay if it doesn't exist + if (realTimeVehicle != null && realTimeVehicle.hasLocationInformation()) { + if (service != null && TextUtils.equals( + realTimeVehicle.serviceTripId, + service!!.serviceTripId + )) { + service!!.realtimeVehicle = realTimeVehicle + createVehicleMarker(realTimeVehicle) + } } } } - private fun animateMarkerToPosition(marker: Marker, toPosition: LatLng) { - val startLatLng = marker.position - val latLngEvaluator = TypeEvaluator { fraction, startValue, endValue -> - LatLng( - startValue.latitude + fraction * (endValue.latitude - startValue.latitude), - startValue.longitude + fraction * (endValue.longitude - startValue.longitude) - ) - } - - val animator = ValueAnimator.ofObject(latLngEvaluator, startLatLng, toPosition) - animator.duration = 1000 // Animation duration in milliseconds - animator.addUpdateListener { animation -> - val animatedValue = animation.animatedValue as LatLng - marker.position = animatedValue - } - animator.start() - } - private fun createVehicleMarker(vehicle: RealTimeVehicle) { var title: String? = null if (TextUtils.isEmpty(service!!.serviceNumber)) { @@ -277,8 +269,8 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { googleMap?.let { map: GoogleMap -> val millis = vehicle.lastUpdateTime * 1000 val time = DateTimeFormats.printTime(fragment.context, millis, null) - val snippet: String - snippet = if (TextUtils.isEmpty(vehicle.label)) { + val location = LatLng(vehicle.location.lat, vehicle.location.lon) + val snippet: String = if (TextUtils.isEmpty(vehicle.label)) { "Real-time location as at $time" } else { "Vehicle " + vehicle.label + " location as at " + time @@ -291,9 +283,25 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { .anchor(0.5f, 0.5f) .title(markerTitle) .snippet(snippet) - .position(LatLng(vehicle.location.lat, vehicle.location.lon)) + .position(location) .draggable(false) ) + + // Remove old pulse overlay (if any) + pulseOverlay?.remove() + pulseOverlay = null + + // Create the pulse overlay + val bitmap = getBitmapFromDrawable(fragment.requireContext(), R.drawable.pulse_circle, 125, 125, color) // Convert drawable to Bitmap + val overlayOptions = GroundOverlayOptions() + .position(location, 100f) // Initial size in meters + .image(BitmapDescriptorFactory.fromBitmap(bitmap)) + .transparency(0.5f) + + pulseOverlay = map.addGroundOverlay(overlayOptions) + + // Start the pulse animation + animatePulseOverlay(pulseOverlay) } } diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt new file mode 100644 index 00000000..153c1bdb --- /dev/null +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt @@ -0,0 +1,85 @@ +package com.skedgo.tripkit.ui.utils + +import android.animation.TypeEvaluator +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import androidx.core.content.ContextCompat +import com.google.android.gms.maps.model.GroundOverlay +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Marker +import android.animation.ValueAnimator + +object MapUtils { + + /** + * Converts a drawable resource to a bitmap with specified width, height, and color. + * + * @param context The application context. + * @param drawableRes The drawable resource ID. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @param color The color to apply as a tint. + * @return The generated bitmap. + */ + fun getBitmapFromDrawable(context: Context, drawableRes: Int, width: Int, height: Int, color: Int): Bitmap { + val drawable = ContextCompat.getDrawable(context, drawableRes) + ?: return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + + // Apply color filter + drawable.setTint(color) + drawable.setBounds(0, 0, canvas.width, canvas.height) + drawable.draw(canvas) + + return bitmap + } + + /** + * Animates a marker from its current position to a target position. + * + * @param marker The marker to animate. + * @param toPosition The target position. + * @param duration The duration of the animation in milliseconds. + */ + fun animateMarkerToPosition(marker: Marker, toPosition: LatLng, duration: Long = 1000L) { + val startLatLng = marker.position + val latLngEvaluator = TypeEvaluator { fraction, startValue, endValue -> + LatLng( + startValue.latitude + fraction * (endValue.latitude - startValue.latitude), + startValue.longitude + fraction * (endValue.longitude - startValue.longitude) + ) + } + + val animator = ValueAnimator.ofObject(latLngEvaluator, startLatLng, toPosition) + animator.duration = duration + animator.addUpdateListener { animation -> + val animatedValue = animation.animatedValue as LatLng + marker.position = animatedValue + } + animator.start() + } + + /** + * Animates a GroundOverlay to create a pulsing effect by changing its dimensions. + * + * @param overlay The overlay to animate. + * @param minSize The minimum size of the overlay in meters. + * @param maxSize The maximum size of the overlay in meters. + * @param duration The duration of the pulse animation in milliseconds. + */ + fun animatePulseOverlay(overlay: GroundOverlay?, minSize: Float = 100f, maxSize: Float = 300f, duration: Long = 2500L) { + overlay?.let { groundOverlay -> + val animator = ValueAnimator.ofFloat(minSize, maxSize) + animator.duration = duration + animator.repeatCount = ValueAnimator.INFINITE + animator.repeatMode = ValueAnimator.RESTART // Ensures it restarts instead of reversing + animator.addUpdateListener { animation -> + val animatedSize = animation.animatedValue as Float + groundOverlay.setDimensions(animatedSize) // Dynamically update size + } + animator.start() + } + } +} \ No newline at end of file diff --git a/TripKitAndroidUI/src/main/res/anim/pulse.xml b/TripKitAndroidUI/src/main/res/anim/pulse.xml new file mode 100644 index 00000000..246f6cb4 --- /dev/null +++ b/TripKitAndroidUI/src/main/res/anim/pulse.xml @@ -0,0 +1,18 @@ + + + + diff --git a/TripKitAndroidUI/src/main/res/drawable/pulse_circle.xml b/TripKitAndroidUI/src/main/res/drawable/pulse_circle.xml new file mode 100644 index 00000000..f55e51e5 --- /dev/null +++ b/TripKitAndroidUI/src/main/res/drawable/pulse_circle.xml @@ -0,0 +1,4 @@ + + + + From 5a86aaf546954f5e1c3c80ab143c22c4b974f0da Mon Sep 17 00:00:00 2001 From: json Date: Tue, 7 Jan 2025 22:52:46 +0800 Subject: [PATCH 4/7] [23125] Updated the pulse animation to be dynamic in size with animated removal --- .../ui/timetables/TimetableMapContributor.kt | 75 ++++++++++++------ .../com/skedgo/tripkit/ui/utils/MapUtils.kt | 76 ++++++++++++++++--- 2 files changed, 115 insertions(+), 36 deletions(-) diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt index d4029533..8e630786 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt @@ -1,16 +1,10 @@ package com.skedgo.tripkit.ui.timetables -import android.animation.TypeEvaluator -import android.animation.ValueAnimator import android.content.Context -import android.graphics.Bitmap -import android.graphics.Canvas import android.graphics.Color import android.text.TextUtils -import android.util.Log import android.view.View import android.widget.TextView -import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProviders import com.google.android.gms.maps.CameraUpdateFactory @@ -47,6 +41,7 @@ import com.skedgo.tripkit.ui.servicedetail.GetStopDisplayText import com.skedgo.tripkit.ui.utils.MapUtils.animateMarkerToPosition import com.skedgo.tripkit.ui.utils.MapUtils.animatePulseOverlay import com.skedgo.tripkit.ui.utils.MapUtils.getBitmapFromDrawable +import com.skedgo.tripkit.ui.utils.MapUtils.hidePulseOverlay import dagger.Lazy import io.reactivex.disposables.CompositeDisposable import timber.log.Timber @@ -61,16 +56,22 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { @Inject lateinit var regionService: RegionService + @Inject lateinit var vehicleMarkerIconCreatorLazy: Lazy + @Inject lateinit var realTimeViewModelFactory: RealTimeViewModelFactory + @Inject lateinit var getStopDisplayText: GetStopDisplayText + @Inject lateinit var errorLogger: ErrorLogger + @Inject lateinit var viewModel: ServiceStopMapViewModel + @Inject lateinit var serviceStopCalloutAdapter: ServiceStopInfoWindowAdapter @@ -112,19 +113,25 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { googleMap = map previousCameraPosition = map.cameraPosition + googleMap?.setOnCameraIdleListener { + val zoomLevel = googleMap?.cameraPosition?.zoom ?: return@setOnCameraIdleListener + animatePulseOverlay(pulseOverlay, zoomLevel) + } + //map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(mStop!!.lat, mStop!!.lon), 15.0f)) - autoDisposable.add(viewModel.drawStops - .subscribe({ (newMarkerOptions, removedStopIds) -> - for (id in removedStopIds) { - stopCodesToMarkerMap[id]!!.remove() - stopCodesToMarkerMap.remove(id) - } - for ((first, second) in newMarkerOptions) { - val marker = map.addMarker(first) - stopCodesToMarkerMap[second!!] = marker - } - }, {}) + autoDisposable.add( + viewModel.drawStops + .subscribe({ (newMarkerOptions, removedStopIds) -> + for (id in removedStopIds) { + stopCodesToMarkerMap[id]!!.remove() + stopCodesToMarkerMap.remove(id) + } + for ((first, second) in newMarkerOptions) { + val marker = map.addMarker(first) + stopCodesToMarkerMap[second!!] = marker + } + }, {}) ) @@ -179,6 +186,10 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { // Stop real-time updates viewModel.stopRealtimeUpdates() + // Cleanup pulse animation + hidePulseOverlay(pulseOverlay) + pulseOverlay = null + // Safely remove the real-time vehicle marker if it exists realTimeVehicleMarker?.let { marker -> marker.remove() @@ -222,9 +233,13 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { realTimeVehicleMarker?.let { marker -> // Animate existing marker if it already exists if (realTimeVehicle != null && realTimeVehicle.hasLocationInformation()) { - animateMarkerToPosition(marker, LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon)) + animateMarkerToPosition( + marker, + LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon) + ) marker.rotation = realTimeVehicle.location.bearing.toFloat() - pulseOverlay?.position = LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon) + pulseOverlay?.position = + LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon) } return } @@ -234,7 +249,8 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { if (service != null && TextUtils.equals( realTimeVehicle.serviceTripId, service!!.serviceTripId - )) { + ) + ) { service!!.realtimeVehicle = realTimeVehicle createVehicleMarker(realTimeVehicle) } @@ -287,12 +303,18 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { .draggable(false) ) - // Remove old pulse overlay (if any) - pulseOverlay?.remove() + // Cleanup pulse animation + hidePulseOverlay(pulseOverlay) pulseOverlay = null // Create the pulse overlay - val bitmap = getBitmapFromDrawable(fragment.requireContext(), R.drawable.pulse_circle, 125, 125, color) // Convert drawable to Bitmap + val bitmap = getBitmapFromDrawable( + fragment.requireContext(), + R.drawable.pulse_circle, + 125, + 125, + color + ) // Convert drawable to Bitmap val overlayOptions = GroundOverlayOptions() .position(location, 100f) // Initial size in meters .image(BitmapDescriptorFactory.fromBitmap(bitmap)) @@ -300,8 +322,11 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { pulseOverlay = map.addGroundOverlay(overlayOptions) - // Start the pulse animation - animatePulseOverlay(pulseOverlay) + // Get the current zoom level + val zoomLevel = map.cameraPosition.zoom + + // Start the pulse animation with zoom level + animatePulseOverlay(pulseOverlay, zoomLevel) } } diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt index 153c1bdb..0a6d80a0 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt @@ -9,9 +9,14 @@ import com.google.android.gms.maps.model.GroundOverlay import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker import android.animation.ValueAnimator +import androidx.core.animation.addListener +import kotlin.math.pow object MapUtils { + private var pulseAnimator: ValueAnimator? = null + private var hideAnimator: ValueAnimator? = null + /** * Converts a drawable resource to a bitmap with specified width, height, and color. * @@ -65,21 +70,70 @@ object MapUtils { * Animates a GroundOverlay to create a pulsing effect by changing its dimensions. * * @param overlay The overlay to animate. - * @param minSize The minimum size of the overlay in meters. - * @param maxSize The maximum size of the overlay in meters. + * @param zoomLevel The current zoom level of the map. + * @param baseMinSize The base minimum size of the overlay in meters (at reference zoom level). + * @param baseMaxSize The base maximum size of the overlay in meters (at reference zoom level). * @param duration The duration of the pulse animation in milliseconds. */ - fun animatePulseOverlay(overlay: GroundOverlay?, minSize: Float = 100f, maxSize: Float = 300f, duration: Long = 2500L) { + fun animatePulseOverlay( + overlay: GroundOverlay?, + zoomLevel: Float, + baseMinSize: Float = 150f, + baseMaxSize: Float = 350f, + duration: Long = 2500L + ) { + overlay?.let { groundOverlay -> + // Stop any existing animation + pulseAnimator?.cancel() + + // Adjust min and max sizes based on zoom level + val baselineZoom = 15f + val scaleFactor = 2.0.pow((baselineZoom - zoomLevel).toDouble()).toFloat() + val adjustedMinSize = baseMinSize * scaleFactor // Use / instead of * for reversed scaling + val adjustedMaxSize = baseMaxSize * scaleFactor + + // Debugging log to verify sizes + println("Zoom Level: $zoomLevel, Min Size: $adjustedMinSize, Max Size: $adjustedMaxSize") + + pulseAnimator = ValueAnimator.ofFloat(adjustedMinSize, adjustedMaxSize).apply { + this.duration = duration + this.repeatCount = ValueAnimator.INFINITE + this.repeatMode = ValueAnimator.RESTART // Ensures it restarts instead of reversing + addUpdateListener { animation -> + val animatedSize = animation.animatedValue as Float + groundOverlay.setDimensions(animatedSize) // Dynamically update size + } + start() + } + } + } + + /** + * Animates hiding of a GroundOverlay by fading out and then removing it. + * + * @param overlay The GroundOverlay to hide and remove. + * @param duration The duration of the fade-out animation in milliseconds. + */ + fun hidePulseOverlay(overlay: GroundOverlay?, duration: Long = 500L) { overlay?.let { groundOverlay -> - val animator = ValueAnimator.ofFloat(minSize, maxSize) - animator.duration = duration - animator.repeatCount = ValueAnimator.INFINITE - animator.repeatMode = ValueAnimator.RESTART // Ensures it restarts instead of reversing - animator.addUpdateListener { animation -> - val animatedSize = animation.animatedValue as Float - groundOverlay.setDimensions(animatedSize) // Dynamically update size + // Stop any existing animations + pulseAnimator?.cancel() + hideAnimator?.cancel() + + // Animate the transparency to fade out + hideAnimator = ValueAnimator.ofFloat(0.5f, 1.0f).apply { + this.duration = duration + addUpdateListener { animation -> + val transparency = animation.animatedValue as Float + groundOverlay.transparency = transparency + } + addListener(onEnd = { + // Remove the overlay after the animation ends + groundOverlay.remove() + }) + start() } - animator.start() } } + } \ No newline at end of file From 21ef9de38ecb54795fb473c8c68c8ec6d543971d Mon Sep 17 00:00:00 2001 From: json Date: Wed, 8 Jan 2025 20:33:46 +0800 Subject: [PATCH 5/7] [23125] Working fade mechanism for marker and pulse set at 15 seconds timer --- .../ui/timetables/TimetableMapContributor.kt | 114 +++++++++++++++--- .../com/skedgo/tripkit/ui/utils/MapUtils.kt | 82 +++++++++++++ 2 files changed, 177 insertions(+), 19 deletions(-) diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt index 8e630786..8393e8a6 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt @@ -3,6 +3,7 @@ package com.skedgo.tripkit.ui.timetables import android.content.Context import android.graphics.Color import android.text.TextUtils +import android.util.Log import android.view.View import android.widget.TextView import androidx.fragment.app.Fragment @@ -40,8 +41,13 @@ import com.skedgo.tripkit.ui.realtime.RealTimeViewModelFactory import com.skedgo.tripkit.ui.servicedetail.GetStopDisplayText import com.skedgo.tripkit.ui.utils.MapUtils.animateMarkerToPosition import com.skedgo.tripkit.ui.utils.MapUtils.animatePulseOverlay +import com.skedgo.tripkit.ui.utils.MapUtils.calculateAgeFactor +import com.skedgo.tripkit.ui.utils.MapUtils.calculateFadeFromAgeFactor +import com.skedgo.tripkit.ui.utils.MapUtils.formatElapsedTime import com.skedgo.tripkit.ui.utils.MapUtils.getBitmapFromDrawable import com.skedgo.tripkit.ui.utils.MapUtils.hidePulseOverlay +import com.skedgo.tripkit.ui.utils.MapUtils.updateMarkerOpacity +import com.skedgo.tripkit.ui.utils.MapUtils.updateOverlayTransparency import dagger.Lazy import io.reactivex.disposables.CompositeDisposable import timber.log.Timber @@ -86,6 +92,8 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { private var pulseOverlay: GroundOverlay? = null + private val handler = android.os.Handler() + override fun initialize() { TripKitUI.getInstance() .serviceStopMapComponent() @@ -108,6 +116,13 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } + private val fadeRunnable = object : Runnable { + override fun run() { + updateVehicleMarkerAppearance() + handler.postDelayed(this, 1000) // Schedule next update after 3 seconds + } + } + override fun safeToUseMap(context: Context, map: GoogleMap) { googleMap = map @@ -118,6 +133,9 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { animatePulseOverlay(pulseOverlay, zoomLevel) } + // Start periodic updates + startMarkerUpdateInterval() + //map.moveCamera(CameraUpdateFactory.newLatLngZoom(LatLng(mStop!!.lat, mStop!!.lon), 15.0f)) autoDisposable.add( @@ -176,6 +194,7 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } override fun cleanup() { + stopMarkerUpdateInterval() // Stop periodic updates stopCodesToMarkerMap.forEach { it.value.remove() } serviceLines.forEach { it.remove() } autoDisposable.clear() @@ -233,13 +252,18 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { realTimeVehicleMarker?.let { marker -> // Animate existing marker if it already exists if (realTimeVehicle != null && realTimeVehicle.hasLocationInformation()) { - animateMarkerToPosition( - marker, + val newLatLng = LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon) - ) + + animateMarkerToPosition(marker, newLatLng) marker.rotation = realTimeVehicle.location.bearing.toFloat() - pulseOverlay?.position = - LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon) + pulseOverlay?.position = newLatLng + + // Check if the location has changed + if (marker.position != newLatLng) { + // Update independent last known update time + service?.realtimeVehicle?.lastUpdateTime = System.currentTimeMillis() + } } return } @@ -251,6 +275,7 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { service!!.serviceTripId ) ) { + realTimeVehicle.lastUpdateTime = System.currentTimeMillis() service!!.realtimeVehicle = realTimeVehicle createVehicleMarker(realTimeVehicle) } @@ -307,20 +332,7 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { hidePulseOverlay(pulseOverlay) pulseOverlay = null - // Create the pulse overlay - val bitmap = getBitmapFromDrawable( - fragment.requireContext(), - R.drawable.pulse_circle, - 125, - 125, - color - ) // Convert drawable to Bitmap - val overlayOptions = GroundOverlayOptions() - .position(location, 100f) // Initial size in meters - .image(BitmapDescriptorFactory.fromBitmap(bitmap)) - .transparency(0.5f) - - pulseOverlay = map.addGroundOverlay(overlayOptions) + showPulseOverlay(color, location, map) // Get the current zoom level val zoomLevel = map.cameraPosition.zoom @@ -330,6 +342,70 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } } + private fun showPulseOverlay(color: Int, location: LatLng, map: GoogleMap) { + // Create the pulse overlay + val bitmap = getBitmapFromDrawable( + fragment.requireContext(), + R.drawable.pulse_circle, + 125, + 125, + color + ) // Convert drawable to Bitmap + val overlayOptions = GroundOverlayOptions() + .position(location, 100f) // Initial size in meters + .image(BitmapDescriptorFactory.fromBitmap(bitmap)) + .transparency(0.5f) + + pulseOverlay = map.addGroundOverlay(overlayOptions) + } + + private fun updateVehicleMarkerAppearance() { + val realTimeVehicle = service?.realtimeVehicle ?: return + + realTimeVehicleMarker?.let { marker -> + pulseOverlay?.let { overlay -> + // Calculate time since the last known update + val currentTimeMillis = System.currentTimeMillis() + val lastUpdateTimeMillis = realTimeVehicle.lastUpdateTime // Already in milliseconds + val ageInSeconds = + ((currentTimeMillis - lastUpdateTimeMillis) / 1000).coerceAtLeast(1) // Start from 1 second + + // Calculate age factor and fade level + val ageFactor = calculateAgeFactor(ageInSeconds) + val fadeLevel = calculateFadeFromAgeFactor(ageFactor) + + // Update marker opacity and snippet + updateMarkerOpacity(marker, fadeLevel) + marker.snippet = formatElapsedTime(ageInSeconds, realTimeVehicle) + + if (ageFactor < 0.1f) { + pulseOverlay?.isVisible = false + } else { + pulseOverlay?.isVisible = true + // Update overlay transparency using age factor directly + updateOverlayTransparency(overlay, fadeLevel) + } + } + } + } + + + /** + * Starts the periodic updates for marker fading and snippet updates. + */ + private fun startMarkerUpdateInterval() { + // Delay the first execution to avoid immediate update showing "1 second ago" twice + handler.postDelayed(fadeRunnable, 1000) // 1-second delay + } + + /** + * Stops the periodic updates for marker fading and snippet updates. + */ + private fun stopMarkerUpdateInterval() { + handler.removeCallbacks(fadeRunnable) + } + + fun getMapPreviousPosition(): CameraPosition? { return previousCameraPosition } diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt index 0a6d80a0..f7186d10 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt @@ -9,7 +9,9 @@ import com.google.android.gms.maps.model.GroundOverlay import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker import android.animation.ValueAnimator +import android.annotation.SuppressLint import androidx.core.animation.addListener +import com.skedgo.tripkit.routing.RealTimeVehicle import kotlin.math.pow object MapUtils { @@ -136,4 +138,84 @@ object MapUtils { } } + /** + * Updates the marker's opacity based on the fade level. + * + * @param marker The marker to update. + * @param fadeLevel The calculated fade level (0.3 to 1.0). + */ + fun updateMarkerOpacity(marker: Marker?, fadeLevel: Float) { + marker?.alpha = fadeLevel.coerceIn(0.3f, 1.0f) // Adjusts alpha based on fade level + } + + /** + * Updates the overlay's transparency based on the fade level. + * + * @param overlay The overlay to update. + * @param fadeLevel The calculated fade level (0.3 to 1.0). + */ + fun updateOverlayTransparency(overlay: GroundOverlay?, fadeLevel: Float) { + overlay?.transparency = (1f - fadeLevel).coerceIn(0.0f, 0.7f) // Adjusts transparency + } + + /** + * Formats elapsed time into a human-readable string (e.g., "15 seconds ago" or "1 minute and 15 seconds ago"). + * + * @param ageInSeconds The age of the data in seconds. + * @param vehicle The RealTimeVehicle object to include its label in the message. + * @return The formatted elapsed time string. + */ + @SuppressLint("DefaultLocale") + fun formatElapsedTime(ageInSeconds: Long, vehicle: RealTimeVehicle): String { + return if (ageInSeconds < 60) { + "Vehicle ${vehicle.label} updated ${formatTimeUnit(ageInSeconds, "second")} ago" + } else { + val minutes = ageInSeconds / 60 + val seconds = ageInSeconds % 60 + + if (seconds == 0L) { + "Vehicle ${vehicle.label} updated ${formatTimeUnit(minutes, "minute")} ago" + } else { + "Vehicle ${vehicle.label} updated ${formatTimeUnit(minutes, "minute")} and ${formatTimeUnit(seconds, "second")} ago" + } + } + } + + /** + * Formats a time unit with proper pluralization. + * + * @param value The value of the time unit (e.g., 1, 15). + * @param unit The time unit (e.g., "second", "minute"). + * @return A formatted string with singular or plural unit (e.g., "1 second", "15 seconds"). + */ + private fun formatTimeUnit(value: Long, unit: String): String { + return "$value $unit${if (value != 1L) "s" else ""}" + } + + /** + * Calculates the age factor based on the elapsed time and a maximum duration. + * + * @param ageInSeconds The elapsed time in seconds. + * @param maxDuration The maximum duration, in seconds, after which the factor becomes 0. + * @return The calculated age factor, clamped between 0.0 and 1.0. + */ + fun calculateAgeFactor(ageInSeconds: Long, maxDuration: Int = 15): Float { + return if (ageInSeconds >= maxDuration) { + 0.0f // Fully aged at or beyond maxDuration + } else { + 1 - (ageInSeconds / maxDuration.toFloat()) // Linearly decreases from 1 to 0 + } + } + + /** + * Calculates the fade level based on the age factor. + * + * @param ageFactor The age factor, clamped between 0.0 and 1.0. + * @param maxFade The maximum fade level (e.g., 0.3F for 30% visibility). + * @return The fade level, clamped between maxFade and 1.0. + */ + fun calculateFadeFromAgeFactor(ageFactor: Float, maxFade: Float = 0.3f): Float { + return (maxFade + (1 - maxFade) * ageFactor).coerceIn(maxFade, 1.0f) + } + } \ No newline at end of file From 2782e313df3b0e6f0aec9d8d90ce7c3e694d93f4 Mon Sep 17 00:00:00 2001 From: json Date: Wed, 8 Jan 2025 21:21:59 +0800 Subject: [PATCH 6/7] [23125] Updateed scheme for marker and pulse, including info window, pending list realtime update --- .../ui/timetables/TimetableMapContributor.kt | 35 +++++------ .../com/skedgo/tripkit/ui/utils/MapUtils.kt | 62 +++++++++++++------ 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt index 8393e8a6..d5a1d295 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt @@ -119,7 +119,7 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { private val fadeRunnable = object : Runnable { override fun run() { updateVehicleMarkerAppearance() - handler.postDelayed(this, 1000) // Schedule next update after 3 seconds + handler.postDelayed(this, 1000) // Schedule next update after 1 second } } @@ -322,6 +322,7 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { .rotation(bearing.toFloat()) .flat(true) .anchor(0.5f, 0.5f) + .infoWindowAnchor(0.5f, 0.0f) .title(markerTitle) .snippet(snippet) .position(location) @@ -332,7 +333,20 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { hidePulseOverlay(pulseOverlay) pulseOverlay = null - showPulseOverlay(color, location, map) + // Create the pulse overlay + val bitmap = getBitmapFromDrawable( + fragment.requireContext(), + R.drawable.pulse_circle, + 125, + 125, + color + ) // Convert drawable to Bitmap + val overlayOptions = GroundOverlayOptions() + .position(location, 100f) // Initial size in meters + .image(BitmapDescriptorFactory.fromBitmap(bitmap)) + .transparency(0.5f) + + pulseOverlay = map.addGroundOverlay(overlayOptions) // Get the current zoom level val zoomLevel = map.cameraPosition.zoom @@ -342,23 +356,6 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } } - private fun showPulseOverlay(color: Int, location: LatLng, map: GoogleMap) { - // Create the pulse overlay - val bitmap = getBitmapFromDrawable( - fragment.requireContext(), - R.drawable.pulse_circle, - 125, - 125, - color - ) // Convert drawable to Bitmap - val overlayOptions = GroundOverlayOptions() - .position(location, 100f) // Initial size in meters - .image(BitmapDescriptorFactory.fromBitmap(bitmap)) - .transparency(0.5f) - - pulseOverlay = map.addGroundOverlay(overlayOptions) - } - private fun updateVehicleMarkerAppearance() { val realTimeVehicle = service?.realtimeVehicle ?: return diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt index f7186d10..51ce9878 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt @@ -1,16 +1,16 @@ package com.skedgo.tripkit.ui.utils import android.animation.TypeEvaluator +import android.animation.ValueAnimator +import android.annotation.SuppressLint import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas +import androidx.core.animation.addListener import androidx.core.content.ContextCompat import com.google.android.gms.maps.model.GroundOverlay import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker -import android.animation.ValueAnimator -import android.annotation.SuppressLint -import androidx.core.animation.addListener import com.skedgo.tripkit.routing.RealTimeVehicle import kotlin.math.pow @@ -29,7 +29,13 @@ object MapUtils { * @param color The color to apply as a tint. * @return The generated bitmap. */ - fun getBitmapFromDrawable(context: Context, drawableRes: Int, width: Int, height: Int, color: Int): Bitmap { + fun getBitmapFromDrawable( + context: Context, + drawableRes: Int, + width: Int, + height: Int, + color: Int + ): Bitmap { val drawable = ContextCompat.getDrawable(context, drawableRes) ?: return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) @@ -91,7 +97,8 @@ object MapUtils { // Adjust min and max sizes based on zoom level val baselineZoom = 15f val scaleFactor = 2.0.pow((baselineZoom - zoomLevel).toDouble()).toFloat() - val adjustedMinSize = baseMinSize * scaleFactor // Use / instead of * for reversed scaling + val adjustedMinSize = + baseMinSize * scaleFactor // Use / instead of * for reversed scaling val adjustedMaxSize = baseMaxSize * scaleFactor // Debugging log to verify sizes @@ -149,13 +156,13 @@ object MapUtils { } /** - * Updates the overlay's transparency based on the fade level. + * Updates the overlay's transparency based directly on the fade level. * * @param overlay The overlay to update. - * @param fadeLevel The calculated fade level (0.3 to 1.0). + * @param fadeLevel The calculated fade level (0.1 to 0.5). */ fun updateOverlayTransparency(overlay: GroundOverlay?, fadeLevel: Float) { - overlay?.transparency = (1f - fadeLevel).coerceIn(0.0f, 0.7f) // Adjusts transparency + overlay?.transparency = 1 - (fadeLevel / 2) } /** @@ -176,7 +183,12 @@ object MapUtils { if (seconds == 0L) { "Vehicle ${vehicle.label} updated ${formatTimeUnit(minutes, "minute")} ago" } else { - "Vehicle ${vehicle.label} updated ${formatTimeUnit(minutes, "minute")} and ${formatTimeUnit(seconds, "second")} ago" + "Vehicle ${vehicle.label} updated ${ + formatTimeUnit( + minutes, + "minute" + ) + } and ${formatTimeUnit(seconds, "second")} ago" } } } @@ -193,17 +205,27 @@ object MapUtils { } /** - * Calculates the age factor based on the elapsed time and a maximum duration. + * Calculates the age factor based on the elapsed time. * * @param ageInSeconds The elapsed time in seconds. - * @param maxDuration The maximum duration, in seconds, after which the factor becomes 0. + * @param startFadeDuration The duration (in seconds) before fading starts (e.g., 120 seconds). + * @param maxFadeDuration The duration (in seconds) after which the factor becomes 0 (e.g., 180 seconds). * @return The calculated age factor, clamped between 0.0 and 1.0. */ - fun calculateAgeFactor(ageInSeconds: Long, maxDuration: Int = 15): Float { - return if (ageInSeconds >= maxDuration) { - 0.0f // Fully aged at or beyond maxDuration - } else { - 1 - (ageInSeconds / maxDuration.toFloat()) // Linearly decreases from 1 to 0 + fun calculateAgeFactor( + ageInSeconds: Long, + startFadeDuration: Int = 120, + maxFadeDuration: Int = 180 + ): Float { + return when { + ageInSeconds <= startFadeDuration -> 1.0f // Fully visible + ageInSeconds >= maxFadeDuration -> 0.0f // Fully aged out + else -> { + // Linearly decrease from 1.0 to 0.0 between startFadeDuration and maxFadeDuration + val fadeRange = (maxFadeDuration - startFadeDuration).toFloat() + val fadeStart = startFadeDuration.toFloat() + 1.0f - ((ageInSeconds - fadeStart) / fadeRange) + } } } @@ -211,11 +233,11 @@ object MapUtils { * Calculates the fade level based on the age factor. * * @param ageFactor The age factor, clamped between 0.0 and 1.0. - * @param maxFade The maximum fade level (e.g., 0.3F for 30% visibility). - * @return The fade level, clamped between maxFade and 1.0. + * @param minFade The minimum fade level (e.g., 0.3 for 30% visibility). + * @return The fade level, clamped between minFade and 1.0. */ - fun calculateFadeFromAgeFactor(ageFactor: Float, maxFade: Float = 0.3f): Float { - return (maxFade + (1 - maxFade) * ageFactor).coerceIn(maxFade, 1.0f) + fun calculateFadeFromAgeFactor(ageFactor: Float, minFade: Float = 0.3f): Float { + return (minFade + (1.0f - minFade) * ageFactor).coerceIn(minFade, 1.0f) } } \ No newline at end of file From 8485fe804a553d136a38a81720ddcdda3881a238 Mon Sep 17 00:00:00 2001 From: json Date: Fri, 10 Jan 2025 23:15:54 +0800 Subject: [PATCH 7/7] [23125] Working real time indicator in the occupancy indicator in the list # Conflicts: # TripKitAndroidUI/src/main/res/values/strings.xml --- .../ui/servicedetail/ServiceDetailFragment.kt | 13 +++++++++++++ .../servicedetail/ServiceDetailViewModel.kt | 2 ++ .../ui/timetables/TimetableMapContributor.kt | 11 ++++++++++- .../com/skedgo/tripkit/ui/utils/MapUtils.kt | 19 +++++++------------ .../service_detail_fragment_content.xml | 12 ++++++++++++ .../src/main/res/values/strings.xml | 1 + .../main/res/xml/service_detail_motion.xml | 6 ++++++ 7 files changed, 51 insertions(+), 13 deletions(-) diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/servicedetail/ServiceDetailFragment.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/servicedetail/ServiceDetailFragment.kt index 399b5c92..5676b0a3 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/servicedetail/ServiceDetailFragment.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/servicedetail/ServiceDetailFragment.kt @@ -105,6 +105,19 @@ class ServiceDetailFragment : BaseTripKitFragment() { mapContributor.setStop(stop) mapContributor.setService(timetableEntry) } + + (mapContributor as? TimetableMapContributor)?.let { contributor -> + contributor.formattedElapsedTime.observe(viewLifecycleOwner) { formattedTime -> + // Handle the formatted time in the fragment + handleFormattedElapsedTime(formattedTime) + } + } + } + + private fun handleFormattedElapsedTime(formattedTime: String) { + viewModel.apply { + lastUpdatedText.set(formattedTime) + } } class Builder { diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/servicedetail/ServiceDetailViewModel.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/servicedetail/ServiceDetailViewModel.kt index 76b37bd1..668a008c 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/servicedetail/ServiceDetailViewModel.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/servicedetail/ServiceDetailViewModel.kt @@ -66,6 +66,8 @@ class ServiceDetailViewModel @Inject constructor( val showOccupancyInfo = ObservableBoolean(false) + val lastUpdatedText = ObservableField() + val itemBinding = ItemBinding.of( BR.viewModel, R.layout.service_detail_fragment_list_item diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt index d5a1d295..217e8077 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/timetables/TimetableMapContributor.kt @@ -7,6 +7,8 @@ import android.util.Log import android.view.View import android.widget.TextView import androidx.fragment.app.Fragment +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModelProviders import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap @@ -81,6 +83,9 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { @Inject lateinit var serviceStopCalloutAdapter: ServiceStopInfoWindowAdapter + private val _formattedElapsedTime = MutableLiveData() + val formattedElapsedTime: LiveData get() = _formattedElapsedTime + private var mStop: ScheduledStop? = null private var service: TimetableEntry? = null private var realTimeVehicleMarker: Marker? = null @@ -365,7 +370,7 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { val currentTimeMillis = System.currentTimeMillis() val lastUpdateTimeMillis = realTimeVehicle.lastUpdateTime // Already in milliseconds val ageInSeconds = - ((currentTimeMillis - lastUpdateTimeMillis) / 1000).coerceAtLeast(1) // Start from 1 second + ((currentTimeMillis - lastUpdateTimeMillis) / 1000) // Start from 1 second // Calculate age factor and fade level val ageFactor = calculateAgeFactor(ageInSeconds) @@ -373,6 +378,10 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { // Update marker opacity and snippet updateMarkerOpacity(marker, fadeLevel) + + // Post the formatted elapsed time + _formattedElapsedTime.postValue(formatElapsedTime(ageInSeconds)) + marker.snippet = formatElapsedTime(ageInSeconds, realTimeVehicle) if (ageFactor < 0.1f) { diff --git a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt index 51ce9878..c7b32be4 100644 --- a/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt @@ -169,26 +169,21 @@ object MapUtils { * Formats elapsed time into a human-readable string (e.g., "15 seconds ago" or "1 minute and 15 seconds ago"). * * @param ageInSeconds The age of the data in seconds. - * @param vehicle The RealTimeVehicle object to include its label in the message. + * @param vehicle Optional RealTimeVehicle object. If provided, its label will be included in the message. * @return The formatted elapsed time string. */ @SuppressLint("DefaultLocale") - fun formatElapsedTime(ageInSeconds: Long, vehicle: RealTimeVehicle): String { + fun formatElapsedTime(ageInSeconds: Long, vehicle: RealTimeVehicle? = null): String { + val prefix = vehicle?.label?.let { "Vehicle $it updated" } ?: "Last updated:" return if (ageInSeconds < 60) { - "Vehicle ${vehicle.label} updated ${formatTimeUnit(ageInSeconds, "second")} ago" + "$prefix ${formatTimeUnit(ageInSeconds, "second")} ago" } else { val minutes = ageInSeconds / 60 val seconds = ageInSeconds % 60 - if (seconds == 0L) { - "Vehicle ${vehicle.label} updated ${formatTimeUnit(minutes, "minute")} ago" + "$prefix ${formatTimeUnit(minutes, "minute")} ago" } else { - "Vehicle ${vehicle.label} updated ${ - formatTimeUnit( - minutes, - "minute" - ) - } and ${formatTimeUnit(seconds, "second")} ago" + "$prefix ${formatTimeUnit(minutes, "minute")} and ${formatTimeUnit(seconds, "second")} ago" } } } @@ -201,7 +196,7 @@ object MapUtils { * @return A formatted string with singular or plural unit (e.g., "1 second", "15 seconds"). */ private fun formatTimeUnit(value: Long, unit: String): String { - return "$value $unit${if (value != 1L) "s" else ""}" + return "$value $unit${if (value > 1L) "s" else ""}" } /** diff --git a/TripKitAndroidUI/src/main/res/layout/service_detail_fragment_content.xml b/TripKitAndroidUI/src/main/res/layout/service_detail_fragment_content.xml index 0e4f2852..e3a3df2a 100644 --- a/TripKitAndroidUI/src/main/res/layout/service_detail_fragment_content.xml +++ b/TripKitAndroidUI/src/main/res/layout/service_detail_fragment_content.xml @@ -115,6 +115,18 @@ app:layout_constraintTop_toTopOf="@+id/occupancyView" tools:text="Label O" /> + + Background location access "To enable background location, we'll redirect you to the app settings. Once there, go to: Permissions > Location > Allow all the time, then try again." Terms of Use + Last Updated: Routing from %s to %s is not yet supported diff --git a/TripKitAndroidUI/src/main/res/xml/service_detail_motion.xml b/TripKitAndroidUI/src/main/res/xml/service_detail_motion.xml index 189e5352..79bff329 100644 --- a/TripKitAndroidUI/src/main/res/xml/service_detail_motion.xml +++ b/TripKitAndroidUI/src/main/res/xml/service_detail_motion.xml @@ -23,6 +23,9 @@ android:alpha="0" app:visibilityMode="ignore" /> + + + @@ -72,6 +75,9 @@ + + +