diff --git a/.gitignore b/.gitignore index f2bb714a..0a06454c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ # Needed for configuration only *.iml # Comment if you like setup things -/.idea +.idea # Customize more directories if needed #/.idea/caches #/.idea/libraries diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 525587b4..9d5a94f5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,14 +37,14 @@ plugins { val localProperties = loadProperties("$projectDir/../local.properties") android { - compileSdk = 35 + compileSdk = 36 namespace = "illyan.jay" defaultConfig { applicationId = "illyan.jay" minSdk = 23 - targetSdk = 35 - versionCode = 19 - versionName = "0.4.1-alpha" + targetSdk = 36 + versionCode = 20 + versionName = "0.5.0-alpha" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true @@ -101,13 +101,13 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 isCoreLibraryDesugaringEnabled = true } kotlin { - jvmToolchain(17) + jvmToolchain(21) } buildFeatures { @@ -137,17 +137,18 @@ dependencies { // Core implementation(libs.jetbrains.kotlin.stdlib) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.splash) implementation(libs.androidx.collection.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.ktx) implementation(libs.google.material) + implementation(libs.google.material.icons) + implementation(libs.jetbrains.kotlinx.datetime) implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar")))) // Compose - implementation(libs.androidx.constraintlayout.compose) implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.util) @@ -169,10 +170,6 @@ dependencies { // Biometric Auth //implementation "androidx.biometric:biometric:1.2.0-alpha05" - // Material design icons - implementation(libs.androidx.compose.material.icons.core) - implementation(libs.androidx.compose.material.icons.extended) - // Day-Night Cycle in Theming implementation(libs.solarized) @@ -184,13 +181,13 @@ dependencies { // Mapbox implementation(libs.mapbox.maps) + implementation(libs.mapbox.maps.compose) implementation(libs.mapbox.search) implementation(libs.mapbox.navigation) // Accompanist - implementation(libs.accompanist.systemuicontroller) implementation(libs.accompanist.permissions) - implementation(libs.accompanist.placeholder.material) + implementation(libs.placeholder) // Hilt implementation(libs.hilt) @@ -201,7 +198,8 @@ dependencies { implementation(libs.timber) // Navigation - implementation(libs.compose.destinations.animations.core) + implementation(libs.compose.destinations.core) + implementation(libs.compose.destinations.bottom.sheet) ksp(libs.compose.destinations.ksp) // Coil @@ -234,6 +232,11 @@ dependencies { implementation(libs.google.maps.utils) implementation(libs.google.maps.utils.ktx) + // Credential Manager + Google ID + implementation(libs.androidx.credentials) + implementation(libs.androidx.credentials.play.services.auth) + implementation(libs.google.identity.googleid) + // Firebase implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5db5cba0..d2e3344d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,21 +37,16 @@ android:name=".MainApplication" android:allowBackup="false" android:enableOnBackInvokedCallback="true" - android:icon="@mipmap/ic_launcher" + android:icon="@mipmap/ic_launcher_foreground" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:theme="@style/Theme.Jay" android:usesCleartextTraffic="false"> - - - + diff --git a/app/src/main/java/illyan/jay/MainActivity.kt b/app/src/main/java/illyan/jay/MainActivity.kt index caa3ae27..80a7433e 100644 --- a/app/src/main/java/illyan/jay/MainActivity.kt +++ b/app/src/main/java/illyan/jay/MainActivity.kt @@ -18,11 +18,8 @@ package illyan.jay -import android.content.Intent import android.os.Bundle import androidx.activity.compose.setContent -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -34,18 +31,17 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInAccount -import com.google.android.gms.tasks.Task import com.mapbox.navigation.base.options.NavigationOptions import com.mapbox.navigation.core.lifecycle.MapboxNavigationApp import com.ramcosta.composedestinations.DestinationsNavHost +import com.ramcosta.composedestinations.generated.NavGraphs import dagger.hilt.android.AndroidEntryPoint import illyan.jay.domain.interactor.AuthInteractor -import illyan.jay.ui.NavGraphs import illyan.jay.ui.components.PreviewAccessibility +import illyan.jay.ui.theme.DefaultDestinationTransitions import illyan.jay.ui.theme.JayThemeWithViewModel import illyan.jay.util.MapboxExceptionHandler import javax.inject.Inject @@ -56,8 +52,6 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var authInteractor: AuthInteractor - lateinit var googleSignInLauncher: ActivityResultLauncher - init { lifecycle.addObserver(object : DefaultLifecycleObserver { override fun onResume(owner: LifecycleOwner) { @@ -73,12 +67,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - googleSignInLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { - val task: Task = GoogleSignIn.getSignedInAccountFromIntent(it.data) - authInteractor.handleGoogleSignInResult(this, task) - } + installSplashScreen() if (!MapboxNavigationApp.isSetup()) { MapboxNavigationApp.setup { @@ -114,7 +103,8 @@ fun MainScreen( modifier: Modifier = Modifier ) { DestinationsNavHost( - navGraph = NavGraphs.home, - modifier = modifier + navGraph = NavGraphs.root, + modifier = modifier, + defaultTransitions = DefaultDestinationTransitions ) } diff --git a/app/src/main/java/illyan/jay/data/datastore/datasource/AppSettingsDataSource.kt b/app/src/main/java/illyan/jay/data/datastore/datasource/AppSettingsDataSource.kt index b0cdc924..cbfa5313 100644 --- a/app/src/main/java/illyan/jay/data/datastore/datasource/AppSettingsDataSource.kt +++ b/app/src/main/java/illyan/jay/data/datastore/datasource/AppSettingsDataSource.kt @@ -23,11 +23,13 @@ import illyan.jay.data.datastore.model.AppSettings import illyan.jay.domain.model.DomainPreferences import kotlinx.coroutines.flow.map import timber.log.Timber -import java.time.ZonedDateTime import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) @Singleton class AppSettingsDataSource @Inject constructor( private val appSettingsDataStore: DataStore @@ -55,7 +57,7 @@ class AppSettingsDataSource @Inject constructor( suspend fun updateAppPreferences(transform: (DomainPreferences) -> DomainPreferences) { Timber.v("Updating App Preferences requested") updateAppSettings { - it.copy(preferences = transform(it.preferences).copy(lastUpdate = ZonedDateTime.now())) + it.copy(preferences = transform(it.preferences).copy(lastUpdate = Clock.System.now())) } } } \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/data/firestore/Mapping.kt b/app/src/main/java/illyan/jay/data/firestore/Mapping.kt index 321c6d40..425d7f05 100644 --- a/app/src/main/java/illyan/jay/data/firestore/Mapping.kt +++ b/app/src/main/java/illyan/jay/data/firestore/Mapping.kt @@ -16,6 +16,8 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class, ExperimentalSerializationApi::class) + package illyan.jay.data.firestore import android.os.Parcel @@ -39,8 +41,8 @@ import illyan.jay.domain.model.DomainPreferences import illyan.jay.domain.model.DomainSensorEvent import illyan.jay.domain.model.DomainSession import illyan.jay.util.toGeoPoint +import illyan.jay.util.toKotlinInstant import illyan.jay.util.toTimestamp -import illyan.jay.util.toZonedDateTime import kotlinx.parcelize.Parcelize import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -57,7 +59,9 @@ import java.util.zip.GZIPInputStream import java.util.zip.GZIPOutputStream import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) fun DomainSession.toFirestoreModel() = FirestoreSession( uuid = uuid, startDateTime = startDateTime.toTimestamp(), @@ -74,8 +78,8 @@ fun FirestoreSession.toDomainModel( ownerUUID: String ) = DomainSession( uuid = uuid, - startDateTime = startDateTime.toZonedDateTime(), - endDateTime = endDateTime?.toZonedDateTime(), + startDateTime = startDateTime.toKotlinInstant(), + endDateTime = endDateTime?.toKotlinInstant(), startLocationName = startLocationName, endLocationName = endLocationName, clientUUID = clientUUID, @@ -96,8 +100,8 @@ fun FirestoreUserPreferences.toDomainModel( showAds = showAds, theme = theme, dynamicColorEnabled = dynamicColorEnabled, - lastUpdate = lastUpdate.toZonedDateTime(), - lastUpdateToAnalytics = lastUpdateToAnalytics?.toZonedDateTime(), + lastUpdate = lastUpdate.toKotlinInstant(), + lastUpdateToAnalytics = lastUpdateToAnalytics?.toKotlinInstant(), shouldSync = true, ) @@ -118,7 +122,7 @@ fun List.toPath( ): FirestorePath { val pathLocations = map { FirestoreLocation( - timestamp = it.zonedDateTime.toTimestamp(), + timestamp = it.timestamp.toTimestamp(), latitude = it.latitude, longitude = it.longitude, speed = it.speed, @@ -162,7 +166,7 @@ fun List.toFirebaseSensorEvents( ): FirestoreSensorEvents { val events = map { FirestoreSensorEvent( - timestamp = it.zonedDateTime.toTimestamp(), + timestamp = it.timestamp.toTimestamp(), accuracy = it.accuracy.toInt(), type = it.type.toInt(), x = it.x, @@ -185,7 +189,7 @@ fun List.toFirebaseSensorEvents( ProtoBuf.encodeToByteArray( data.map { FirestoreSensorEvent( - timestamp = it.zonedDateTime.toTimestamp(), + timestamp = it.timestamp.toTimestamp(), accuracy = it.accuracy.toInt(), type = it.type.toInt(), x = it.x, @@ -197,11 +201,9 @@ fun List.toFirebaseSensorEvents( }, dataSizeHeuristic = { data -> val startMilli = data - .minBy { it.zonedDateTime.toInstant().toEpochMilli() } - .zonedDateTime.toInstant().toEpochMilli() + .minOf { it.timestamp.toEpochMilliseconds() } val endMilli = data - .maxBy { it.zonedDateTime.toInstant().toEpochMilli() } - .zonedDateTime.toInstant().toEpochMilli() + .maxOf { it.timestamp.toEpochMilliseconds() } val durationInMinutes = (endMilli - startMilli).milliseconds.inWholeMinutes "$durationInMinutes minutes of sensor data" } @@ -254,7 +256,7 @@ private fun testCompressions(domainLocations: List) { ProtoBuf.encodeToByteArray( data.map { LocationWithoutSessionIdOptimized( - zonedDateTime = it.zonedDateTime.toTimestamp(), + zonedDateTime = it.timestamp.toTimestamp(), latitude = it.latitude, longitude = it.longitude, speed = it.speed, @@ -272,7 +274,7 @@ private fun testCompressions(domainLocations: List) { ProtoBuf.encodeToByteArray( data.map { LocationWithoutSessionId( - zonedDateTime = it.zonedDateTime.toTimestamp(), + zonedDateTime = it.timestamp.toTimestamp(), latitude = it.latitude, longitude = it.longitude, speed = it.speed, @@ -290,7 +292,7 @@ private fun testCompressions(domainLocations: List) { ProtoBuf.encodeToByteArray( data.map { LocationWithoutSessionIdUnoptimized( - zonedDateTime = it.zonedDateTime.toTimestamp(), + zonedDateTime = it.timestamp.toTimestamp(), latitude = it.latitude.toDouble(), longitude = it.longitude.toDouble(), speed = it.speed.toDouble(), @@ -330,11 +332,9 @@ private fun testCompressions(domainLocations: List) { ), dataSizeHeuristic = { locations -> val startMilli = locations - .minBy { it.zonedDateTime.toInstant().toEpochMilli() } - .zonedDateTime.toInstant().toEpochMilli() + .minOf { it.timestamp.toEpochMilliseconds() } val endMilli = locations - .maxBy { it.zonedDateTime.toInstant().toEpochMilli() } - .zonedDateTime.toInstant().toEpochMilli() + .maxOf { it.timestamp.toEpochMilliseconds() } val durationInMinutes = (endMilli - startMilli).milliseconds.inWholeMinutes "$durationInMinutes minutes of location data" } @@ -436,14 +436,14 @@ fun List.toChunkedFirebaseSensorEvents( thresholdInMinutes: Int = 5 ): List { if (isEmpty()) return emptyList() - val startMilli = minOf { it.zonedDateTime.toInstant().toEpochMilli() } + val startMilli = minOf { it.timestamp.toEpochMilliseconds() } val groupedByTime = groupBy { - (it.zonedDateTime.toInstant().toEpochMilli() - startMilli) / + (it.timestamp.toEpochMilliseconds() - startMilli) / thresholdInMinutes.minutes.inWholeMilliseconds } return groupedByTime.map { groups -> groups.value - .sortedBy { it.zonedDateTime.toInstant().toEpochMilli() } + .sortedBy { it.timestamp.toEpochMilliseconds() } .toFirebaseSensorEvents(sessionUUID, ownerUUID) } } @@ -458,7 +458,7 @@ fun List.toDomainLocations(): List { domainLocations.addAll(locations.map { it.toDomainModel(path.sessionUUID) }) } - return domainLocations.sortedBy { it.zonedDateTime.toInstant().toEpochMilli() } + return domainLocations.sortedBy { it.timestamp.toEpochMilliseconds() } } @OptIn(ExperimentalSerializationApi::class) @@ -484,14 +484,14 @@ fun List.toDomainSensorEvents(): List domainSensorEvents.addAll(sensorEvents.map { it.toDomainModel(events.sessionUUID) }) } - return domainSensorEvents.sortedBy { it.zonedDateTime.toInstant().toEpochMilli() } + return domainSensorEvents.sortedBy { it.timestamp.toEpochMilliseconds() } } fun FirestoreLocation.toDomainModel( sessionUUID: String ) = DomainLocation( latitude = latitude, - zonedDateTime = timestamp.toZonedDateTime(), + timestamp = timestamp.toKotlinInstant(), longitude = longitude, speed = speed, sessionUUID = sessionUUID, @@ -506,7 +506,7 @@ fun FirestoreLocation.toDomainModel( fun FirestoreSensorEvent.toDomainModel( sessionUUID: String ) = DomainSensorEvent( - zonedDateTime = timestamp.toZonedDateTime(), + timestamp = timestamp.toKotlinInstant(), sessionUUID = sessionUUID, accuracy = accuracy.toByte(), type = type.toByte(), diff --git a/app/src/main/java/illyan/jay/data/firestore/datasource/PathFirestoreDataSource.kt b/app/src/main/java/illyan/jay/data/firestore/datasource/PathFirestoreDataSource.kt index 74670f5b..5878f9fd 100644 --- a/app/src/main/java/illyan/jay/data/firestore/datasource/PathFirestoreDataSource.kt +++ b/app/src/main/java/illyan/jay/data/firestore/datasource/PathFirestoreDataSource.kt @@ -23,8 +23,8 @@ import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.MetadataChanges import com.google.firebase.firestore.WriteBatch -import com.google.firebase.firestore.ktx.snapshots -import com.google.firebase.firestore.ktx.toObjects +import com.google.firebase.firestore.snapshots +import com.google.firebase.firestore.toObjects import com.google.maps.android.ktx.utils.sphericalPathLength import illyan.jay.data.firestore.model.FirestorePath import illyan.jay.data.firestore.toDomainAggressions @@ -44,7 +44,9 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.minutes +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) @Singleton class PathFirestoreDataSource @Inject constructor( private val firestore: FirebaseFirestore, @@ -145,18 +147,18 @@ class PathFirestoreDataSource @Inject constructor( val aggressionsForThisSession = domainAggressions.filter { it.sessionUUID.contentEquals(session.uuid) } if (session.distance == null) { session.distance = locationsForThisSession - .sortedBy { it.zonedDateTime.toInstant().toEpochMilli() } + .sortedBy { it.timestamp } .map { it.latLng }.sphericalPathLength().toFloat() } val thresholdInMinutes = 300 if (locationsForThisSession.isEmpty()) return emptyList() - val startMilli = locationsForThisSession.minOf { it.zonedDateTime.toInstant().toEpochMilli() } + val startMilli = locationsForThisSession.minOf { it.timestamp.toEpochMilliseconds() } val groupedByTime = locationsForThisSession.groupBy { - (it.zonedDateTime.toInstant().toEpochMilli() - startMilli) / thresholdInMinutes.minutes.inWholeMilliseconds + (it.timestamp.toEpochMilliseconds() - startMilli) / thresholdInMinutes.minutes.inWholeMilliseconds } paths.addAll(groupedByTime.map { groups -> groups.value - .sortedBy { it.zonedDateTime.toInstant().toEpochMilli() } + .sortedBy { it.timestamp } .toPath(session.uuid, session.ownerUUID!!) }) val aggressionsGroupedByTime = aggressionsForThisSession.groupBy { diff --git a/app/src/main/java/illyan/jay/data/firestore/datasource/PreferencesFirestoreDataSource.kt b/app/src/main/java/illyan/jay/data/firestore/datasource/PreferencesFirestoreDataSource.kt index 65575365..e0d490b3 100644 --- a/app/src/main/java/illyan/jay/data/firestore/datasource/PreferencesFirestoreDataSource.kt +++ b/app/src/main/java/illyan/jay/data/firestore/datasource/PreferencesFirestoreDataSource.kt @@ -36,7 +36,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import timber.log.Timber import javax.inject.Inject +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) class PreferencesFirestoreDataSource @Inject constructor( private val firestore: FirebaseFirestore, private val authInteractor: AuthInteractor, diff --git a/app/src/main/java/illyan/jay/data/firestore/datasource/SensorEventsFirestoreDataSource.kt b/app/src/main/java/illyan/jay/data/firestore/datasource/SensorEventsFirestoreDataSource.kt index dd9456f7..60933b76 100644 --- a/app/src/main/java/illyan/jay/data/firestore/datasource/SensorEventsFirestoreDataSource.kt +++ b/app/src/main/java/illyan/jay/data/firestore/datasource/SensorEventsFirestoreDataSource.kt @@ -23,8 +23,8 @@ import com.google.firebase.firestore.DocumentReference import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.MetadataChanges import com.google.firebase.firestore.WriteBatch -import com.google.firebase.firestore.ktx.snapshots -import com.google.firebase.firestore.ktx.toObjects +import com.google.firebase.firestore.snapshots +import com.google.firebase.firestore.toObjects import illyan.jay.data.firestore.model.FirestoreSensorEvents import illyan.jay.data.firestore.toChunkedFirebaseSensorEvents import illyan.jay.data.firestore.toDomainSensorEvents diff --git a/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSession.kt b/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSession.kt index 6cf12f0f..08916ba6 100644 --- a/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSession.kt +++ b/app/src/main/java/illyan/jay/data/firestore/model/FirestoreSession.kt @@ -22,11 +22,14 @@ import com.google.firebase.Timestamp import com.google.firebase.firestore.GeoPoint import com.google.firebase.firestore.PropertyName import illyan.jay.util.toTimestamp -import java.time.Instant +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) data class FirestoreSession( @PropertyName(FieldUUID) val uuid: String = "", - @PropertyName(FieldStartDateTime) val startDateTime: Timestamp = Instant.EPOCH.toTimestamp(), + @PropertyName(FieldStartDateTime) val startDateTime: Timestamp = Clock.System.now().toTimestamp(), @PropertyName(FieldEndDateTime) val endDateTime: Timestamp? = null, @PropertyName(FieldStartLocation) val startLocation: GeoPoint? = null, @PropertyName(FieldEndLocation) val endLocation: GeoPoint? = null, diff --git a/app/src/main/java/illyan/jay/data/resolver/PreferencesResolver.kt b/app/src/main/java/illyan/jay/data/resolver/PreferencesResolver.kt index d873470c..64501fdb 100644 --- a/app/src/main/java/illyan/jay/data/resolver/PreferencesResolver.kt +++ b/app/src/main/java/illyan/jay/data/resolver/PreferencesResolver.kt @@ -43,7 +43,9 @@ import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) class PreferencesResolver @Inject constructor( private val authInteractor: AuthInteractor, private val appSettingsDataSource: AppSettingsDataSource, diff --git a/app/src/main/java/illyan/jay/data/room/Mapping.kt b/app/src/main/java/illyan/jay/data/room/Mapping.kt index 3a92c968..b562e1d4 100644 --- a/app/src/main/java/illyan/jay/data/room/Mapping.kt +++ b/app/src/main/java/illyan/jay/data/room/Mapping.kt @@ -16,6 +16,8 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.data.room import android.hardware.SensorEvent @@ -33,16 +35,14 @@ import illyan.jay.domain.model.DomainPreferences import illyan.jay.domain.model.DomainSensorEvent import illyan.jay.domain.model.DomainSession import illyan.jay.util.sensorTimestampToAbsoluteTime -import illyan.jay.util.toZonedDateTime -import java.time.Instant -import java.time.ZoneOffset -import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant // Session fun RoomSession.toDomainModel() = DomainSession( uuid = uuid, - startDateTime = Instant.ofEpochMilli(startDateTime).atZone(ZoneOffset.UTC), - endDateTime = endDateTime?.let { Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC) }, + startDateTime = Instant.fromEpochMilliseconds(startDateTime), + endDateTime = endDateTime?.let { Instant.fromEpochMilliseconds(it) }, startLocationLatitude = startLocationLatitude, startLocationLongitude = startLocationLongitude, endLocationLatitude = endLocationLatitude, @@ -54,10 +54,11 @@ fun RoomSession.toDomainModel() = DomainSession( clientUUID = clientUUID, ) +@OptIn(ExperimentalTime::class) fun DomainSession.toRoomModel() = RoomSession( uuid = uuid, - startDateTime = startDateTime.toInstant().toEpochMilli(), - endDateTime = endDateTime?.toInstant()?.toEpochMilli(), + startDateTime = startDateTime.toEpochMilliseconds(), + endDateTime = endDateTime?.toEpochMilliseconds(), startLocationLatitude = startLocationLatitude, startLocationLongitude = startLocationLongitude, endLocationLatitude = endLocationLatitude, @@ -72,7 +73,7 @@ fun DomainSession.toRoomModel() = RoomSession( // Location fun RoomLocation.toDomainModel() = DomainLocation( latitude = latitude, - zonedDateTime = Instant.ofEpochMilli(time).atZone(ZoneOffset.UTC), + timestamp = Instant.fromEpochMilliseconds(time), longitude = longitude, speed = speed, sessionUUID = sessionUUID, @@ -86,7 +87,7 @@ fun RoomLocation.toDomainModel() = DomainLocation( fun DomainLocation.toRoomModel() = RoomLocation( sessionUUID = sessionUUID, - time = zonedDateTime.toInstant().toEpochMilli(), + time = timestamp.toEpochMilliseconds(), latitude = latitude, longitude = longitude, speed = speed, @@ -103,7 +104,7 @@ fun Location.toDomainModel( ): DomainLocation { val domainLocation = DomainLocation( sessionUUID = sessionUUID, - zonedDateTime = Instant.ofEpochMilli(time).atZone(ZoneOffset.UTC), + timestamp = Instant.fromEpochMilliseconds(time), latitude = latitude.toFloat(), longitude = longitude.toFloat() ) @@ -123,7 +124,7 @@ fun Location.toDomainModel( // Rotation fun RoomSensorEvent.toDomainModel() = DomainSensorEvent( - zonedDateTime = Instant.ofEpochMilli(time).atZone(ZoneOffset.UTC), + timestamp = Instant.fromEpochMilliseconds(time), sessionUUID = sessionUUID, accuracy = accuracy, x = x, @@ -134,7 +135,7 @@ fun RoomSensorEvent.toDomainModel() = DomainSensorEvent( fun DomainSensorEvent.toRoomModel() = RoomSensorEvent( sessionUUID = sessionUUID, - time = zonedDateTime.toInstant().toEpochMilli(), + time = timestamp.toEpochMilliseconds(), type = type, accuracy = accuracy, x = x, @@ -145,7 +146,7 @@ fun DomainSensorEvent.toRoomModel() = RoomSensorEvent( // Sensors fun SensorEvent.toDomainModel(sessionUUID: String) = DomainSensorEvent( sessionUUID = sessionUUID, - zonedDateTime = Instant.ofEpochMilli(sensorTimestampToAbsoluteTime(timestamp)).atZone(ZoneOffset.UTC), + timestamp = Instant.fromEpochMilliseconds(sensorTimestampToAbsoluteTime(timestamp)), type = sensor.type.toByte(), accuracy = accuracy.toByte(), x = values[0], @@ -161,14 +162,14 @@ fun RoomPreferences.toDomainModel() = DomainPreferences( showAds = showAds, theme = theme, dynamicColorEnabled = dynamicColorEnabled, - lastUpdate = Instant.ofEpochMilli(lastUpdate).toZonedDateTime(), - lastUpdateToAnalytics = lastUpdateToAnalytics?.let { return@let Instant.ofEpochMilli(it).toZonedDateTime() }, + lastUpdate = Instant.fromEpochMilliseconds(lastUpdate), + lastUpdateToAnalytics = lastUpdateToAnalytics?.let { return@let Instant.fromEpochMilliseconds(it) }, shouldSync = shouldSync, ) fun DomainPreferences.toRoomModel( userUUID: String, - lastUpdate: ZonedDateTime = this.lastUpdate + lastUpdate: Instant = this.lastUpdate ) = RoomPreferences( userUUID = userUUID, analyticsEnabled = analyticsEnabled, @@ -176,8 +177,8 @@ fun DomainPreferences.toRoomModel( showAds = showAds, theme = theme, dynamicColorEnabled = dynamicColorEnabled, - lastUpdate = lastUpdate.toInstant().toEpochMilli(), - lastUpdateToAnalytics = lastUpdateToAnalytics?.toInstant()?.toEpochMilli(), + lastUpdate = lastUpdate.toEpochMilliseconds(), + lastUpdateToAnalytics = lastUpdateToAnalytics?.toEpochMilliseconds(), shouldSync = shouldSync, ) diff --git a/app/src/main/java/illyan/jay/data/room/datasource/PreferencesRoomDataSource.kt b/app/src/main/java/illyan/jay/data/room/datasource/PreferencesRoomDataSource.kt index ccaa5bcf..2cef6bfd 100644 --- a/app/src/main/java/illyan/jay/data/room/datasource/PreferencesRoomDataSource.kt +++ b/app/src/main/java/illyan/jay/data/room/datasource/PreferencesRoomDataSource.kt @@ -27,7 +27,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) class PreferencesRoomDataSource @Inject constructor( private val preferencesDao: PreferencesDao ) { diff --git a/app/src/main/java/illyan/jay/data/room/datasource/SessionRoomDataSource.kt b/app/src/main/java/illyan/jay/data/room/datasource/SessionRoomDataSource.kt index c8092b7f..2ea0a939 100644 --- a/app/src/main/java/illyan/jay/data/room/datasource/SessionRoomDataSource.kt +++ b/app/src/main/java/illyan/jay/data/room/datasource/SessionRoomDataSource.kt @@ -26,12 +26,13 @@ import illyan.jay.data.room.toRoomModel import illyan.jay.domain.model.DomainSession import kotlinx.coroutines.flow.map import timber.log.Timber -import java.time.Instant -import java.time.ZoneId -import java.time.ZonedDateTime -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Session disk data source using Room to communicate with the SQLite database. @@ -39,6 +40,7 @@ import javax.inject.Singleton * @property sessionDao used to insert, update, delete and query commands using Room. * @constructor Create empty Session disk data source */ +@OptIn(ExperimentalTime::class, ExperimentalUuidApi::class) @Singleton class SessionRoomDataSource @Inject constructor( private val sessionDao: SessionDao, @@ -119,13 +121,11 @@ class SessionRoomDataSource @Inject constructor( ownerUUID: String? = null, clientUUID: String? = null, ): String { - val uuid = UUID.randomUUID().toString() + val uuid = Uuid.random().toString() sessionDao.insertSession( RoomSession( uuid = uuid, - startDateTime = Instant.now() - .atZone(ZoneId.systemDefault()) - .toInstant().toEpochMilli(), + startDateTime = Clock.System.now().toEpochMilliseconds(), endDateTime = null, ownerUUID = ownerUUID, clientUUID = clientUUID @@ -168,7 +168,7 @@ class SessionRoomDataSource @Inject constructor( */ fun stopSession( session: DomainSession, - endTime: ZonedDateTime = Instant.now().atZone(ZoneId.systemDefault()) + endTime: Instant = Clock.System.now() ): Long { if (session.endDateTime == null) session.endDateTime = endTime return saveSession(session) @@ -183,7 +183,7 @@ class SessionRoomDataSource @Inject constructor( */ fun stopSessions( sessions: List, - endTime: ZonedDateTime = Instant.now().atZone(ZoneId.systemDefault()) + endTime: Instant = Clock.System.now() ) { sessions.forEach { if (it.endDateTime == null) it.endDateTime = endTime } saveSessions(sessions) @@ -240,4 +240,3 @@ class SessionRoomDataSource @Inject constructor( sessionDao.saveDistanceForSession(sessionUUID, distance) } } - diff --git a/app/src/main/java/illyan/jay/data/sensor/SensorFusion.kt b/app/src/main/java/illyan/jay/data/sensor/SensorFusion.kt index ebc3d0b4..132bf3ff 100644 --- a/app/src/main/java/illyan/jay/data/sensor/SensorFusion.kt +++ b/app/src/main/java/illyan/jay/data/sensor/SensorFusion.kt @@ -24,7 +24,9 @@ import illyan.jay.domain.model.DomainSensorEvent import timber.log.Timber import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) object SensorFusion { fun fuseSensorsWithInterval( interval: Duration = 10.milliseconds, @@ -37,7 +39,7 @@ object SensorFusion { angAccel: List, ): List { val allTimestamps = (accRaw + accSmooth + dirX + dirY + dirZ + angVel + angAccel) - .map { it.zonedDateTime.toInstant().toEpochMilli() } + .map { it.timestamp.toEpochMilliseconds() } .distinct() .sorted() @@ -70,7 +72,7 @@ object SensorFusion { angVel: List, angAccel: List, intervals: List = (accRaw + accSmooth + dirX + dirY + dirZ + angVel + angAccel) - .map { it.zonedDateTime.toInstant().toEpochMilli() } + .map { it.timestamp.toEpochMilliseconds() } .distinct() .sorted() ): List { @@ -109,11 +111,11 @@ object SensorFusion { val firstEvent = events.first() val lastEvent = events.last() return timestamps.map { timestamp -> - val beforeEvent = events.firstOrNull { it.zonedDateTime.toInstant().toEpochMilli() <= timestamp } ?: firstEvent - val afterEvent = events.firstOrNull { it.zonedDateTime.toInstant().toEpochMilli() >= timestamp } ?: lastEvent + val beforeEvent = events.firstOrNull { it.timestamp.toEpochMilliseconds() <= timestamp } ?: firstEvent + val afterEvent = events.firstOrNull { it.timestamp.toEpochMilliseconds() >= timestamp } ?: lastEvent if (beforeEvent == afterEvent) return@map Triple(beforeEvent.x.toDouble(), beforeEvent.y.toDouble(), beforeEvent.z.toDouble()) - val fraction = (timestamp - beforeEvent.zonedDateTime.toInstant().toEpochMilli()).toFloat() / - (afterEvent.zonedDateTime.toInstant().toEpochMilli() - beforeEvent.zonedDateTime.toInstant().toEpochMilli()).toFloat() + val fraction = (timestamp - beforeEvent.timestamp.toEpochMilliseconds()).toFloat() / + (afterEvent.timestamp - beforeEvent.timestamp).inWholeMilliseconds val interpolatedEventX = lerp( beforeEvent.x, afterEvent.x, diff --git a/app/src/main/java/illyan/jay/data/serializers/ZonedDateTimeSerializer.kt b/app/src/main/java/illyan/jay/data/serializers/InstantSerializer.kt similarity index 72% rename from app/src/main/java/illyan/jay/data/serializers/ZonedDateTimeSerializer.kt rename to app/src/main/java/illyan/jay/data/serializers/InstantSerializer.kt index 57bc7108..bccd2140 100644 --- a/app/src/main/java/illyan/jay/data/serializers/ZonedDateTimeSerializer.kt +++ b/app/src/main/java/illyan/jay/data/serializers/InstantSerializer.kt @@ -25,16 +25,22 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant -object ZonedDateTimeSerializer : KSerializer { +@OptIn(ExperimentalTime::class) +object InstantSerializer : KSerializer { override val descriptor: SerialDescriptor - get() = PrimitiveSerialDescriptor("java.time.ZonedDateTime", PrimitiveKind.STRING) + get() = PrimitiveSerialDescriptor( + "illyan.jay.data.serializers.InstantSerializer", + PrimitiveKind.STRING + ) - override fun deserialize(decoder: Decoder): ZonedDateTime { - return ZonedDateTime.parse(decoder.decodeString()) + override fun deserialize(decoder: Decoder): Instant { + return Instant.parse(decoder.decodeString()) } - override fun serialize(encoder: Encoder, value: ZonedDateTime) { + override fun serialize(encoder: Encoder, value: Instant) { encoder.encodeString(value.toString()) } } diff --git a/app/src/main/java/illyan/jay/data/serializers/ZonedDateTimeNullableSerializer.kt b/app/src/main/java/illyan/jay/data/serializers/ZonedDateTimeNullableSerializer.kt deleted file mode 100644 index c4865cc5..00000000 --- a/app/src/main/java/illyan/jay/data/serializers/ZonedDateTimeNullableSerializer.kt +++ /dev/null @@ -1,24 +0,0 @@ -package illyan.jay.data.serializers - -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import java.time.ZonedDateTime - -object ZonedDateTimeNullableSerializer : KSerializer { - override val descriptor: SerialDescriptor - get() = PrimitiveSerialDescriptor("java.time.ZonedDateTime?", PrimitiveKind.STRING) - - override fun deserialize(decoder: Decoder): ZonedDateTime? { - val decoded = decoder.decodeString() - if (decoded == "null") return null - return ZonedDateTime.parse(decoded) - } - - override fun serialize(encoder: Encoder, value: ZonedDateTime?) { - encoder.encodeString(value.toString()) - } -} diff --git a/app/src/main/java/illyan/jay/di/AppModule.kt b/app/src/main/java/illyan/jay/di/AppModule.kt index 370e2a35..d8ddbf0d 100644 --- a/app/src/main/java/illyan/jay/di/AppModule.kt +++ b/app/src/main/java/illyan/jay/di/AppModule.kt @@ -26,10 +26,10 @@ import androidx.core.graphics.drawable.IconCompat import androidx.lifecycle.ProcessLifecycleOwner import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.google.android.gms.location.LocationServices -import com.google.firebase.analytics.ktx.analytics -import com.google.firebase.crashlytics.ktx.crashlytics -import com.google.firebase.ktx.Firebase -import com.google.firebase.perf.ktx.performance +import com.google.firebase.Firebase +import com.google.firebase.analytics.analytics +import com.google.firebase.crashlytics.crashlytics +import com.google.firebase.perf.performance import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/java/illyan/jay/di/FirebaseModule.kt b/app/src/main/java/illyan/jay/di/FirebaseModule.kt index 74e41e94..e6251435 100644 --- a/app/src/main/java/illyan/jay/di/FirebaseModule.kt +++ b/app/src/main/java/illyan/jay/di/FirebaseModule.kt @@ -21,16 +21,16 @@ package illyan.jay.di import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.NetworkRequest -import com.google.firebase.auth.ktx.auth +import com.google.firebase.Firebase +import com.google.firebase.auth.auth import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestoreSettings import com.google.firebase.firestore.PersistentCacheSettings -import com.google.firebase.firestore.ktx.firestore -import com.google.firebase.ktx.Firebase +import com.google.firebase.firestore.firestore import com.google.firebase.ml.modeldownloader.FirebaseModelDownloader import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import com.google.firebase.remoteconfig.remoteConfig +import com.google.firebase.remoteconfig.remoteConfigSettings import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/java/illyan/jay/di/FirestoreModule.kt b/app/src/main/java/illyan/jay/di/FirestoreModule.kt index 1c079b30..a59fd329 100644 --- a/app/src/main/java/illyan/jay/di/FirestoreModule.kt +++ b/app/src/main/java/illyan/jay/di/FirestoreModule.kt @@ -23,9 +23,9 @@ import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.MetadataChanges import com.google.firebase.firestore.QuerySnapshot -import com.google.firebase.firestore.ktx.snapshots -import com.google.firebase.firestore.ktx.toObject -import com.google.firebase.firestore.ktx.toObjects +import com.google.firebase.firestore.snapshots +import com.google.firebase.firestore.toObject +import com.google.firebase.firestore.toObjects import dagger.Module import dagger.Provides import dagger.hilt.InstallIn diff --git a/app/src/main/java/illyan/jay/domain/interactor/AuthInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/AuthInteractor.kt index 73b918a7..b8b47cec 100644 --- a/app/src/main/java/illyan/jay/domain/interactor/AuthInteractor.kt +++ b/app/src/main/java/illyan/jay/domain/interactor/AuthInteractor.kt @@ -20,19 +20,20 @@ package illyan.jay.domain.interactor import android.app.Activity import androidx.compose.runtime.mutableStateListOf -import com.google.android.gms.auth.api.signin.GoogleSignIn -import com.google.android.gms.auth.api.signin.GoogleSignInAccount -import com.google.android.gms.auth.api.signin.GoogleSignInClient -import com.google.android.gms.auth.api.signin.GoogleSignInOptions -import com.google.android.gms.common.api.ApiException -import com.google.android.gms.tasks.Task +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.exceptions.GetCredentialException +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.firebase.analytics.FirebaseAnalytics -import com.google.firebase.analytics.ktx.logEvent +import com.google.firebase.analytics.logEvent import com.google.firebase.auth.AuthCredential import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.GoogleAuthProvider import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.google.firebase.remoteconfig.ktx.get +import com.google.firebase.remoteconfig.get +import illyan.jay.BuildConfig import illyan.jay.MainActivity import illyan.jay.di.CoroutineScopeIO import illyan.jay.util.awaitOperations @@ -74,9 +75,6 @@ class AuthInteractor @Inject constructor( private val _userDisplayNameStateFlow = MutableStateFlow(auth.currentUser?.displayName) val userDisplayNameStateFlow = _userDisplayNameStateFlow.asStateFlow() - private val _googleSignInClient = MutableStateFlow(null) - private val googleSignInClient = _googleSignInClient.asStateFlow() - private val googleAuthStateListeners = mutableStateListOf<(Int) -> Unit>() val isUserSignedIn get() = auth.currentUser != null @@ -109,6 +107,8 @@ class AuthInteractor @Inject constructor( val size = onSignOutListeners.size if (size == 0) { Timber.i("No sign out listeners detected, signing out user ${userUUID?.take(4)}") + auth.signOut() + _isSigningOut.update { false } } else { Timber.i("Notifying sign out listeners") coroutineScopeIO.launch { @@ -120,71 +120,71 @@ class AuthInteractor @Inject constructor( } } Timber.i("All listeners notified, signing out user ${userUUID?.take(4)}") + auth.signOut() + _isSigningOut.update { false } } - auth.signOut() - googleSignInClient.value?.signOut() - _isSigningOut.update { false } } } fun signInViaGoogle(activity: MainActivity) { if (isUserSignedIn) return - if (_googleSignInClient.value == null) { - remoteConfig.fetchAndActivate().addOnSuccessListener { - remoteConfig.ensureInitialized().addOnSuccessListener { - val defaultWebClientId = remoteConfig["default_web_client_id"].asString() - if (defaultWebClientId.isEmpty()) { - // TODO: throw error or show error message (use local broadcast manager to send a broadcast to UI?) - } else { - _googleSignInClient.update { - GoogleSignIn.getClient( - activity, - GoogleSignInOptions - .Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) - .requestIdToken(defaultWebClientId) - .requestEmail() - .build() - ) + remoteConfig.fetchAndActivate().addOnSuccessListener { + remoteConfig.ensureInitialized().addOnSuccessListener { + val serverClientId = remoteConfig["default_web_client_id"].asString() + if (serverClientId.isBlank()) { + Timber.e("Server client ID is blank in Remote Config") + googleAuthStateListeners.forEach { it(-1) } + googleAuthStateListeners.clear() + analytics.logEvent(FirebaseAnalytics.Event.LOGIN) { + param(FirebaseAnalytics.Param.METHOD, "Google") + } + } else { + coroutineScopeIO.launch { + try { + val credentialManager = CredentialManager.create(activity) + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) + .setServerClientId(serverClientId) + .setAutoSelectEnabled(true) + .build() + val request = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + val result = credentialManager.getCredential(activity, request) + val credential = result.credential + if (credential is CustomCredential && + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + ) { + val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + val idToken = googleIdTokenCredential.idToken + signInWithCredential( + activity, + GoogleAuthProvider.getCredential(idToken, null) + ) + } else { + Timber.e("Unexpected credential type: ${'$'}{credential.javaClass.name}") + } + } catch (e: GetCredentialException) { + Timber.e(e, "Credential retrieval failed") + googleAuthStateListeners.forEach { it(-1) } + googleAuthStateListeners.clear() + analytics.logEvent(FirebaseAnalytics.Event.LOGIN) { + param(FirebaseAnalytics.Param.METHOD, "Google") + } + } catch (e: Exception) { + Timber.e(e, "Credential retrieval error") + googleAuthStateListeners.forEach { it(-1) } + googleAuthStateListeners.clear() + analytics.logEvent(FirebaseAnalytics.Event.LOGIN) { + param(FirebaseAnalytics.Param.METHOD, "Google") + } } - activity.googleSignInLauncher.launch(googleSignInClient.value!!.signInIntent) } } } - } else { - activity.googleSignInLauncher.launch(googleSignInClient.value!!.signInIntent) } } - fun handleGoogleSignInResult( - activity: Activity, - completedTask: Task - ) { - try { - val account = completedTask.getResult(ApiException::class.java) - account.idToken?.let { - signInWithCredential( - activity, - GoogleAuthProvider.getCredential(it, null) - ) - } - } catch (e: ApiException) { - // The ApiException status code indicates the detailed failure reason. - // Please refer to the GoogleSignInStatusCodes class reference for more information. - googleAuthStateListeners.forEach { it(e.statusCode) } - googleAuthStateListeners.clear() - Timber.e( - e, - "signInResult:failed code = ${e.statusCode}\n" + - "Used api key: " + - remoteConfig["default_web_client_id"].asString() - .take(4) + "..." + - "\n${e.message}" - ) - analytics.logEvent(FirebaseAnalytics.Event.LOGIN) { - param(FirebaseAnalytics.Param.METHOD, "Google") - } - } - } private fun signInWithCredential( activity: Activity, @@ -196,7 +196,7 @@ class AuthInteractor @Inject constructor( Timber.i("Firebase authentication successful") } else { // If sign in fails, display a message to the user. - Timber.e(task.exception, task.exception?.message) + Timber.e(task.exception) } } } diff --git a/app/src/main/java/illyan/jay/domain/interactor/ModelInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/ModelInteractor.kt index e0a95147..6f611170 100644 --- a/app/src/main/java/illyan/jay/domain/interactor/ModelInteractor.kt +++ b/app/src/main/java/illyan/jay/domain/interactor/ModelInteractor.kt @@ -25,7 +25,6 @@ import com.google.firebase.ml.modeldownloader.DownloadType import illyan.jay.data.firebaseml.datasource.FirebaseMLDataSource import illyan.jay.data.sensor.SensorFusion import illyan.jay.di.CoroutineScopeIO -import illyan.jay.util.toZonedDateTime import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -36,11 +35,12 @@ import org.tensorflow.lite.Interpreter import timber.log.Timber import java.nio.ByteBuffer import java.nio.ByteOrder -import java.time.Instant -import java.time.ZonedDateTime import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) @Singleton class ModelInteractor @Inject constructor( private val firebaseMLDataSource: FirebaseMLDataSource, @@ -75,10 +75,10 @@ class ModelInteractor @Inject constructor( suspend fun getFilteredDriverAggression( modelName: String, sessionUUID: String - ): Flow> { + ): Flow> { Timber.d("Filtering aggression values for session ${sessionUUID.take(4)}") - val flow = MutableStateFlow>(emptyMap()) - val outputMap = mutableMapOf() + val flow = MutableStateFlow>(emptyMap()) + val outputMap = mutableMapOf() downloadedModels.first().firstOrNull { it.name == modelName }?.let { model -> val modelFile = model.file if (modelFile != null) { @@ -139,7 +139,7 @@ class ModelInteractor @Inject constructor( } Timber.v("Model outputs: ${outputs.distinct().joinToString()}") chunk.forEach { advancedImuSensorData -> - outputMap[Instant.ofEpochMilli(advancedImuSensorData.timestamp).toZonedDateTime()] = outputs[0].toDouble() + outputMap[Instant.fromEpochMilliseconds(advancedImuSensorData.timestamp)] = outputs[0].toDouble() } flow.update { outputMap } } @@ -153,4 +153,4 @@ class ModelInteractor @Inject constructor( } return flow.asStateFlow() } -} \ No newline at end of file +} 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 844c889e..8c267cf3 100644 --- a/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt +++ b/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt @@ -16,6 +16,8 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.domain.interactor import com.google.firebase.firestore.FirebaseFirestore @@ -48,9 +50,11 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.ExperimentalTime +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Session interactor is a layer which aims to be the intermediary @@ -59,6 +63,7 @@ import javax.inject.Singleton * @property sessionRoomDataSource local database * @constructor Create empty Session interactor */ +@OptIn(ExperimentalUuidApi::class) @Singleton class SessionInteractor @Inject constructor( private val sessionRoomDataSource: SessionRoomDataSource, @@ -317,7 +322,7 @@ class SessionInteractor @Inject constructor( appSettingsDataSource.appSettings.first { settings -> Timber.d("Client UUID = ${settings.clientUUID}") if (settings.clientUUID == null) { - val clientUUID = UUID.randomUUID().toString() + val clientUUID = Uuid.random().toString() Timber.d("Generating new client UUID: $clientUUID") appSettingsDataSource.updateAppSettings { it.copy(clientUUID = clientUUID) @@ -377,7 +382,7 @@ class SessionInteractor @Inject constructor( ) { locationRoomDataSource.getLocations(session.uuid).first { locations -> val startLocationLatLng = locations.minByOrNull { - it.zonedDateTime.toInstant().toEpochMilli() + it.timestamp.toEpochMilliseconds() }?.latLng session.startLocation = startLocationLatLng startLocationLatLng?.let { @@ -421,11 +426,11 @@ class SessionInteractor @Inject constructor( ) { locationRoomDataSource.getLocations(session.uuid).first { locations -> val endLocation = locations.maxByOrNull { - it.zonedDateTime.toInstant().toEpochMilli() + it.timestamp.toEpochMilliseconds() } val endLocationLatLng = endLocation?.latLng session.endLocation = endLocationLatLng - if (session.endDateTime == null) session.endDateTime = endLocation?.zonedDateTime + if (session.endDateTime == null) session.endDateTime = endLocation?.timestamp endLocationLatLng?.let { coroutineScopeIO.launch { sessionRoomDataSource.saveEndLocationForSession( diff --git a/app/src/main/java/illyan/jay/domain/interactor/SettingsInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/SettingsInteractor.kt index 3597768a..bb74abbc 100644 --- a/app/src/main/java/illyan/jay/domain/interactor/SettingsInteractor.kt +++ b/app/src/main/java/illyan/jay/domain/interactor/SettingsInteractor.kt @@ -30,7 +30,10 @@ import timber.log.Timber import java.time.ZonedDateTime import javax.inject.Inject import javax.inject.Singleton +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) @Singleton class SettingsInteractor @Inject constructor( private val appSettingsDataSource: AppSettingsDataSource, @@ -70,7 +73,7 @@ class SettingsInteractor @Inject constructor( appSettingsDataSource.updateAppPreferences { it.copy( analyticsEnabled = value, - lastUpdateToAnalytics = ZonedDateTime.now() + lastUpdateToAnalytics = Clock.System.now() ) } } diff --git a/app/src/main/java/illyan/jay/domain/model/DomainLocation.kt b/app/src/main/java/illyan/jay/domain/model/DomainLocation.kt index 99691597..b63c099e 100644 --- a/app/src/main/java/illyan/jay/domain/model/DomainLocation.kt +++ b/app/src/main/java/illyan/jay/domain/model/DomainLocation.kt @@ -23,11 +23,13 @@ import com.google.android.gms.maps.model.LatLng import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant @Parcelize -data class DomainLocation( +data class DomainLocation @OptIn(ExperimentalTime::class) constructor( var sessionUUID: String, - val zonedDateTime: ZonedDateTime, + val timestamp: Instant, val latitude: Float, val longitude: Float, var speed: Float = Float.MIN_VALUE, diff --git a/app/src/main/java/illyan/jay/domain/model/DomainPreferences.kt b/app/src/main/java/illyan/jay/domain/model/DomainPreferences.kt index 81fb092f..da412369 100644 --- a/app/src/main/java/illyan/jay/domain/model/DomainPreferences.kt +++ b/app/src/main/java/illyan/jay/domain/model/DomainPreferences.kt @@ -18,35 +18,38 @@ package illyan.jay.domain.model -import illyan.jay.data.serializers.ZonedDateTimeNullableSerializer -import illyan.jay.data.serializers.ZonedDateTimeSerializer +import illyan.jay.data.serializers.InstantSerializer import kotlinx.serialization.Serializable import java.time.ZonedDateTime +import kotlin.time.Clock +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) @Serializable -data class DomainPreferences( +data class DomainPreferences @OptIn(ExperimentalTime::class) constructor( val userUUID: String? = null, val analyticsEnabled: Boolean = false, val freeDriveAutoStart: Boolean = false, val showAds: Boolean = false, val theme: Theme = Theme.System, val dynamicColorEnabled: Boolean = true, - @Serializable(with = ZonedDateTimeSerializer::class) - val lastUpdate: ZonedDateTime = ZonedDateTime.now(), - @Serializable(with = ZonedDateTimeNullableSerializer::class) - val lastUpdateToAnalytics: ZonedDateTime? = null, + @Serializable(with = InstantSerializer::class) + val lastUpdate: Instant = Clock.System.now(), + @Serializable(with = InstantSerializer::class) + val lastUpdateToAnalytics: Instant? = null, val shouldSync: Boolean = false, ) { fun isBefore(other: DomainPreferences): Boolean { - return lastUpdate.toInstant().toEpochMilli() < other.lastUpdate.toInstant().toEpochMilli() + return lastUpdate < other.lastUpdate } fun isAfter(other: DomainPreferences): Boolean { - return lastUpdate.toInstant().toEpochMilli() > other.lastUpdate.toInstant().toEpochMilli() + return lastUpdate > other.lastUpdate } companion object { val Default = DomainPreferences() } -} \ No newline at end of file +} diff --git a/app/src/main/java/illyan/jay/domain/model/DomainSensorEvent.kt b/app/src/main/java/illyan/jay/domain/model/DomainSensorEvent.kt index 74fc4050..6b03a4a5 100644 --- a/app/src/main/java/illyan/jay/domain/model/DomainSensorEvent.kt +++ b/app/src/main/java/illyan/jay/domain/model/DomainSensorEvent.kt @@ -18,11 +18,12 @@ package illyan.jay.domain.model -import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant -data class DomainSensorEvent( +data class DomainSensorEvent @OptIn(ExperimentalTime::class) constructor( val sessionUUID: String, - val zonedDateTime: ZonedDateTime, + val timestamp: Instant, val type: Byte, val accuracy: Byte, // enum val x: Float, diff --git a/app/src/main/java/illyan/jay/domain/model/DomainSession.kt b/app/src/main/java/illyan/jay/domain/model/DomainSession.kt index 36a8eef9..e7eb4148 100644 --- a/app/src/main/java/illyan/jay/domain/model/DomainSession.kt +++ b/app/src/main/java/illyan/jay/domain/model/DomainSession.kt @@ -20,11 +20,13 @@ package illyan.jay.domain.model import com.google.android.gms.maps.model.LatLng import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant -data class DomainSession( +data class DomainSession @OptIn(ExperimentalTime::class) constructor( var uuid: String, - val startDateTime: ZonedDateTime, - var endDateTime: ZonedDateTime?, + val startDateTime: Instant, + var endDateTime: Instant?, var startLocationLatitude: Float? = null, var startLocationLongitude: Float? = null, var endLocationLatitude: Float? = null, diff --git a/app/src/main/java/illyan/jay/ui/about/About.kt b/app/src/main/java/illyan/jay/ui/about/About.kt index d1be10a7..9318a0ab 100644 --- a/app/src/main/java/illyan/jay/ui/about/About.kt +++ b/app/src/main/java/illyan/jay/ui/about/About.kt @@ -65,6 +65,7 @@ import com.google.android.gms.ads.AdRequest import com.google.android.gms.ads.AdSize import com.google.android.gms.ads.AdView import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.generated.destinations.LibrariesDialogScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import illyan.jay.BuildConfig @@ -74,15 +75,13 @@ import illyan.jay.ui.components.JayDialogContent import illyan.jay.ui.components.JayDialogContentPadding import illyan.jay.ui.components.MenuButton import illyan.jay.ui.components.PreviewAccessibility -import illyan.jay.ui.destinations.LibrariesDialogScreenDestination import illyan.jay.ui.profile.ProfileNavGraph import illyan.jay.ui.settings.user.BooleanSetting import illyan.jay.ui.theme.JayTheme import illyan.jay.ui.theme.signaturePink import illyan.jay.util.TestAdUnitIds -@ProfileNavGraph -@Destination +@Destination @Composable fun AboutDialogScreen( viewModel: AboutViewModel = hiltViewModel(), diff --git a/app/src/main/java/illyan/jay/ui/about/AboutViewModel.kt b/app/src/main/java/illyan/jay/ui/about/AboutViewModel.kt index a2b2f206..0b49b74a 100644 --- a/app/src/main/java/illyan/jay/ui/about/AboutViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/about/AboutViewModel.kt @@ -21,7 +21,7 @@ package illyan.jay.ui.about import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.firebase.remoteconfig.FirebaseRemoteConfig -import com.google.firebase.remoteconfig.ktx.get +import com.google.firebase.remoteconfig.get import dagger.hilt.android.lifecycle.HiltViewModel import illyan.jay.domain.interactor.SettingsInteractor import illyan.jay.domain.model.DomainPreferences diff --git a/app/src/main/java/illyan/jay/ui/components/AvatarAsyncImage.kt b/app/src/main/java/illyan/jay/ui/components/AvatarAsyncImage.kt index 2a259300..57407ff9 100644 --- a/app/src/main/java/illyan/jay/ui/components/AvatarAsyncImage.kt +++ b/app/src/main/java/illyan/jay/ui/components/AvatarAsyncImage.kt @@ -35,9 +35,9 @@ import androidx.compose.ui.res.stringResource import coil.compose.AsyncImagePainter import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImageContent -import com.google.accompanist.placeholder.PlaceholderHighlight -import com.google.accompanist.placeholder.material.placeholder -import com.google.accompanist.placeholder.material.shimmer +import com.eygraber.compose.placeholder.PlaceholderHighlight +import com.eygraber.compose.placeholder.material3.placeholder +import com.eygraber.compose.placeholder.material3.shimmer import illyan.jay.R import illyan.jay.ui.theme.JayTheme diff --git a/app/src/main/java/illyan/jay/ui/components/FadingEdge.kt b/app/src/main/java/illyan/jay/ui/components/FadingEdge.kt index 46c8d9fa..05f8c008 100644 --- a/app/src/main/java/illyan/jay/ui/components/FadingEdge.kt +++ b/app/src/main/java/illyan/jay/ui/components/FadingEdge.kt @@ -20,7 +20,7 @@ package illyan.jay.ui.components import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.material.MaterialTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier @@ -48,7 +48,7 @@ fun Modifier.horizontalFadingEdge( value = length } ) { - val color = edgeColor ?: MaterialTheme.colors.surface + val color = edgeColor ?: MaterialTheme.colorScheme.surface drawWithContent { val lengthValue = length.toPx() @@ -102,7 +102,7 @@ fun Modifier.verticalFadingEdge( value = length } ) { - val color = edgeColor ?: MaterialTheme.colors.surface + val color = edgeColor ?: MaterialTheme.colorScheme.surface drawWithContent { val lengthValue = length.toPx() @@ -156,7 +156,7 @@ fun Modifier.verticalFadingEdge( value = length } ) { - val color = edgeColor ?: MaterialTheme.colors.surface + val color = edgeColor ?: MaterialTheme.colorScheme.surface drawWithContent { val topFadingEdgeStrength by derivedStateOf { diff --git a/app/src/main/java/illyan/jay/ui/components/JayDialogContent.kt b/app/src/main/java/illyan/jay/ui/components/JayDialogContent.kt index f0620739..6e3d1690 100644 --- a/app/src/main/java/illyan/jay/ui/components/JayDialogContent.kt +++ b/app/src/main/java/illyan/jay/ui/components/JayDialogContent.kt @@ -42,6 +42,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -51,7 +53,7 @@ import androidx.compose.ui.unit.dp @Composable fun JayDialogContent( modifier: Modifier = Modifier, - textModifier: Modifier = Modifier.heightIn(max = (LocalConfiguration.current.screenHeightDp * 0.66f).dp), + textModifier: Modifier = Modifier.heightIn(max = (with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() } * 0.66f)), icon: @Composable (BoxScope.() -> Unit)? = null, title: @Composable (BoxScope.() -> Unit)? = null, text: @Composable (BoxScope.() -> Unit)? = null, @@ -167,8 +169,7 @@ fun JayDialogSurface( tonalElevation: Dp = AlertDialogDefaults.TonalElevation, content: @Composable () -> Unit ) { - val configuration = LocalConfiguration.current - val screenWidthDp by remember { derivedStateOf { configuration.screenWidthDp.dp } } + val screenWidthDp = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.width.toDp() } // Increase width to edge of the screen until reaching DialogMaxWidth Surface( modifier = modifier.dialogWidth(screenWidthDp), diff --git a/app/src/main/java/illyan/jay/ui/components/MenuButton.kt b/app/src/main/java/illyan/jay/ui/components/MenuButton.kt index a0579e22..88fe385d 100644 --- a/app/src/main/java/illyan/jay/ui/components/MenuButton.kt +++ b/app/src/main/java/illyan/jay/ui/components/MenuButton.kt @@ -36,7 +36,10 @@ fun MenuButton( modifier = modifier, onClick = onClick, ) { - Text(text = text) + Text( + modifier = Modifier.weight(1f, fill = false), + text = text + ) Icon( imageVector = Icons.Rounded.ChevronRight, contentDescription = "", ) diff --git a/app/src/main/java/illyan/jay/ui/freedrive/FreeDrive.kt b/app/src/main/java/illyan/jay/ui/freedrive/FreeDrive.kt index 3e958c6d..ff680099 100644 --- a/app/src/main/java/illyan/jay/ui/freedrive/FreeDrive.kt +++ b/app/src/main/java/illyan/jay/ui/freedrive/FreeDrive.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -85,8 +86,7 @@ fun calculatePaddingOffset(): PaddingValues { } @OptIn(ExperimentalPermissionsApi::class) -@MenuNavGraph -@Destination +@Destination @Composable fun FreeDrive( viewModel: FreeDriveViewModel = hiltViewModel(), @@ -97,9 +97,10 @@ fun FreeDrive( AppSettings.default.preferences.freeDriveAutoStart ) val cameraPadding by cameraPadding.collectAsStateWithLifecycle() + val density = LocalDensity.current.density LaunchedEffect(cameraPadding) { viewModel.followingPaddingOffset = - (cameraPadding + calculatePaddingOffset()).toEdgeInsets(density.value) + (cameraPadding + calculatePaddingOffset()).toEdgeInsets(density) } SheetScreenBackPressHandler(destinationsNavigator = destinationsNavigator) val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) diff --git a/app/src/main/java/illyan/jay/ui/home/Home.kt b/app/src/main/java/illyan/jay/ui/home/Home.kt index 346c14f8..2515e809 100644 --- a/app/src/main/java/illyan/jay/ui/home/Home.kt +++ b/app/src/main/java/illyan/jay/ui/home/Home.kt @@ -17,7 +17,6 @@ */ @file:OptIn( - ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class ) @@ -28,7 +27,6 @@ import android.Manifest import android.app.Activity import android.content.Context import android.content.Intent -import android.content.res.Configuration import android.net.Uri import android.os.Parcelable import androidx.compose.animation.AnimatedVisibility @@ -38,61 +36,61 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.BottomSheetScaffold -import androidx.compose.material.BottomSheetState -import androidx.compose.material.BottomSheetValue -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Surface import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material.rememberBottomSheetScaffoldState -import androidx.compose.material.rememberBottomSheetState +import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -100,6 +98,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.mapSaver import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip @@ -112,11 +111,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.SoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -127,11 +126,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.max import androidx.compose.ui.zIndex -import androidx.constraintlayout.compose.ConstraintLayout import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.localbroadcastmanager.content.LocalBroadcastManager -import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.isGranted @@ -142,14 +139,13 @@ import com.mapbox.maps.MapView import com.mapbox.maps.plugin.animation.camera import com.mapbox.maps.plugin.locationcomponent.location import com.ramcosta.composedestinations.DestinationsNavHost -import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations -import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.NavGraph -import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.NavGraphs import illyan.jay.MainActivity import illyan.jay.R -import illyan.jay.ui.NavGraphs +import illyan.jay.domain.model.Theme import illyan.jay.ui.components.AvatarAsyncImage import illyan.jay.ui.components.PreviewAccessibility import illyan.jay.ui.map.BmeK @@ -162,8 +158,10 @@ import illyan.jay.ui.poi.model.Place import illyan.jay.ui.profile.ProfileDialog import illyan.jay.ui.search.SearchViewModel import illyan.jay.ui.search.SearchViewModel.Companion.KeySearchQuery +import illyan.jay.ui.theme.DarkMapStyleUrl +import illyan.jay.ui.theme.DefaultDestinationTransitions import illyan.jay.ui.theme.JayTheme -import illyan.jay.ui.theme.mapStyleUrl +import illyan.jay.ui.theme.LightMapStyleUrl import illyan.jay.util.extraOptions import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -173,11 +171,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -@RootNavGraph(start = true) -@NavGraph -annotation class HomeNavGraph( - val start: Boolean = false, -) +@NavGraph(start = true) +annotation class HomeNavGraph val RoundedCornerRadius = 24.dp val SearchBarSpaceBetween = 8.dp @@ -200,8 +195,7 @@ const val BottomSheetPartialMaxFraction = 1f private val _mapView: MutableStateFlow = MutableStateFlow(null) val mapView = _mapView.asStateFlow() -lateinit var sheetState: BottomSheetState -var isSearching: Boolean = false +lateinit var sheetState: SheetState private val _bottomSheetFraction = MutableStateFlow(0f) val bottomSheetFraction = _bottomSheetFraction.asStateFlow() @@ -276,7 +270,7 @@ fun tryFlyToLocation( extraCameraOptions: (CameraOptions.Builder) -> CameraOptions.Builder = { it }, onFly: () -> Unit = {}, ) { - if (sheetState.progress == 1f && // animation is not running + if (sheetState.currentValue == sheetState.targetValue && // animation is not running sheetState.requireOffset() >= 10f && extraCondition() ) { @@ -302,7 +296,7 @@ fun tryFlyToPath( Timber.e(IllegalArgumentException("Path is empty, flying cancelled")) return } - val canFly = sheetState.progress == 1f && + val canFly = sheetState.currentValue == sheetState.targetValue && sheetState.requireOffset() >= 10f && extraCondition() if (canFly) { @@ -324,33 +318,35 @@ fun tryFlyToPath( fun onSearchBarDrag( coroutineScope: CoroutineScope, - bottomSheetState: BottomSheetState, + bottomSheetState: SheetState, enabled: Boolean = true, onEnabledChange: (Boolean) -> Unit = {}, ) { // By dragging the search bar, we can toggle bottom sheet state if (enabled) { bottomSheetState.apply { - if (isCollapsed) { + if (currentValue != SheetValue.Expanded) { onEnabledChange(false) coroutineScope.launch { expand() } - } else if (isExpanded) { + } else { onEnabledChange(false) - coroutineScope.launch { collapse() } + coroutineScope.launch { hide() } } } Timber.d("Search bar is dragged!") + } else { + Timber.d("Search bar drag is disabled, not toggling bottom sheet state!") } } fun calculateCornerRadius( - bottomSheetState: BottomSheetState, + bottomSheetState: SheetState, maxCornerRadius: Dp = RoundedCornerRadius, minCornerRadius: Dp = 0.dp, threshold: Float = BottomSheetPartialExpendedFraction, fraction: Float = 0f, ): Dp { - return if (bottomSheetState.isCollapsed) { + return if (bottomSheetState.currentValue != SheetValue.Expanded) { maxCornerRadius } else { val max = BottomSheetPartialMaxFraction @@ -407,8 +403,7 @@ fun refreshCameraPadding() { } } -@HomeNavGraph(start = true) -@Destination +@Destination(start = true) @Composable fun HomeScreen( context: Context = LocalContext.current, @@ -432,28 +427,23 @@ fun HomeScreen( } onDispose { viewModel.dispose() } } - val density = LocalDensity.current.density - val configuration = LocalConfiguration.current - val maxHeight = when (configuration.orientation) { - Configuration.ORIENTATION_PORTRAIT -> configuration.screenHeightDp - Configuration.ORIENTATION_LANDSCAPE -> configuration.screenWidthDp - else -> configuration.screenHeightDp - }.dp - LaunchedEffect(density) { _density.update { density } } + val density = LocalDensity.current + val maxHeight = with (density) { LocalWindowInfo.current.containerSize.height.toDp() } + LaunchedEffect(density) { _density.update { density.density } } LaunchedEffect(maxHeight) { _screenHeight.update { maxHeight } } - ConstraintLayout( + Box( modifier = Modifier .fillMaxSize() .onGloballyPositioned { coordinates -> var topSet = false - val absoluteTopPosition = (coordinates.positionInWindow().y / density).dp + val absoluteTopPosition = with(density) { coordinates.positionInWindow().y.toDp() } if (_absoluteTop.value != absoluteTopPosition) { _absoluteTop.update { absoluteTopPosition } topSet = true } var bottomSet = false val absoluteBottomPosition = - ((coordinates.positionInWindow().y + coordinates.size.height) / density).dp + ((coordinates.positionInWindow().y + coordinates.size.height) / density.density).dp if (_absoluteBottom.value != absoluteBottomPosition) { bottomSet = true _absoluteBottom.update { absoluteBottomPosition } @@ -462,14 +452,20 @@ fun HomeScreen( refreshCameraPadding() Timber.d( "Camera bottom padding: ${ - absoluteBottomPosition - sheetState.getOffsetAsDp(density) + absoluteBottomPosition - sheetState.getOffsetAsDp(density.density) }" ) } } ) { - val (searchBar, scaffold) = createRefs() - val bottomSheetState = rememberBottomSheetState(initialValue = BottomSheetValue.Expanded) + val bottomSheetState = rememberStandardBottomSheetState( + initialValue = SheetValue.Expanded, + skipHiddenState = false, + confirmValueChange = { + Timber.v("Bottom sheet state changed to $it") + true + } + ) val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState) sheetState = bottomSheetState var isTextFieldFocused by remember { mutableStateOf(false) } @@ -486,12 +482,12 @@ fun HomeScreen( } var shouldTriggerBottomSheetOnDrag by remember { mutableStateOf(true) } val softwareKeyboardController = LocalSoftwareKeyboardController.current - val sheetCollapsing = bottomSheetState.isCollapsed + val sheetCollapsing = bottomSheetState.targetValue != SheetValue.Expanded val focusManager = LocalFocusManager.current BackPressHandler { onHomeBackPress(isTextFieldFocused, focusManager, context) } - LaunchedEffect(bottomSheetState.progress) { refreshCameraPadding() } + LaunchedEffect(bottomSheetState.currentValue) { refreshCameraPadding() } LaunchedEffect(sheetCollapsing) { onSheetStateChanged( isTextFieldFocused, @@ -524,56 +520,7 @@ fun HomeScreen( Intent.ACTION_SEARCH ) } - BottomSearchBar( - modifier = Modifier - .zIndex(1f) // Search bar is in front of everything else - .constrainAs(searchBar) { - bottom.linkTo(scaffold.bottom) - centerHorizontallyTo(parent) - } - .widthIn(max = HomeBarMaxWidth) - .imePadding() - .navigationBarsPadding(), - isUserSignedIn = isUserSignedIn, - userPhotoUrl = userPhotoUrl, - onDrag = { - onSearchBarDrag( - bottomSheetState = bottomSheetState, - enabled = shouldTriggerBottomSheetOnDrag, - onEnabledChange = { shouldTriggerBottomSheetOnDrag = it }, - coroutineScope = coroutineScope - ) - }, - bottomSheetState = bottomSheetState, - onTextFieldFocusChanged = { - isTextFieldFocused = it.hasFocus || it.isFocused - isSearching = isTextFieldFocused - if (isTextFieldFocused) { - coroutineScope.launch { - // When searching, show search results on bottom sheet - bottomSheetState.expand() - } - } - }, - onSearchQueryChanged = { searchQuery = it }, - onSearchQueried = { - LocalBroadcastManager.getInstance(context) - .sendBroadcast( - it, - SearchViewModel.KeySearchSelected, - SearchViewModel.ActionSearchSelected - ) - }, - onShowProfile = { isProfileDialogShowing = true } - ) BottomSheetScaffold( - modifier = Modifier - .constrainAs(scaffold) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - end.linkTo(parent.end) - }, sheetContent = { BottomSheetScreen( modifier = Modifier.imePadding(), @@ -586,9 +533,14 @@ fun HomeScreen( fraction = it, threshold = BottomSheetPartialExpendedFraction ) - } + }, + searchChanged = { + isTextFieldFocused = it + focusManager.clearFocus() + }, ) }, + sheetDragHandle = null, sheetPeekHeight = SearchBarHeight, scaffoldState = scaffoldState, sheetShape = RoundedCornerShape( @@ -610,17 +562,16 @@ fun HomeScreen( var didLoadInLocation by rememberSaveable { mutableStateOf(false) } var didLoadInLocationWithoutPermissions by rememberSaveable { mutableStateOf(false) } - var isMapInitialized by rememberSaveable { mutableStateOf(false) } var isMapVisible by rememberSaveable { mutableStateOf(false) } val sheetContentHeight by sheetContentHeight.collectAsStateWithLifecycle() LaunchedEffect( - bottomSheetState.getOffsetAsDp(density), + bottomSheetState.getOffsetAsDp(density.density), isMapVisible, initialLocationLoaded ) { refreshCameraPadding() // Permissions probably granted because there is a location to focus on - if (bottomSheetState.isExpanded && + if (bottomSheetState.hasExpandedState && !didLoadInLocation && cameraOptionsBuilder != null && initialLocationLoaded && @@ -630,7 +581,7 @@ fun HomeScreen( ) { Timber.d( "Focusing camera to location\n" + - "Current sheetHeight: ${bottomSheetState.getOffsetAsDp(density)}\n" + + "Current sheetHeight: ${bottomSheetState.getOffsetAsDp(density.density)}\n" + "Current sheetState:\n${sheetState}" + "Sheet content height = $sheetContentHeight" ) @@ -644,7 +595,7 @@ fun HomeScreen( } } // Permissions not granted - if (bottomSheetState.isExpanded && + if (bottomSheetState.hasExpandedState && !didLoadInLocationWithoutPermissions && !locationPermissionState.status.isGranted && isMapVisible && @@ -653,30 +604,22 @@ fun HomeScreen( ) { Timber.d( "Focusing camera to location" + - "Current sheetHeight: ${bottomSheetState.getOffsetAsDp(density)}\n" + + "Current sheetHeight: ${bottomSheetState.getOffsetAsDp(density.density)}\n" + "Current sheetState:\n${sheetState}\n" + "Sheet content height = $sheetContentHeight" ) didLoadInLocationWithoutPermissions = true flyToLocation { it.zoom(4.0) - .center(Point.fromLngLat(BmeK.longitude, BmeK.latitude)) - .padding(cameraPadding.value, context) + .center(Point.fromLngLat(BmeK.longitude, BmeK.latitude)) + .padding(cameraPadding.value, context) } } } if (initialLocationLoaded || cameraOptionsBuilder != null) { - ConstraintLayout(modifier = Modifier.fillMaxSize()) { - val (foreground, map) = createRefs() - androidx.compose.animation.AnimatedVisibility( - modifier = Modifier - .zIndex(1f) - .constrainAs(foreground) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - end.linkTo(parent.end) - }, + Box(modifier = Modifier.fillMaxSize()) { + this@Column.AnimatedVisibility( + modifier = Modifier.zIndex(1f), visible = !isMapVisible, exit = fadeOut(animationSpec = tween(800)) ) { @@ -686,17 +629,20 @@ fun HomeScreen( .background(MaterialTheme.colorScheme.background) ) } - val styleUrl by mapStyleUrl.collectAsStateWithLifecycle() + val isNight by viewModel.isNight.collectAsStateWithLifecycle() + val theme by viewModel.theme.collectAsStateWithLifecycle() + val systemInDarkMode = isSystemInDarkTheme() + val styleUrl = remember(isNight, theme, systemInDarkMode) { + when (theme ?: Theme.System) { + Theme.Dark -> DarkMapStyleUrl + Theme.Light -> LightMapStyleUrl + Theme.DayNightCycle -> if (isNight) DarkMapStyleUrl else LightMapStyleUrl + Theme.System -> if (systemInDarkMode) DarkMapStyleUrl else LightMapStyleUrl + } + } MapboxMap( // Budapest University of Technology and Economics - modifier = Modifier - .fillMaxSize() - .constrainAs(map) { - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - end.linkTo(parent.end) - }, + modifier = Modifier.fillMaxSize(), cameraOptionsBuilder = cameraOptionsBuilder?.padding( cameraPaddingValues, context ) ?: CameraOptions.Builder() @@ -712,7 +658,6 @@ fun HomeScreen( coroutineScope.launch { sheetState.expand() } }, onMapInitialized = { view -> - isMapInitialized = true _mapView.update { view } when (locationPermissionState.status) { @@ -730,6 +675,43 @@ fun HomeScreen( } } } + + BottomSearchBar( + modifier = Modifier + .zIndex(2f) // Search bar is in front of everything else + .align(Alignment.BottomCenter) + .widthIn(max = HomeBarMaxWidth), + isUserSignedIn = isUserSignedIn, + userPhotoUrl = userPhotoUrl, + onDrag = { + onSearchBarDrag( + bottomSheetState = bottomSheetState, + enabled = shouldTriggerBottomSheetOnDrag, + onEnabledChange = { shouldTriggerBottomSheetOnDrag = it }, + coroutineScope = coroutineScope + ) + }, + bottomSheetState = bottomSheetState, + onTextFieldFocusChanged = { + isTextFieldFocused = it.hasFocus || it.isFocused + if (isTextFieldFocused) { + coroutineScope.launch { + // When searching, show search results on bottom sheet + bottomSheetState.expand() + } + } + }, + onSearchQueryChanged = { searchQuery = it }, + onSearchQueried = { + LocalBroadcastManager.getInstance(context) + .sendBroadcast( + it, + SearchViewModel.KeySearchSelected, + SearchViewModel.ActionSearchSelected + ) + }, + onShowProfile = { isProfileDialogShowing = true } + ) } } @@ -739,7 +721,7 @@ private fun onHomeBackPress( context: Context, ) { Timber.d("Handling back press from Home!") - if (sheetState.isCollapsed) (context as MainActivity).moveTaskToBack(false) + if (sheetState.currentValue != SheetValue.Expanded) (context as MainActivity).moveTaskToBack(false) if (isTextFieldFocused) { // Remove the focus from the textfield focusManager.clearFocus() @@ -750,10 +732,10 @@ private fun onHomeBackPress( private suspend fun onSheetStateChanged( isTextFieldFocused: Boolean, - bottomSheetState: BottomSheetState, + bottomSheetState: SheetState, softwareKeyboardController: SoftwareKeyboardController?, ) { - if (bottomSheetState.isCollapsed) { + if (bottomSheetState.currentValue != SheetValue.Expanded) { // Close the keyboard when closing the bottom sheet if (isTextFieldFocused) { // If searching right now, expand bottom sheet @@ -768,14 +750,13 @@ private suspend fun onSheetStateChanged( fun BottomSearchBar( modifier: Modifier = Modifier, onDrag: (Float) -> Unit = {}, - bottomSheetState: BottomSheetState? = null, + bottomSheetState: SheetState? = null, onTextFieldFocusChanged: (FocusState) -> Unit = {}, onSearchQueryChanged: (String) -> Unit = {}, onSearchQueried: (String) -> Unit = {}, isUserSignedIn: Boolean = false, userPhotoUrl: Uri? = null, onShowProfile: () -> Unit = {}, - onDragAreaOffset: Dp = 48.dp, ) { val elevation = 8.dp val cardElevation = CardDefaults.cardElevation( @@ -792,9 +773,12 @@ fun BottomSearchBar( ) val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current - val sheetCollapsing by remember { derivedStateOf { bottomSheetState?.isCollapsed ?: false } } - LaunchedEffect(sheetCollapsing) { - if (sheetCollapsing) { + // We should help gestures when not searching + // and bottomSheet is collapsed or collapsing + val sheetHidden = remember(bottomSheetState?.currentValue) { bottomSheetState?.currentValue != SheetValue.Expanded } + LaunchedEffect(sheetHidden) { + Timber.v("BottomSearchBar LaunchedEffect: sheetHidden = $sheetHidden") + if (sheetHidden) { launch { focusRequester.freeFocus() focusManager.clearFocus() @@ -802,20 +786,19 @@ fun BottomSearchBar( } } val interactionSource = remember { MutableInteractionSource() } - val draggable = Modifier.draggable( - interactionSource = interactionSource, - orientation = Orientation.Vertical, - state = rememberDraggableState { onDrag(it) } - ) var searchFieldFocusState by rememberSaveable { mutableStateOf(null) } - // We should help gestures when not searching - // and bottomSheet is collapsed or collapsing - val shouldHelpGestures = searchFieldFocusState?.isFocused == false && - bottomSheetState?.isCollapsed == true + ElevatedCard( modifier = modifier - .then(if (shouldHelpGestures) draggable else Modifier) - .offset(y = onDragAreaOffset), + .draggable( + enabled = sheetHidden && searchFieldFocusState?.isFocused == false, + interactionSource = interactionSource, + orientation = Orientation.Vertical, + state = rememberDraggableState { + onDrag(it) + Timber.v("Dragging search bar with offset: $it") + } + ), shape = RoundedCornerShape( topStart = RoundedCornerRadius, topEnd = RoundedCornerRadius @@ -913,7 +896,9 @@ fun BottomSearchBar( ) } } - Spacer(modifier = Modifier.height(onDragAreaOffset)) + Spacer(modifier = Modifier.height( + (WindowInsets.navigationBars.union(WindowInsets.ime)).asPaddingValues().calculateBottomPadding() + )) } } } @@ -933,8 +918,9 @@ fun BottomSheetScreen( modifier: Modifier = Modifier, isSearching: Boolean = false, onBottomSheetFractionChange: (Float) -> Unit = {}, + searchChanged: (Boolean) -> Unit = {}, ) { - ConstraintLayout( + Box( modifier = modifier .background(MaterialTheme.colorScheme.surfaceColorAtElevation(0.dp)) .layout { measurable, constraints -> @@ -969,33 +955,29 @@ fun BottomSheetScreen( } val screenHeight by _screenHeight.collectAsStateWithLifecycle() val density = LocalDensity.current - LaunchedEffect(sheetState.progress) { + LaunchedEffect(sheetState.currentValue) { val offset = sheetState.requireOffset() val bottomSheetFraction = 1 - offset / (screenHeight.value * density.density) _bottomSheetFraction.update { bottomSheetFraction } onBottomSheetFractionChange(bottomSheetFraction) } - ConstraintLayout( + Box( modifier = modifier ) { - val (menu, search) = createRefs() SheetNavHost( - modifier = Modifier.constrainAs(menu) { - bottom.linkTo(parent.bottom) - }, + modifier = Modifier.align(Alignment.BottomCenter), isSearching = isSearching ) SearchNavHost( - modifier = Modifier.constrainAs(search) { - bottom.linkTo(parent.bottom) - }, - isSearching = isSearching + modifier = Modifier.align(Alignment.BottomCenter), + isSearching = isSearching, + onClearFocusOnSearch = { searchChanged(false) } ) } } } -@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialNavigationApi::class) +@OptIn(ExperimentalAnimationApi::class) @Composable private fun SheetNavHost( modifier: Modifier = Modifier, @@ -1023,7 +1005,7 @@ private fun SheetNavHost( .layout { measurable, constraints -> val placeable = measurable.measure(constraints) val height = placeable.measuredHeight.toDp() - if (sheetState.isExpanded && + if (sheetState.hasExpandedState && height >= sheetMinHeight && height != sheetState.getOffsetAsDp(density) ) { @@ -1034,16 +1016,7 @@ private fun SheetNavHost( } } .alpha(alpha = sheetAlpha), - engine = rememberAnimatedNavHostEngine( - rootDefaultAnimations = RootNavGraphDefaultAnimations( - enterTransition = { - slideInVertically(tween(200)) + fadeIn(tween(200)) - }, - exitTransition = { - slideOutVertically(tween(200)) + fadeOut(tween(200)) - } - ) - ) + defaultTransitions = DefaultDestinationTransitions ) } @@ -1052,16 +1025,14 @@ private fun SearchNavHost( modifier: Modifier = Modifier, isSearching: Boolean, fullScreenFraction: Float = BottomSheetPartialMaxFraction, + onClearFocusOnSearch: () -> Unit ) { - val coroutineScope = rememberCoroutineScope() BackPressHandler( customDisposableEffectKey = isSearching, isEnabled = { isSearching } ) { if (isSearching) { - coroutineScope.launch { - sheetState.collapse() - } + onClearFocusOnSearch() } } val searchAlpha by animateFloatAsState( @@ -1092,9 +1063,10 @@ private fun SearchNavHost( .alpha(alpha = searchAlpha) .padding(bottom = SearchBarHeight - RoundedCornerRadius) .navigationBarsPadding(), + defaultTransitions = DefaultDestinationTransitions ) } -fun BottomSheetState.getOffsetAsDp(density: Float): Dp { +fun SheetState.getOffsetAsDp(density: Float): Dp { return (try { requireOffset() } catch (e: Exception) { 0f } / density).dp -} \ No newline at end of file +} diff --git a/app/src/main/java/illyan/jay/ui/home/HomeViewModel.kt b/app/src/main/java/illyan/jay/ui/home/HomeViewModel.kt index a9bdf665..d66501cc 100644 --- a/app/src/main/java/illyan/jay/ui/home/HomeViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/home/HomeViewModel.kt @@ -27,30 +27,64 @@ import com.google.firebase.perf.FirebasePerformance import com.mapbox.geojson.Point import com.mapbox.maps.CameraOptions import dagger.hilt.android.lifecycle.HiltViewModel +import dev.zotov.phototime.solarized.Solarized import illyan.jay.di.CoroutineDispatcherIO import illyan.jay.domain.interactor.AuthInteractor import illyan.jay.domain.interactor.MapboxInteractor import illyan.jay.domain.interactor.SessionInteractor +import illyan.jay.domain.interactor.SettingsInteractor +import illyan.jay.domain.model.Theme import illyan.jay.ui.map.BmeK import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.LocalDateTime +import java.util.TimeZone import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class HomeViewModel @Inject constructor( private val mapboxInteractor: MapboxInteractor, private val sessionInteractor: SessionInteractor, + settingsInteractor: SettingsInteractor, authInteractor: AuthInteractor, performance: FirebasePerformance, @CoroutineDispatcherIO private val dispatcherIO: CoroutineDispatcher, ) : ViewModel() { + private val currentLocation = MutableStateFlow(null) + + val isNight = flow { + while (true) { + emit(Unit) + delay(1.seconds) // refreshing every second + } + }.combine(currentLocation) { _, location -> + location?.let { + val solarized = Solarized( + it.latitude, + it.longitude, + LocalDateTime.now(), + TimeZone.getDefault() + ) + val now = LocalDateTime.now() + now.isBefore(solarized.day?.start) || now.isAfter(solarized.day?.end) + } ?: false + }.stateIn(viewModelScope, SharingStarted.Eagerly, false) + + val theme = settingsInteractor.userPreferences.map { it?.theme } + .stateIn(viewModelScope, SharingStarted.Eagerly, Theme.System) + private val _initialLocation = MutableStateFlow(null) val initialLocation = _initialLocation.asStateFlow() @@ -71,6 +105,7 @@ class HomeViewModel @Inject constructor( private val callback = object : LocationCallback() { override fun onLocationResult(result: LocationResult) { + currentLocation.update { result.lastLocation } result.lastLocation?.let { if (_initialLocation.value == null) { _initialLocation.value = it diff --git a/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt b/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt index e819e23e..9100515b 100644 --- a/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt +++ b/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt @@ -23,9 +23,13 @@ import androidx.compose.animation.Crossfade import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Alignment import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -50,25 +54,21 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ChainStyle -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.generated.destinations.LibraryDialogScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import illyan.compose.scrollbar.drawVerticalScrollbar import illyan.jay.R import illyan.jay.ui.components.JayDialogContent import illyan.jay.ui.components.PreviewAll -import illyan.jay.ui.destinations.LibraryDialogScreenDestination import illyan.jay.ui.libraries.model.UiLibrary import illyan.jay.ui.profile.ProfileNavGraph import illyan.jay.ui.theme.JayTheme -@ProfileNavGraph -@Destination +@Destination @Composable fun LibrariesDialogScreen( viewModel: LibrariesViewModel = hiltViewModel(), @@ -157,61 +157,39 @@ fun LibraryItem( onClick = onClick, colors = cardColors, ) { - ConstraintLayout( + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, ) { - val (item, icon) = createRefs() - createHorizontalChain( - item, - icon, - chainStyle = ChainStyle.SpreadInside - ) - createStartBarrier() - Icon( - modifier = Modifier.constrainAs(icon) { - end.linkTo(parent.end) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - }, - imageVector = Icons.Rounded.ChevronRight, contentDescription = "" - ) - LazyRow( - modifier = Modifier.constrainAs(item) { - start.linkTo(parent.start) - end.linkTo(icon.start) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - width = Dimension.fillToConstraints - } - ) { - item { - Column { + Column(modifier = Modifier.weight(1f).padding(end = 8.dp)) { + Text( + text = library.name, + style = MaterialTheme.typography.titleSmall, + color = AlertDialogDefaults.titleContentColor, + ) + Crossfade( + targetState = library.repositoryUrl to library.moreInfoUrl, + label = "Library URLs" + ) { repositoryAndMoreInfoUrls -> + val shownText = repositoryAndMoreInfoUrls.run { + // Show Repo URL, then More Info URL, then null + if (first != null) first else if (second != null) second else null + } + shownText?.let { Text( - text = library.name, - style = MaterialTheme.typography.titleSmall, - color = AlertDialogDefaults.titleContentColor, + text = it, + style = MaterialTheme.typography.bodySmall, + color = AlertDialogDefaults.textContentColor, ) - Crossfade( - targetState = library.repositoryUrl to library.moreInfoUrl, - label = "Library URLs" - ) { repositoryAndMoreInfoUrls -> - val shownText = repositoryAndMoreInfoUrls.run { - // Show Repo URL, then More Info URL, then null - if (first != null) first else if (second != null) second else null - } - shownText?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = AlertDialogDefaults.textContentColor, - ) - } - } } } } + Icon( + imageVector = Icons.Rounded.ChevronRight, + contentDescription = "" + ) } } } diff --git a/app/src/main/java/illyan/jay/ui/library/Library.kt b/app/src/main/java/illyan/jay/ui/library/Library.kt index b5ec9729..0554bd72 100644 --- a/app/src/main/java/illyan/jay/ui/library/Library.kt +++ b/app/src/main/java/illyan/jay/ui/library/Library.kt @@ -60,8 +60,7 @@ import illyan.jay.ui.libraries.model.toUiModel import illyan.jay.ui.profile.ProfileNavGraph import illyan.jay.ui.theme.JayTheme -@ProfileNavGraph -@Destination +@Destination @Composable fun LibraryDialogScreen( library: UiLibrary @@ -165,7 +164,6 @@ fun LibraryScreen( ) } Crossfade( - modifier = Modifier.animateContentSize(), targetState = library.license?.url, label = "License" ) { diff --git a/app/src/main/java/illyan/jay/ui/login/Login.kt b/app/src/main/java/illyan/jay/ui/login/Login.kt index 18fbda54..e54b1d68 100644 --- a/app/src/main/java/illyan/jay/ui/login/Login.kt +++ b/app/src/main/java/illyan/jay/ui/login/Login.kt @@ -18,6 +18,7 @@ package illyan.jay.ui.login +import androidx.activity.compose.LocalActivity import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -38,25 +39,24 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination +import illyan.jay.MainActivity import illyan.jay.R import illyan.jay.ui.components.JayDialogContent import illyan.jay.ui.components.JayDialogSurface import illyan.jay.ui.components.LoadingIndicator import illyan.jay.ui.components.PreviewAccessibility -import illyan.jay.ui.profile.LocalDialogActivityProvider import illyan.jay.ui.profile.LocalDialogDismissRequest import illyan.jay.ui.profile.ProfileNavGraph import illyan.jay.ui.theme.JayTheme -@ProfileNavGraph -@Destination +@Destination @Composable fun LoginDialogScreen( viewModel: LoginViewModel = hiltViewModel(), ) { val isUserSignedIn by viewModel.isUserSignedIn.collectAsStateWithLifecycle() val isUserSigningIn by viewModel.isSigningIn.collectAsStateWithLifecycle() - val activity = LocalDialogActivityProvider.current + val activity = LocalActivity.current val dismissDialog = LocalDialogDismissRequest.current LaunchedEffect(isUserSignedIn) { if (isUserSignedIn) dismissDialog() @@ -64,7 +64,7 @@ fun LoginDialogScreen( LoginDialogContent( modifier = Modifier.fillMaxWidth(), isUserSigningIn = isUserSigningIn, - signInViaGoogle = { activity?.let { viewModel.signInViaGoogle(it) } } + signInViaGoogle = { (activity as? MainActivity)?.let { viewModel.signInViaGoogle(it) } } ) } 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 482b1936..978faaa8 100644 --- a/app/src/main/java/illyan/jay/ui/map/Map.kt +++ b/app/src/main/java/illyan/jay/ui/map/Map.kt @@ -261,7 +261,8 @@ fun MapsNotSupportedCard( modifier = Modifier.padding(top = 8.dp), text = stringResource(R.string.mapbox_map_problem_not_affecting_jay), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center ) } } diff --git a/app/src/main/java/illyan/jay/ui/menu/Menu.kt b/app/src/main/java/illyan/jay/ui/menu/Menu.kt index 8b2b30d2..4b808651 100644 --- a/app/src/main/java/illyan/jay/ui/menu/Menu.kt +++ b/app/src/main/java/illyan/jay/ui/menu/Menu.kt @@ -25,6 +25,7 @@ import android.window.OnBackInvokedCallback import android.window.OnBackInvokedDispatcher import android.window.OnBackInvokedDispatcher.PRIORITY_DEFAULT import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.annotation.RequiresApi import androidx.compose.animation.Crossfade @@ -39,7 +40,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.DarkMode import androidx.compose.material.icons.rounded.LightMode @@ -54,26 +54,27 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.NavGraph -import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.annotation.NavHostGraph +import com.ramcosta.composedestinations.generated.destinations.FreeDriveDestination +import com.ramcosta.composedestinations.generated.destinations.SessionsDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import illyan.jay.BuildConfig @@ -81,19 +82,12 @@ import illyan.jay.MainActivity import illyan.jay.R import illyan.jay.domain.model.Theme import illyan.jay.ui.components.PreviewAccessibility -import illyan.jay.ui.destinations.FreeDriveDestination -import illyan.jay.ui.destinations.SessionsDestination import illyan.jay.ui.home.RoundedCornerRadius -import illyan.jay.ui.home.isSearching import illyan.jay.ui.home.sheetState import illyan.jay.ui.theme.JayTheme -import illyan.jay.ui.theme.LocalTheme -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch import timber.log.Timber -@RootNavGraph -@NavGraph +@NavHostGraph annotation class MenuNavGraph( val start: Boolean = false, ) @@ -111,14 +105,14 @@ val DefaultScreenOnSheetPadding = PaddingValues( bottom = RoundedCornerRadius + MenuItemPadding * 2 ) -@MenuNavGraph(start = true) -@Destination +@Destination(start = true) @Composable fun MenuScreen( destinationsNavigator: DestinationsNavigator = EmptyDestinationsNavigator, viewModel: MenuViewModel = hiltViewModel(), ) { val context = LocalContext.current + val theme by viewModel.theme.collectAsStateWithLifecycle() BackPressHandler { Timber.d("Intercepted back press!") (context as Activity).moveTaskToBack(false) @@ -137,11 +131,13 @@ fun MenuScreen( destinationsNavigator.navigate(SessionsDestination) }, onToggleTheme = viewModel::toggleTheme, + theme = theme, ) } @Composable fun MenuContent( + theme: Theme = Theme.System, onNavigateToBme: () -> Unit = {}, onFreeDrive: () -> Unit = {}, onSessions: () -> Unit = {}, @@ -187,7 +183,6 @@ fun MenuContent( ) } item { - val theme = LocalTheme.current MenuItemCard( title = stringResource(R.string.toggle_theme), icon = when (theme) { @@ -277,7 +272,6 @@ fun BackPressHandler( ) { val currentOnBackPressed by rememberUpdatedState(newValue = onBackPressed) val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher - val lifecycleOwner = LocalLifecycleOwner.current if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { val backInvokedCallback = remember { @@ -286,7 +280,7 @@ fun BackPressHandler( currentOnBackPressed() } } - val activity = LocalContext.current as? Activity + val activity = LocalActivity.current val backInvokedDispatcher = activity?.onBackInvokedDispatcher DisposableEffect(backInvokedDispatcher, customDisposableEffectKey) { if (isEnabled()) { @@ -310,7 +304,7 @@ fun BackPressHandler( } DisposableEffect(backPressedDispatcher, customDisposableEffectKey) { if (isEnabled()) { - backPressedDispatcher?.addCallback(lifecycleOwner, backCallback) + backPressedDispatcher?.addCallback(backCallback) } onDispose { backCallback.remove() @@ -319,13 +313,12 @@ fun BackPressHandler( } } -@OptIn(ExperimentalMaterialApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SheetScreenBackPressHandler( customDisposableEffectKey: Any? = null, isEnabled: () -> Boolean = { true }, context: Context = LocalContext.current, - coroutineScope: CoroutineScope = rememberCoroutineScope(), destinationsNavigator: DestinationsNavigator, onBackPressed: () -> Unit = {}, ) { @@ -336,13 +329,8 @@ fun SheetScreenBackPressHandler( onBackPressed() Timber.d("Handling back press in Navigation!") // If searching and back is pressed, close the sheet instead of the app - if (sheetState.isCollapsed) (context as MainActivity).moveTaskToBack(false) - if (isSearching) { - coroutineScope.launch { - // This call will automatically unfocus the textfield - // because BottomSearchBar listens on sheet changes. - sheetState.collapse() - } + if (sheetState.currentValue != SheetValue.Expanded) { + (context as MainActivity).moveTaskToBack(false) } else { destinationsNavigator.navigateUp() } diff --git a/app/src/main/java/illyan/jay/ui/menu/MenuViewModel.kt b/app/src/main/java/illyan/jay/ui/menu/MenuViewModel.kt index 76aa1f53..dcda5a88 100644 --- a/app/src/main/java/illyan/jay/ui/menu/MenuViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/menu/MenuViewModel.kt @@ -22,7 +22,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.google.firebase.analytics.FirebaseAnalytics -import com.google.firebase.analytics.ktx.logEvent +import com.google.firebase.analytics.logEvent import com.mapbox.search.result.SearchResultType import dagger.hilt.android.lifecycle.HiltViewModel import illyan.jay.domain.interactor.SettingsInteractor @@ -32,8 +32,10 @@ import illyan.jay.ui.map.BmeK import illyan.jay.ui.poi.model.Place import illyan.jay.ui.search.SearchViewModel import illyan.jay.ui.sheet.SheetViewModel +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -44,6 +46,12 @@ class MenuViewModel @Inject constructor( private val localBroadcastManager: LocalBroadcastManager, private val settingsInteractor: SettingsInteractor, ) : ViewModel() { + val theme = settingsInteractor.userPreferences.map { it?.theme ?: Theme.System } + .stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = Theme.System, + ) private fun onClickButton(buttonName: String) { Timber.i("Clicked \"$buttonName\" button") analytics.logEvent(FirebaseAnalytics.Event.SELECT_ITEM) { diff --git a/app/src/main/java/illyan/jay/ui/poi/Poi.kt b/app/src/main/java/illyan/jay/ui/poi/Poi.kt index bef0939f..4b1f577f 100644 --- a/app/src/main/java/illyan/jay/ui/poi/Poi.kt +++ b/app/src/main/java/illyan/jay/ui/poi/Poi.kt @@ -29,7 +29,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -97,12 +97,10 @@ const val smallZoom = 8.0 const val verySmallZoom = 6.0 const val minZoom = 3.0 - -@OptIn(ExperimentalMaterialApi::class) -@SheetNavGraph -@Destination +@OptIn(ExperimentalMaterial3Api::class) +@Destination @Composable -fun Poi( +fun PoiScreen( placeToNavigate: Place, destinationsNavigator: DestinationsNavigator = EmptyDestinationsNavigator, viewModel: PoiViewModel = hiltViewModel(), @@ -115,8 +113,8 @@ fun Poi( var sheetHeightNotSet by remember { mutableStateOf(true) } val place by viewModel.place.collectAsStateWithLifecycle() val placeMetadata by viewModel.placeInfo.collectAsStateWithLifecycle() - LaunchedEffect(sheetState.progress) { - sheetHeightNotSet = sheetState.progress != 1f + LaunchedEffect(sheetState.currentValue) { + sheetHeightNotSet = sheetState.currentValue != sheetState.targetValue } val context = LocalContext.current val mapMarkers by mapMarkers.collectAsStateWithLifecycle() diff --git a/app/src/main/java/illyan/jay/ui/profile/Profile.kt b/app/src/main/java/illyan/jay/ui/profile/Profile.kt index e9c631bf..c3dbba1d 100644 --- a/app/src/main/java/illyan/jay/ui/profile/Profile.kt +++ b/app/src/main/java/illyan/jay/ui/profile/Profile.kt @@ -16,18 +16,16 @@ * If not, see . */ +@file:OptIn(ExperimentalUuidApi::class) + package illyan.jay.ui.profile import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -41,6 +39,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -68,25 +67,22 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import androidx.constraintlayout.compose.ChainStyle -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.ramcosta.composedestinations.DestinationsNavHost -import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations -import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.NavGraph -import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.annotation.NavHostGraph +import com.ramcosta.composedestinations.generated.NavGraphs +import com.ramcosta.composedestinations.generated.destinations.AboutDialogScreenDestination +import com.ramcosta.composedestinations.generated.destinations.LoginDialogScreenDestination +import com.ramcosta.composedestinations.generated.destinations.UserSettingsDialogScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator +import com.ramcosta.composedestinations.rememberNavHostEngine import com.ramcosta.composedestinations.utils.currentDestinationAsState import com.ramcosta.composedestinations.utils.startDestination import illyan.jay.MainActivity import illyan.jay.R -import illyan.jay.ui.NavGraphs import illyan.jay.ui.components.AvatarAsyncImage import illyan.jay.ui.components.CopiedToKeyboardTooltip import illyan.jay.ui.components.JayDialogContent @@ -95,23 +91,20 @@ import illyan.jay.ui.components.MenuButton import illyan.jay.ui.components.PreviewAccessibility import illyan.jay.ui.components.TooltipElevatedCard import illyan.jay.ui.components.dialogWidth -import illyan.jay.ui.destinations.AboutDialogScreenDestination -import illyan.jay.ui.destinations.LoginDialogScreenDestination -import illyan.jay.ui.destinations.UserSettingsDialogScreenDestination import illyan.jay.ui.home.RoundedCornerRadius +import illyan.jay.ui.theme.DefaultDestinationTransitions import illyan.jay.ui.theme.JayTheme -import java.util.UUID +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid -@RootNavGraph -@NavGraph +@NavHostGraph annotation class ProfileNavGraph( val start: Boolean = false, ) val LocalDialogDismissRequest = compositionLocalOf { {} } -val LocalDialogActivityProvider = compositionLocalOf { null } -@OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalMaterial3Api::class, +@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class, ExperimentalAnimationApi::class ) @Composable @@ -123,52 +116,45 @@ fun ProfileDialog( val context = LocalContext.current // Don't use exit animations because // it looks choppy while Dialog resizes due to content change. - val engine = rememberAnimatedNavHostEngine( - rootDefaultAnimations = RootNavGraphDefaultAnimations( - enterTransition = { - slideInHorizontally(tween(200)) { it / 8 } + fadeIn(tween(200)) - }, - popEnterTransition = { - slideInHorizontally(tween(200)) { -it / 8 } + fadeIn(tween(200)) - } - ) - ) + val engine = rememberNavHostEngine() val navController = engine.rememberNavController() val currentDestination by navController.currentDestinationAsState() - val onDismissRequest: () -> Unit = { - if (currentDestination == NavGraphs.profile.startDestination) { - onDialogClosed() - } else { - navController.navigateUp() + val onDismissRequest: () -> Unit = remember(currentDestination) { + { + if (currentDestination == NavGraphs.profile.startDestination) { + onDialogClosed() + } else { + navController.navigateUp() + } } } - AlertDialog( + BasicAlertDialog( + onDismissRequest = onDismissRequest, properties = DialogProperties( usePlatformDefaultWidth = false ), - onDismissRequest = onDismissRequest, - ) { - JayDialogContent( - surface = { JayDialogSurface(content = it) }, - ) { - CompositionLocalProvider( - LocalDialogDismissRequest provides onDismissRequest, - LocalDialogActivityProvider provides context as MainActivity + content = { + JayDialogContent( + surface = { JayDialogSurface(content = it) }, ) { - DestinationsNavHost( - modifier = Modifier.fillMaxWidth(), - navGraph = NavGraphs.profile, - engine = engine, - navController = navController, - ) + CompositionLocalProvider( + LocalDialogDismissRequest provides onDismissRequest, + ) { + DestinationsNavHost( + modifier = Modifier.fillMaxWidth(), + navGraph = NavGraphs.profile, + engine = engine, + navController = navController, + defaultTransitions = DefaultDestinationTransitions + ) + } } } - } + ) } } -@ProfileNavGraph(start = true) -@Destination +@Destination(start = true) @Composable fun ProfileDialogScreen( viewModel: ProfileViewModel = hiltViewModel(), @@ -248,7 +234,7 @@ fun ProfileDialogContent( ) } -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalAnimationApi::class) @Composable fun ProfileButtons( onShowSettingsScreen: () -> Unit = {}, @@ -331,7 +317,7 @@ private fun PreviewProfileDialogScreen( JayDialogSurface { ProfileDialogContent( modifier = Modifier.dialogWidth(), - userUUID = UUID.randomUUID().toString(), + userUUID = Uuid.random().toString(), userPhotoUrl = null, confidentialInfo = listOf( stringResource(R.string.name) to name, @@ -345,7 +331,7 @@ private fun PreviewProfileDialogScreen( } } -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalAnimationApi::class) @Composable fun ProfileTitleScreen( modifier: Modifier = Modifier, @@ -425,40 +411,21 @@ fun ProfileDetailsScreen( Column( modifier = modifier ) { - ConstraintLayout( + Row( modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - val (confidentialInfoText, toggleButton) = createRefs() - createHorizontalChain( - confidentialInfoText, - toggleButton, - chainStyle = ChainStyle.SpreadInside + UserInfoList( + modifier = Modifier.weight(1f), + confidentialInfo = confidentialInfo, + info = info, + showConfidentialInfo = showConfidentialInfo ) - createStartBarrier() ConfidentialInfoToggleButton( - modifier = Modifier - .constrainAs(toggleButton) { - end.linkTo(parent.end) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - }, showConfidentialInfo = showConfidentialInfo, anyConfidentialInfo = confidentialInfo.isNotEmpty(), onVisibilityChanged = onConfidentialInfoVisibilityChanged ) - UserInfoList( - modifier = Modifier - .constrainAs(confidentialInfoText) { - start.linkTo(parent.start) - end.linkTo(toggleButton.start) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - width = Dimension.fillToConstraints - }, - confidentialInfo = confidentialInfo, - info = info, - showConfidentialInfo = showConfidentialInfo - ) } } } diff --git a/app/src/main/java/illyan/jay/ui/search/Search.kt b/app/src/main/java/illyan/jay/ui/search/Search.kt index a1f78e8f..98624b04 100644 --- a/app/src/main/java/illyan/jay/ui/search/Search.kt +++ b/app/src/main/java/illyan/jay/ui/search/Search.kt @@ -68,7 +68,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.NavGraph -import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.annotation.NavHostGraph +import com.ramcosta.composedestinations.annotation.RootGraph import illyan.jay.R import illyan.jay.ui.components.MediumCircularProgressIndicator import illyan.jay.ui.components.PreviewAccessibility @@ -78,8 +79,7 @@ import illyan.jay.ui.theme.JayTheme import illyan.jay.ui.theme.signaturePink import java.util.UUID -@RootNavGraph -@NavGraph +@NavHostGraph annotation class SearchNavGraph( val start: Boolean = false, ) @@ -89,8 +89,7 @@ val SearchItemsCornerRadius = 24.dp val DividerStartPadding = 56.dp val DividerThickness = 1.dp -@SearchNavGraph(start = true) -@Destination +@Destination(start = true) @Composable fun SearchScreen( viewModel: SearchViewModel = hiltViewModel(), 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 f0213b3c..5468b654 100644 --- a/app/src/main/java/illyan/jay/ui/session/Session.kt +++ b/app/src/main/java/illyan/jay/ui/session/Session.kt @@ -16,6 +16,8 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.ui.session import android.annotation.SuppressLint @@ -31,10 +33,10 @@ 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.automirrored.rounded.ArrowRightAlt import androidx.compose.material.icons.rounded.MoreHoriz +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme @@ -110,9 +112,12 @@ import illyan.jay.util.format import kotlinx.coroutines.delay import timber.log.Timber import java.math.RoundingMode +import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.TimeZone import kotlin.math.abs +import kotlin.time.ExperimentalTime +import kotlin.time.toJavaInstant val DefaultScreenOnSheetPadding = PaddingValues( top = MenuItemPadding * 2, @@ -140,13 +145,13 @@ fun createGradientFromLocations( getColorFraction: (UiLocation) -> Float, ): Expression { if (locations.size < 2) return defaultGradient() - val startMilli = locations.minOf { it.zonedDateTime.toInstant().toEpochMilli() } - val endMilli = locations.maxOf { it.zonedDateTime.toInstant().toEpochMilli() } + val startMilli = locations.minOf { it.timestamp.toEpochMilliseconds() } + val endMilli = locations.maxOf { it.timestamp.toEpochMilliseconds() } val durationMilli = (endMilli - startMilli) val colorsWithKeys = locations.sortedBy { - it.zonedDateTime.toInstant().toEpochMilli() + it.timestamp }.map { - val currentMilli = it.zonedDateTime.toInstant().toEpochMilli() + val currentMilli = it.timestamp.toEpochMilliseconds() lerp(start, stop, getColorFraction(it).coerceIn(0f, 1f)) to (currentMilli - startMilli).toDouble() / durationMilli } @@ -243,9 +248,8 @@ fun aggressionGradient( ) } -@OptIn(ExperimentalMaterialApi::class) -@MenuNavGraph -@Destination +@OptIn(ExperimentalMaterial3Api::class) +@Destination @Composable fun SessionScreen( sessionUUID: String, @@ -268,7 +272,7 @@ fun SessionScreen( } val sheetHeightNotSet by remember { derivedStateOf { - val isAnimationRunning = sheetState.progress != 1f + val isAnimationRunning = sheetState.currentValue != sheetState.targetValue val almostReachedTargetHeight = abs(currentOffset - previousOffset) < 2f isAnimationRunning || !almostReachedTargetHeight || !noMoreOffsetChanges } @@ -311,7 +315,7 @@ fun SessionScreen( selectedGradientFilter, aggressions ) { - val sortedLocations = path?.sortedBy { it.zonedDateTime }?.map { it.latLng } + val sortedLocations = path?.sortedBy { it.timestamp }?.map { it.latLng } val startPoint = sortedLocations?.first() val endPoint = sortedLocations?.last() val points = sortedLocations?.map { it.toMapboxPoint() } ?: emptyList() @@ -503,14 +507,14 @@ fun SessionDetailsScreen( hour = stringResource(R.string.hour_short), day = stringResource(R.string.day_short) ), - stringResource(R.string.start_date) to session?.startDateTime?.format( + stringResource(R.string.start_date) to session?.startDateTime?.toJavaInstant()?.atZone(ZoneOffset.systemDefault())?.format( if (locale != null) { dateTimeFormatter.withLocale(locale) } else { dateTimeFormatter } ), - stringResource(R.string.end_date) to (session?.endDateTime?.format( + stringResource(R.string.end_date) to (session?.endDateTime?.toJavaInstant()?.atZone(ZoneOffset.systemDefault())?.format( if (locale != null) { dateTimeFormatter.withLocale(locale) } else { 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 06390770..361b0f83 100644 --- a/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/session/SessionViewModel.kt @@ -31,7 +31,6 @@ import illyan.jay.domain.model.DomainAggression import illyan.jay.ui.session.model.UiLocation import illyan.jay.ui.session.model.UiSession import illyan.jay.ui.session.model.toUiModel -import illyan.jay.util.toZonedDateTime import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -45,12 +44,13 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber -import java.time.Instant -import java.time.ZonedDateTime import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException import kotlin.math.abs +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) @HiltViewModel class SessionViewModel @Inject constructor( private val sessionInteractor: SessionInteractor, @@ -62,7 +62,7 @@ class SessionViewModel @Inject constructor( private val _isModelAvailable = MutableStateFlow(false) val isModelAvailable = _isModelAvailable.asStateFlow() - private val _aggressions = MutableStateFlow?>(null) + private val _aggressions = MutableStateFlow?>(null) val aggressions = _aggressions .asStateFlow() .map { aggressions -> aggressions?.map { it.value.toFloat() } } @@ -73,7 +73,7 @@ class SessionViewModel @Inject constructor( // Find closest zonedDateTime of a location for each aggression val closestAggressionToLocationTimestamp = aggressions?.minByOrNull { // Time difference - abs(it.key.toInstant().toEpochMilli() - location.zonedDateTime.toInstant().toEpochMilli()) + abs((it.key - location.timestamp).inWholeMilliseconds) }?.value // Timber.d("Aggression difference: $closestAggressionToLocationTimestamp") location.copy(aggression = closestAggressionToLocationTimestamp?.toFloat()) @@ -118,7 +118,7 @@ class SessionViewModel @Inject constructor( locationInteractor.getSyncedPath(sessionUUID).collectLatest { locations -> Timber.d("Loaded path with ${locations?.size} locations for session with ID: $sessionUUID") if (!locations.isNullOrEmpty()) { - val sortedPath = locations.sortedBy { it.zonedDateTime.toInstant() } + val sortedPath = locations.sortedBy { it.timestamp } _path.update { sortedPath.map { it.toUiModel() } } } } @@ -127,7 +127,7 @@ class SessionViewModel @Inject constructor( locationInteractor.getSyncedPathAggressions(sessionUUID).collectLatest { aggressions -> Timber.d("Loaded ${aggressions?.size} aggressions for session with ID: $sessionUUID") _aggressions.update { aggressions?.associate { - Instant.ofEpochMilli(it.timestamp).toZonedDateTime() to it.aggression.toDouble() + Instant.fromEpochMilliseconds(it.timestamp) to it.aggression.toDouble() } } } } @@ -152,7 +152,7 @@ class SessionViewModel @Inject constructor( ).collectLatest { filteredAggressions -> Timber.d("Loaded ${filteredAggressions.size} aggressions for session with ID: ${sessionUUID.take(4)}") val aggressions = filteredAggressions.map { - DomainAggression(sessionUUID, it.key.toInstant().toEpochMilli(), it.value.toFloat()) + DomainAggression(sessionUUID, it.key.toEpochMilliseconds(), it.value.toFloat()) } locationInteractor.saveAggressions(aggressions) if (sessionInteractor.syncedSessions.first()?.map { it.uuid }?.contains(sessionUUID) == true) { diff --git a/app/src/main/java/illyan/jay/ui/session/model/UiLocation.kt b/app/src/main/java/illyan/jay/ui/session/model/UiLocation.kt index b4872f10..248b36e0 100644 --- a/app/src/main/java/illyan/jay/ui/session/model/UiLocation.kt +++ b/app/src/main/java/illyan/jay/ui/session/model/UiLocation.kt @@ -16,14 +16,18 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.ui.session.model import com.google.android.gms.maps.model.LatLng import illyan.jay.domain.model.DomainLocation -import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) data class UiLocation( - val zonedDateTime: ZonedDateTime, + val timestamp: Instant, val latLng: LatLng, var speed: Float, var accuracy: Byte, @@ -36,7 +40,7 @@ data class UiLocation( ) fun DomainLocation.toUiModel(aggression: Float? = null) = UiLocation( - zonedDateTime = zonedDateTime, + timestamp = timestamp, latLng = latLng, speed = speed, accuracy = accuracy, diff --git a/app/src/main/java/illyan/jay/ui/session/model/UiSession.kt b/app/src/main/java/illyan/jay/ui/session/model/UiSession.kt index ce0fe521..407996a7 100644 --- a/app/src/main/java/illyan/jay/ui/session/model/UiSession.kt +++ b/app/src/main/java/illyan/jay/ui/session/model/UiSession.kt @@ -16,6 +16,8 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.ui.session.model import com.google.android.gms.maps.model.LatLng @@ -23,13 +25,16 @@ import illyan.jay.domain.model.DomainLocation import illyan.jay.domain.model.DomainSession import illyan.jay.util.sphericalPathLength import java.time.ZonedDateTime +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant data class UiSession( val uuid: String, - val startDateTime: ZonedDateTime, - val endDateTime: ZonedDateTime?, + val startDateTime: Instant, + val endDateTime: Instant?, val startCoordinate: LatLng?, val endCoordinate: LatLng?, val totalDistance: Double?, @@ -40,7 +45,7 @@ data class UiSession( fun DomainSession.toUiModel( locations: List? = null, - currentTime: ZonedDateTime = ZonedDateTime.now(), + currentTime: Instant = Clock.System.now(), ): UiSession { return toUiModel( locations?.sphericalPathLength(), @@ -50,7 +55,7 @@ fun DomainSession.toUiModel( fun DomainSession.toUiModel( totalDistance: Double?, - currentTime: ZonedDateTime = ZonedDateTime.now(), + currentTime: Instant = Clock.System.now(), ): UiSession { return UiSession( uuid = uuid, @@ -62,11 +67,9 @@ fun DomainSession.toUiModel( startLocationName = startLocationName, endLocationName = endLocationName, duration = if (endDateTime != null) { - (endDateTime!!.toInstant().toEpochMilli() - startDateTime.toInstant().toEpochMilli()) - .milliseconds + (endDateTime!! - startDateTime) } else { - (currentTime.toInstant().toEpochMilli() - startDateTime.toInstant().toEpochMilli()) - .milliseconds + (currentTime - startDateTime) } ) } diff --git a/app/src/main/java/illyan/jay/ui/sessions/Sessions.kt b/app/src/main/java/illyan/jay/ui/sessions/Sessions.kt index bbbd4847..764594ce 100644 --- a/app/src/main/java/illyan/jay/ui/sessions/Sessions.kt +++ b/app/src/main/java/illyan/jay/ui/sessions/Sessions.kt @@ -16,11 +16,13 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.ui.sessions import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateContentSize +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -32,6 +34,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -41,8 +44,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowRightAlt import androidx.compose.material.icons.rounded.AddChart -import androidx.compose.material.icons.rounded.ArrowRightAlt import androidx.compose.material.icons.rounded.CloudOff import androidx.compose.material.icons.rounded.CloudSync import androidx.compose.material.icons.rounded.CloudUpload @@ -78,12 +81,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.gms.maps.model.LatLng import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.generated.destinations.SessionScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import illyan.compose.scrollbar.drawVerticalScrollbar @@ -92,7 +94,6 @@ import illyan.jay.ui.components.MediumCircularProgressIndicator import illyan.jay.ui.components.PreviewAccessibility import illyan.jay.ui.components.SmallCircularProgressIndicator import illyan.jay.ui.components.TooltipButton -import illyan.jay.ui.destinations.SessionScreenDestination import illyan.jay.ui.home.RoundedCornerRadius import illyan.jay.ui.menu.MenuItemPadding import illyan.jay.ui.menu.MenuNavGraph @@ -106,10 +107,11 @@ import illyan.jay.util.plus import me.saket.swipe.SwipeAction import me.saket.swipe.SwipeableActionsBox import java.math.RoundingMode -import java.time.ZonedDateTime import java.util.UUID import kotlin.random.Random +import kotlin.time.Clock import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime val DefaultContentPadding = PaddingValues( bottom = RoundedCornerRadius @@ -119,8 +121,7 @@ val DefaultScreenOnSheetPadding = PaddingValues( top = MenuItemPadding * 2 ) -@MenuNavGraph -@Destination +@Destination @Composable fun Sessions( destinationsNavigator: DestinationsNavigator = EmptyDestinationsNavigator, @@ -200,35 +201,14 @@ fun SessionsScreen( val showButtons = isUserSignedIn && (canSyncSessions || areThereSyncedSessions || areThereSessionsNotOwned) || canDeleteSessions - ConstraintLayout( + Column( modifier = modifier.padding( - DefaultContentPadding + if (!showButtons) { - DefaultScreenOnSheetPadding - } else PaddingValues() + DefaultScreenOnSheetPadding ) ) { - val (column, globalLoadingIndicator, buttons) = createRefs() - AnimatedVisibility( - modifier = Modifier - .constrainAs(globalLoadingIndicator) { - top.linkTo(parent.top) - end.linkTo(parent.end) - }, - visible = isLoading - ) { - MediumCircularProgressIndicator(modifier = Modifier.padding(end = MenuItemPadding * 2)) - } SessionsInteractorButtonList( modifier = Modifier - .zIndex(2f) - .padding( - start = MenuItemPadding, - bottom = MenuItemPadding, - ) - .constrainAs(buttons) { - bottom.linkTo(parent.bottom) - start.linkTo(parent.start) - }, + .zIndex(2f), showSyncButton = isUserSignedIn && canSyncSessions, showOwnAllSessionsButton = isUserSignedIn && areThereSessionsNotOwned, showDeleteSessionsFromCloudButton = isUserSignedIn && areThereSyncedSessions, @@ -238,18 +218,18 @@ fun SessionsScreen( onDeleteSessionsFromCloud = deleteAllSyncedData, onDeleteSessionsLocally = deleteSessionsLocally, ) + AnimatedVisibility( + modifier = Modifier + .fillMaxWidth(), + visible = isLoading + ) { + MediumCircularProgressIndicator(modifier = Modifier.padding(end = MenuItemPadding * 2)) + } Column( modifier = Modifier .padding( - top = MenuItemPadding, bottom = MenuItemPadding + RoundedCornerRadius, ) - .constrainAs(column) { - top.linkTo(parent.top) - bottom.linkTo(buttons.top) - start.linkTo(parent.start) - end.linkTo(parent.end) - } ) { SessionsList( modifier = Modifier.fillMaxWidth(), @@ -296,9 +276,9 @@ private fun SessionsScreenPreview() { private fun generateUiSessions(number: Int): List { return List(number) { - val now = ZonedDateTime.now() - val startTime = now.minusSeconds(Random.nextLong(5000, 10000)) - val endTime = if (Random.nextInt(3) == 0) null else now.minusSeconds(Random.nextLong(1000, 4000)) + val now = Clock.System.now() + val startTime = now.minus(Random.nextLong(5000, 10000).seconds) + val endTime = if (Random.nextInt(3) == 0) null else now.minus(Random.nextLong(1000, 4000).seconds) val ownerUUID = UUID.randomUUID().toString() UiSession( uuid = UUID.randomUUID().toString(), @@ -311,7 +291,7 @@ private fun generateUiSessions(number: Int): List { startLocationName = "City number $it", endLocationName = "City number ${Random.nextInt(it + 1)}", totalDistance = Random.nextDouble(100.0, 10000.0), - duration = ((endTime?.toEpochSecond() ?: now.toEpochSecond()) - startTime.toEpochSecond()).seconds, + duration = (endTime ?: now) - startTime, endCoordinate = LatLng(Random.nextDouble(-90.0, 90.0), Random.nextDouble(-90.0, 90.0)), startCoordinate = LatLng( Random.nextDouble(-90.0, 90.0), @@ -335,6 +315,7 @@ fun SessionsInteractorButtonList( ) { LazyRow( modifier = modifier, + contentPadding = PaddingValues(start = MenuItemPadding) ) { item { SessionInteractionButton( @@ -459,7 +440,7 @@ fun SessionsList( .padding( start = contentPadding.calculateStartPadding(layoutDirection), end = contentPadding.calculateEndPadding(layoutDirection), - top = DefaultContentPadding.calculateBottomPadding() + DefaultScreenOnSheetPadding.calculateTopPadding() / 2 + top = DefaultScreenOnSheetPadding.calculateTopPadding() / 2 ) .clip(RoundedCornerShape(12.dp)), contentPadding = PaddingValues( @@ -502,7 +483,7 @@ fun SessionsList( modifier = Modifier .fillMaxWidth() .cardPlaceholder(isPlaceholderVisible) - .animateItemPlacement(), + .animateItem(), session = session, onClick = { onSessionSelected(it) }, onSync = { syncSession(it) }, @@ -594,7 +575,7 @@ private fun SessionLoadingIndicatorPreview() { } } -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable fun SessionCard( modifier: Modifier = Modifier, @@ -649,65 +630,66 @@ fun SessionCard( .background(containerColor) ) { Column { - ConstraintLayout( + Box( modifier = Modifier.fillMaxWidth(), ) { - val (title, labels) = createRefs() Box( modifier = Modifier - .constrainAs(title) { - start.linkTo(parent.start) - top.linkTo(parent.top) - bottom.linkTo(parent.bottom) - end.linkTo(labels.start) - width = Dimension.fillToConstraints - } + .fillMaxSize() ) { - LazyRow( - contentPadding = PaddingValues(horizontal = MenuItemPadding * 2) + Row( + modifier = Modifier + .padding(PaddingValues(horizontal = MenuItemPadding * 2)) + .clip(RoundedCornerShape(6.dp)) + .clickable { onClick() }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - item { - Row( - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .clickable { onClick() }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Crossfade( - modifier = Modifier.animateContentSize(), - targetState = session?.startLocationName, - label = "Start location name", - ) { - Text( - text = it ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } + val startLocationText = session?.startLocationName + ?: stringResource(R.string.unknown) + Crossfade( + modifier = Modifier + .weight( + startLocationText.length.toFloat(), + fill = false + ), + targetState = startLocationText, + label = "Start location name", + ) { startLocationText -> + Text( + text = startLocationText, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowRightAlt, + contentDescription = "", + tint = MaterialTheme.colorScheme.onSurface, + ) + val endLocationText = session?.endLocationName + ?: stringResource(R.string.unknown) + Crossfade( + modifier = Modifier + .weight( + (if (session?.endDateTime == null) "..." else endLocationText).length.toFloat(), + fill = false + ), + targetState = (session?.endDateTime == null) to endLocationText, + label = "End location name", + ) { + if (it.first) { Icon( - imageVector = Icons.Rounded.ArrowRightAlt, contentDescription = "", + imageVector = Icons.Rounded.MoreHoriz, + contentDescription = "", tint = MaterialTheme.colorScheme.onSurface, ) - Crossfade( - modifier = Modifier.animateContentSize(), - targetState = (session?.endDateTime == null) to session?.endLocationName, - label = "End location name", - ) { - if (it.first) { - Icon( - imageVector = Icons.Rounded.MoreHoriz, - contentDescription = "", - tint = MaterialTheme.colorScheme.onSurface, - ) - } else { - Text( - text = it.second ?: stringResource(R.string.unknown), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } + } else { + Text( + text = it.second, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) } } } @@ -718,10 +700,7 @@ fun SessionCard( session?.isNotOwned == true androidx.compose.animation.AnimatedVisibility( modifier = Modifier - .constrainAs(labels) { - end.linkTo(parent.end) - top.linkTo(parent.top) - }, + .align(Alignment.TopEnd), visible = areLabelsVisible ) { Row( @@ -730,13 +709,22 @@ fun SessionCard( verticalAlignment = Alignment.CenterVertically, ) { AnimatedVisibility(visible = session?.isLocal == true) { - Icon(imageVector = Icons.Rounded.Save, contentDescription = "") + Icon( + imageVector = Icons.Rounded.Save, + contentDescription = "" + ) } AnimatedVisibility(visible = session?.isSynced == true) { - Icon(imageVector = Icons.Rounded.CloudSync, contentDescription = "") + Icon( + imageVector = Icons.Rounded.CloudSync, + contentDescription = "" + ) } AnimatedVisibility(visible = session?.isNotOwned == true) { - Icon(imageVector = Icons.Rounded.PersonOff, contentDescription = "") + Icon( + imageVector = Icons.Rounded.PersonOff, + contentDescription = "" + ) } } } @@ -749,6 +737,7 @@ fun SessionCard( verticalAlignment = Alignment.CenterVertically ) { SessionDetailsList( + modifier = Modifier.weight(1f, fill = false), details = listOf( stringResource(R.string.distance) to if (session?.totalDistance == null) { stringResource(R.string.unknown) @@ -769,7 +758,9 @@ fun SessionCard( ) ?: stringResource(R.string.unknown)) ), ) - content() + Box(modifier = Modifier.padding(end = MenuItemPadding)) { + content() + } } } } diff --git a/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt b/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt index 5f6c5354..21330902 100644 --- a/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt @@ -50,7 +50,10 @@ import timber.log.Timber import java.time.ZonedDateTime import javax.inject.Inject import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) @HiltViewModel class SessionsViewModel @Inject constructor( private val sessionInteractor: SessionInteractor, @@ -67,7 +70,7 @@ class SessionsViewModel @Inject constructor( // FIXME: create a list with not yet synced sessions with the cloud (local cache vs fresh cloud data) private val deleteRequestedOnSessions = MutableStateFlow(persistentListOf()) - private val _ownedLocalSessionUUIDs = MutableStateFlow(listOf>()) + private val _ownedLocalSessionUUIDs = MutableStateFlow(listOf>()) val ownedLocalSessionUUIDs = _ownedLocalSessionUUIDs.asStateFlow() val isUserSignedIn = authInteractor.isUserSignedInStateFlow @@ -97,7 +100,7 @@ class SessionsViewModel @Inject constructor( } }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - private val _notOwnedSessionUUIDs = MutableStateFlow(listOf>()) + private val _notOwnedSessionUUIDs = MutableStateFlow(listOf>()) val isLoading = combine( localSessionsLoading, @@ -127,13 +130,13 @@ class SessionsViewModel @Inject constructor( notOwnedSessionUUIDs, deleteRequestedOnSessions, ) { synced, ownedLocal, notOwnedLocal, deleting -> - val sessions = mutableListOf>() + val sessions = mutableListOf>() sessions.addAll(synced.map { it.uuid to it.startDateTime }) sessions.addAll(ownedLocal) sessions.addAll(notOwnedLocal) val distinctSessions = sessions.distinct() val sortedSessions = distinctSessions - .sortedByDescending { it.second.toInstant().toEpochMilli() } + .sortedByDescending { it.second } .map { it.first } sortedSessions.intersect(sessionStateFlows.keys).forEach { uuid -> val sessionFlow = sessionStateFlows[uuid]!! diff --git a/app/src/main/java/illyan/jay/ui/sessions/model/UiSession.kt b/app/src/main/java/illyan/jay/ui/sessions/model/UiSession.kt index 4e7bd452..822bd6f4 100644 --- a/app/src/main/java/illyan/jay/ui/sessions/model/UiSession.kt +++ b/app/src/main/java/illyan/jay/ui/sessions/model/UiSession.kt @@ -16,6 +16,8 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.ui.sessions.model import com.google.android.gms.maps.model.LatLng @@ -23,13 +25,17 @@ import illyan.jay.domain.model.DomainLocation import illyan.jay.domain.model.DomainSession import illyan.jay.util.sphericalPathLength import java.time.ZonedDateTime +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant +@OptIn(ExperimentalTime::class) data class UiSession( val uuid: String, - val startDateTime: ZonedDateTime, - val endDateTime: ZonedDateTime?, + val startDateTime: Instant, + val endDateTime: Instant?, val startCoordinate: LatLng?, val endCoordinate: LatLng?, val totalDistance: Double?, @@ -52,7 +58,7 @@ fun DomainSession.toUiModel( locations: List, currentClientUUID: String, isLocal: Boolean = clientUUID == currentClientUUID, - currentTime: ZonedDateTime = ZonedDateTime.now(), + currentTime: Instant = Clock.System.now(), isSynced: Boolean = false, ): UiSession { return toUiModel( @@ -68,7 +74,7 @@ fun DomainSession.toUiModel( totalDistance: Double? = distance?.toDouble(), currentClientUUID: String, isLocal: Boolean = clientUUID == currentClientUUID, - currentTime: ZonedDateTime = ZonedDateTime.now(), + currentTime: Instant = Clock.System.now(), isSynced: Boolean = false, ): UiSession { return UiSession( @@ -81,11 +87,9 @@ fun DomainSession.toUiModel( startLocationName = startLocationName, endLocationName = endLocationName, duration = if (endDateTime != null) { - (endDateTime!!.toInstant().toEpochMilli() - startDateTime.toInstant().toEpochMilli()) - .milliseconds + (endDateTime!! - startDateTime) } else { - (currentTime.toInstant().toEpochMilli() - startDateTime.toInstant().toEpochMilli()) - .milliseconds + (currentTime - startDateTime) }, isSynced = isSynced, isLocal = isLocal, diff --git a/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt b/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt index 1872a3be..0858ea5f 100644 --- a/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt +++ b/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt @@ -23,6 +23,7 @@ import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -54,12 +55,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination @@ -75,8 +76,7 @@ import illyan.jay.ui.home.RoundedCornerRadius import illyan.jay.ui.profile.ProfileNavGraph import illyan.jay.ui.theme.JayTheme -@ProfileNavGraph -@Destination +@Destination @Composable fun DataSettingsDialogScreen( viewModel: DataSettingsViewModel = hiltViewModel(), @@ -279,31 +279,19 @@ fun MenuButtonWithDescription( text = description, showDescription = showDescription, ) { - ConstraintLayout( + Row( modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween ) { - val (button, toggle) = createRefs() - Row( - modifier = Modifier - .constrainAs(button) { - end.linkTo(toggle.start) - start.linkTo(parent.start) - width = Dimension.fillToConstraints - } - ) { - MenuButton( - onClick = onClick, - text = text, - ) - } + MenuButton( + modifier = Modifier.weight(1f, fill = false), + onClick = onClick, + text = text, + ) IconToggleButton( checked = showDescription, onCheckedChange = { showDescription = it }, - modifier = Modifier.constrainAs(toggle) { - top.linkTo(parent.top) - end.linkTo(parent.end) - } ) { Icon( imageVector = if (showDescription) { @@ -359,7 +347,7 @@ fun DescriptionCard( @Composable fun PreviewDataSettingsDialogContent() { JayTheme { - val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp + val screenWidthDp = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.width.toDp() } DataSettingsDialogContent(modifier = Modifier.dialogWidth(screenWidthDp)) } } \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/ui/settings/ml/MLSettings.kt b/app/src/main/java/illyan/jay/ui/settings/ml/MLSettings.kt index 71f57a1d..a5285b86 100644 --- a/app/src/main/java/illyan/jay/ui/settings/ml/MLSettings.kt +++ b/app/src/main/java/illyan/jay/ui/settings/ml/MLSettings.kt @@ -68,8 +68,7 @@ import illyan.jay.ui.settings.ml.model.UiModel import illyan.jay.ui.settings.user.BasicSetting import illyan.jay.ui.theme.JayTheme -@ProfileNavGraph -@Destination +@Destination @Composable fun MLSettingsDialogScreen( viewModel: MLSettingsViewModel = hiltViewModel(), diff --git a/app/src/main/java/illyan/jay/ui/settings/user/UserSettings.kt b/app/src/main/java/illyan/jay/ui/settings/user/UserSettings.kt index a9eedeb5..6f6846ae 100644 --- a/app/src/main/java/illyan/jay/ui/settings/user/UserSettings.kt +++ b/app/src/main/java/illyan/jay/ui/settings/user/UserSettings.kt @@ -16,6 +16,8 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.ui.settings.user import android.content.res.Configuration @@ -89,6 +91,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.generated.destinations.DataSettingsDialogScreenDestination +import com.ramcosta.composedestinations.generated.destinations.MLSettingsDialogScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator import illyan.jay.R @@ -102,20 +106,28 @@ import illyan.jay.ui.components.MenuButton import illyan.jay.ui.components.PreviewAccessibility import illyan.jay.ui.components.SmallCircularProgressIndicator import illyan.jay.ui.components.TooltipElevatedCard -import illyan.jay.ui.destinations.DataSettingsDialogScreenDestination -import illyan.jay.ui.destinations.MLSettingsDialogScreenDestination import illyan.jay.ui.profile.ProfileNavGraph import illyan.jay.ui.settings.user.model.UiPreferences import illyan.jay.ui.theme.JayTheme +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.format.DateTimeFormat +import kotlinx.datetime.toLocalDateTime import java.time.ZoneId import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.util.UUID import kotlin.random.Random +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant -@ProfileNavGraph -@Destination +@Destination @Composable fun UserSettingsDialogScreen( viewModel: UserSettingsViewModel = hiltViewModel(), @@ -140,7 +152,9 @@ fun UserSettingsDialogScreen( setFreeDriveAutoStart = viewModel::setFreeDriveAutoStart, setAdVisibility = viewModel::setAdVisibility, setDynamicColorEnabled = viewModel::setDynamicColorEnabled, - navigateToDataSettings = { destinationsNavigator.navigate(DataSettingsDialogScreenDestination) }, + navigateToDataSettings = { destinationsNavigator.navigate( + DataSettingsDialogScreenDestination + ) }, navigateToMLSettings = { destinationsNavigator.navigate(MLSettingsDialogScreenDestination) }, ) } @@ -163,7 +177,6 @@ fun UserSettingsDialogContent( navigateToMLSettings: () -> Unit = {}, ) { Crossfade( - modifier = modifier.animateContentSize(), targetState = showAnalyticsRequestDialog, label = "User Settings Dialog Content", ) { @@ -427,20 +440,12 @@ private fun SyncPreferencesButton( @Composable private fun LastUpdateLabel( - lastUpdate: ZonedDateTime, + lastUpdate: Instant, ) { - val time = lastUpdate - .withZoneSameInstant(ZoneId.systemDefault()) - .minusNanos(lastUpdate.nano.toLong()) // No millis in formatted time - .format(DateTimeFormatter.ISO_LOCAL_TIME) - val date = lastUpdate - .withZoneSameInstant(ZoneId.systemDefault()) - .minusNanos(lastUpdate.nano.toLong()) // No millis in formatted time - .format(DateTimeFormatter.ISO_LOCAL_DATE) val isDateVisible by remember { derivedStateOf { - lastUpdate.toEpochSecond().seconds.inWholeDays != - ZonedDateTime.now().toEpochSecond().seconds.inWholeDays + lastUpdate.toEpochMilliseconds().milliseconds.inWholeDays != + Clock.System.now().toEpochMilliseconds().milliseconds.inWholeDays } } val textStyle = MaterialTheme.typography.bodyMedium @@ -453,12 +458,16 @@ private fun LastUpdateLabel( ) { AnimatedVisibility(visible = isDateVisible) { Text( - text = date, + text = lastUpdate.toLocalDateTime(TimeZone.currentSystemDefault()).date.format( + LocalDate.Formats.ISO + ), style = textStyle, ) } Text( - text = time, + text = lastUpdate.toLocalDateTime(TimeZone.currentSystemDefault()).time.format( + LocalTime.Formats.ISO + ), style = textStyle ) } @@ -917,7 +926,7 @@ private fun generateRandomUserPreferences(): UiPreferences { return UiPreferences( userUUID = UUID.randomUUID().toString(), clientUUID = UUID.randomUUID().toString(), - lastUpdate = ZonedDateTime.now().minusDays(if (Random.nextBoolean()) 1 else 0), + lastUpdate = Clock.System.now().minus((if (Random.nextBoolean()) 1 else 0).days), analyticsEnabled = Random.nextBoolean(), freeDriveAutoStart = Random.nextBoolean(), showAds = Random.nextBoolean(), diff --git a/app/src/main/java/illyan/jay/ui/settings/user/UserSettingsViewModel.kt b/app/src/main/java/illyan/jay/ui/settings/user/UserSettingsViewModel.kt index e59e6b19..63aaae4c 100644 --- a/app/src/main/java/illyan/jay/ui/settings/user/UserSettingsViewModel.kt +++ b/app/src/main/java/illyan/jay/ui/settings/user/UserSettingsViewModel.kt @@ -36,7 +36,11 @@ import kotlinx.coroutines.flow.update import timber.log.Timber import java.time.ZonedDateTime import javax.inject.Inject +import kotlin.time.Clock +import kotlin.time.Duration.Companion.days +import kotlin.time.ExperimentalTime +@OptIn(ExperimentalTime::class) @HiltViewModel class UserSettingsViewModel @Inject constructor( private val settingsInteractor: SettingsInteractor, @@ -71,8 +75,8 @@ class UserSettingsViewModel @Inject constructor( // or it was turned off a while ago, show the dialog again val shouldShowAnalyticsRequest = uiPreferences?.let { if (it.lastUpdateToAnalytics == null) return@let true - val thresholdTime = it.lastUpdateToAnalytics.plusDays(DaysToWaitForRequest) - val isAnalyticsSetLongTimeAgo = thresholdTime < ZonedDateTime.now() + val thresholdTime = it.lastUpdateToAnalytics.plus(DaysToWaitForRequest.days) + val isAnalyticsSetLongTimeAgo = thresholdTime < Clock.System.now() isAnalyticsSetLongTimeAgo && !it.analyticsEnabled } ?: true Timber.v("Should show Analytics Request on User Settings Screen? $shouldShowAnalyticsRequest") diff --git a/app/src/main/java/illyan/jay/ui/settings/user/model/UiPreferences.kt b/app/src/main/java/illyan/jay/ui/settings/user/model/UiPreferences.kt index d44679a7..7b800efd 100644 --- a/app/src/main/java/illyan/jay/ui/settings/user/model/UiPreferences.kt +++ b/app/src/main/java/illyan/jay/ui/settings/user/model/UiPreferences.kt @@ -16,12 +16,16 @@ * If not, see . */ +@file:OptIn(ExperimentalTime::class) + package illyan.jay.ui.settings.user.model import android.os.Build import illyan.jay.domain.model.DomainPreferences import illyan.jay.domain.model.Theme import java.time.ZonedDateTime +import kotlin.time.ExperimentalTime +import kotlin.time.Instant data class UiPreferences( val userUUID: String? = null, @@ -31,8 +35,8 @@ data class UiPreferences( val theme: Theme = DomainPreferences.Default.theme, val dynamicColorEnabled: Boolean = DomainPreferences.Default.dynamicColorEnabled, val canUseDynamicColor: Boolean = false, - val lastUpdate: ZonedDateTime = DomainPreferences.Default.lastUpdate, - val lastUpdateToAnalytics: ZonedDateTime? = null, + val lastUpdate: Instant = DomainPreferences.Default.lastUpdate, + val lastUpdateToAnalytics: Instant? = null, val clientUUID: String? = null, val downloadedModels: Int = 0, ) diff --git a/app/src/main/java/illyan/jay/ui/sheet/Sheet.kt b/app/src/main/java/illyan/jay/ui/sheet/Sheet.kt index addcfd2a..6223595e 100644 --- a/app/src/main/java/illyan/jay/ui/sheet/Sheet.kt +++ b/app/src/main/java/illyan/jay/ui/sheet/Sheet.kt @@ -19,36 +19,26 @@ package illyan.jay.ui.sheet import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel -import com.google.accompanist.navigation.material.ExperimentalMaterialNavigationApi import com.ramcosta.composedestinations.DestinationsNavHost -import com.ramcosta.composedestinations.animations.defaults.RootNavGraphDefaultAnimations -import com.ramcosta.composedestinations.animations.rememberAnimatedNavHostEngine import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.NavGraph -import com.ramcosta.composedestinations.annotation.RootNavGraph +import com.ramcosta.composedestinations.annotation.NavHostGraph +import com.ramcosta.composedestinations.generated.NavGraphs +import com.ramcosta.composedestinations.generated.destinations.PoiScreenDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator -import illyan.jay.ui.NavGraphs -import illyan.jay.ui.destinations.PoiDestination +import illyan.jay.ui.theme.DefaultDestinationTransitions -@RootNavGraph -@NavGraph +@NavHostGraph annotation class SheetNavGraph( val start: Boolean = false, ) -@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterialNavigationApi::class) -@SheetNavGraph(start = true) -@Destination +@OptIn(ExperimentalAnimationApi::class) +@Destination(start = true) @Composable fun SheetScreen( modifier: Modifier = Modifier, @@ -58,7 +48,7 @@ fun SheetScreen( DisposableEffect(Unit) { viewModel.loadReceiver { destinationsNavigator.navigate( - PoiDestination(it) + PoiScreenDestination(it) ) } onDispose { viewModel.dispose() } @@ -66,15 +56,6 @@ fun SheetScreen( DestinationsNavHost( modifier = modifier, navGraph = NavGraphs.menu, - engine = rememberAnimatedNavHostEngine( - rootDefaultAnimations = RootNavGraphDefaultAnimations( - enterTransition = { - slideInVertically(tween(200)) + fadeIn(tween(200)) - }, - exitTransition = { - slideOutVertically(tween(200)) + fadeOut(tween(200)) - } - ) - ) + defaultTransitions = DefaultDestinationTransitions ) } \ No newline at end of file diff --git a/app/src/main/java/illyan/jay/ui/theme/Animation.kt b/app/src/main/java/illyan/jay/ui/theme/Animation.kt new file mode 100644 index 00000000..3f73008e --- /dev/null +++ b/app/src/main/java/illyan/jay/ui/theme/Animation.kt @@ -0,0 +1,26 @@ +package illyan.jay.ui.theme + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.navigation.NavBackStackEntry +import com.ramcosta.composedestinations.animations.NavHostAnimatedDestinationStyle + +object DefaultDestinationTransitions : NavHostAnimatedDestinationStyle() { + override val enterTransition: AnimatedContentTransitionScope.() -> EnterTransition + get() = { slideInVertically(tween(200)) + fadeIn(tween(200)) } + + override val exitTransition: AnimatedContentTransitionScope.() -> ExitTransition + get() = { slideOutVertically(tween(200)) + fadeOut(tween(200)) } + + override val popEnterTransition: AnimatedContentTransitionScope.() -> EnterTransition + get() = { slideInVertically(tween(200)) + fadeIn(tween(200)) } + + override val popExitTransition: AnimatedContentTransitionScope.() -> ExitTransition + get() = { slideOutVertically(tween(200)) + fadeOut(tween(200)) } +} diff --git a/app/src/main/java/illyan/jay/ui/theme/Theme.kt b/app/src/main/java/illyan/jay/ui/theme/Theme.kt index d5ff25e2..a826e012 100644 --- a/app/src/main/java/illyan/jay/ui/theme/Theme.kt +++ b/app/src/main/java/illyan/jay/ui/theme/Theme.kt @@ -18,11 +18,15 @@ package illyan.jay.ui.theme -import android.app.Activity +import android.annotation.SuppressLint import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalActivity +import androidx.activity.enableEdgeToEdge +import androidx.annotation.RequiresApi import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ColorScheme @@ -32,29 +36,22 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State -import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalInspectionMode import androidx.core.view.WindowCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.accompanist.systemuicontroller.rememberSystemUiController -import illyan.jay.R import illyan.jay.domain.model.Theme import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlin.math.roundToInt private val LightColors = lightColorScheme( primary = md_theme_light_primary, @@ -203,10 +200,8 @@ fun animateColorScheme( return remember { derivedStateOf { getCurrentColorScheme() } } } -private const val LightMapStyleUrl = "mapbox://styles/illyan/cl3kgeewz004k15ldn7x091r2" -private const val DarkMapStyleUrl = "mapbox://styles/illyan/cl3kg2wpq001414muhgrpj15u" -private val _mapStyleUrl = MutableStateFlow(LightMapStyleUrl) -val mapStyleUrl = _mapStyleUrl.asStateFlow() +const val LightMapStyleUrl = "mapbox://styles/illyan/cl3kgeewz004k15ldn7x091r2" +const val DarkMapStyleUrl = "mapbox://styles/illyan/cl3kg2wpq001414muhgrpj15u" private lateinit var darkMapMarkers: MapMarkers // val drawable = AppCompatResources.getDrawable(context, R.drawable.jay_puck_transparent_background) @@ -215,112 +210,126 @@ private lateinit var lightMapMarkers: MapMarkers private val _mapMarkers = MutableStateFlow(null) val mapMarkers = _mapMarkers.asStateFlow() -val LocalTheme = compositionLocalOf { null } - @Composable fun JayThemeWithViewModel( viewModel: ThemeViewModel = hiltViewModel(), content: @Composable () -> Unit, ) { + val theme by viewModel.theme.collectAsStateWithLifecycle() + val dynamicColorEnabled by viewModel.dynamicColorEnabled.collectAsStateWithLifecycle() + val isNight by viewModel.isNight.collectAsStateWithLifecycle() JayTheme( - themeState = viewModel.theme.collectAsStateWithLifecycle(), - dynamicColorEnabledState = viewModel.dynamicColorEnabled.collectAsStateWithLifecycle(), - isNightState = viewModel.isNight.collectAsStateWithLifecycle(), - content = content + theme = theme ?: Theme.System, + dynamicColorEnabled = dynamicColorEnabled, + isNight = isNight, + content = content, ) } +@SuppressLint("NewApi") @Composable fun JayTheme( - themeState: State = mutableStateOf(Theme.System), - dynamicColorEnabledState: State = mutableStateOf(true), - isNightState: State = mutableStateOf(true), + theme: Theme = Theme.System, + dynamicColorEnabled: Boolean = false, + isNight: Boolean = true, content: @Composable () -> Unit, ) { - val theme by themeState - val dynamicColorEnabled by dynamicColorEnabledState - val isNight by isNightState - val isSystemInDarkTheme: Boolean = isSystemInDarkTheme() - val isDark by remember { - derivedStateOf { - when (theme) { - Theme.Light -> false - Theme.Dark -> true - Theme.System -> isSystemInDarkTheme - Theme.DayNightCycle -> isNight - null -> null - } + val isSystemInDarkTheme = isSystemInDarkTheme() + val isDark = remember(theme, isNight, isSystemInDarkTheme) { + when (theme) { + Theme.Light -> false + Theme.Dark -> true + Theme.System -> isSystemInDarkTheme + Theme.DayNightCycle -> isNight } } - val context = LocalContext.current - val targetColorScheme by remember { - derivedStateOf { - val canUseDynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - if (dynamicColorEnabled && canUseDynamicColor) { - when (theme) { - Theme.Dark -> dynamicDarkColorScheme(context) - Theme.Light -> dynamicLightColorScheme(context) - Theme.System -> if (isSystemInDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - Theme.DayNightCycle -> if (isNight) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - null -> LightColors - } - } else { - when (theme) { - Theme.Dark -> DarkColors - Theme.Light -> LightColors - Theme.System -> if (isSystemInDarkTheme) DarkColors else LightColors - Theme.DayNightCycle -> if (isNight) DarkColors else LightColors - null -> LightColors - } + val dynamicLightColorScheme = dynamicLightColorScheme() + val dynamicDarkColorScheme = dynamicDarkColorScheme() + val canUseDynamicColors = canUseDynamicColors() + val targetColorScheme = remember(theme, dynamicColorEnabled, isNight, isSystemInDarkTheme, isDark) { + if (dynamicColorEnabled && canUseDynamicColors) { + when (theme) { + Theme.Dark -> dynamicDarkColorScheme + Theme.Light -> dynamicLightColorScheme + Theme.System -> if (isSystemInDarkTheme) dynamicDarkColorScheme else dynamicLightColorScheme + Theme.DayNightCycle -> if (isNight) dynamicDarkColorScheme else dynamicLightColorScheme + } + } else { + when (theme) { + Theme.Dark -> DarkColors + Theme.Light -> LightColors + Theme.System -> if (isSystemInDarkTheme) DarkColors else LightColors + Theme.DayNightCycle -> if (isNight) DarkColors else LightColors } } } - val systemUiController = rememberSystemUiController() - val colorScheme by animateColorScheme(targetColorScheme, spring(stiffness = Spring.StiffnessLow)) - val view = LocalView.current - val density = LocalDensity.current.density - val markerHeight = (36.dp * density).value.roundToInt() - lightMapMarkers = MapMarkers( - height = markerHeight, - locationPuckDrawableId = R.drawable.jay_puck_transparent_background, - poiDrawableId = R.drawable.jay_marker_icon_v3_round, - pathStartDrawableId = R.drawable.jay_begin_light_marker_icon, - pathEndDrawableId = R.drawable.jay_finish_light_marker_icon, - ) - darkMapMarkers = MapMarkers( - height = markerHeight, - locationPuckDrawableId = R.drawable.jay_puck_transparent_background, - poiDrawableId = R.drawable.jay_marker_icon_v3_round, - pathStartDrawableId = R.drawable.jay_begin_dark_marker_icon, - pathEndDrawableId = R.drawable.jay_finish_dark_marker_icon, + + ThemeSystemWindow(isDark, dynamicColorEnabled) + + MaterialTheme( + colorScheme = targetColorScheme, + typography = MaterialTheme.typography, + content = content ) - if (!view.isInEditMode) { - LaunchedEffect(isDark) { - isDark?.let { isDark -> - val window = (view.context as Activity).window - WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = isDark - WindowCompat.setDecorFitsSystemWindows(window, false) +} - // Update all of the system bar colors to be transparent - // and use dark icons if we're in light theme - systemUiController.setSystemBarsColor( - color = Color.Transparent, - darkIcons = !isDark - ) - _mapStyleUrl.update { if (isDark) DarkMapStyleUrl else LightMapStyleUrl } - _mapMarkers.update { if (isDark) darkMapMarkers else lightMapMarkers } - } +@Composable +fun canUseDynamicColors(): Boolean { + return LocalInspectionMode.current || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S +} + +@SuppressLint("NewApi") +@Composable +fun ThemeSystemWindow(isDark: Boolean, isDynamicColors: Boolean) { + val dynamicDarkColorScheme = dynamicDarkColorScheme() + val dynamicLightColorScheme = dynamicLightColorScheme() + val canUseDynamicColors = canUseDynamicColors() + val colorScheme = remember(isDark, isDynamicColors) { + if (isDynamicColors && canUseDynamicColors) { + if (isDark) dynamicDarkColorScheme else dynamicLightColorScheme + } else if (isDark) { + DarkColors + } else { + LightColors } } + if (!LocalInspectionMode.current) { + val activity = LocalActivity.current as ComponentActivity + SideEffect { + WindowCompat.getInsetsController(activity.window, activity.window.decorView).isAppearanceLightStatusBars = !isDark + } + LaunchedEffect(colorScheme) { + activity.enableEdgeToEdge( + statusBarStyle = SystemBarStyle.auto( + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + ) { isDark }, + navigationBarStyle = SystemBarStyle.auto( + colorScheme.background.toArgb(), + colorScheme.background.toArgb(), + ) { isDark }, + ) + } + } +} - CompositionLocalProvider( - LocalTheme provides theme, - ) { - MaterialTheme( - colorScheme = colorScheme, - typography = Typography, - content = content - ) +@RequiresApi(Build.VERSION_CODES.S) +@Composable +fun dynamicDarkColorScheme(): ColorScheme { + return if (canUseDynamicColors()) { + dynamicDarkColorScheme(LocalContext.current) + } else { + DarkColors + } +} + +@RequiresApi(Build.VERSION_CODES.S) +@Composable +fun dynamicLightColorScheme(): ColorScheme { + return if (canUseDynamicColors()) { + dynamicLightColorScheme(LocalContext.current) + } else { + LightColors } } diff --git a/app/src/main/java/illyan/jay/util/Util.kt b/app/src/main/java/illyan/jay/util/Util.kt index 4ac5969c..fa417f9c 100644 --- a/app/src/main/java/illyan/jay/util/Util.kt +++ b/app/src/main/java/illyan/jay/util/Util.kt @@ -16,6 +16,8 @@ * If not, see . */ // ktlint-disable filename +@file:OptIn(ExperimentalTime::class) + package illyan.jay.util import android.os.SystemClock @@ -31,9 +33,9 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.core.graphics.ColorUtils -import com.google.accompanist.placeholder.PlaceholderHighlight -import com.google.accompanist.placeholder.material.placeholder -import com.google.accompanist.placeholder.material.shimmer +import com.eygraber.compose.placeholder.PlaceholderHighlight +import com.eygraber.compose.placeholder.material3.placeholder +import com.eygraber.compose.placeholder.material3.shimmer import com.google.android.gms.maps.model.LatLng import com.google.android.gms.tasks.Task import com.google.firebase.Timestamp @@ -42,7 +44,7 @@ import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.GeoPoint import com.google.firebase.firestore.Query import com.google.firebase.firestore.WriteBatch -import com.google.firebase.firestore.ktx.snapshots +import com.google.firebase.firestore.snapshots import com.google.maps.android.SphericalUtil import com.google.maps.android.ktx.utils.sphericalPathLength import com.mapbox.geojson.Point @@ -54,11 +56,11 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.first import timber.log.Timber -import java.time.Instant -import java.time.ZoneOffset -import java.time.ZonedDateTime +import kotlin.time.Clock import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +import kotlin.time.ExperimentalTime +import kotlin.time.Instant /** * Sensor timestamp to absolute time. @@ -69,8 +71,8 @@ import kotlin.time.Duration.Companion.seconds * counted from Instant.EPOCH. * @return timestamp counted from Instant.EPOCH. */ -fun sensorTimestampToAbsoluteTime(timestamp: Long) = Instant.now() - .toEpochMilli() - (SystemClock.elapsedRealtimeNanos() - timestamp) / 1.seconds.inWholeMicroseconds +fun sensorTimestampToAbsoluteTime(timestamp: Long) = Clock.System.now() + .toEpochMilliseconds() - (SystemClock.elapsedRealtimeNanos() - timestamp) / 1.seconds.inWholeMicroseconds fun Duration.format( separator: String = " ", @@ -144,18 +146,12 @@ fun GeoPoint.toLatLng() = LatLng(latitude, longitude) fun Point.toLatLng() = LatLng(latitude(), longitude()) -fun Instant.toTimestamp() = Timestamp(epochSecond, nano) - -fun Instant.toZonedDateTime(): ZonedDateTime = toTimestamp().toZonedDateTime() - -fun ZonedDateTime.toTimestamp() = toInstant().toTimestamp() - -fun Timestamp.toInstant(): Instant = Instant.ofEpochSecond(seconds, nanoseconds.toLong()) +fun Instant.toTimestamp() = Timestamp(epochSeconds, nanosecondsOfSecond) -fun Timestamp.toZonedDateTime(): ZonedDateTime = toInstant().atZone(ZoneOffset.UTC) +fun Timestamp.toKotlinInstant() = Instant.fromEpochSeconds(seconds, nanoseconds) fun List.sphericalPathLength() = sortedBy { - it.zonedDateTime.toInstant().toEpochMilli() + it.timestamp.toEpochMilliseconds() }.map { it.latLng }.sphericalPathLength() fun Modifier.textPlaceholder( diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml new file mode 100644 index 00000000..9ae330ed --- /dev/null +++ b/app/src/main/res/values-night-v31/themes.xml @@ -0,0 +1,25 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 00000000..3484658e --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,25 @@ + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c18dc4c3..2de8ea98 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,77 +1,85 @@ [versions] -compose = "1.6.8" +compose = "1.8.3" compose-compiler = "1.5.14" -kotlin = "2.0.0" -ksp = "2.0.0-1.0.23" -agp = "8.5.1" -accompanist = "0.34.0" -room = "2.6.1" - -jetbrains-kotlinx-serialization = "1.7.1" -jetbrains-kotlinx-collections-immutable = "0.3.7" -jetbrains-kotlinx-coroutines = "1.9.0-RC" - -androidx-core = "1.13.1" -androidx-collection = "1.4.2" -androidx-appcompat = "1.7.0" -androidx-activity = "1.9.1" -androidx-compose-material3 = "1.2.1" -androidx-constraintlayout-compose = "1.0.1" -androidx-profileinstaller = "1.3.1" -androidx-datastore = "1.1.1" -androidx-navigation-safeargs = "2.7.7" -androidx-test-junit = "1.2.1" -androidx-lifecycle = "2.8.4" - -hilt = "2.51.1" +kotlin = "2.2.0" +ksp = "2.2.0-2.0.2" +agp = "8.12.0" +accompanist = "0.37.3" +room = "2.7.2" +androidx-credentials = "1.5.0" +google-identity-googleid = "1.1.1" + +jetbrains-kotlinx-serialization = "1.9.0" +jetbrains-kotlinx-collections-immutable = "0.4.0" +jetbrains-kotlinx-coroutines = "1.10.2" +jetbrains-kotlinx-datetime = "0.7.1" + +androidx-core = "1.16.0" +androidx-collection = "1.5.0" +androidx-appcompat = "1.7.1" +androidx-activity = "1.10.1" +androidx-compose-material3 = "1.3.2" +androidx-constraintlayout-compose = "1.1.1" +androidx-profileinstaller = "1.4.1" +androidx-datastore = "1.1.7" +androidx-navigation-safeargs = "2.9.3" +androidx-test-junit = "1.3.0" +androidx-lifecycle = "2.9.2" + +hilt = "2.57" hilt-navigation-compose = "1.2.0" -zstd-jni = "1.5.6-4" +zstd-jni = "1.5.7-4" solarized = "1.0.8" hlcaptain-compose-scrollbar = "0.0.3-alpha" saket-swipe = "1.3.0" timber = "5.0.1" -compose-destinations = "1.10.2" +compose-destinations = "2.2.0" coil-compose = "2.7.0" apache-commons-math3 = "3.6.1" -mapbox-maps = "11.5.1" -mapbox-search = "2.3.1" -mapbox-navigation = "3.3.0-beta.1" +mapbox-maps = "11.14.0" +mapbox-search = "2.14.0" +mapbox-navigation = "3.11.0" google-secrets = "2.0.1" -google-gms-services = "4.4.2" +google-gms-services = "4.4.3" google-gms-play-services-location = "21.3.0" -google-gms-play-services-auth = "21.2.0" -google-gms-play-services-ads = "23.2.0" +google-gms-play-services-auth = "21.4.0" +google-gms-play-services-ads = "24.5.0" google-material = "1.12.0" -firebase-crashlytics = "3.0.2" -firebase-perf = "1.4.2" -firebase-bom = "33.1.2" -tensorflow-lite = "2.16.1" +firebase-crashlytics = "3.0.6" +firebase-perf = "2.0.0" +firebase-bom = "34.1.0" +tensorflow-lite = "2.17.0" -junit5 = "1.9.3.0" -junit = "5.10.3" +junit5 = "1.13.1.0" +junit = "5.13.4" junit4 = "4.13.2" -mockk = "1.13.12" +mockk = "1.14.5" -desugar-jdk-libs = "2.0.4" +desugar-jdk-libs = "2.1.5" -sonarqube = "4.0.0.2929" +sonarqube = "6.2.0.5505" [libraries] jetbrains-kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } +androidx-credentials = { module = "androidx.credentials:credentials", version.ref = "androidx-credentials" } +androidx-credentials-play-services-auth = { module = "androidx.credentials:credentials-play-services-auth", version.ref = "androidx-credentials" } +google-identity-googleid = { module = "com.google.android.libraries.identity.googleid:googleid", version.ref = "google-identity-googleid" } jetbrains-kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "jetbrains-kotlinx-collections-immutable" } jetbrains-kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "jetbrains-kotlinx-serialization" } jetbrains-kotlinx-serialization-protobuf = { module = "org.jetbrains.kotlinx:kotlinx-serialization-protobuf", version.ref = "jetbrains-kotlinx-serialization" } jetbrains-kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "jetbrains-kotlinx-coroutines" } jetbrains-kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "jetbrains-kotlinx-coroutines" } jetbrains-kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "jetbrains-kotlinx-coroutines" } +jetbrains-kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "jetbrains-kotlinx-datetime" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } +androidx-splash = { module = "androidx.core:core-splashscreen", version = "1.2.0-rc01" } androidx-collection-ktx = { module = "androidx.collection:collection-ktx", version.ref = "androidx-collection" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat" } androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } @@ -82,14 +90,10 @@ androidx-compose-ui-util = { module = "androidx.compose.ui:ui-util", version.ref androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "compose" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "compose" } androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "compose" } -androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "compose" } androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref = "androidx-compose-material3" } -androidx-compose-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "compose" } -androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" } -androidx-constraintlayout-compose = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "androidx-constraintlayout-compose" } androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "compose" } androidx-profileinstaller = { module = "androidx.profileinstaller:profileinstaller", version.ref = "androidx-profileinstaller" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } @@ -99,11 +103,12 @@ androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "a androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } google-material = { module = "com.google.android.material:material", version.ref = "google-material" } +google-material-icons = { module = "androidx.compose.material:material-icons-extended", version = "1.7.8" } google-gms-play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "google-gms-play-services-location" } google-gms-play-services-auth = { module = "com.google.android.gms:play-services-auth", version.ref = "google-gms-play-services-auth" } google-gms-play-services-ads = { module = "com.google.android.gms:play-services-ads", version.ref = "google-gms-play-services-ads" } -google-maps-utils = { module = "com.google.maps.android:android-maps-utils", version = "3.8.2" } -google-maps-utils-ktx = { module = "com.google.maps.android:maps-utils-ktx", version = "5.1.1" } +google-maps-utils = { module = "com.google.maps.android:android-maps-utils", version = "3.14.0" } +google-maps-utils-ktx = { module = "com.google.maps.android:maps-utils-ktx", version = "5.2.0" } firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-auth = { module = "com.google.firebase:firebase-auth" } @@ -123,19 +128,20 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } solarized = { module = "com.github.phototime:solarized-android", version.ref = "solarized" } apache-commons-math3 = { module = "org.apache.commons:commons-math3", version.ref = "apache-commons-math3" } -mapbox-maps = { module = "com.mapbox.maps:android", version.ref = "mapbox-maps" } -mapbox-search = { module = "com.mapbox.search:mapbox-search-android-ui", version.ref = "mapbox-search" } -mapbox-navigation = { module = "com.mapbox.navigationcore:android", version.ref = "mapbox-navigation" } +mapbox-maps = { module = "com.mapbox.maps:android-ndk27", version.ref = "mapbox-maps" } +mapbox-maps-compose = { module = "com.mapbox.extension:maps-compose-ndk27", version.ref = "mapbox-maps" } +mapbox-search = { module = "com.mapbox.search:mapbox-search-android-ui-ndk27", version.ref = "mapbox-search" } +mapbox-navigation = { module = "com.mapbox.navigationcore:android-ndk27", version.ref = "mapbox-navigation" } -accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } -accompanist-placeholder-material = { module = "com.google.accompanist:accompanist-placeholder-material", version.ref = "accompanist" } +placeholder = { module = "com.eygraber:compose-placeholder-material3", version = "1.0.11" } hilt = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt-navigation-compose" } -compose-destinations-animations-core = { module = "io.github.raamcosta.compose-destinations:animations-core", version.ref = "compose-destinations" } +compose-destinations-core = { module = "io.github.raamcosta.compose-destinations:core", version.ref = "compose-destinations" } +compose-destinations-bottom-sheet = { module = "io.github.raamcosta.compose-destinations:bottom-sheet", version.ref = "compose-destinations" } compose-destinations-ksp = { module = "io.github.raamcosta.compose-destinations:ksp", version.ref = "compose-destinations" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 080d0926..84e8b461 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,24 +1,6 @@ -# -# Copyright (c) 2023-2024 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 . -# - -#Thu Jul 27 12:20:23 CEST 2023 +#Sun Aug 03 22:23:00 CEST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle.kts b/settings.gradle.kts index 96e26814..2301531f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -69,5 +69,9 @@ dependencyResolutionManagement { } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} + rootProject.name = "Jay" include(":app")