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