diff --git a/app/build.gradle b/app/build.gradle
index 86aa22d9..918fb6da 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -40,8 +40,8 @@ android {
applicationId "illyan.jay"
minSdk 21
targetSdk 33
- versionCode 6
- versionName "0.2.3-alpha"
+ versionCode 7
+ versionName "0.2.4-alpha"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 47122567..c6357d67 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,6 +42,9 @@
+
Unit = {}
) {
- firestore.runBatch {
- deleteLocationsForUser(
- batch = it,
- userUUID = userUUID
- )
- }
+ val batch = firestore.batch()
+ deleteLocationsForUser(
+ batch = batch,
+ userUUID = userUUID,
+ onWriteFinished = {
+ batch.commit()
+ onDelete()
+ }
+ )
}
- fun deleteLocationsForUser(
+ suspend fun deleteLocationsForUser(
batch: WriteBatch,
- onWriteFinished: () -> Unit = {},
- userUUID: String = authInteractor.userUUID.toString()
+ userUUID: String = authInteractor.userUUID.toString(),
+ onWriteFinished: () -> Unit = {}
) {
if (!authInteractor.isUserSignedIn) return
- val arePathsDeleted = MutableStateFlow(false)
+ val completableDeferred = CompletableDeferred()
+ Timber.v("Adding snapshot listener to delete Sessions with ${userUUID.take(4)} as Owner")
val pathSnapshotListener = firestore
.collection(FirestorePath.CollectionName)
.whereEqualTo(FirestorePath.FieldOwnerUUID, userUUID)
.addSnapshotListener { pathSnapshot, pathError ->
if (pathError != null) {
Timber.e(pathError, "Error while deleting user data: ${pathError.message}")
- } else if (!arePathsDeleted.value) {
- Timber.d("Delete ${pathSnapshot!!.documents.size} path data for user ${userUUID.take(4)}")
+ } else {
+ Timber.d("Batch delete ${pathSnapshot!!.documents.size} path data for user ${userUUID.take(4)}")
pathSnapshot.documents.forEach {
batch.delete(it.reference)
}
- arePathsDeleted.value = true
- }
- }
- coroutineScopeIO.launch {
- arePathsDeleted.first {
- if (it) {
- Timber.d("Removing path listener from Firestore")
- pathSnapshotListener.remove()
onWriteFinished()
+ completableDeferred.complete(Unit)
}
- it
}
- }
+ completableDeferred.await()
+ Timber.v("Removing snapshot listener from Firestore")
+ pathSnapshotListener.remove()
}
- fun deleteLocationsForSession(
- batch: WriteBatch,
+ suspend fun deleteLocationsForSession(
sessionUUID: String,
onDelete: () -> Unit = {}
) {
- val arePathsDeleted = MutableStateFlow(false)
- val pathSnapshotListener = firestore
- .collection(FirestorePath.CollectionName)
- .whereEqualTo(FirestorePath.FieldSessionUUID, sessionUUID)
- .addSnapshotListener { pathSnapshot, pathError ->
- if (pathError != null) {
- Timber.e(pathError, "Error while deleting path data: ${pathError.message}")
- } else {
- Timber.d("Delete ${pathSnapshot!!.documents.size} path data for user ${authInteractor.userUUID?.take(4)}")
- pathSnapshot.documents.forEach {
- batch.delete(it.reference)
- }
- arePathsDeleted.value = true
- onDelete()
- }
- }
- coroutineScopeIO.launch {
- arePathsDeleted.first { arePathsDeleted ->
- if (arePathsDeleted) {
- Timber.d("Removing path listener from Firestore")
- pathSnapshotListener.remove()
- }
- arePathsDeleted
+ val batch = firestore.batch()
+ deleteLocationsForSessions(
+ batch = batch,
+ sessionUUIDs = listOf(sessionUUID),
+ onWriteFinished = {
+ batch.commit()
+ onDelete()
}
- }
- }
-
- fun deleteLocationsForSession(
- sessionUUID: String,
- onDelete: () -> Unit = {}
- ) {
- firestore.runBatch {
- deleteLocationsForSession(
- batch = it,
- sessionUUID = sessionUUID,
- onDelete = onDelete
- )
- }
+ )
}
- fun deleteLocationsForSessions(
+ suspend fun deleteLocationsForSessions(
sessionUUIDs: List,
- onDelete: (String) -> Unit = {}
+ onDelete: () -> Unit = {}
) {
- if (sessionUUIDs.isEmpty()) {
- Timber.d("No sessions given to delete paths for!")
- return
- }
- sessionUUIDs.forEach { deleteLocationsForSession(it) { onDelete(it) } }
+ val batch = firestore.batch()
+ deleteLocationsForSessions(
+ batch = batch,
+ sessionUUIDs = sessionUUIDs,
+ onWriteFinished = {
+ batch.commit()
+ onDelete()
+ }
+ )
}
- fun deleteLocationsForSessions(
+ suspend fun deleteLocationsForSessions(
batch: WriteBatch,
sessionUUIDs: List,
onWriteFinished: () -> Unit = {}
) {
- val numberOfDeletions = MutableStateFlow(sessionUUIDs.size)
if (sessionUUIDs.isEmpty()) {
- Timber.d("No sessions given to delete paths for!")
+ Timber.d("No sessions given to delete paths for")
return
}
- sessionUUIDs.forEach {
- deleteLocationsForSession(batch, it) {
- numberOfDeletions.value = numberOfDeletions.value - 1
- }
- }
- coroutineScopeIO.launch {
- numberOfDeletions.first {
- if (it <= 0) {
- onWriteFinished()
- true
- } else {
- false
+ // [Query.whereIn] can only take in at most 10 objects to compare
+ sessionUUIDs.chunked(10).forEach { chunk ->
+ val completableDeferred = CompletableDeferred()
+ Timber.v("Adding snapshot listener to batch delete ${chunk.size} path data")
+ val pathSnapshotListener = firestore
+ .collection(FirestorePath.CollectionName)
+ .whereIn(FirestorePath.FieldSessionUUID, chunk)
+ .addSnapshotListener { pathSnapshot, pathError ->
+ if (pathError != null) {
+ Timber.e(pathError, "Error while deleting path data: ${pathError.message}")
+ } else {
+ Timber.d("Batch delete ${pathSnapshot!!.documents.size} path data for user ${authInteractor.userUUID?.take(4)}")
+ pathSnapshot.documents.forEach {
+ batch.delete(it.reference)
+ }
+ completableDeferred.complete(Unit)
+ onWriteFinished()
+ }
}
- }
+ completableDeferred.await()
+ Timber.v("Removing snapshot listener from Firestore")
+ pathSnapshotListener.remove()
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/data/network/datasource/SessionNetworkDataSource.kt b/app/src/main/java/illyan/jay/data/network/datasource/SessionNetworkDataSource.kt
index e5687cd3..443e3280 100644
--- a/app/src/main/java/illyan/jay/data/network/datasource/SessionNetworkDataSource.kt
+++ b/app/src/main/java/illyan/jay/data/network/datasource/SessionNetworkDataSource.kt
@@ -155,7 +155,7 @@ class SessionNetworkDataSource @Inject constructor(
val userRef = firestore
.collection(FirestoreUser.CollectionName)
.document(userUUID)
- Timber.i("Deleting ${domainSessions.size} sessions for user ${userUUID.take(4)} from the cloud")
+ Timber.i("Batch remove ${domainSessions.size} sessions for user ${userUUID.take(4)} from the cloud")
batch.set(
userRef,
mapOf(FirestoreUser.FieldSessions to FieldValue.arrayRemove(*domainSessions.map { it.toFirestoreModel() }.toTypedArray())),
diff --git a/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt
index e9ac562a..b1c24618 100644
--- a/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt
+++ b/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt
@@ -30,13 +30,13 @@ import illyan.jay.data.network.datasource.UserNetworkDataSource
import illyan.jay.di.CoroutineScopeIO
import illyan.jay.domain.model.DomainLocation
import illyan.jay.domain.model.DomainSession
+import illyan.jay.util.completeNext
+import illyan.jay.util.runBatch
import illyan.jay.util.sphericalPathLength
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
@@ -152,65 +152,31 @@ class SessionInteractor @Inject constructor(
suspend fun deleteSyncedSessions() {
if (!authInteractor.isUserSignedIn) return
- Timber.i("Trying to delete sessions' data for user ${authInteractor.userUUID?.take(4)}")
- val batch = firestore.batch()
- val finishedDeletingSessions = MutableStateFlow(false)
- val finishedDeletingLocations = MutableStateFlow(false)
- sessionNetworkDataSource.deleteAllSessions(
- batch = batch,
- onWriteFinished = { finishedDeletingSessions.value = true }
- )
- locationNetworkDataSource.deleteLocationsForUser(
- batch = batch,
- onWriteFinished = { finishedDeletingLocations.value = true }
- )
- coroutineScopeIO.launch {
- combine(
- finishedDeletingSessions,
- finishedDeletingLocations
- ) {
- sessions, locations ->
- sessions && locations
- }.first {
- if (it) {
- batch.commit()
- true
- } else {
- false
- }
- }
+ Timber.d("Batch created to delete session data for user ${authInteractor.userUUID?.take(4)} from cloud")
+ firestore.runBatch(2) { batch, completableDeferred ->
+ sessionNetworkDataSource.deleteAllSessions(
+ batch = batch,
+ onWriteFinished = { completableDeferred.completeNext() }
+ )
+ locationNetworkDataSource.deleteLocationsForUser(
+ batch = batch,
+ onWriteFinished = { completableDeferred.completeNext() }
+ )
}
}
suspend fun deleteAllSyncedData() {
if (!authInteractor.isUserSignedIn) return
- Timber.i("Trying to delete user data for user ${authInteractor.userUUID?.take(4)}")
- val batch = firestore.batch()
- val finishedDeletingUserData = MutableStateFlow(false)
- val finishedDeletingLocations = MutableStateFlow(false)
- userNetworkDataSource.deleteUserData(
- batch = batch,
- onWriteFinished = { finishedDeletingUserData.value = true }
- )
- locationNetworkDataSource.deleteLocationsForUser(
- batch = batch,
- onWriteFinished = { finishedDeletingLocations.value = true }
- )
- coroutineScopeIO.launch {
- combine(
- finishedDeletingUserData,
- finishedDeletingLocations
- ) {
- user, locations ->
- user && locations
- }.first {
- if (it) {
- batch.commit()
- true
- } else {
- false
- }
- }
+ Timber.d("Batch created to delete user ${authInteractor.userUUID?.take(4)} from cloud")
+ firestore.runBatch(2) { batch, completableDeferred ->
+ userNetworkDataSource.deleteUserData(
+ batch = batch,
+ onWriteFinished = { completableDeferred.completeNext() }
+ )
+ locationNetworkDataSource.deleteLocationsForUser(
+ batch = batch,
+ onWriteFinished = { completableDeferred.completeNext() }
+ )
}
}
@@ -550,44 +516,28 @@ class SessionInteractor @Inject constructor(
sessionDiskDataSource.deleteSessions(stoppedSessions)
}
- fun deleteSessionFromCloud(sessionUUID: String) = deleteSessionsFromCloud(listOf(sessionUUID))
+ suspend fun deleteSessionFromCloud(sessionUUID: String) = deleteSessionsFromCloud(listOf(sessionUUID))
@JvmName("deleteSessionsFromCloudByUUIDs")
- fun deleteSessionsFromCloud(sessionUUIDs: List) {
- val batch = firestore.batch()
- val finishedDeletingSessions = MutableStateFlow(false)
- val finishedDeletingLocations = MutableStateFlow(false)
- sessionNetworkDataSource.deleteSessions(
- batch = batch,
- sessionUUIDs = sessionUUIDs,
- onWriteFinished = { finishedDeletingSessions.value = true }
- )
- locationNetworkDataSource.deleteLocationsForSessions(
- batch = batch,
- sessionUUIDs = sessionUUIDs,
- onWriteFinished = { finishedDeletingLocations.value = true }
- )
- coroutineScopeIO.launch {
- combine(
- finishedDeletingSessions,
- finishedDeletingLocations
- ) {
- sessions, locations ->
- sessions && locations
- }.first {
- if (it) {
- batch.commit()
- true
- } else {
- false
- }
- }
+ suspend fun deleteSessionsFromCloud(sessionUUIDs: List) {
+ Timber.d("Batch created to delete ${sessionUUIDs.size} sessions from cloud")
+ firestore.runBatch(2) { batch, completableDeferred ->
+ sessionNetworkDataSource.deleteSessions(
+ batch = batch,
+ sessionUUIDs = sessionUUIDs,
+ onWriteFinished = { completableDeferred.completeNext() }
+ )
+ locationNetworkDataSource.deleteLocationsForSessions(
+ batch = batch,
+ sessionUUIDs = sessionUUIDs,
+ onWriteFinished = { completableDeferred.completeNext() }
+ )
}
}
- fun deleteSessionFromCloud(domainSession: DomainSession) = deleteSessionsFromCloud(listOf(domainSession))
+ suspend fun deleteSessionFromCloud(domainSession: DomainSession) = deleteSessionsFromCloud(listOf(domainSession))
- fun deleteSessionsFromCloud(domainSessions: List) {
+ suspend fun deleteSessionsFromCloud(domainSessions: List) {
deleteSessionsFromCloud(domainSessions.map { it.uuid })
}
diff --git a/app/src/main/java/illyan/jay/ui/map/Map.kt b/app/src/main/java/illyan/jay/ui/map/Map.kt
index b0b6a246..6dc2fc2d 100644
--- a/app/src/main/java/illyan/jay/ui/map/Map.kt
+++ b/app/src/main/java/illyan/jay/ui/map/Map.kt
@@ -21,6 +21,8 @@ package illyan.jay.ui.map
import android.content.Context
import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.statusBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
@@ -30,6 +32,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.viewinterop.AndroidView
@@ -40,13 +43,16 @@ import com.mapbox.maps.EdgeInsets
import com.mapbox.maps.MapInitOptions
import com.mapbox.maps.MapOptions
import com.mapbox.maps.MapView
+import com.mapbox.maps.MapboxExperimental
import com.mapbox.maps.ResourceOptions
import com.mapbox.maps.Style
import com.mapbox.maps.extension.observable.eventdata.MapLoadedEventData
import com.mapbox.maps.extension.style.expressions.dsl.generated.interpolate
import com.mapbox.maps.plugin.LocationPuck2D
+import com.mapbox.maps.plugin.compass.compass
import com.mapbox.maps.plugin.gestures.gestures
import com.mapbox.maps.plugin.locationcomponent.LocationComponentPlugin
+import com.mapbox.maps.plugin.logo.logo
import com.mapbox.maps.plugin.scalebar.scalebar
import illyan.jay.R
import illyan.jay.ui.poi.model.Place
@@ -106,10 +112,14 @@ fun MapboxMap(
.pitch(cameraPitch)
.padding(cameraPadding)
.build(),
+
)
val map = remember {
- val initializedMap = MapView(context, options)
+ val initializedMap = MapView(
+ context = context,
+ mapInitOptions = options,
+ )
onMapInitialized(initializedMap)
initializedMap
}
@@ -128,6 +138,7 @@ fun MapboxMap(
)
}
+@OptIn(MapboxExperimental::class)
@Composable
private fun MapboxMapContainer(
modifier: Modifier,
@@ -141,12 +152,16 @@ private fun MapboxMapContainer(
map.getMapboxMap().addOnCameraChangeListener { onCameraChanged(map.getMapboxMap().cameraState) }
onDispose { map.getMapboxMap().removeOnMapLoadedListener(onMapLoadedListener) }
}
+ val statusBarHeight = LocalDensity.current.run { WindowInsets.statusBars.getTop(this) }
+ val fixedStatusBarHeight = remember { statusBarHeight }
AndroidView(
modifier = modifier,
factory = { map }
) {
+ it.logo.position = 0
+ it.logo.marginTop = fixedStatusBarHeight.toFloat()
+ it.compass.marginTop = fixedStatusBarHeight.toFloat()
it.gestures.scrollEnabled = true
- // it.logo.enabled = false // Logo is enabled due to Terms of Service
it.scalebar.isMetricUnits = true // TODO: set this in settings or based on location, etc.
it.scalebar.enabled = false // TODO: enable it later if needed (though pay attention to ugly design)
}
diff --git a/app/src/main/java/illyan/jay/ui/session/Session.kt b/app/src/main/java/illyan/jay/ui/session/Session.kt
index 39caf2af..4ccc0bdb 100644
--- a/app/src/main/java/illyan/jay/ui/session/Session.kt
+++ b/app/src/main/java/illyan/jay/ui/session/Session.kt
@@ -18,7 +18,6 @@
package illyan.jay.ui.session
-import android.graphics.Color.parseColor
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
@@ -29,12 +28,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowRightAlt
import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.TabRowDefaults
+import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -45,6 +49,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
@@ -58,6 +65,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.mapbox.geojson.Feature
import com.mapbox.geojson.LineString
import com.mapbox.geojson.Point
+import com.mapbox.maps.extension.style.expressions.generated.Expression
import com.mapbox.maps.extension.style.expressions.generated.Expression.Companion.interpolate
import com.mapbox.maps.extension.style.layers.addLayerBelow
import com.mapbox.maps.extension.style.layers.generated.lineLayer
@@ -83,11 +91,13 @@ import illyan.jay.ui.home.tryFlyToPath
import illyan.jay.ui.menu.MenuItemPadding
import illyan.jay.ui.menu.MenuNavGraph
import illyan.jay.ui.menu.SheetScreenBackPressHandler
+import illyan.jay.ui.session.model.GradientFilter
import illyan.jay.ui.session.model.UiLocation
import illyan.jay.ui.session.model.UiSession
import illyan.jay.ui.theme.JayTheme
import illyan.jay.ui.theme.mapMarkers
import illyan.jay.ui.theme.signatureBlue
+import illyan.jay.ui.theme.signaturePink
import illyan.jay.util.format
import timber.log.Timber
import java.math.RoundingMode
@@ -99,6 +109,101 @@ val DefaultScreenOnSheetPadding = PaddingValues(
bottom = RoundedCornerRadius + MenuItemPadding * 2
)
+fun defaultGradient(
+ start: Color = Color(red = 0x00, green = 0xFF, blue = 0x8c),
+ end: Color = MaterialTheme.signatureBlue,
+): Expression {
+ return interpolate {
+ linear()
+ lineProgress()
+ stop(0.0) { color(start.toArgb()) }
+ stop(1.0) { color(end.toArgb()) }
+ }
+}
+
+fun createGradientFromLocations(
+ locations: List,
+ start: Color = Color(red = 0x00, green = 0xFF, blue = 0x8c),
+ stop: Color = MaterialTheme.signatureBlue,
+ getColorFraction: (UiLocation) -> Float,
+): Expression {
+ if (locations.isEmpty()) return defaultGradient()
+ val startMilli = locations.minOf { it.zonedDateTime.toInstant().toEpochMilli() }
+ val endMilli = locations.maxOf { it.zonedDateTime.toInstant().toEpochMilli() }
+ val durationMilli = (endMilli - startMilli)
+ val colorsWithKeys = locations.sortedBy {
+ it.zonedDateTime.toInstant().toEpochMilli()
+ }.map {
+ val currentMilli = it.zonedDateTime.toInstant().toEpochMilli()
+ lerp(start, stop, getColorFraction(it).coerceIn(0f, 1f)) to
+ (currentMilli - startMilli).toDouble() / durationMilli
+ }
+ return interpolate {
+ linear()
+ lineProgress()
+ colorsWithKeys.forEach {
+ stop(it.second) { color(it.first.toArgb()) }
+ }
+ }
+}
+
+fun elevationGradient(
+ locations: List,
+ deepestColor: Color = Color.Blue,
+ highestColor: Color = MaterialTheme.signaturePink,
+) = createGradientFromLocations(
+ locations = locations,
+ start = deepestColor,
+ stop = highestColor,
+ getColorFraction = { location ->
+ val minElevation = locations.minOf { it.altitude }
+ val maxElevation = locations.maxOf { it.altitude }
+ if (minElevation == maxElevation) {
+ 0.5f
+ } else {
+ (location.altitude - minElevation).toFloat() / (maxElevation - minElevation)
+ }
+ }
+)
+
+fun velocityGradient(
+ locations: List,
+ fastestColor: Color = Color(red = 0x00, green = 0xFF, blue = 0x8c),
+ slowestColor: Color = Color.Red,
+) = createGradientFromLocations(
+ locations = locations,
+ start = slowestColor,
+ stop = fastestColor,
+ getColorFraction = { location ->
+ val minVelocity = locations.minOf { it.speed }
+ val maxVelocity = locations.maxOf { it.speed }
+ if (minVelocity == maxVelocity) {
+ 0.5f
+ } else {
+ (location.speed - minVelocity) / (maxVelocity - minVelocity)
+ }
+ }
+)
+
+fun gpsAccuracyGradient(
+ locations: List,
+ mostAccurateColor: Color = Color(red = 0x00, green = 0xFF, blue = 0x8c),
+ leastAccurateColor: Color = Color.Red,
+) = createGradientFromLocations(
+ locations = locations,
+ start = leastAccurateColor,
+ stop = mostAccurateColor,
+ getColorFraction = { location ->
+ val leastAccurate = locations.maxOf { it.accuracy }
+ val mostAccurate = locations.minOf { it.accuracy }
+ if (leastAccurate == mostAccurate) {
+ 0.5f
+ } else {
+ (location.accuracy - leastAccurate).toFloat() / (mostAccurate - leastAccurate)
+ }
+ }
+)
+
@OptIn(ExperimentalMaterialApi::class)
@MenuNavGraph
@Destination
@@ -112,6 +217,7 @@ fun SessionScreen(
LaunchedEffect(Unit) {
viewModel.load(sessionUUID)
}
+ val gradientFilter by viewModel.gradientFilter.collectAsStateWithLifecycle()
var sheetHeightNotSet by remember { mutableStateOf(true) }
var flownToPath by remember { mutableStateOf(false) }
val path by viewModel.path.collectAsStateWithLifecycle()
@@ -147,7 +253,8 @@ fun SessionScreen(
val density = LocalDensity.current.density
val mapMarkers by mapMarkers.collectAsStateWithLifecycle()
DisposableEffect(
- path
+ path,
+ gradientFilter
) {
val points = path?.map {
Point.fromLngLat(it.latLng.longitude, it.latLng.latitude)
@@ -179,11 +286,23 @@ fun SessionScreen(
lineJoin(LineJoin.ROUND)
lineWidth(lineWidth)
lineGradient(
- interpolate {
- linear()
- lineProgress()
- stop(0.0) { color(parseColor("#00ff8c")) }
- stop(1.0) { color(MaterialTheme.signatureBlue.toArgb()) }
+ when(gradientFilter) {
+ GradientFilter.Default -> defaultGradient()
+ GradientFilter.Velocity -> velocityGradient(
+ locations = path ?: emptyList(),
+ slowestColor = Color.Red,
+ fastestColor = Color.Green
+ )
+ GradientFilter.Elevation -> elevationGradient(
+ locations = path ?: emptyList(),
+ deepestColor = MaterialTheme.signatureBlue,
+ highestColor = MaterialTheme.signaturePink
+ )
+ GradientFilter.GpsAccuracy -> gpsAccuracyGradient(
+ locations = path ?: emptyList(),
+ leastAccurateColor = Color.Red,
+ mostAccurateColor = Color.Green
+ )
}
)
},
@@ -225,6 +344,8 @@ fun SessionScreen(
.padding(DefaultScreenOnSheetPadding),
session = session,
path = path,
+ gradientFilter = gradientFilter,
+ setGradientFilter = viewModel::setGradientFilter,
)
}
@@ -233,6 +354,8 @@ fun SessionDetailsScreen(
modifier: Modifier = Modifier,
session: UiSession? = null,
path: List? = null,
+ gradientFilter: GradientFilter = GradientFilter.Default,
+ setGradientFilter: (GradientFilter) -> Unit = {}
) {
Column(
modifier = modifier,
@@ -361,6 +484,41 @@ fun SessionDetailsScreen(
stringResource(R.string.session_id) to session?.uuid
),
)
+ val selectedTabIndex = gradientFilter.ordinal
+ TabRow(
+ modifier = Modifier
+ .padding(horizontal = MenuItemPadding),
+ divider = {},
+ selectedTabIndex = selectedTabIndex,
+ indicator = {
+ TabRowDefaults.Indicator(
+ Modifier
+ .tabIndicatorOffset(it[selectedTabIndex])
+ .padding(horizontal = MenuItemPadding)
+ .clip(RoundedCornerShape(percent = 100))
+ )
+ }
+ ) {
+ GradientFilter.values().forEach {
+ Tab(
+ modifier = Modifier.clip(RoundedCornerShape(MenuItemPadding)),
+ selected = it == gradientFilter,
+ onClick = { setGradientFilter(it) },
+ text = {
+ Text(
+ text = stringResource(
+ when(it) {
+ GradientFilter.Default -> R.string.gradient_filter_default
+ GradientFilter.Velocity -> R.string.gradient_filter_velocity
+ GradientFilter.Elevation -> R.string.gradient_filter_elevation
+ GradientFilter.GpsAccuracy -> R.string.gradient_filter_gps_accuracy
+ }
+ )
+ )
+ }
+ )
+ }
+ }
}
}
diff --git a/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt b/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt
index fecc2e6c..47b28877 100644
--- a/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt
+++ b/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt
@@ -24,6 +24,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import illyan.jay.di.CoroutineDispatcherIO
import illyan.jay.domain.interactor.LocationInteractor
import illyan.jay.domain.interactor.SessionInteractor
+import illyan.jay.ui.session.model.GradientFilter
import illyan.jay.ui.session.model.UiLocation
import illyan.jay.ui.session.model.UiSession
import illyan.jay.ui.session.model.toUiModel
@@ -31,6 +32,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -47,8 +49,10 @@ class SessionViewModel @Inject constructor(
private val _path = MutableStateFlow?>(null)
val path = _path.asStateFlow()
- fun load(sessionUUID: String) {
+ private val _gradientFilter = MutableStateFlow(GradientFilter.Default)
+ val gradientFilter = _gradientFilter.asStateFlow()
+ fun load(sessionUUID: String) {
viewModelScope.launch(dispatcherIO) {
Timber.d("Trying to load session with ID: $sessionUUID")
sessionInteractor.getSession(sessionUUID).collectLatest { session ->
@@ -71,4 +75,8 @@ class SessionViewModel @Inject constructor(
}
}
}
+
+ fun setGradientFilter(filter: GradientFilter) {
+ _gradientFilter.update { filter }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/ui/session/model/GradientFilter.kt b/app/src/main/java/illyan/jay/ui/session/model/GradientFilter.kt
new file mode 100644
index 00000000..df78b185
--- /dev/null
+++ b/app/src/main/java/illyan/jay/ui/session/model/GradientFilter.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.ui.session.model
+
+enum class GradientFilter {
+ Default,
+ Velocity,
+ Elevation,
+ GpsAccuracy
+}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/util/Util.kt b/app/src/main/java/illyan/jay/util/Util.kt
index c0d2b3c3..806f9523 100644
--- a/app/src/main/java/illyan/jay/util/Util.kt
+++ b/app/src/main/java/illyan/jay/util/Util.kt
@@ -40,14 +40,21 @@ import com.google.accompanist.placeholder.PlaceholderHighlight
import com.google.accompanist.placeholder.material.placeholder
import com.google.accompanist.placeholder.material.shimmer
import com.google.android.gms.maps.model.LatLng
+import com.google.android.gms.tasks.Task
import com.google.firebase.Timestamp
+import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.GeoPoint
+import com.google.firebase.firestore.WriteBatch
import com.google.maps.android.SphericalUtil
import com.google.maps.android.ktx.utils.sphericalPathLength
import com.mapbox.geojson.Point
import com.mapbox.maps.CameraOptions
import com.mapbox.maps.EdgeInsets
import illyan.jay.domain.model.DomainLocation
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.awaitAll
+import timber.log.Timber
import java.time.Instant
import java.time.ZoneOffset
import java.time.ZonedDateTime
@@ -204,7 +211,7 @@ fun Modifier.cardPlaceholder(
}
/**
- * Finds all potential URLs's start and end indices in the String.
+ * Finds all potential URLs' start and end indices in the String.
*/
fun String.findUrlIntervals(
urlRegex: Regex = "[(http(s)?):\\/\\/(www\\.)?a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,6}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)".toRegex()
@@ -229,3 +236,27 @@ fun Color.setLightness(lightness: Float): Color {
hsl[2] = lightness
return Color.hsl(hsl[0], hsl[1], hsl[2], alpha = alpha)
}
+
+fun Collection>.completeNext() = firstOrNull { !it.isCompleted }?.complete(Unit) ?: false
+
+suspend inline fun WriteBatch.awaitAllThenCommit(vararg deferred: Deferred): Task {
+ Timber.v("Awaiting modifications to batch")
+ awaitAll(*deferred)
+ Timber.d("Committing batch")
+ return commit()
+}
+
+suspend inline fun WriteBatch.awaitAllThenCommit(deferred: List>) = awaitAllThenCommit(*deferred.toTypedArray())
+
+suspend fun FirebaseFirestore.runBatch(
+ numberOfWrites: Int,
+ body: suspend (
+ batch: WriteBatch,
+ completableDeferred: List>
+ ) -> Unit
+): Task {
+ val batch = batch()
+ val completableDeferred = List(numberOfWrites) { CompletableDeferred() }
+ body(batch, completableDeferred)
+ return batch.awaitAllThenCommit(completableDeferred)
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index fc268d31..b5324ce5 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -119,4 +119,9 @@
Session ID
Start location
End location
+ Default
+ Velocity
+ Elevation
+ Accuracy
+ Filters
\ No newline at end of file
diff --git a/gradle.properties b/gradle.properties
index f06612f7..7bdd7ee9 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License along with Jay.
# If not, see .
#
-org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects