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/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 0af197df..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 @@ -3,14 +3,19 @@ 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 +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 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 @@ -36,8 +41,18 @@ 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.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 import java.util.Collections import javax.inject.Inject @@ -49,19 +64,28 @@ 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 + private val _formattedElapsedTime = MutableLiveData() + val formattedElapsedTime: LiveData get() = _formattedElapsedTime + private var mStop: ScheduledStop? = null private var service: TimetableEntry? = null private var realTimeVehicleMarker: Marker? = null @@ -71,6 +95,10 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { private var previousCameraPosition: CameraPosition? = null + private var pulseOverlay: GroundOverlay? = null + + private val handler = android.os.Handler() + override fun initialize() { TripKitUI.getInstance() .serviceStopMapComponent() @@ -93,24 +121,40 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } + private val fadeRunnable = object : Runnable { + override fun run() { + updateVehicleMarkerAppearance() + handler.postDelayed(this, 1000) // Schedule next update after 1 second + } + } + override fun safeToUseMap(context: Context, map: GoogleMap) { googleMap = map previousCameraPosition = map.cameraPosition + googleMap?.setOnCameraIdleListener { + val zoomLevel = googleMap?.cameraPosition?.zoom ?: return@setOnCameraIdleListener + animatePulseOverlay(pulseOverlay, zoomLevel) + } + + // Start periodic updates + startMarkerUpdateInterval() + //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 + } + }, {}) ) @@ -155,11 +199,28 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } override fun cleanup() { + stopMarkerUpdateInterval() // Stop periodic updates stopCodesToMarkerMap.forEach { it.value.remove() } serviceLines.forEach { it.remove() } autoDisposable.clear() + cleanupServiceDetailVehicleUpdates() } + private fun cleanupServiceDetailVehicleUpdates() { + // 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() + realTimeVehicleMarker = null // Clear the reference to avoid memory leaks + Timber.d("Real-time vehicle marker removed") + } + } fun setService(service: TimetableEntry?) { viewModel.service.accept(service) @@ -192,16 +253,34 @@ class TimetableMapContributor(val fragment: Fragment) : TripKitMapContributor { } private fun setRealTimeVehicle(realTimeVehicle: RealTimeVehicle?) { - realTimeVehicleMarker?.remove() - if (realTimeVehicle == null) { - return - } - googleMap?.let { - if (realTimeVehicle.hasLocationInformation()) { + googleMap?.let { map -> + realTimeVehicleMarker?.let { marker -> + // Animate existing marker if it already exists + if (realTimeVehicle != null && realTimeVehicle.hasLocationInformation()) { + val newLatLng = + LatLng(realTimeVehicle.location.lat, realTimeVehicle.location.lon) + + animateMarkerToPosition(marker, newLatLng) + marker.rotation = realTimeVehicle.location.bearing.toFloat() + 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 + } + + // 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 - )) { + ) + ) { + realTimeVehicle.lastUpdateTime = System.currentTimeMillis() service!!.realtimeVehicle = realTimeVehicle createVehicleMarker(realTimeVehicle) } @@ -236,8 +315,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 @@ -248,14 +327,91 @@ 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(LatLng(vehicle.location.lat, vehicle.location.lon)) + .position(location) .draggable(false) ) + + // 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 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 + + // Start the pulse animation with zoom level + animatePulseOverlay(pulseOverlay, zoomLevel) + } + } + + 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) // 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) + + // Post the formatted elapsed time + _formattedElapsedTime.postValue(formatElapsedTime(ageInSeconds)) + + 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 new file mode 100644 index 00000000..c7b32be4 --- /dev/null +++ b/TripKitAndroidUI/src/main/java/com/skedgo/tripkit/ui/utils/MapUtils.kt @@ -0,0 +1,238 @@ +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 com.skedgo.tripkit.routing.RealTimeVehicle +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. + * + * @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 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?, + 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 -> + // 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() + } + } + } + + /** + * 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 directly on the fade level. + * + * @param overlay The overlay to update. + * @param fadeLevel The calculated fade level (0.1 to 0.5). + */ + fun updateOverlayTransparency(overlay: GroundOverlay?, fadeLevel: Float) { + overlay?.transparency = 1 - (fadeLevel / 2) + } + + /** + * 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 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? = null): String { + val prefix = vehicle?.label?.let { "Vehicle $it updated" } ?: "Last updated:" + return if (ageInSeconds < 60) { + "$prefix ${formatTimeUnit(ageInSeconds, "second")} ago" + } else { + val minutes = ageInSeconds / 60 + val seconds = ageInSeconds % 60 + if (seconds == 0L) { + "$prefix ${formatTimeUnit(minutes, "minute")} ago" + } else { + "$prefix ${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. + * + * @param ageInSeconds The elapsed time in seconds. + * @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, + 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) + } + } + } + + /** + * Calculates the fade level based on the age factor. + * + * @param ageFactor The age factor, clamped between 0.0 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, minFade: Float = 0.3f): Float { + return (minFade + (1.0f - minFade) * ageFactor).coerceIn(minFade, 1.0f) + } + +} \ 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 @@ + + + + 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 @@ + + +