diff --git a/app/build.gradle b/app/build.gradle
index 9154cdba..9320a97a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -40,8 +40,8 @@ android {
applicationId "illyan.jay"
minSdk 21
targetSdk 33
- versionCode 9
- versionName "0.2.6-alpha"
+ versionCode 10
+ versionName "0.2.7-alpha"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
diff --git a/app/src/main/java/illyan/jay/MainActivity.kt b/app/src/main/java/illyan/jay/MainActivity.kt
index 0bcd0f79..ecf25913 100644
--- a/app/src/main/java/illyan/jay/MainActivity.kt
+++ b/app/src/main/java/illyan/jay/MainActivity.kt
@@ -34,7 +34,7 @@ import com.ramcosta.composedestinations.DestinationsNavHost
import dagger.hilt.android.AndroidEntryPoint
import illyan.jay.domain.interactor.AuthInteractor
import illyan.jay.ui.NavGraphs
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.theme.JayTheme
import javax.inject.Inject
@@ -65,7 +65,7 @@ class MainActivity : AppCompatActivity() {
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
fun MainScreen(
modifier: Modifier = Modifier
diff --git a/app/src/main/java/illyan/jay/ui/components/PreviewLightDarkTheme.kt b/app/src/main/java/illyan/jay/data/DataStatus.kt
similarity index 66%
rename from app/src/main/java/illyan/jay/ui/components/PreviewLightDarkTheme.kt
rename to app/src/main/java/illyan/jay/data/DataStatus.kt
index 151a32ff..66a73fcc 100644
--- a/app/src/main/java/illyan/jay/ui/components/PreviewLightDarkTheme.kt
+++ b/app/src/main/java/illyan/jay/data/DataStatus.kt
@@ -16,20 +16,9 @@
* If not, see .
*/
-package illyan.jay.ui.components
+package illyan.jay.data
-import android.content.res.Configuration.UI_MODE_NIGHT_YES
-import androidx.compose.ui.tooling.preview.Preview
-
-@Preview(
- name = "Dark theme",
- group = "themes",
- uiMode = UI_MODE_NIGHT_YES,
- showBackground = true,
-)
-@Preview(
- name = "Light theme",
- group = "themes",
- showBackground = true,
+data class DataStatus(
+ val data: T? = null,
+ val isLoading: Boolean? = null
)
-annotation class PreviewLightDarkTheme
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/data/network/Mapping.kt b/app/src/main/java/illyan/jay/data/network/Mapping.kt
index 62054eca..8c08b440 100644
--- a/app/src/main/java/illyan/jay/data/network/Mapping.kt
+++ b/app/src/main/java/illyan/jay/data/network/Mapping.kt
@@ -25,9 +25,11 @@ import com.google.firebase.Timestamp
import com.google.firebase.firestore.Blob
import com.google.firebase.firestore.DocumentSnapshot
import illyan.jay.BuildConfig
+import illyan.jay.data.DataStatus
import illyan.jay.data.network.model.FirestoreLocation
import illyan.jay.data.network.model.FirestorePath
import illyan.jay.data.network.model.FirestoreSession
+import illyan.jay.data.network.model.FirestoreUser
import illyan.jay.data.network.model.FirestoreUserPreferences
import illyan.jay.data.network.serializers.TimestampSerializer
import illyan.jay.domain.model.DomainLocation
@@ -379,3 +381,17 @@ fun FirestoreLocation.toDomainModel(
speedAccuracy = speedAccuracy,
verticalAccuracy = verticalAccuracy.toShort()
)
+
+fun DataStatus.toDomainPreferencesStatus(): DataStatus {
+ return DataStatus(
+ data = data?.run { preferences?.toDomainModel(uuid) },
+ isLoading = isLoading
+ )
+}
+
+fun DataStatus.toDomainSessionsStatus(): DataStatus> {
+ return DataStatus(
+ data = data?.run { sessions.map { it.toDomainModel(uuid) } },
+ isLoading = isLoading
+ )
+}
diff --git a/app/src/main/java/illyan/jay/data/network/datasource/PreferencesNetworkDataSource.kt b/app/src/main/java/illyan/jay/data/network/datasource/PreferencesNetworkDataSource.kt
index 43b09cb8..d697fe15 100644
--- a/app/src/main/java/illyan/jay/data/network/datasource/PreferencesNetworkDataSource.kt
+++ b/app/src/main/java/illyan/jay/data/network/datasource/PreferencesNetworkDataSource.kt
@@ -21,8 +21,10 @@ package illyan.jay.data.network.datasource
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import com.google.firebase.firestore.WriteBatch
+import illyan.jay.data.DataStatus
import illyan.jay.data.network.model.FirestoreUser
import illyan.jay.data.network.toDomainModel
+import illyan.jay.data.network.toDomainPreferencesStatus
import illyan.jay.data.network.toFirestoreModel
import illyan.jay.di.CoroutineScopeIO
import illyan.jay.domain.interactor.AuthInteractor
@@ -30,7 +32,7 @@ import illyan.jay.domain.model.DomainPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import timber.log.Timber
import javax.inject.Inject
@@ -41,41 +43,48 @@ class PreferencesNetworkDataSource @Inject constructor(
private val userNetworkDataSource: UserNetworkDataSource,
@CoroutineScopeIO private val coroutineScopeIO: CoroutineScope
) {
- val isLoading = userNetworkDataSource.isLoading
- val isLoadingFromCloud = userNetworkDataSource.isLoadingFromCloud
-
- val preferences: StateFlow by lazy {
- combine(
- userNetworkDataSource.user,
- userNetworkDataSource.isLoading
- ) { user, loading ->
- if (user?.preferences != null) {
- val preferences = user.preferences.toDomainModel(userUUID = user.uuid)
- Timber.d("Firebase got preferences for user ${user.uuid.take(4)}")
- preferences
- } else if (loading) {
- null
- } else {
- null
+ val preferences: StateFlow> by lazy {
+ userNetworkDataSource.userStatus.map { userStatus ->
+ val status = resolvePreferencesFromStatus(userStatus)
+ status.data?.let {
+ Timber.d("Firebase got preferences for user ${it.userUUID?.take(4)}")
}
- }.stateIn(coroutineScopeIO, SharingStarted.Eagerly, null)
+ status
+ }.stateIn(
+ coroutineScopeIO,
+ SharingStarted.Eagerly,
+ userNetworkDataSource.userStatus.value.toDomainPreferencesStatus()
+ )
}
- val cloudPreferences: StateFlow by lazy {
- combine(
- userNetworkDataSource.cloudUser,
- userNetworkDataSource.isLoadingFromCloud
- ) { user, loading ->
- if (user?.preferences != null) {
- val preferences = user.preferences.toDomainModel(userUUID = user.uuid)
- Timber.d("Firebase got cloud preferences for user ${user.uuid.take(4)}")
- preferences
- } else if (loading) {
- null
- } else {
- null
+ val cloudPreferencesStatus: StateFlow> by lazy {
+ userNetworkDataSource.cloudUserStatus.map { userStatus ->
+ val status = resolvePreferencesFromStatus(userStatus)
+ status.data?.let {
+ Timber.d("Firebase got cloud preferences for user ${it.userUUID?.take(4)}")
}
- }.stateIn(coroutineScopeIO, SharingStarted.Eagerly, null)
+ status
+ }.stateIn(
+ coroutineScopeIO,
+ SharingStarted.Eagerly,
+ userNetworkDataSource.cloudUserStatus.value.toDomainPreferencesStatus()
+ )
+ }
+
+ private fun resolvePreferencesFromStatus(
+ status: DataStatus
+ ): DataStatus {
+ val user = status.data
+ val loading = status.isLoading
+ val preferences = if (user?.preferences != null) {
+ val userPreferences = user.preferences.toDomainModel(userUUID = user.uuid)
+ userPreferences
+ } else if (loading != false) { // If loading or not initialized
+ null
+ } else {
+ null
+ }
+ return DataStatus(data = preferences, isLoading = loading)
}
fun setPreferences(
diff --git a/app/src/main/java/illyan/jay/data/network/datasource/SessionNetworkDataSource.kt b/app/src/main/java/illyan/jay/data/network/datasource/SessionNetworkDataSource.kt
index 443e3280..f3accda3 100644
--- a/app/src/main/java/illyan/jay/data/network/datasource/SessionNetworkDataSource.kt
+++ b/app/src/main/java/illyan/jay/data/network/datasource/SessionNetworkDataSource.kt
@@ -22,8 +22,10 @@ import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import com.google.firebase.firestore.WriteBatch
+import illyan.jay.data.DataStatus
import illyan.jay.data.network.model.FirestoreUser
import illyan.jay.data.network.toDomainModel
+import illyan.jay.data.network.toDomainSessionsStatus
import illyan.jay.data.network.toFirestoreModel
import illyan.jay.di.CoroutineScopeIO
import illyan.jay.domain.interactor.AuthInteractor
@@ -31,8 +33,8 @@ import illyan.jay.domain.model.DomainSession
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -46,24 +48,55 @@ class SessionNetworkDataSource @Inject constructor(
private val userNetworkDataSource: UserNetworkDataSource,
@CoroutineScopeIO private val coroutineScopeIO: CoroutineScope
) {
- val sessions: StateFlow?> by lazy {
- combine(
- userNetworkDataSource.user,
- userNetworkDataSource.isLoading
- ) { user, loading ->
- if (user != null) {
- val domainSessions = user.sessions.map { it.toDomainModel(user.uuid) }
- Timber.d("Firebase got sessions with IDs: ${domainSessions.map { it.uuid.take(4) }}")
- domainSessions
- } else if (loading) {
- null
- } else {
- emptyList()
+ val sessionsStatus: StateFlow>> by lazy {
+ userNetworkDataSource.userStatus.map { userStatus ->
+ val status = resolveSessionsFromStatus(userStatus)
+ status.data?.let { sessions ->
+ Timber.d("Firebase got sessions with IDs: ${sessions.map { it.uuid.take(4) }}")
}
- }.stateIn(coroutineScopeIO, SharingStarted.Eagerly, null)
+ status
+ }.stateIn(
+ coroutineScopeIO,
+ SharingStarted.Eagerly,
+ userNetworkDataSource.userStatus.value.toDomainSessionsStatus()
+ )
+ }
+
+ val cloudSessionsStatus: StateFlow>> by lazy {
+ userNetworkDataSource.cloudUserStatus.map { userStatus ->
+ val status = resolveSessionsFromStatus(userStatus)
+ status.data?.let { sessions ->
+ Timber.d("Firebase got sessions with IDs: ${sessions.map { it.uuid.take(4) }}")
+ }
+ status
+ }.stateIn(
+ coroutineScopeIO,
+ SharingStarted.Eagerly,
+ userNetworkDataSource.cloudUserStatus.value.toDomainSessionsStatus()
+ )
}
- // FIXME: may user `lazy` more often or change SharingStarted to Lazily instead of Eagerly
+ val sessions = sessionsStatus.map { it.data }
+ .stateIn(coroutineScopeIO, SharingStarted.Eagerly, sessionsStatus.value.data)
+
+ val cloudSessions = cloudSessionsStatus.map { it.data }
+ .stateIn(coroutineScopeIO, SharingStarted.Eagerly, cloudSessionsStatus.value.data)
+
+ private fun resolveSessionsFromStatus(
+ status: DataStatus
+ ): DataStatus> {
+ val user = status.data
+ val loading = status.isLoading
+ val sessions = if (user != null) {
+ val domainSessions = user.sessions.map { it.toDomainModel(user.uuid) }
+ domainSessions
+ } else if (loading != false) { // If loading or not initialized
+ null
+ } else {
+ emptyList()
+ }
+ return DataStatus(data = sessions, isLoading = loading)
+ }
fun deleteSession(
sessionUUID: String,
@@ -77,25 +110,36 @@ class SessionNetworkDataSource @Inject constructor(
onSuccess = onSuccess,
)
- fun deleteAllSessions(
+ suspend fun deleteAllSessions(
onFailure: (Exception) -> Unit = { Timber.e(it, "Error while deleting sessions: ${it.message}") },
onCancel: () -> Unit = { Timber.i("Deleting sessions canceled") },
onSuccess: () -> Unit = { Timber.i("Deleted sessions") }
- ) = deleteSessions(
- sessionUUIDs = userNetworkDataSource.user.value?.sessions?.map { it.uuid } ?: emptyList(),
- onFailure = onFailure,
- onCancel = onCancel,
- onSuccess = onSuccess,
- )
+ ) {
+ cloudSessions.first { sessions ->
+ deleteSessions(
+ sessionUUIDs = sessions?.map { it.uuid } ?: emptyList(),
+ onFailure = onFailure,
+ onCancel = onCancel,
+ onSuccess = onSuccess,
+ )
+ true
+ }
+
+ }
- fun deleteAllSessions(
+ suspend fun deleteAllSessions(
batch: WriteBatch,
onWriteFinished: () -> Unit = {}
- ) = deleteSessions(
- batch = batch,
- sessionUUIDs = userNetworkDataSource.user.value?.sessions?.map { it.uuid } ?: emptyList(),
- onWriteFinished = onWriteFinished,
- )
+ ) {
+ cloudSessions.first { sessions ->
+ deleteSessions(
+ batch = batch,
+ sessionUUIDs = sessions?.map { it.uuid } ?: emptyList(),
+ onWriteFinished = onWriteFinished,
+ )
+ true
+ }
+ }
@JvmName("deleteSessionsByUUIDs")
fun deleteSessions(
@@ -104,7 +148,10 @@ class SessionNetworkDataSource @Inject constructor(
userUUID: String = authInteractor.userUUID.toString(),
onWriteFinished: () -> Unit = {}
) {
- if (!authInteractor.isUserSignedIn || sessionUUIDs.isEmpty()) return
+ if (!authInteractor.isUserSignedIn || sessionUUIDs.isEmpty()) {
+ onWriteFinished()
+ return
+ }
coroutineScopeIO.launch {
userNetworkDataSource.user.first { user ->
user?.let {
@@ -151,7 +198,10 @@ class SessionNetworkDataSource @Inject constructor(
userUUID: String = authInteractor.userUUID.toString(),
onWriteFinished: () -> Unit = {}
) {
- if (!authInteractor.isUserSignedIn || domainSessions.isEmpty()) return
+ if (!authInteractor.isUserSignedIn || domainSessions.isEmpty()) {
+ onWriteFinished()
+ return
+ }
val userRef = firestore
.collection(FirestoreUser.CollectionName)
.document(userUUID)
@@ -171,7 +221,10 @@ class SessionNetworkDataSource @Inject constructor(
onCancel: () -> Unit = { Timber.i("Deleting ${domainSessions.size} sessions canceled") },
onSuccess: () -> Unit = { Timber.i("Deleted ${domainSessions.size} sessions") }
) {
- if (!authInteractor.isUserSignedIn || domainSessions.isEmpty()) return
+ if (!authInteractor.isUserSignedIn || domainSessions.isEmpty()) {
+ onSuccess()
+ return
+ }
firestore.runBatch { batch ->
deleteSessions(
batch = batch,
diff --git a/app/src/main/java/illyan/jay/data/network/datasource/UserNetworkDataSource.kt b/app/src/main/java/illyan/jay/data/network/datasource/UserNetworkDataSource.kt
index ded591d6..305e3f87 100644
--- a/app/src/main/java/illyan/jay/data/network/datasource/UserNetworkDataSource.kt
+++ b/app/src/main/java/illyan/jay/data/network/datasource/UserNetworkDataSource.kt
@@ -28,11 +28,20 @@ import com.google.firebase.firestore.ListenerRegistration
import com.google.firebase.firestore.MetadataChanges
import com.google.firebase.firestore.WriteBatch
import com.google.firebase.firestore.ktx.toObject
+import illyan.jay.data.DataStatus
import illyan.jay.data.network.model.FirestoreUser
+import illyan.jay.di.CoroutineScopeIO
import illyan.jay.domain.interactor.AuthInteractor
+import illyan.jay.util.runBatch
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import timber.log.Timber
import java.util.concurrent.Executors
import javax.inject.Inject
@@ -43,48 +52,53 @@ class UserNetworkDataSource @Inject constructor(
private val firestore: FirebaseFirestore,
private val authInteractor: AuthInteractor,
private val appLifecycle: Lifecycle,
+ @CoroutineScopeIO private val coroutineScopeIO: CoroutineScope
) : DefaultLifecycleObserver {
+ private val executor = Executors.newSingleThreadExecutor()
+
private val _userListenerRegistration = MutableStateFlow(null)
private val _userReference = MutableStateFlow(null)
- private val _user = MutableStateFlow(null)
- private val _cloudUser = MutableStateFlow(null)
+ private val _userStatus = MutableStateFlow(DataStatus())
+ private val _cloudUserStatus = MutableStateFlow(DataStatus())
- val user: StateFlow by lazy {
- if (_userListenerRegistration.value == null && !isLoading.value) {
+ val userStatus: StateFlow> by lazy {
+ if (_userListenerRegistration.value == null && _userStatus.value.isLoading != true) {
Timber.d("User StateFlow requested, but listener registration is null, reloading it")
refreshUser()
}
- _user.asStateFlow()
+ _userStatus.asStateFlow()
}
- val cloudUser: StateFlow by lazy {
- if (_userListenerRegistration.value == null && !isLoadingFromCloud.value) {
+
+ val user = userStatus.map {
+ it.data
+ }.stateIn(coroutineScopeIO, SharingStarted.Eagerly, userStatus.value.data)
+
+ val cloudUserStatus: StateFlow> by lazy {
+ if (_userListenerRegistration.value == null && _cloudUserStatus.value.isLoading != true) {
Timber.d("User StateFlow requested, but listener registration is null, reloading it")
refreshUser()
}
- _cloudUser.asStateFlow()
+ _cloudUserStatus.asStateFlow()
}
- private val _isLoading = MutableStateFlow(false)
- val isLoading = _isLoading.asStateFlow()
-
- private val _isLoadingFromCloud = MutableStateFlow(false)
- val isLoadingFromCloud = _isLoadingFromCloud.asStateFlow()
-
- private val executor = Executors.newSingleThreadExecutor()
+ val cloudUser = cloudUserStatus.map {
+ it.data
+ }.stateIn(coroutineScopeIO, SharingStarted.Eagerly, cloudUserStatus.value.data)
init {
- authInteractor.addAuthStateListener {
+ authInteractor.addAuthStateListener { state ->
if (authInteractor.isUserSignedIn) {
if (authInteractor.userUUID != null &&
- authInteractor.userUUID != _user.value?.uuid
+ authInteractor.userUUID != _userStatus.value.data?.uuid
) {
- Timber.d("Reloading snapshot listener for user ${_user.value?.uuid?.take(4)}")
+ Timber.d("Reloading snapshot listener for user ${state.currentUser?.uid?.take(4)}")
+ resetUserListenerData()
refreshUser()
} else {
- Timber.d("User not changed from ${_user.value?.uuid?.take(4)}, not reloading snapshot listener on auth state change")
+ Timber.d("User not changed from ${_userStatus.value.data?.uuid?.take(4)}, not reloading snapshot listener on auth state change")
}
} else {
- Timber.d("Removing snapshot listener for user ${_user.value?.uuid?.take(4)}")
+ Timber.d("Removing snapshot listener for user ${_userStatus.value.data?.uuid?.take(4)}")
resetUserListenerData()
}
}
@@ -92,11 +106,12 @@ class UserNetworkDataSource @Inject constructor(
}
private fun resetUserListenerData() {
+ Timber.v("Resetting user listener state")
_userListenerRegistration.value?.remove()
- if (_userListenerRegistration.value != null) _userListenerRegistration.value = null
- if (_userReference.value != null) _userReference.value = null
- if (_isLoading.value) _isLoading.value = false
- if (_isLoadingFromCloud.value) _isLoadingFromCloud.value = false
+ if (_userListenerRegistration.value != null) _userListenerRegistration.update { null }
+ if (_userReference.value != null) _userReference.update { null }
+ _userStatus.update { DataStatus() }
+ _cloudUserStatus.update { DataStatus() }
}
override fun onStart(owner: LifecycleOwner) {
@@ -115,13 +130,16 @@ class UserNetworkDataSource @Inject constructor(
private fun refreshUser(
userUUID: String = authInteractor.userUUID.toString(),
onError: (Exception) -> Unit = { Timber.e(it, "Error while getting user data: ${it.message}") },
- onSuccess: (FirestoreUser) -> Unit = {},
+ onSuccess: (FirestoreUser?) -> Unit = {},
) {
- if (!authInteractor.isUserSignedIn || _isLoadingFromCloud.value) {
+ Timber.v("Refreshing user ${userUUID.take(4)} requested")
+ if (!authInteractor.isUserSignedIn || _cloudUserStatus.value.isLoading == true) {
Timber.d("Not refreshing user, due to another being loaded in or user is not signed in")
return
}
resetUserListenerData()
+ _userStatus.update { it.copy(isLoading = true) }
+ _cloudUserStatus.update { it.copy(isLoading = true) }
Timber.d("Connecting snapshot listener to Firebase to get ${userUUID.take(4)} user's data")
val snapshotListener = EventListener { snapshot, error ->
Timber.v("New snapshot regarding user ${userUUID.take(4)}")
@@ -129,51 +147,40 @@ class UserNetworkDataSource @Inject constructor(
onError(error)
} else {
val user = snapshot?.toObject()
- if (user == null) {
- onError(NoSuchElementException("User document does not exist"))
- } else {
- onSuccess(user)
- }
+ onSuccess(user)
if (snapshot != null) {
// Update _userReference value with snapshot when snapshot is not null
- _userReference.value = snapshot
+ _userReference.update { snapshot }
} else if (_userReference.value != null) {
// If snapshot is null, then _userReference is invalid if not null. Assign null to it.
- _userReference.value = null
+ _userReference.update { null }
}
// Cache
if (user != null) {
- if (_user.value != null) {
+ if (_userStatus.value.data != null) {
Timber.v("Refreshing Cached ${userUUID.take(4)} user's data")
} else {
Timber.d("Firestore loaded ${userUUID.take(4)} user's data from Cache")
}
- _user.value = user
- } else if (_user.value != null) {
-
- _user.value = null
- }
- if (_isLoading.value) {
- _isLoading.value = false
+ _userStatus.update { DataStatus(data = user, isLoading = false) }
+ } else {
+ _userStatus.update { DataStatus(data = null, isLoading = false) }
}
// Cloud
if (snapshot?.metadata?.isFromCache == false) {
if (user != null) {
- if (_cloudUser.value != null) {
+ if (_cloudUserStatus.value.data != null) {
Timber.v("Firestore loaded fresh ${userUUID.take(4)} user's data from Cloud")
} else {
Timber.d("Firestore loaded ${userUUID.take(4)} user's data from Cloud")
}
- _cloudUser.value = user
- } else if (_cloudUser.value != null) {
- _cloudUser.value = null
+ _cloudUserStatus.update { DataStatus(data = user, isLoading = false) }
+ } else {
+ _cloudUserStatus.update { DataStatus(data = null, isLoading = false) }
}
}
- if (_isLoadingFromCloud.value && snapshot?.metadata?.isFromCache == false) {
- _isLoadingFromCloud.value = false
- }
}
}
_userListenerRegistration.value = firestore
@@ -182,13 +189,16 @@ class UserNetworkDataSource @Inject constructor(
.addSnapshotListener(executor, MetadataChanges.INCLUDE, snapshotListener)
}
- fun deleteUserData(
+ suspend fun deleteUserData(
onCancel: () -> Unit = { Timber.i("User data deletion canceled") },
onFailure: (Exception) -> Unit = { Timber.e(it) },
onSuccess: () -> Unit = { Timber.i("User data deletion successful") },
) {
- firestore.runBatch {
- deleteUserData(batch = it)
+ firestore.runBatch(1) { batch, onOperationFinished ->
+ deleteUserData(
+ batch = batch,
+ onWriteFinished = onOperationFinished
+ )
}.addOnSuccessListener {
onSuccess()
}.addOnFailureListener {
@@ -198,13 +208,16 @@ class UserNetworkDataSource @Inject constructor(
}
}
- fun deleteUserData(
+ suspend fun deleteUserData(
batch: WriteBatch,
onWriteFinished: () -> Unit = {}
) {
- _userReference.value?.apply {
- batch.delete(reference)
- onWriteFinished()
+ _userReference.first { snapshot ->
+ snapshot?.let {
+ batch.delete(it.reference)
+ onWriteFinished()
+ }
+ true
}
}
}
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 b9761cc1..3a146868 100644
--- a/app/src/main/java/illyan/jay/domain/interactor/AuthInteractor.kt
+++ b/app/src/main/java/illyan/jay/domain/interactor/AuthInteractor.kt
@@ -19,7 +19,6 @@
package illyan.jay.domain.interactor
import android.app.Activity
-import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import com.google.android.gms.auth.api.signin.GoogleSignIn
import com.google.android.gms.auth.api.signin.GoogleSignInAccount
@@ -35,13 +34,12 @@ import com.google.firebase.auth.GoogleAuthProvider
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
import com.google.firebase.remoteconfig.ktx.get
import illyan.jay.MainActivity
-import illyan.jay.R
import illyan.jay.di.CoroutineScopeIO
+import illyan.jay.util.awaitOperations
import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
-import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -57,13 +55,12 @@ import javax.inject.Singleton
@Singleton
class AuthInteractor @Inject constructor(
private val auth: FirebaseAuth,
- private val context: Context,
private val analytics: FirebaseAnalytics,
private val remoteConfig: FirebaseRemoteConfig,
@CoroutineScopeIO private val coroutineScopeIO: CoroutineScope,
) {
- private val _currentUserStateFlow = MutableStateFlow(auth.currentUser)
- val currentUserStateFlow = _currentUserStateFlow.asStateFlow()
+ private val _userStateFlow = MutableStateFlow(auth.currentUser)
+ val userStateFlow = _userStateFlow.asStateFlow()
private val _isUserSignedInStateFlow = MutableStateFlow(auth.currentUser != null)
val isUserSignedInStateFlow = _isUserSignedInStateFlow.asStateFlow()
@@ -74,6 +71,9 @@ class AuthInteractor @Inject constructor(
private val _userUUIDStateFlow = MutableStateFlow(auth.currentUser?.uid)
val userUUIDStateFlow = _userUUIDStateFlow.asStateFlow()
+ private val _userDisplayNameStateFlow = MutableStateFlow(auth.currentUser?.displayName)
+ val userDisplayNameStateFlow = _userDisplayNameStateFlow.asStateFlow()
+
private val _googleSignInClient = MutableStateFlow(null)
private val googleSignInClient = _googleSignInClient.asStateFlow()
@@ -86,51 +86,41 @@ class AuthInteractor @Inject constructor(
val isUserSigningOut = _isSigningOut.asStateFlow()
init {
- addAuthStateListener {
- if (it.currentUser != null) {
- Timber.i("User ${it.currentUser!!.uid.take(4)} signed into Firebase")
+ addAuthStateListener { state ->
+ if (state.currentUser != null) {
+ Timber.i("User ${state.currentUser!!.uid.take(4)} signed into Firebase")
} else {
Timber.i("User ${userUUID?.take(4)} signed out of Firebase")
}
- _currentUserStateFlow.value = it.currentUser
- _isUserSignedInStateFlow.value = it.currentUser != null
- _userPhotoUrlStateFlow.value = it.currentUser?.photoUrl
- _userUUIDStateFlow.value = it.currentUser?.uid
+ _userStateFlow.update { state.currentUser }
+ _isUserSignedInStateFlow.update { state.currentUser != null }
+ _userPhotoUrlStateFlow.update { state.currentUser?.photoUrl }
+ _userUUIDStateFlow.update { state.currentUser?.uid }
+ _userDisplayNameStateFlow.update { state.currentUser?.displayName }
}
}
fun signOut() {
Timber.i("Sign out requested for user ${userUUID?.take(4)}")
- _isSigningOut.value = true
+ _isSigningOut.update { true }
val size = onSignOutListeners.size
if (size == 0) {
Timber.i("No sign out listeners detected, signing out user ${userUUID?.take(4)}")
- auth.signOut()
- googleSignInClient.value?.signOut()
- _isSigningOut.value = false
} else {
Timber.i("Notifying sign out listeners")
- val approvedListeners = MutableStateFlow(0)
- onSignOutListeners.forEach {
- coroutineScopeIO.launch {
- it(auth).first {
- approvedListeners.value++
- Timber.d("${approvedListeners.value++} listeners approved sign out")
- true
- }
- }
- }
coroutineScopeIO.launch {
- approvedListeners.first {
- if (it >= size) {
- Timber.i("All listeners notified, signing out user ${userUUID?.take(4)}")
- auth.signOut()
- googleSignInClient.value?.signOut()
- _isSigningOut.value = false
+ awaitOperations(size) { onOperationFinished ->
+ onSignOutListeners.forEach {
+ coroutineScopeIO.launch {
+ it(onOperationFinished)
+ }
}
- it >= size
}
+ Timber.i("All listeners notified, signing out user ${userUUID?.take(4)}")
}
+ auth.signOut()
+ googleSignInClient.value?.signOut()
+ _isSigningOut.update { false }
}
}
@@ -174,7 +164,7 @@ class AuthInteractor @Inject constructor(
e,
"signInResult:failed code = ${e.statusCode}\n" +
"Used api key: " +
- context.getString(R.string.default_web_client_id)
+ remoteConfig["default_web_client_id"].asString()
.take(4) + "..." +
"\n${e.message}"
)
@@ -215,10 +205,10 @@ class AuthInteractor @Inject constructor(
}
// Each listener emit when they are ready to sign out
- private val onSignOutListeners = mutableListOf<(FirebaseAuth) -> Flow>()
+ private val onSignOutListeners = mutableListOf<(onOperationFinished: () -> Unit) -> Unit>()
fun addOnSignOutListener(
- listener: (FirebaseAuth) -> Flow
+ listener: (() -> Unit) -> Unit
) {
onSignOutListeners.add(listener)
}
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 b1c24618..7ca4de72 100644
--- a/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt
+++ b/app/src/main/java/illyan/jay/domain/interactor/SessionInteractor.kt
@@ -19,6 +19,7 @@
package illyan.jay.domain.interactor
import com.google.firebase.firestore.FirebaseFirestore
+import com.google.firebase.firestore.WriteBatch
import com.mapbox.geojson.Point
import com.mapbox.search.ReverseGeoOptions
import illyan.jay.data.disk.datasource.LocationDiskDataSource
@@ -30,7 +31,7 @@ import illyan.jay.data.network.datasource.UserNetworkDataSource
import illyan.jay.di.CoroutineScopeIO
import illyan.jay.domain.model.DomainLocation
import illyan.jay.domain.model.DomainSession
-import illyan.jay.util.completeNext
+import illyan.jay.util.awaitOperations
import illyan.jay.util.runBatch
import illyan.jay.util.sphericalPathLength
import kotlinx.coroutines.CoroutineScope
@@ -44,7 +45,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
-import java.util.*
+import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@@ -153,14 +154,14 @@ class SessionInteractor @Inject constructor(
suspend fun deleteSyncedSessions() {
if (!authInteractor.isUserSignedIn) return
Timber.d("Batch created to delete session data for user ${authInteractor.userUUID?.take(4)} from cloud")
- firestore.runBatch(2) { batch, completableDeferred ->
+ firestore.runBatch(2) { batch, onOperationFinished ->
sessionNetworkDataSource.deleteAllSessions(
batch = batch,
- onWriteFinished = { completableDeferred.completeNext() }
+ onWriteFinished = { onOperationFinished() }
)
locationNetworkDataSource.deleteLocationsForUser(
batch = batch,
- onWriteFinished = { completableDeferred.completeNext() }
+ onWriteFinished = { onOperationFinished() }
)
}
}
@@ -168,16 +169,29 @@ class SessionInteractor @Inject constructor(
suspend fun deleteAllSyncedData() {
if (!authInteractor.isUserSignedIn) return
Timber.d("Batch created to delete user ${authInteractor.userUUID?.take(4)} from cloud")
- firestore.runBatch(2) { batch, completableDeferred ->
- userNetworkDataSource.deleteUserData(
+ firestore.runBatch(1) { batch, onOperationFinished ->
+ deleteAllSyncedData(
batch = batch,
- onWriteFinished = { completableDeferred.completeNext() }
+ onWriteFinished = onOperationFinished
+ )
+ }
+ }
+
+ suspend fun deleteAllSyncedData(
+ batch: WriteBatch,
+ onWriteFinished: () -> Unit = {}
+ ) {
+ awaitOperations(2) { onOperationFinished ->
+ sessionNetworkDataSource.deleteAllSessions(
+ batch = batch,
+ onWriteFinished = onOperationFinished
)
locationNetworkDataSource.deleteLocationsForUser(
batch = batch,
- onWriteFinished = { completableDeferred.completeNext() }
+ onWriteFinished = onOperationFinished
)
}
+ onWriteFinished()
}
fun uploadSessions(
@@ -430,8 +444,6 @@ class SessionInteractor @Inject constructor(
*/
suspend fun stopOngoingSessions() {
getOngoingSessions().first { sessions ->
- sessionDiskDataSource.stopSessions(sessions)
- refreshDistanceForSessions(sessions)
sessions.forEach { session ->
coroutineScopeIO.launch {
stopSession(session)
@@ -443,14 +455,7 @@ class SessionInteractor @Inject constructor(
suspend fun stopDanglingSessions() {
if (!serviceInteractor.isJayServiceRunning()) {
- getOngoingSessions().first { sessions ->
- sessions.forEach { session ->
- coroutineScopeIO.launch {
- stopSession(session)
- }
- }
- true
- }
+ stopOngoingSessions()
}
}
@@ -521,16 +526,16 @@ class SessionInteractor @Inject constructor(
@JvmName("deleteSessionsFromCloudByUUIDs")
suspend fun deleteSessionsFromCloud(sessionUUIDs: List) {
Timber.d("Batch created to delete ${sessionUUIDs.size} sessions from cloud")
- firestore.runBatch(2) { batch, completableDeferred ->
+ firestore.runBatch(2) { batch, onOperationFinished ->
sessionNetworkDataSource.deleteSessions(
batch = batch,
sessionUUIDs = sessionUUIDs,
- onWriteFinished = { completableDeferred.completeNext() }
+ onWriteFinished = onOperationFinished
)
locationNetworkDataSource.deleteLocationsForSessions(
batch = batch,
sessionUUIDs = sessionUUIDs,
- onWriteFinished = { completableDeferred.completeNext() }
+ onWriteFinished = onOperationFinished
)
}
}
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 a445bda1..64182959 100644
--- a/app/src/main/java/illyan/jay/domain/interactor/SettingsInteractor.kt
+++ b/app/src/main/java/illyan/jay/domain/interactor/SettingsInteractor.kt
@@ -19,6 +19,7 @@
package illyan.jay.domain.interactor
import androidx.datastore.core.DataStore
+import illyan.jay.data.DataStatus
import illyan.jay.data.disk.datasource.PreferencesDiskDataSource
import illyan.jay.data.disk.model.AppSettings
import illyan.jay.data.network.datasource.PreferencesNetworkDataSource
@@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import java.time.ZonedDateTime
@@ -62,7 +64,7 @@ class SettingsInteractor @Inject constructor(
get() = userPreferences.value?.freeDriveAutoStart
set(value) {
Timber.v("FreeDriveAutoStart preference change requested to $value")
- if (value != null && !isLoading.value) {
+ if (value != null && isLoading.value == false) {
coroutineScopeIO.launch {
if (authInteractor.isUserSignedIn) {
preferencesDiskDataSource.setFreeDriveAutoStart(authInteractor.userUUID!!, value)
@@ -77,7 +79,7 @@ class SettingsInteractor @Inject constructor(
get() = userPreferences.value?.analyticsEnabled
set(value) {
Timber.v("AnalyticsEnabled preference change requested to $value")
- if (value != null && !isLoading.value) {
+ if (value != null && isLoading.value == false) {
coroutineScopeIO.launch {
if (authInteractor.isUserSignedIn) {
preferencesDiskDataSource.setAnalyticsEnabled(authInteractor.userUUID!!, value)
@@ -94,7 +96,7 @@ class SettingsInteractor @Inject constructor(
get() = userPreferences.value?.showAds
set(value) {
Timber.v("ShowAds preference change requested to $value")
- if (value != null && !isLoading.value) {
+ if (value != null && isLoading.value == false) {
coroutineScopeIO.launch {
if (authInteractor.isUserSignedIn) {
preferencesDiskDataSource.setShowAds(authInteractor.userUUID!!, value)
@@ -111,7 +113,7 @@ class SettingsInteractor @Inject constructor(
get() = userPreferences.value?.shouldSync
set(value) {
Timber.v("ShouldSync preference change requested to $value")
- if (value != null && !isLoading.value) {
+ if (value != null && isLoading.value == false) {
coroutineScopeIO.launch {
if (authInteractor.isUserSignedIn) {
preferencesDiskDataSource.setShouldSync(authInteractor.userUUID!!, value)
@@ -123,19 +125,18 @@ class SettingsInteractor @Inject constructor(
val arePreferencesSynced by lazy {
combine(
isLoading,
- preferencesNetworkDataSource.isLoadingFromCloud,
localUserPreferences,
- syncedUserPreferences
- ) { loading, cloudLoading, local, synced ->
- if (!loading && !cloudLoading) {
- local == synced
+ cloudPreferencesStatus
+ ) { loading, local, syncedStatus ->
+ if (loading == false && syncedStatus.isLoading == false) {
+ local == syncedStatus.data
} else {
false
}
}.stateIn(
coroutineScopeIO,
SharingStarted.Eagerly,
- localUserPreferences.value == syncedUserPreferences.value
+ localUserPreferences.value == cloudPreferencesStatus.value.data
)
}
@@ -151,29 +152,29 @@ class SettingsInteractor @Inject constructor(
isLoading,
localUserPreferences
) { userSignedIn, loading, local ->
- userSignedIn && !loading && local != null
+ userSignedIn && loading == false && local != null
}.stateIn(coroutineScopeIO, SharingStarted.Eagerly, false)
}
private val _localUserPreferences = MutableStateFlow(null)
val localUserPreferences = _localUserPreferences.asStateFlow()
- val isSyncLoading = preferencesNetworkDataSource.isLoading
- private val _isLocalLoading = MutableStateFlow(false)
- val isLocalLoading: StateFlow
- get() {
- return _isLocalLoading.asStateFlow()
- }
+ private val _isLocalLoading = MutableStateFlow(null)
+ val isLocalLoading: StateFlow = _isLocalLoading.asStateFlow()
- val syncedUserPreferences = preferencesNetworkDataSource.cloudPreferences
+ val cloudPreferencesStatus = preferencesNetworkDataSource.cloudPreferencesStatus
val isLoading = combine(
- preferencesNetworkDataSource.isLoading,
+ cloudPreferencesStatus,
isLocalLoading
- ) { loadingFromCache, loadingFromDisk ->
- loadingFromCache || loadingFromDisk
- }.stateIn(coroutineScopeIO, SharingStarted.Eagerly, false)
+ ) { status, loadingFromDisk ->
+ if (status.isLoading == null && loadingFromDisk == null) {
+ null
+ } else {
+ status.isLoading == true || loadingFromDisk == true
+ }
+ }.stateIn(coroutineScopeIO, SharingStarted.Eagerly, null)
init {
_isLocalLoading.value = true
@@ -183,7 +184,7 @@ class SettingsInteractor @Inject constructor(
coroutineScopeIO.launch {
preferencesDiskDataSource.getPreferences(uuid).collectLatest {
_localUserPreferences.value = it
- if (_isLocalLoading.value) _isLocalLoading.value = false
+ if (_isLocalLoading.value != false) _isLocalLoading.update { false }
}
}
} else { // Offline user
@@ -191,34 +192,32 @@ class SettingsInteractor @Inject constructor(
coroutineScopeIO.launch {
appSettingsFlow.collectLatest {
_localUserPreferences.value = it.preferences
- if (_isLocalLoading.value) _isLocalLoading.value = false
+ if (_isLocalLoading.value != false) _isLocalLoading.update { false }
}
}
}
}
}
}
- // TODO: store local settings for each user
+
val userPreferences by lazy {
combine(
- syncedUserPreferences,
+ cloudPreferencesStatus,
localUserPreferences,
authInteractor.isUserSignedInStateFlow,
isLocalLoading,
- isSyncLoading
) { flows ->
- val syncedPreferences = flows[0] as DomainPreferences?
+ val syncedStatus = flows[0] as DataStatus
val localPreferences = flows[1] as DomainPreferences?
val isUserSignedIn = flows[2] as Boolean
- val isLocalLoading = flows[3] as Boolean
- val isSyncedLoading = flows[4] as Boolean
+ val isLocalLoading = flows[3] as Boolean?
resolvePreferences(
- syncedPreferences = syncedPreferences,
+ syncedPreferences = syncedStatus.data,
localPreferences = localPreferences,
isUserSignedIn = isUserSignedIn,
isLocalLoading = isLocalLoading,
- isSyncedLoading = isSyncedLoading
+ isSyncLoading = syncedStatus.isLoading
)
}.stateIn(coroutineScopeIO, SharingStarted.Eagerly, null)
}
@@ -237,7 +236,7 @@ class SettingsInteractor @Inject constructor(
* @param localPreferences The user's preferences stored on the device.
* @param isUserSignedIn Whether the user is currently signed in.
* @param isLocalLoading Whether local preferences are currently loading.
- * @param isSyncedLoading Whether synced preferences are currently loading.
+ * @param isSyncLoading Whether synced preferences are currently loading.
* @return The resolved user preferences.
* If both local and synced preferences are null, null will be returned.
*
@@ -252,8 +251,8 @@ class SettingsInteractor @Inject constructor(
syncedPreferences: DomainPreferences?,
localPreferences: DomainPreferences?,
isUserSignedIn: Boolean,
- isLocalLoading: Boolean,
- isSyncedLoading: Boolean
+ isLocalLoading: Boolean?,
+ isSyncLoading: Boolean?,
): DomainPreferences? {
// While either is loading, preferences are null
// If local is loaded, the preferences are local, cloud still loading
@@ -269,31 +268,31 @@ class SettingsInteractor @Inject constructor(
// If local is more fresh, then update synced version. (EASIER)
return if (isUserSignedIn) {
- if (isLocalLoading && isSyncedLoading) { // While either is loading, preferences are null
+ if (isLocalLoading != false && isSyncLoading != false) { // While either is loading, preferences are null
Timber.v("While local or synced preferences are loading, returning null")
null
- } else if (!isLocalLoading && isSyncedLoading) { // If local is loaded, the preferences are local, cloud still loading
+ } else if (isLocalLoading == false && isSyncLoading != false) { // If local is loaded, the preferences are local, cloud still loading
Timber.v("If local is loaded and cloud is not, returning local preferences")
localPreferences
- } else if (isLocalLoading && !isSyncedLoading) { // If cloud is loaded, the preferences are cloud, local still loading
+ } else if (isLocalLoading != false && isSyncLoading == false) { // If cloud is loaded, the preferences are cloud, local still loading
Timber.v("If cloud is loaded and local is not, returning cloud preferences")
syncedPreferences
} else {
if (localPreferences == null && syncedPreferences == null) {
// User don't have local nor synced preferences? Create and upload local preferences.
- Timber.v("User don't have local nor synced preferences, create and upload one")
+ Timber.v("User doesn't have local nor synced preferences, create and upload one")
val freshPreferences = DomainPreferences(userUUID = authInteractor.userUUID)
preferencesDiskDataSource.upsertPreferences(freshPreferences)
preferencesNetworkDataSource.setPreferences(freshPreferences)
null
} else if (localPreferences == null && syncedPreferences != null) {
// User don't have local but have synced Preferences? Use synced preferences.
- Timber.v("User don't have local but have synced preferences, save synced preferences")
+ Timber.v("User doesn't have local but have synced preferences, save synced preferences")
preferencesDiskDataSource.upsertPreferences(syncedPreferences)
syncedPreferences
- } else if (localPreferences != null && syncedPreferences == null) {
+ } else if (localPreferences != null && localPreferences.shouldSync && syncedPreferences == null) {
// User have local but not synced preferences? Upload local preferences.
- Timber.v("User have local but not synced preferences, upload local preferences")
+ Timber.v("User has local preferences which need to be synced but has no preferences in cloud, upload local preferences")
preferencesNetworkDataSource.setPreferences(localPreferences)
localPreferences
} else { // Both sessions are now loaded in and not null
diff --git a/app/src/main/java/illyan/jay/domain/interactor/UserInteractor.kt b/app/src/main/java/illyan/jay/domain/interactor/UserInteractor.kt
new file mode 100644
index 00000000..3c732064
--- /dev/null
+++ b/app/src/main/java/illyan/jay/domain/interactor/UserInteractor.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.domain.interactor
+
+import com.google.firebase.firestore.FirebaseFirestore
+import illyan.jay.data.network.datasource.UserNetworkDataSource
+import illyan.jay.util.runBatch
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class UserInteractor @Inject constructor(
+ private val authInteractor: AuthInteractor,
+ private val settingsInteractor: SettingsInteractor,
+ private val userNetworkDataSource: UserNetworkDataSource,
+ private val sessionInteractor: SessionInteractor,
+ private val firestore: FirebaseFirestore,
+) {
+ // TODO: estimate cache size in the future
+ val _cachedUserDataSizeInBytes = MutableStateFlow(null)
+ val cachedUserDataSizeInBytes = _cachedUserDataSizeInBytes.asStateFlow()
+
+ suspend fun deleteAllSyncedData() {
+ Timber.v("Deleting synced data requested")
+ if (!authInteractor.isUserSignedIn) {
+ Timber.e(IllegalStateException("Deleting synced data failed due to user not signed in"))
+ } else {
+ // Turn off sync, so data won't automatically upload to the cloud when deleted
+ settingsInteractor.shouldSync = false
+ // 1. delete turn sync preferences off to stop syncing with cloud data
+ // 2. delete session data (also locations) from cloud
+ // 3. delete other user data, including preferences from cloud
+ firestore.runBatch(numberOfOperations = 2) { batch, onOperationFinished ->
+ sessionInteractor.deleteAllSyncedData(
+ batch = batch,
+ onWriteFinished = onOperationFinished
+ )
+ settingsInteractor.localUserPreferences.first {
+ if (it?.shouldSync == false) {
+ userNetworkDataSource.deleteUserData(
+ batch = batch,
+ onWriteFinished = onOperationFinished
+ )
+ }
+ it?.shouldSync == false
+ }
+ }
+ }
+ }
+
+ suspend fun deleteAllLocalData() {
+ Timber.v("Deleting local user data requested")
+ if (authInteractor.isUserSignedIn) {
+ sessionInteractor.deleteOwnedSessions()
+ }
+ }
+
+ suspend fun deleteAllUserData() {
+ // 1. delete all synced data
+ // 2. delete all local data
+ if (authInteractor.isUserSignedIn) {
+ deleteAllSyncedData()
+ }
+ deleteAllLocalData()
+ }
+
+ suspend fun deleteAllPublicData() {
+ Timber.v("Deleting local not owned data requested")
+ sessionInteractor.deleteNotOwnedSessions()
+ }
+}
\ No newline at end of file
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 4a8e82c0..50d40544 100644
--- a/app/src/main/java/illyan/jay/ui/about/About.kt
+++ b/app/src/main/java/illyan/jay/ui/about/About.kt
@@ -19,9 +19,15 @@
package illyan.jay.ui.about
import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Spring
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@@ -39,16 +45,22 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.android.gms.ads.AdListener
import com.google.android.gms.ads.AdRequest
import com.google.android.gms.ads.AdSize
import com.google.android.gms.ads.AdView
@@ -59,11 +71,12 @@ import illyan.jay.BuildConfig
import illyan.jay.R
import illyan.jay.domain.model.libraries.Library
import illyan.jay.ui.components.JayDialogContent
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.JayDialogContentPadding
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.destinations.LibrariesDialogScreenDestination
-import illyan.jay.ui.profile.ProfileMenuItem
+import illyan.jay.ui.profile.MenuButton
import illyan.jay.ui.profile.ProfileNavGraph
-import illyan.jay.ui.settings.ShowAdsSetting
+import illyan.jay.ui.settings.general.ShowAdsSetting
import illyan.jay.ui.theme.JayTheme
import illyan.jay.ui.theme.signaturePink
import illyan.jay.util.TestAdUnitIds
@@ -95,22 +108,43 @@ fun AboutDialogContent(
setAdVisibility: (Boolean) -> Unit = {},
onNavigateToLibraries: () -> Unit = {},
) {
- JayDialogContent(
+ Column(
modifier = modifier,
- title = { AboutTitle() },
- text = {
- AboutScreen(
- isShowingAd = isShowingAd,
- setAdVisibility = setAdVisibility,
- onNavigateToLibraries = onNavigateToLibraries,
- aboutBannerAdUnitId = aboutBannerAdUnitId,
- )
- },
- buttons = {
- AboutButtons()
- },
- containerColor = Color.Transparent,
- )
+ ) {
+ JayDialogContent(
+ title = { AboutTitle() },
+ textPaddingValues = PaddingValues(),
+ text = {
+ AboutScreen(
+ isShowingAd = isShowingAd,
+ setAdVisibility = setAdVisibility,
+ onNavigateToLibraries = onNavigateToLibraries,
+ )
+ },
+ containerColor = Color.Transparent,
+ dialogPaddingValues = PaddingValues(
+ start = JayDialogContentPadding.calculateStartPadding(LayoutDirection.Ltr),
+ end = JayDialogContentPadding.calculateEndPadding(LayoutDirection.Ltr),
+ top = JayDialogContentPadding.calculateTopPadding()
+ ),
+ )
+ AboutAdScreen(
+ modifier = Modifier.align(Alignment.CenterHorizontally),
+ isShowingAd = isShowingAd,
+ adUnitId = aboutBannerAdUnitId
+ )
+ JayDialogContent(
+ buttons = {
+ AboutButtons()
+ },
+ containerColor = Color.Transparent,
+ dialogPaddingValues = PaddingValues(
+ start = JayDialogContentPadding.calculateStartPadding(LayoutDirection.Ltr),
+ end = JayDialogContentPadding.calculateEndPadding(LayoutDirection.Ltr),
+ bottom = JayDialogContentPadding.calculateBottomPadding()
+ ),
+ )
+ }
}
@Composable
@@ -132,34 +166,36 @@ fun AboutTitle() {
@Composable
fun AboutScreen(
+ modifier: Modifier = Modifier,
isShowingAd: Boolean = false,
- aboutBannerAdUnitId: String = TestAdUnitIds.Banner,
setAdVisibility: (Boolean) -> Unit = {},
onNavigateToLibraries: () -> Unit = {},
) {
val uriHandler = LocalUriHandler.current
- Column {
+ Column(
+ modifier = modifier,
+ ) {
Column(
verticalArrangement = Arrangement.spacedBy((-12).dp)
) {
- ProfileMenuItem(
+ MenuButton(
text = stringResource(R.string.libraries),
onClick = onNavigateToLibraries
)
AnimatedVisibility(visible = Library.Jay.license?.url != null) {
- ProfileMenuItem(
+ MenuButton(
text = stringResource(R.string.jay_license),
onClick = { Library.Jay.license?.url?.let { uriHandler.openUri(it) } }
)
}
AnimatedVisibility(visible = Library.Jay.privacyPolicyUrl != null) {
- ProfileMenuItem(
+ MenuButton(
text = stringResource(R.string.privacy_policy),
onClick = { Library.Jay.privacyPolicyUrl?.let { uriHandler.openUri(it) } }
)
}
AnimatedVisibility(visible = Library.Jay.termsAndConditionsUrl != null) {
- ProfileMenuItem(
+ MenuButton(
text = stringResource(R.string.terms_and_conditions),
onClick = { Library.Jay.termsAndConditionsUrl?.let { uriHandler.openUri(it) } }
)
@@ -170,7 +206,6 @@ fun AboutScreen(
DonationScreen(
isShowingAd = isShowingAd,
setAdVisibility = setAdVisibility,
- aboutBannerAdUnitId = aboutBannerAdUnitId
)
}
}
@@ -180,7 +215,6 @@ fun DonationScreen(
modifier: Modifier = Modifier,
isShowingAd: Boolean = false,
setAdVisibility: (Boolean) -> Unit = {},
- aboutBannerAdUnitId: String = TestAdUnitIds.Banner,
) {
Column(
modifier = modifier
@@ -190,11 +224,6 @@ fun DonationScreen(
isShowingAd = isShowingAd,
setAdVisibility = setAdVisibility,
)
- AboutAdScreen(
- modifier = Modifier.align(Alignment.CenterHorizontally),
- isShowingAd = isShowingAd,
- adUnitId = aboutBannerAdUnitId
- )
}
}
@@ -244,6 +273,7 @@ fun AboutAdSetting(
)
AnimatedVisibility(visible = !isShowingAd) {
Card(
+ modifier = Modifier.padding(bottom = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
@@ -274,20 +304,35 @@ fun AboutAdScreen(
.clip(RoundedCornerShape(6.dp)),
visible = isShowingAd
) {
+ var isAdLoaded by rememberSaveable { mutableStateOf(false) }
+ val adAlpha by animateFloatAsState(
+ targetValue = if (isAdLoaded) 1f else 0f,
+ animationSpec = spring(
+ stiffness = Spring.StiffnessLow
+ )
+ )
AndroidView(
- modifier = Modifier.heightIn(min = AdSize.BANNER.height.dp),
+ modifier = Modifier
+ .alpha(adAlpha)
+ .heightIn(min = AdSize.BANNER.height.dp),
factory = { context ->
AdView(context).apply {
setAdSize(AdSize.BANNER)
this.adUnitId = adUnitId
loadAd(AdRequest.Builder().build())
+ this.adListener = object : AdListener() {
+ override fun onAdLoaded() {
+ super.onAdLoaded()
+ isAdLoaded = true
+ }
+ }
}
}
)
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun AboutDialogScreenPreview() {
JayTheme {
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 c53586e1..31d75ae5 100644
--- a/app/src/main/java/illyan/jay/ui/components/AvatarAsyncImage.kt
+++ b/app/src/main/java/illyan/jay/ui/components/AvatarAsyncImage.kt
@@ -90,7 +90,7 @@ fun DefaultAvatar(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun DefaultAvatarPreview() {
JayTheme {
@@ -109,7 +109,7 @@ fun BrokenAvatar(
)
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun BrokenAvatarPreview() {
JayTheme {
@@ -130,7 +130,7 @@ fun PlaceholderAvatar(
)
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun PlaceholderAvatarPreview() {
JayTheme {
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 329c94cc..6bbd5555 100644
--- a/app/src/main/java/illyan/jay/ui/components/JayDialogContent.kt
+++ b/app/src/main/java/illyan/jay/ui/components/JayDialogContent.kt
@@ -50,9 +50,10 @@ fun JayDialogContent(
title: @Composable (() -> Unit)? = null,
text: @Composable (() -> Unit)? = null,
buttons: @Composable (() -> Unit)? = null,
- iconPaddingValues: PaddingValues = IconPadding,
- titlePaddingValues: PaddingValues = TitlePadding,
- textPaddingValues: PaddingValues = TextPadding,
+ dialogPaddingValues: PaddingValues = JayDialogContentPadding,
+ iconPaddingValues: PaddingValues = JayDialogIconPadding,
+ titlePaddingValues: PaddingValues = JayDialogTitlePadding,
+ textPaddingValues: PaddingValues = JayDialogTextPadding,
shape: Shape = AlertDialogDefaults.shape,
containerColor: Color = AlertDialogDefaults.containerColor,
iconContentColor: Color = AlertDialogDefaults.iconContentColor,
@@ -74,7 +75,7 @@ fun JayDialogContent(
}
) {
Column(
- modifier = Modifier.padding(DialogPadding)
+ modifier = Modifier.padding(dialogPaddingValues)
) {
AnimatedVisibility(visible = icon != null) {
icon?.let {
@@ -176,7 +177,7 @@ internal val DialogMinWidth = 280.dp
internal val DialogMaxWidth = 560.dp
// Paddings for each of the dialog's parts.
-private val DialogPadding = PaddingValues(all = 24.dp)
-private val IconPadding = PaddingValues(bottom = 16.dp)
-private val TitlePadding = PaddingValues(bottom = 16.dp)
-private val TextPadding = PaddingValues(bottom = 16.dp)
+val JayDialogContentPadding = PaddingValues(all = 24.dp)
+val JayDialogIconPadding = PaddingValues(bottom = 16.dp)
+val JayDialogTitlePadding = PaddingValues(bottom = 16.dp)
+val JayDialogTextPadding = PaddingValues(bottom = 16.dp)
diff --git a/app/src/main/java/illyan/jay/ui/components/Previews.kt b/app/src/main/java/illyan/jay/ui/components/Previews.kt
new file mode 100644
index 00000000..1756b7d5
--- /dev/null
+++ b/app/src/main/java/illyan/jay/ui/components/Previews.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.ui.components
+
+import android.content.res.Configuration.UI_MODE_NIGHT_YES
+import androidx.compose.ui.tooling.preview.Devices
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(
+ name = "Dark theme",
+ group = "themes",
+ uiMode = UI_MODE_NIGHT_YES,
+ showBackground = true,
+)
+@Preview(
+ name = "Light theme",
+ group = "themes",
+ showBackground = true,
+)
+annotation class PreviewLightDarkTheme
+
+// Jay is not intended to use with foldable phones or desktops (yet)
+//@Preview(
+// name = "Foldable screen",
+// device = Devices.FOLDABLE,
+// showBackground = true
+//)
+//@Preview(
+// name = "Desktop screen",
+// device = Devices.DESKTOP,
+// showBackground = true
+//)
+@Preview(
+ name = "Phone screen",
+ group = "screens",
+ device = Devices.PHONE,
+ showBackground = true
+)
+@Preview(
+ name = "Nexus 5 screen",
+ group = "screens",
+ device = Devices.NEXUS_5,
+ showBackground = true
+)
+@Preview(
+ name = "Pixel 1 screen",
+ group = "screens",
+ device = Devices.PIXEL,
+ showBackground = true
+)
+@Preview(
+ name = "Pixel 4 XL screen",
+ group = "screens",
+ device = Devices.PIXEL_4_XL,
+ showBackground = true
+)
+annotation class PreviewDeviceScreens
+
+@Preview(
+ name = "Large font scale",
+ group = "font_scales",
+ fontScale = 1.5f,
+ showBackground = true
+)
+@Preview(
+ name = "Extra large font scale",
+ group = "font_scales",
+ fontScale = 2.0f,
+ showBackground = true
+)
+annotation class PreviewFontScales
+
+@PreviewLightDarkTheme
+@PreviewDeviceScreens
+@PreviewFontScales
+annotation class PreviewThemesScreensFonts
\ No newline at end of file
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 41918a5b..86ad526d 100644
--- a/app/src/main/java/illyan/jay/ui/freedrive/FreeDrive.kt
+++ b/app/src/main/java/illyan/jay/ui/freedrive/FreeDrive.kt
@@ -24,12 +24,10 @@ import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -53,7 +51,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import illyan.jay.R
import illyan.jay.data.disk.model.AppSettings
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.home.RoundedCornerRadius
import illyan.jay.ui.home.absoluteBottom
import illyan.jay.ui.home.absoluteTop
@@ -66,6 +64,7 @@ import illyan.jay.ui.map.turnOnWithDefaultPuck
import illyan.jay.ui.menu.MenuItemPadding
import illyan.jay.ui.menu.MenuNavGraph
import illyan.jay.ui.menu.SheetScreenBackPressHandler
+import illyan.jay.ui.settings.general.BooleanSetting
import illyan.jay.ui.theme.JayTheme
import illyan.jay.util.plus
@@ -188,21 +187,13 @@ fun FreeDriveScreenWithPermission(
)
}
}
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(8.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Text(
- text = stringResource(R.string.automatically_turn_on_free_driving),
- style = MaterialTheme.typography.labelLarge,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Switch(
- checked = startServiceAutomatically,
- onCheckedChange = { setStartServiceAutomatically(it) }
- )
- }
+ BooleanSetting(
+ settingName = stringResource(R.string.automatically_turn_on_free_driving),
+ value = startServiceAutomatically,
+ setValue = setStartServiceAutomatically,
+ enabledText = stringResource(R.string.on),
+ disabledText = stringResource(R.string.off),
+ )
}
}
@@ -222,7 +213,7 @@ fun FreeDriveScreenWithoutPermission(
)
Text(
text = stringResource(R.string.location_permission_denied_description),
- style = MaterialTheme.typography.bodyLarge,
+ style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Column(
@@ -240,14 +231,14 @@ fun FreeDriveScreenWithoutPermission(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun FreeDriveScreenWithPermissionPreview() {
JayTheme {
FreeDriveScreenWithPermission()
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun FreeDriveScreenWithoutPermissionPreview() {
JayTheme {
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 638c7d0f..796669a2 100644
--- a/app/src/main/java/illyan/jay/ui/home/Home.kt
+++ b/app/src/main/java/illyan/jay/ui/home/Home.kt
@@ -94,6 +94,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.ExperimentalComposeUiApi
@@ -151,7 +152,7 @@ import illyan.jay.MainActivity
import illyan.jay.R
import illyan.jay.ui.NavGraphs
import illyan.jay.ui.components.AvatarAsyncImage
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.map.BmeK
import illyan.jay.ui.map.MapboxMap
import illyan.jay.ui.map.padding
@@ -361,7 +362,7 @@ fun calculateCornerRadius(
maxCornerRadius,
minCornerRadius,
(max - (max - fraction) / threshold).coerceIn(min, max)
- ).coerceAtLeast(0.dp)
+ ).coerceIn(minCornerRadius, maxCornerRadius)
}
}
@@ -472,7 +473,17 @@ fun HomeScreen(
val bottomSheetState = scaffoldState.bottomSheetState
sheetState = bottomSheetState
var isTextFieldFocused by remember { mutableStateOf(false) }
- var roundDp by remember { mutableStateOf(RoundedCornerRadius) }
+ var roundDp by rememberSaveable(
+ stateSaver = run {
+ val key = "searchBarCornerDp"
+ mapSaver(
+ save = { mapOf(key to it.value) },
+ restore = { Dp(it[key] as Float) }
+ )
+ }
+ ) {
+ mutableStateOf(RoundedCornerRadius)
+ }
var shouldTriggerBottomSheetOnDrag by remember { mutableStateOf(true) }
val softwareKeyboardController = LocalSoftwareKeyboardController.current
val sheetCollapsing = bottomSheetState.isCollapsing()
@@ -925,7 +936,7 @@ fun BottomSearchBar(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
fun BottomSearchBarPreview() {
JayTheme {
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 31003caf..47103b86 100644
--- a/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt
+++ b/app/src/main/java/illyan/jay/ui/libraries/Libraries.kt
@@ -59,7 +59,7 @@ 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.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.destinations.LibraryDialogScreenDestination
import illyan.jay.ui.libraries.model.UiLibrary
import illyan.jay.ui.profile.ProfileNavGraph
@@ -216,7 +216,7 @@ fun LibraryItem(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun LibrariesDialogContentPreview() {
val libraries = LibrariesViewModel.Libraries
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 31593896..e8e9d70b 100644
--- a/app/src/main/java/illyan/jay/ui/library/Library.kt
+++ b/app/src/main/java/illyan/jay/ui/library/Library.kt
@@ -21,10 +21,24 @@ package illyan.jay.ui.library
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+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.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronRight
-import androidx.compose.material3.*
+import androidx.compose.material3.AlertDialogDefaults
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -38,10 +52,10 @@ import illyan.jay.domain.model.libraries.Library
import illyan.jay.ui.components.JayDialogContent
import illyan.jay.ui.components.JayTextCard
import illyan.jay.ui.components.LicenseOfType
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.libraries.model.UiLibrary
import illyan.jay.ui.libraries.model.toUiModel
-import illyan.jay.ui.profile.ProfileMenuItem
+import illyan.jay.ui.profile.MenuButton
import illyan.jay.ui.profile.ProfileNavGraph
import illyan.jay.ui.theme.JayTheme
@@ -133,13 +147,13 @@ fun LibraryScreen(
val uriHandler = LocalUriHandler.current
Column(modifier = modifier) {
AnimatedVisibility(visible = library.privacyPolicyUrl != null) {
- ProfileMenuItem(
+ MenuButton(
text = stringResource(R.string.privacy_policy),
onClick = { library.privacyPolicyUrl?.let { uriHandler.openUri(it) } }
)
}
AnimatedVisibility(visible = library.termsAndConditionsUrl != null) {
- ProfileMenuItem(
+ MenuButton(
text = stringResource(R.string.license),
onClick = { library.termsAndConditionsUrl?.let { uriHandler.openUri(it) } }
)
@@ -149,7 +163,7 @@ fun LibraryScreen(
targetState = library.license?.url
) {
if (it != null) {
- ProfileMenuItem(
+ MenuButton(
text = stringResource(R.string.license),
onClick = { uriHandler.openUri(it) }
)
@@ -195,7 +209,7 @@ fun LibraryButtons(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun LibraryDialogContentPreview() {
val library = Library.Jay.toUiModel()
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 e1fb9bff..6cc5793e 100644
--- a/app/src/main/java/illyan/jay/ui/login/Login.kt
+++ b/app/src/main/java/illyan/jay/ui/login/Login.kt
@@ -43,7 +43,7 @@ import com.ramcosta.composedestinations.annotation.Destination
import illyan.jay.R
import illyan.jay.ui.components.JayDialogContent
import illyan.jay.ui.components.JayDialogSurface
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.profile.LocalDialogActivityProvider
import illyan.jay.ui.profile.LocalDialogDismissRequest
import illyan.jay.ui.profile.ProfileNavGraph
@@ -160,7 +160,7 @@ fun LoginButtons(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun PreviewLoginDialog() {
JayTheme {
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 6dc2fc2d..3bb010e5 100644
--- a/app/src/main/java/illyan/jay/ui/map/Map.kt
+++ b/app/src/main/java/illyan/jay/ui/map/Map.kt
@@ -152,8 +152,8 @@ private fun MapboxMapContainer(
map.getMapboxMap().addOnCameraChangeListener { onCameraChanged(map.getMapboxMap().cameraState) }
onDispose { map.getMapboxMap().removeOnMapLoadedListener(onMapLoadedListener) }
}
- val statusBarHeight = LocalDensity.current.run { WindowInsets.statusBars.getTop(this) }
- val fixedStatusBarHeight = remember { statusBarHeight }
+ val statusBarHeight = LocalDensity.current.run { WindowInsets.statusBars.getTop(this) }
+ val fixedStatusBarHeight = rememberSaveable { statusBarHeight }
AndroidView(
modifier = modifier,
factory = { map }
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 d2931cb3..b7d3d920 100644
--- a/app/src/main/java/illyan/jay/ui/menu/Menu.kt
+++ b/app/src/main/java/illyan/jay/ui/menu/Menu.kt
@@ -75,7 +75,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import illyan.jay.MainActivity
import illyan.jay.R
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.destinations.FreeDriveDestination
import illyan.jay.ui.destinations.SessionsDestination
import illyan.jay.ui.home.RoundedCornerRadius
@@ -181,7 +181,7 @@ fun MenuContent(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun MenuContentPreview() {
JayTheme {
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 8aebf8f2..4ce24168 100644
--- a/app/src/main/java/illyan/jay/ui/poi/Poi.kt
+++ b/app/src/main/java/illyan/jay/ui/poi/Poi.kt
@@ -58,7 +58,7 @@ import com.ramcosta.composedestinations.annotation.Destination
import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import illyan.jay.R
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.home.RoundedCornerRadius
import illyan.jay.ui.home.mapView
import illyan.jay.ui.home.sheetState
@@ -273,7 +273,7 @@ fun PoiScreen(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun PoiScreenPreview() {
JayTheme {
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 fe13d3fc..beaa7601 100644
--- a/app/src/main/java/illyan/jay/ui/profile/Profile.kt
+++ b/app/src/main/java/illyan/jay/ui/profile/Profile.kt
@@ -19,9 +19,22 @@
package illyan.jay.ui.profile
import android.net.Uri
-import androidx.compose.animation.*
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.Crossfade
+import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
-import androidx.compose.foundation.layout.*
+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
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
@@ -30,8 +43,22 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ChevronRight
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.LockOpen
-import androidx.compose.material3.*
-import androidx.compose.runtime.*
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconToggleButton
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -64,13 +91,18 @@ import com.ramcosta.composedestinations.utils.startDestination
import illyan.jay.MainActivity
import illyan.jay.R
import illyan.jay.ui.NavGraphs
-import illyan.jay.ui.components.*
+import illyan.jay.ui.components.AvatarAsyncImage
+import illyan.jay.ui.components.CopiedToKeyboardTooltip
+import illyan.jay.ui.components.JayDialogContent
+import illyan.jay.ui.components.JayDialogSurface
+import illyan.jay.ui.components.PreviewThemesScreensFonts
+import illyan.jay.ui.components.TooltipElevatedCard
import illyan.jay.ui.destinations.AboutDialogScreenDestination
import illyan.jay.ui.destinations.LoginDialogScreenDestination
-import illyan.jay.ui.destinations.SettingsDialogScreenDestination
+import illyan.jay.ui.destinations.UserSettingsDialogScreenDestination
import illyan.jay.ui.home.RoundedCornerRadius
import illyan.jay.ui.theme.JayTheme
-import java.util.*
+import java.util.UUID
@RootNavGraph
@NavGraph
@@ -82,7 +114,7 @@ val LocalDialogDismissRequest = compositionLocalOf { {} }
val LocalDialogActivityProvider = compositionLocalOf { null }
@OptIn(ExperimentalMaterialNavigationApi::class, ExperimentalMaterial3Api::class,
- ExperimentalAnimationApi::class
+ ExperimentalAnimationApi::class, ExperimentalAnimationApi::class
)
@Composable
fun ProfileDialog(
@@ -92,7 +124,6 @@ fun ProfileDialog(
if (isDialogOpen) {
val context = LocalContext.current
val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
- val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
// Don't use exit animations because
// it looks choppy while Dialog resizes due to content change.
val engine = rememberAnimatedNavHostEngine(
@@ -169,7 +200,7 @@ fun ProfileDialogScreen(
onSignOut = viewModel::signOut,
onShowLoginScreen = { destinationsNavigator.navigate(LoginDialogScreenDestination) },
onShowAboutScreen = { destinationsNavigator.navigate(AboutDialogScreenDestination) },
- onShowSettingsScreen = { destinationsNavigator.navigate(SettingsDialogScreenDestination) }
+ onShowSettingsScreen = { destinationsNavigator.navigate(UserSettingsDialogScreenDestination) }
)
}
@@ -221,6 +252,7 @@ fun ProfileDialogContent(
)
}
+@OptIn(ExperimentalLayoutApi::class)
@Composable
fun ProfileButtons(
onShowSettingsScreen: () -> Unit = {},
@@ -231,7 +263,7 @@ fun ProfileButtons(
isUserSigningOut: Boolean = false,
) {
val onDialogClosed = LocalDialogDismissRequest.current
- Row(
+ FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom
@@ -241,7 +273,8 @@ fun ProfileButtons(
onShowAboutScreen = onShowAboutScreen,
)
Row(
- verticalAlignment = Alignment.Bottom
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { onDialogClosed() }) {
Text(text = stringResource(R.string.close))
@@ -289,7 +322,7 @@ fun ProfileScreen(
)
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun PreviewProfileDialogScreen(
name: String = "Illyan",
@@ -530,11 +563,11 @@ fun ProfileMenu(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy((-12).dp)
) {
- ProfileMenuItem(
+ MenuButton(
onClick = onShowAboutScreen,
text = stringResource(R.string.about)
)
- ProfileMenuItem(
+ MenuButton(
onClick = onShowSettingsScreen,
text = stringResource(R.string.settings)
)
@@ -542,7 +575,7 @@ fun ProfileMenu(
}
@Composable
-fun ProfileMenuItem(
+fun MenuButton(
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
text: String,
diff --git a/app/src/main/java/illyan/jay/ui/profile/ProfileViewModel.kt b/app/src/main/java/illyan/jay/ui/profile/ProfileViewModel.kt
index 2db189fa..2a6ee918 100644
--- a/app/src/main/java/illyan/jay/ui/profile/ProfileViewModel.kt
+++ b/app/src/main/java/illyan/jay/ui/profile/ProfileViewModel.kt
@@ -37,28 +37,28 @@ class ProfileViewModel @Inject constructor(
val userUUID = authInteractor.userUUIDStateFlow
- val userEmail = authInteractor.currentUserStateFlow
+ val userEmail = authInteractor.userStateFlow
.map { it?.email }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
- authInteractor.currentUserStateFlow.value?.email
+ authInteractor.userStateFlow.value?.email
)
- val userPhoneNumber = authInteractor.currentUserStateFlow
+ val userPhoneNumber = authInteractor.userStateFlow
.map { it?.phoneNumber }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
- authInteractor.currentUserStateFlow.value?.phoneNumber
+ authInteractor.userStateFlow.value?.phoneNumber
)
- val userName = authInteractor.currentUserStateFlow
+ val userName = authInteractor.userStateFlow
.map { it?.displayName }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
- authInteractor.currentUserStateFlow.value?.displayName
+ authInteractor.userStateFlow.value?.displayName
)
fun signOut() = authInteractor.signOut()
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 243891ea..f371000e 100644
--- a/app/src/main/java/illyan/jay/ui/search/Search.kt
+++ b/app/src/main/java/illyan/jay/ui/search/Search.kt
@@ -71,7 +71,7 @@ import com.ramcosta.composedestinations.annotation.NavGraph
import com.ramcosta.composedestinations.annotation.RootNavGraph
import illyan.jay.R
import illyan.jay.ui.components.MediumCircularProgressIndicator
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.home.RoundedCornerRadius
import illyan.jay.ui.search.model.UiRecord
import illyan.jay.ui.theme.JayTheme
@@ -197,7 +197,7 @@ fun SearchList(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun SearchListPreview() {
JayTheme {
@@ -405,7 +405,7 @@ fun FavoriteButton(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun SuggestionCardPreview() {
JayTheme {
@@ -439,7 +439,7 @@ fun HistoryCard(
)
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun HistoryCardPreview() {
JayTheme {
@@ -473,7 +473,7 @@ fun FavoriteCard(
)
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun FavoriteCardPreview() {
JayTheme {
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 4ccc0bdb..e3e859f3 100644
--- a/app/src/main/java/illyan/jay/ui/session/Session.kt
+++ b/app/src/main/java/illyan/jay/ui/session/Session.kt
@@ -83,7 +83,7 @@ import com.ramcosta.composedestinations.navigation.DestinationsNavigator
import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import illyan.jay.R
import illyan.jay.ui.components.MediumCircularProgressIndicator
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.home.RoundedCornerRadius
import illyan.jay.ui.home.mapView
import illyan.jay.ui.home.sheetState
@@ -553,7 +553,7 @@ fun SessionDetailsList(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun SessionDetailsScreenPreview(
session: UiSession? = null,
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 14f2248d..236958eb 100644
--- a/app/src/main/java/illyan/jay/ui/sessions/Sessions.kt
+++ b/app/src/main/java/illyan/jay/ui/sessions/Sessions.kt
@@ -89,7 +89,7 @@ import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
import illyan.compose.scrollbar.drawVerticalScrollbar
import illyan.jay.R
import illyan.jay.ui.components.MediumCircularProgressIndicator
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.components.SmallCircularProgressIndicator
import illyan.jay.ui.components.TooltipButton
import illyan.jay.ui.destinations.SessionScreenDestination
@@ -271,7 +271,7 @@ fun SessionsScreen(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun SessionsScreenPreview() {
val sessions = generateUiSessions(10)
@@ -586,7 +586,7 @@ fun SessionLoadingIndicator(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun SessionLoadingIndicatorPreview() {
JayTheme {
@@ -824,7 +824,7 @@ fun SessionDetailsList(
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
private fun SessionCardPreview() {
JayTheme {
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 e720e39b..d686e3c5 100644
--- a/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt
+++ b/app/src/main/java/illyan/jay/ui/sessions/SessionsViewModel.kt
@@ -34,7 +34,17 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.getAndUpdate
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
import java.time.ZonedDateTime
@@ -59,7 +69,7 @@ class SessionsViewModel @Inject constructor(
val ownedLocalSessionUUIDs = _ownedLocalSessionUUIDs.asStateFlow()
val isUserSignedIn = authInteractor.isUserSignedInStateFlow
- val signedInUser = authInteractor.currentUserStateFlow
+ val signedInUser = authInteractor.userStateFlow
private val _syncedSessionsLoading = MutableStateFlow(false)
val syncedSessionsLoading = _syncedSessionsLoading.asStateFlow()
@@ -192,7 +202,7 @@ class SessionsViewModel @Inject constructor(
) { owned, notOwned, ongoing ->
!owned && !notOwned && !ongoing
}.collectLatest {
- if (it) _localSessionsLoading.value = false
+ if (it) _localSessionsLoading.update { false }
}
}
@@ -200,39 +210,39 @@ class SessionsViewModel @Inject constructor(
sessionInteractor.getNotOwnedSessions().collectLatest { sessions ->
_notOwnedSessionUUIDs.value = sessions.map { it.uuid to it.startDateTime }
Timber.d("Got ${sessions.size} not owned sessions")
- loadingNotOwnedSessions.value = false
+ loadingNotOwnedSessions.update { false }
}
}
ongoingSessionsJob = viewModelScope.launch(dispatcherIO) {
sessionInteractor.getOngoingSessionUUIDs().collectLatest {
_ongoingSessionUUIDs.value = it
Timber.d("Got ${it.size} ongoing sessions")
- loadingOngoingSessions.value = false
+ loadingOngoingSessions.update { false }
}
}
ownedSessionsJob = viewModelScope.launch(dispatcherIO) {
sessionInteractor.getOwnSessions().collectLatest { sessions ->
_ownedLocalSessionUUIDs.value = sessions.map { it.uuid to it.startDateTime }
Timber.d("Got ${sessions.size} owned sessions by ${signedInUser.value?.uid?.take(4)}")
- loadingOwnedSessions.value = false
+ loadingOwnedSessions.update { false }
}
}
}
fun loadCloudSessions() {
_syncedSessions.value = emptyList()
- _syncedSessionsLoading.value = true
+ _syncedSessionsLoading.update { true }
if (isUserSignedIn.value) {
viewModelScope.launch(dispatcherIO) {
- sessionInteractor.syncedSessions.collectLatest {
- Timber.d("New number of synced sessions: ${_syncedSessions.value.size} -> ${it?.size}")
- _syncedSessions.value = it ?: emptyList()
- _syncedSessionsLoading.value = it == null
+ sessionInteractor.syncedSessions.collectLatest { sessions ->
+ Timber.d("New number of synced sessions: ${_syncedSessions.value.size} -> ${sessions?.size}")
+ _syncedSessions.update { sessions ?: emptyList() }
+ _syncedSessionsLoading.update { sessions == null }
}
}
} else {
- _syncedSessionsLoading.value = false
+ _syncedSessionsLoading.update { false }
}
}
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
new file mode 100644
index 00000000..21c3a0b8
--- /dev/null
+++ b/app/src/main/java/illyan/jay/ui/settings/data/DataSettings.kt
@@ -0,0 +1,341 @@
+/*
+ * Copyright (c) 2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.ui.settings.data
+
+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.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.rounded.ExpandLess
+import androidx.compose.material.icons.rounded.ExpandMore
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconToggleButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.surfaceColorAtElevation
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+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.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalConfiguration
+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.hilt.navigation.compose.hiltViewModel
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.ramcosta.composedestinations.annotation.Destination
+import com.ramcosta.composedestinations.navigation.DestinationsNavigator
+import com.ramcosta.composedestinations.navigation.EmptyDestinationsNavigator
+import illyan.jay.R
+import illyan.jay.ui.components.AvatarAsyncImage
+import illyan.jay.ui.components.JayDialogContent
+import illyan.jay.ui.components.PreviewThemesScreensFonts
+import illyan.jay.ui.home.RoundedCornerRadius
+import illyan.jay.ui.profile.MenuButton
+import illyan.jay.ui.profile.ProfileNavGraph
+import illyan.jay.ui.theme.JayTheme
+
+@ProfileNavGraph
+@Destination
+@Composable
+fun DataSettingsDialogScreen(
+ viewModel: DataSettingsViewModel = hiltViewModel(),
+ destinationsNavigator: DestinationsNavigator = EmptyDestinationsNavigator,
+) {
+ val userPhotoUrl by viewModel.userPhotoUrl.collectAsStateWithLifecycle()
+ val cachedDataSizeInBytes by viewModel.cachedDataSizeInBytes.collectAsStateWithLifecycle()
+ val isUserSignedIn by viewModel.isUserSignedIn.collectAsStateWithLifecycle()
+ DataSettingsDialogContent(
+ userPhotoUrl = userPhotoUrl,
+ isUserSignedIn = isUserSignedIn,
+ cachedDataSizeInBytes = cachedDataSizeInBytes,
+ onDeleteCached = viewModel::deleteCachedUserData,
+ onDeletePublic = viewModel::deletePublicData,
+ onDeleteSynced = viewModel::deleteSyncedUserData,
+ onDeleteAll = viewModel::deleteAllUserData,
+ onNavigateUp = destinationsNavigator::navigateUp
+ )
+}
+
+@Composable
+fun DataSettingsDialogContent(
+ modifier: Modifier = Modifier,
+ userPhotoUrl: Uri? = null,
+ isUserSignedIn: Boolean = true,
+ cachedDataSizeInBytes: Long? = null,
+ onDeleteCached: () -> Unit = {},
+ onDeletePublic: () -> Unit = {},
+ onDeleteSynced: () -> Unit = {},
+ onDeleteAll: () -> Unit = {},
+ onNavigateUp: () -> Unit = {},
+) {
+ val screenHeightDp = LocalConfiguration.current.screenHeightDp
+ JayDialogContent(
+ modifier = modifier,
+ icon = {
+ DataSettingsIconScreen(
+ userPhotoUrl = userPhotoUrl,
+ isUserSignedIn = isUserSignedIn,
+ )
+ },
+ title = {
+ DataSettingsTitleScreen()
+ },
+ textPaddingValues = PaddingValues(),
+ text = {
+ DataSettingsScreen(
+ modifier = Modifier.heightIn(max = (screenHeightDp * 0.4f).dp),
+ onDeleteCached = onDeleteCached,
+ onDeletePublic = onDeletePublic,
+ onDeleteSynced = onDeleteSynced,
+ onDeleteAll = onDeleteAll,
+ )
+ },
+ buttons = {
+ DataSettingsButtons(
+ cachedDataSizeInBytes = cachedDataSizeInBytes,
+ onNavigateUp = onNavigateUp,
+ )
+ },
+ containerColor = Color.Transparent,
+ )
+}
+
+@Composable
+fun DataSettingsIconScreen(
+ modifier: Modifier = Modifier,
+ isUserSignedIn: Boolean = true,
+ userPhotoUrl: Uri? = null
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ AvatarAsyncImage(
+ modifier = Modifier
+ .size(RoundedCornerRadius * 4)
+ .clip(CircleShape),
+ placeholderEnabled = !isUserSignedIn || userPhotoUrl == null,
+ userPhotoUrl = userPhotoUrl
+ )
+ }
+
+}
+
+@Composable
+fun DataSettingsTitleScreen(
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center
+ ) {
+ Text(text = stringResource(R.string.my_data))
+ }
+}
+
+@Composable
+fun DataSettingsButtons(
+ modifier: Modifier = Modifier,
+ cachedDataSizeInBytes: Long? = null,
+ onNavigateUp: () -> Unit = {},
+) {
+ Row(
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.Bottom
+ ) {
+ val byteString = stringResource(R.string.bytes)
+ Crossfade(targetState = cachedDataSizeInBytes) {
+ when (it != null) {
+ true -> {
+ Row(
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = it.toString(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = byteString,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold
+ )
+ }
+ }
+ else -> {}
+ }
+ }
+ TextButton(onClick = onNavigateUp) {
+ Text(text = stringResource(R.string.back))
+ }
+ }
+}
+
+@Composable
+fun DataSettingsScreen(
+ modifier: Modifier = Modifier,
+ onDeleteCached: () -> Unit = {},
+ onDeletePublic: () -> Unit = {},
+ onDeleteSynced: () -> Unit = {},
+ onDeleteAll: () -> Unit = {},
+) {
+ LazyColumn(
+ modifier = modifier.clip(CardDefaults.shape),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ item {
+ MenuButtonWithDescription(
+ onClick = onDeletePublic,
+ text = stringResource(R.string.delete_public_data),
+ description = stringResource(R.string.delete_public_data_description)
+ )
+ }
+ item {
+ MenuButtonWithDescription(
+ onClick = onDeleteCached,
+ text = stringResource(R.string.delete_from_device),
+ description = stringResource(R.string.delete_from_device_description)
+ )
+ }
+ item {
+ MenuButtonWithDescription(
+ onClick = onDeleteSynced,
+ text = stringResource(R.string.delete_from_cloud),
+ description = stringResource(R.string.delete_from_cloud_description)
+ )
+ }
+ item {
+ MenuButtonWithDescription(
+ onClick = onDeleteAll,
+ text = stringResource(R.string.delete_all_user_data),
+ description = stringResource(R.string.delete_all_user_data_description)
+ )
+ }
+ }
+}
+
+@Composable
+fun MenuButtonWithDescription(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit,
+ text: String,
+ description: String,
+ showDescriptionInitially: Boolean = false,
+) {
+ var showDescription by rememberSaveable { mutableStateOf(showDescriptionInitially) }
+ Column(
+ modifier = modifier,
+ ) {
+ DescriptionCard(
+ onClick = { showDescription = !showDescription },
+ text = description,
+ showDescription = showDescription,
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ MenuButton(
+ onClick = onClick,
+ text = text
+ )
+ IconToggleButton(
+ checked = showDescription,
+ onCheckedChange = { showDescription = it }
+ ) {
+ Icon(
+ imageVector = if (showDescription) {
+ Icons.Rounded.ExpandLess
+ } else {
+ Icons.Rounded.ExpandMore
+ },
+ contentDescription = ""
+ )
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DescriptionCard(
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit = {},
+ text: String,
+ style: TextStyle = MaterialTheme.typography.labelLarge,
+ fontWeight: FontWeight = FontWeight.Normal,
+ showDescription: Boolean = false,
+ textColor: Color = MaterialTheme.colorScheme.onSurface,
+ color: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp),
+ content: @Composable () -> Unit = {},
+) {
+ Card(
+ modifier = modifier,
+ onClick = onClick,
+ colors = CardDefaults.cardColors(containerColor = color)
+ ) {
+ Column(
+ modifier = modifier.padding(horizontal = 2.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp)
+ ) {
+ content()
+ AnimatedVisibility(visible = showDescription) {
+ Text(
+ modifier = Modifier.padding(start = 6.dp, end = 6.dp, bottom = 8.dp),
+ text = text,
+ color = textColor,
+ style = style,
+ fontWeight = fontWeight
+ )
+ }
+ }
+ }
+}
+
+@PreviewThemesScreensFonts
+@Composable
+fun PreviewDataSettingsDialogContent() {
+ JayTheme {
+ DataSettingsDialogContent()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/illyan/jay/ui/settings/data/DataSettingsViewModel.kt b/app/src/main/java/illyan/jay/ui/settings/data/DataSettingsViewModel.kt
new file mode 100644
index 00000000..72f3a836
--- /dev/null
+++ b/app/src/main/java/illyan/jay/ui/settings/data/DataSettingsViewModel.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2022-2023 Balázs Püspök-Kiss (Illyan)
+ *
+ * Jay is a driver behaviour analytics app.
+ *
+ * This file is part of Jay.
+ *
+ * Jay is free software: you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later version.
+ * Jay is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with Jay.
+ * If not, see .
+ */
+
+package illyan.jay.ui.settings.data
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import dagger.hilt.android.lifecycle.HiltViewModel
+import illyan.jay.di.CoroutineDispatcherIO
+import illyan.jay.domain.interactor.AuthInteractor
+import illyan.jay.domain.interactor.UserInteractor
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+@HiltViewModel
+class DataSettingsViewModel @Inject constructor(
+ private val authInteractor: AuthInteractor,
+ private val userInteractor: UserInteractor,
+ @CoroutineDispatcherIO private val dispatcherIO: CoroutineDispatcher
+) : ViewModel() {
+
+ val userPhotoUrl = authInteractor.userPhotoUrlStateFlow
+ val cachedDataSizeInBytes = userInteractor.cachedUserDataSizeInBytes
+ val isUserSignedIn = authInteractor.isUserSignedInStateFlow
+
+ fun deleteSyncedUserData() {
+ viewModelScope.launch(dispatcherIO) {
+ userInteractor.deleteAllSyncedData()
+ }
+ }
+
+ fun deletePublicData() {
+ viewModelScope.launch(dispatcherIO) {
+ userInteractor.deleteAllPublicData()
+ }
+ }
+
+ fun deleteAllUserData() {
+ viewModelScope.launch(dispatcherIO) {
+ userInteractor.deleteAllUserData()
+ }
+ }
+
+ fun deleteCachedUserData() {
+ viewModelScope.launch(dispatcherIO) {
+ userInteractor.deleteAllLocalData()
+ }
+ }
+}
diff --git a/app/src/main/java/illyan/jay/ui/settings/Settings.kt b/app/src/main/java/illyan/jay/ui/settings/general/UserSettings.kt
similarity index 60%
rename from app/src/main/java/illyan/jay/ui/settings/Settings.kt
rename to app/src/main/java/illyan/jay/ui/settings/general/UserSettings.kt
index c1fb74dd..9ff87246 100644
--- a/app/src/main/java/illyan/jay/ui/settings/Settings.kt
+++ b/app/src/main/java/illyan/jay/ui/settings/general/UserSettings.kt
@@ -16,13 +16,15 @@
* If not, see .
*/
-package illyan.jay.ui.settings
+package illyan.jay.ui.settings.general
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
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
import androidx.compose.foundation.layout.heightIn
@@ -43,7 +45,9 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -53,6 +57,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.hilt.navigation.compose.hiltViewModel
@@ -65,24 +70,26 @@ import illyan.jay.ui.components.CopiedToKeyboardTooltip
import illyan.jay.ui.components.JayDialogContent
import illyan.jay.ui.components.JayDialogSurface
import illyan.jay.ui.components.MediumCircularProgressIndicator
-import illyan.jay.ui.components.PreviewLightDarkTheme
+import illyan.jay.ui.components.PreviewThemesScreensFonts
import illyan.jay.ui.components.SmallCircularProgressIndicator
import illyan.jay.ui.components.TooltipElevatedCard
+import illyan.jay.ui.destinations.DataSettingsDialogScreenDestination
+import illyan.jay.ui.profile.MenuButton
import illyan.jay.ui.profile.ProfileNavGraph
-import illyan.jay.ui.settings.model.UiPreferences
+import illyan.jay.ui.settings.general.model.UiPreferences
import illyan.jay.ui.theme.JayTheme
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
+import kotlin.random.Random
import kotlin.time.Duration.Companion.seconds
-
@ProfileNavGraph
@Destination
@Composable
-fun SettingsDialogScreen(
- viewModel: SettingsViewModel = hiltViewModel(),
+fun UserSettingsDialogScreen(
+ viewModel: UserSettingsViewModel = hiltViewModel(),
destinationsNavigator: DestinationsNavigator = EmptyDestinationsNavigator,
) {
val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
@@ -90,7 +97,7 @@ fun SettingsDialogScreen(
val arePreferencesSynced by viewModel.arePreferencesSynced.collectAsStateWithLifecycle()
val canSyncPreferences by viewModel.canSyncPreferences.collectAsStateWithLifecycle()
val shouldSyncPreferences by viewModel.shouldSyncPreferences.collectAsStateWithLifecycle()
- SettingsDialogContent(
+ UserSettingsDialogContent(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = max(200.dp, screenHeightDp - 256.dp)),
@@ -102,11 +109,12 @@ fun SettingsDialogScreen(
setAnalytics = viewModel::setAnalytics,
setFreeDriveAutoStart = viewModel::setFreeDriveAutoStart,
setAdVisibility = viewModel::setAdVisibility,
+ onDeleteUserData = { destinationsNavigator.navigate(DataSettingsDialogScreenDestination) },
)
}
@Composable
-fun SettingsDialogContent(
+fun UserSettingsDialogContent(
modifier: Modifier = Modifier,
preferences: UiPreferences?,
arePreferencesSynced: Boolean = false,
@@ -116,17 +124,18 @@ fun SettingsDialogContent(
setAnalytics: (Boolean) -> Unit = {},
setFreeDriveAutoStart: (Boolean) -> Unit = {},
setAdVisibility: (Boolean) -> Unit = {},
+ onDeleteUserData: () -> Unit = {}
) {
JayDialogContent(
modifier = modifier,
title = {
- SettingsTitle(
+ UserSettingsTitle(
arePreferencesSynced = arePreferencesSynced,
preferences = preferences
)
},
text = {
- SettingsScreen(
+ UserSettingsScreen(
preferences = preferences,
setAnalytics = setAnalytics,
setFreeDriveAutoStart = setFreeDriveAutoStart,
@@ -134,24 +143,25 @@ fun SettingsDialogContent(
)
},
buttons = {
- // TODO: Toggle Settings Sync
- SettingsButtons(
+ UserSettingsButtons(
modifier = Modifier.fillMaxWidth(),
canSyncPreferences = canSyncPreferences,
shouldSyncPreferences = shouldSyncPreferences,
onShouldSyncChanged = onShouldSyncChanged,
+ onDeleteUserData = onDeleteUserData,
)
},
containerColor = Color.Transparent,
)
}
+@OptIn(ExperimentalLayoutApi::class)
@Composable
-fun SettingsTitle(
+fun UserSettingsTitle(
arePreferencesSynced: Boolean = false,
preferences: UiPreferences? = null,
) {
- Row(
+ FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
@@ -159,16 +169,22 @@ fun SettingsTitle(
Text(text = stringResource(R.string.settings))
SyncPreferencesLabel(arePreferencesSynced = arePreferencesSynced)
}
- Crossfade(targetState = preferences != null) {
- if (it && preferences != null) {
- Column(
- horizontalAlignment = Alignment.End
- ) {
- ClientLabel(clientUUID = preferences.clientUUID)
- LastUpdateLabel(lastUpdate = preferences.lastUpdate)
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.End,
+ verticalAlignment = Alignment.Top
+ ) {
+ Crossfade(targetState = preferences != null) {
+ if (it && preferences != null) {
+ Column(
+ horizontalAlignment = Alignment.End
+ ) {
+ ClientLabel(clientUUID = preferences.clientUUID)
+ LastUpdateLabel(lastUpdate = preferences.lastUpdate)
+ }
+ } else {
+ MediumCircularProgressIndicator()
}
- } else {
- MediumCircularProgressIndicator()
}
}
}
@@ -208,56 +224,75 @@ private fun SyncPreferencesLabel(
}
}
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SettingsButtons(
+fun UserSettingsButtons(
modifier: Modifier = Modifier,
canSyncPreferences: Boolean = false,
shouldSyncPreferences: Boolean = false,
onShouldSyncChanged: (Boolean) -> Unit = {},
+ onDeleteUserData: () -> Unit = {}
) {
Row(
- modifier = modifier,
- horizontalArrangement = Arrangement.End
+ modifier = modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
) {
- Card(
- colors = CardDefaults.cardColors(
- containerColor = Color.Transparent,
- disabledContainerColor = Color.Transparent,
- disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant
- ),
- enabled = canSyncPreferences,
- onClick = { onShouldSyncChanged(!shouldSyncPreferences) }
+ MenuButton(
+ text = stringResource(R.string.data_settings),
+ onClick = onDeleteUserData
+ )
+ SyncPreferencesButton(
+ canSyncPreferences = canSyncPreferences,
+ onShouldSyncChanged = onShouldSyncChanged,
+ shouldSyncPreferences = shouldSyncPreferences,
+ )
+ }
+}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun SyncPreferencesButton(
+ canSyncPreferences: Boolean,
+ onShouldSyncChanged: (Boolean) -> Unit,
+ shouldSyncPreferences: Boolean
+) {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = Color.Transparent,
+ disabledContainerColor = Color.Transparent,
+ disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ enabled = canSyncPreferences,
+ onClick = { onShouldSyncChanged(!shouldSyncPreferences) }
+ ) {
+ Row(
+ modifier = Modifier.padding(start = 8.dp, end = 2.dp, top = 2.dp, bottom = 2.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ verticalAlignment = Alignment.CenterVertically
) {
- Row(
- modifier = Modifier.padding(start = 8.dp, end = 2.dp, top = 2.dp, bottom = 2.dp),
- horizontalArrangement = Arrangement.spacedBy(6.dp),
- verticalAlignment = Alignment.CenterVertically
+ Text(
+ modifier = Modifier.animateContentSize(),
+ text = stringResource(
+ if (shouldSyncPreferences) {
+ R.string.syncing
+ } else {
+ R.string.not_syncing
+ }
+ )
+ )
+ FilledIconToggleButton(
+ checked = shouldSyncPreferences,
+ onCheckedChange = onShouldSyncChanged,
+ enabled = canSyncPreferences
) {
- Text(
- modifier = Modifier.animateContentSize(),
- text = stringResource(
- if (shouldSyncPreferences) {
- R.string.syncing
- } else {
- R.string.not_syncing
- }
- )
+ Icon(
+ imageVector = if (shouldSyncPreferences) {
+ Icons.Rounded.Cloud
+ } else {
+ Icons.Rounded.CloudOff
+ },
+ contentDescription = ""
)
- FilledIconToggleButton(
- checked = shouldSyncPreferences,
- onCheckedChange = onShouldSyncChanged,
- enabled = canSyncPreferences
- ) {
- Icon(
- imageVector = if (shouldSyncPreferences) {
- Icons.Rounded.Cloud
- } else {
- Icons.Rounded.CloudOff
- },
- contentDescription = ""
- )
- }
}
}
}
@@ -267,21 +302,40 @@ fun SettingsButtons(
private fun LastUpdateLabel(
lastUpdate: ZonedDateTime,
) {
+ 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
+ }
+ }
+ val textStyle = MaterialTheme.typography.bodyMedium
SettingLabel(
settingName = stringResource(R.string.last_update),
- settingText = lastUpdate
- .withZoneSameInstant(ZoneId.systemDefault())
- .minusNanos(lastUpdate.nano.toLong()) // No millis in formatted time
- .format(
- if (lastUpdate.second.seconds.inWholeDays ==
- ZonedDateTime.now().second.seconds.inWholeDays
- ) {
- DateTimeFormatter.ISO_LOCAL_TIME
- } else {
- DateTimeFormatter.ISO_LOCAL_DATE
+ settingNameStyle = textStyle.plus(TextStyle(fontWeight = FontWeight.SemiBold)),
+ settingIndicator = {
+ Column(
+ horizontalAlignment = Alignment.End
+ ) {
+ AnimatedVisibility(visible = isDateVisible) {
+ Text(
+ text = date,
+ style = textStyle,
+ )
}
- ),
- style = MaterialTheme.typography.bodyMedium
+ Text(
+ text = time,
+ style = textStyle
+ )
+ }
+ },
)
}
@@ -300,7 +354,7 @@ fun ClientLabel(
modifier = Modifier.padding(vertical = 4.dp, horizontal = 8.dp),
settingName = stringResource(R.string.client_id),
settingText = clientUUID?.take(8),
- style = MaterialTheme.typography.bodyMedium
+ settingTextStyle = MaterialTheme.typography.bodyMedium
)
}
}
@@ -311,8 +365,39 @@ fun SettingLabel(
modifier: Modifier = Modifier,
settingText: String? = null,
settingName: String,
- style: TextStyle = LocalTextStyle.current,
- styleName: TextStyle = style.plus(TextStyle(fontWeight = FontWeight.SemiBold)),
+ settingTextStyle: TextStyle = LocalTextStyle.current,
+ settingNameStyle: TextStyle = settingTextStyle.plus(TextStyle(fontWeight = FontWeight.SemiBold)),
+ settingValueTextAlign: TextAlign? = null,
+) {
+ SettingLabel(
+ modifier = modifier,
+ settingIndicator = {
+ Crossfade(
+ modifier = Modifier.animateContentSize(),
+ targetState = settingText
+ ) {
+ if (it != null) {
+ Text(
+ text = it,
+ style = settingTextStyle,
+ textAlign = settingValueTextAlign,
+ )
+ } else {
+ SmallCircularProgressIndicator()
+ }
+ }
+ },
+ settingName = settingName,
+ settingNameStyle = settingNameStyle
+ )
+}
+
+@Composable
+fun SettingLabel(
+ modifier: Modifier = Modifier,
+ settingName: String,
+ settingNameStyle: TextStyle = LocalTextStyle.current.plus(TextStyle(fontWeight = FontWeight.SemiBold)),
+ settingIndicator: @Composable () -> Unit,
) {
Row(
modifier = modifier,
@@ -321,26 +406,14 @@ fun SettingLabel(
) {
Text(
text = settingName,
- style = styleName
+ style = settingNameStyle,
)
- Crossfade(
- modifier = Modifier.animateContentSize(),
- targetState = settingText
- ) {
- if (it != null) {
- Text(
- text = it,
- style = style
- )
- } else {
- SmallCircularProgressIndicator()
- }
- }
+ settingIndicator()
}
}
@Composable
-fun SettingsScreen(
+fun UserSettingsScreen(
preferences: UiPreferences? = null,
setAnalytics: (Boolean) -> Unit = {},
setFreeDriveAutoStart: (Boolean) -> Unit = {},
@@ -367,11 +440,15 @@ fun SettingsScreen(
} else {
Row(
modifier = Modifier.fillMaxWidth(),
- verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
- Text(text = stringResource(R.string.loading))
- MediumCircularProgressIndicator()
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Text(text = stringResource(R.string.loading))
+ MediumCircularProgressIndicator()
+ }
}
}
}
@@ -382,13 +459,17 @@ fun BooleanSetting(
value: Boolean,
setValue: (Boolean) -> Unit,
settingName: String,
- enabledText: String = stringResource(R.string.enabled),
- disabledText: String = stringResource(R.string.disabled),
+ textStyle: TextStyle = MaterialTheme.typography.labelLarge,
+ fontWeight: FontWeight = FontWeight.Normal,
+ enabledText: String = stringResource(R.string.on),
+ disabledText: String = stringResource(R.string.off),
) {
SettingItem(
modifier = Modifier.fillMaxWidth(),
name = settingName,
- onClick = { setValue(!value) }
+ onClick = { setValue(!value) },
+ textStyle = textStyle,
+ fontWeight = fontWeight,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -398,7 +479,11 @@ fun BooleanSetting(
modifier = Modifier.animateContentSize(),
targetState = value
) { enabled ->
- Text(text = if (enabled) enabledText else disabledText)
+ Text(
+ text = if (enabled) enabledText else disabledText,
+ style = textStyle,
+ color = MaterialTheme.colorScheme.onSurface
+ )
}
Switch(
checked = value,
@@ -450,6 +535,8 @@ fun ShowAdsSetting(
fun SettingItem(
modifier: Modifier = Modifier,
name: String,
+ textStyle: TextStyle = MaterialTheme.typography.labelLarge,
+ fontWeight: FontWeight = FontWeight.Normal,
onClick: () -> Unit = {},
content: @Composable () -> Unit = {},
) {
@@ -467,22 +554,38 @@ fun SettingItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
- Text(text = name)
+ Text(
+ modifier = Modifier.weight(1f, fill = false),
+ text = name,
+ style = textStyle,
+ fontWeight = fontWeight,
+ color = MaterialTheme.colorScheme.onSurface
+ )
content()
}
}
}
-@PreviewLightDarkTheme
+@PreviewThemesScreensFonts
@Composable
-fun SettingsDialogScreenPreview() {
+fun UserSettingsDialogScreenPreview() {
JayTheme {
JayDialogSurface {
- SettingsDialogContent(
+ val canSyncPreferences = Random.nextBoolean()
+ val arePreferencesSynced = if (canSyncPreferences) Random.nextBoolean() else false
+ val shouldSyncPreferences = if (arePreferencesSynced) true else Random.nextBoolean()
+ UserSettingsDialogContent(
preferences = UiPreferences(
userUUID = UUID.randomUUID().toString(),
- clientUUID = UUID.randomUUID().toString()
- )
+ clientUUID = UUID.randomUUID().toString(),
+ lastUpdate = ZonedDateTime.now().minusDays(if (Random.nextBoolean()) 1 else 0),
+ analyticsEnabled = Random.nextBoolean(),
+ freeDriveAutoStart = Random.nextBoolean(),
+ showAds = Random.nextBoolean(),
+ ),
+ canSyncPreferences = canSyncPreferences,
+ arePreferencesSynced = arePreferencesSynced,
+ shouldSyncPreferences = shouldSyncPreferences
)
}
}
diff --git a/app/src/main/java/illyan/jay/ui/settings/SettingsViewModel.kt b/app/src/main/java/illyan/jay/ui/settings/general/UserSettingsViewModel.kt
similarity index 94%
rename from app/src/main/java/illyan/jay/ui/settings/SettingsViewModel.kt
rename to app/src/main/java/illyan/jay/ui/settings/general/UserSettingsViewModel.kt
index 4a5f2ee1..6eb5654e 100644
--- a/app/src/main/java/illyan/jay/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/illyan/jay/ui/settings/general/UserSettingsViewModel.kt
@@ -16,21 +16,21 @@
* If not, see .
*/
-package illyan.jay.ui.settings
+package illyan.jay.ui.settings.general
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import illyan.jay.domain.interactor.AuthInteractor
import illyan.jay.domain.interactor.SettingsInteractor
-import illyan.jay.ui.settings.model.toUiModel
+import illyan.jay.ui.settings.general.model.toUiModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
-class SettingsViewModel @Inject constructor(
+class UserSettingsViewModel @Inject constructor(
private val settingsInteractor: SettingsInteractor,
private val authInteractor: AuthInteractor
) : ViewModel() {
diff --git a/app/src/main/java/illyan/jay/ui/settings/model/UiPreferences.kt b/app/src/main/java/illyan/jay/ui/settings/general/model/UiPreferences.kt
similarity index 97%
rename from app/src/main/java/illyan/jay/ui/settings/model/UiPreferences.kt
rename to app/src/main/java/illyan/jay/ui/settings/general/model/UiPreferences.kt
index 3789ac9f..572a19fa 100644
--- a/app/src/main/java/illyan/jay/ui/settings/model/UiPreferences.kt
+++ b/app/src/main/java/illyan/jay/ui/settings/general/model/UiPreferences.kt
@@ -16,7 +16,7 @@
* If not, see .
*/
-package illyan.jay.ui.settings.model
+package illyan.jay.ui.settings.general.model
import illyan.jay.domain.model.DomainPreferences
import java.time.ZonedDateTime
diff --git a/app/src/main/java/illyan/jay/util/Util.kt b/app/src/main/java/illyan/jay/util/Util.kt
index 806f9523..e7c5e4f8 100644
--- a/app/src/main/java/illyan/jay/util/Util.kt
+++ b/app/src/main/java/illyan/jay/util/Util.kt
@@ -249,14 +249,36 @@ suspend inline fun WriteBatch.awaitAllThenCommit(vararg deferred: De
suspend inline fun WriteBatch.awaitAllThenCommit(deferred: List>) = awaitAllThenCommit(*deferred.toTypedArray())
suspend fun FirebaseFirestore.runBatch(
- numberOfWrites: Int,
+ numberOfOperations: Int,
body: suspend (
batch: WriteBatch,
- completableDeferred: List>
+ onOperationFinished: () -> Unit
) -> Unit
): Task {
val batch = batch()
- val completableDeferred = List(numberOfWrites) { CompletableDeferred() }
- body(batch, completableDeferred)
+ val completableDeferred = List(numberOfOperations) { CompletableDeferred() }
+ val onOperationFinished = {
+ completableDeferred.completeNext()
+ Timber.v("${completableDeferred.filter { it.isCompleted }.size} operations done.")
+ }
+ body(batch, onOperationFinished)
return batch.awaitAllThenCommit(completableDeferred)
}
+
+suspend fun awaitOperations(
+ numberOfOperations: Int,
+ body: suspend (onOperationFinished: () -> Unit) -> Unit
+) {
+ val completableDeferred = List(numberOfOperations) { CompletableDeferred() }
+ val onOperationFinished = {
+ val isCompleted = completableDeferred.completeNext()
+ val numberOfCompletedOperations = completableDeferred.filter { it.isCompleted }.size
+ if (isCompleted) {
+ Timber.v("$numberOfCompletedOperations operations completed.")
+ } else {
+ Timber.v("Failed to complete operation. Completed $numberOfCompletedOperations so far.")
+ }
+ }
+ body(onOperationFinished)
+ awaitAll(deferreds = completableDeferred.toTypedArray())
+}
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index b5324ce5..4aa279b4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -31,7 +31,7 @@
List is empty
This list would be more useful with items in it!
Location permission denied
- Navigation requires your precise location to give feedback and turn-by-turn instructions.
+ Jay App collects location data to show analytics about your taken path, like your speed on your route and display where you have been while you were driving. Location data is collected in the background while a Session is running, even when Jay App is in the background or closed.
Request permission
Start free-driving
Stop free-driving
@@ -124,4 +124,17 @@
Elevation
Accuracy
Filters
+ Data Settings
+ Delete all user data
+ Bytes
+ Back
+ Deletes all user related data from the cloud. User settings syncing will automatically stop. It won\'t affect any other data on-device.
+ Deletes data both from the cloud and on-device.
+ My Data
+ On
+ Off
+ Delete public data
+ Delete data visible to everyone. Public data cannot be synced, nor belong to a user. This data (e.g. Session) can be owned and become the user\'s private property.
+ Delete from device
+ Deletes all user related data on-device. It does not affect data uploaded and synced to the cloud.
\ No newline at end of file
diff --git a/docs/docs/privacy-policy.md b/docs/docs/privacy-policy.md
index f5724ede..4334d665 100644
--- a/docs/docs/privacy-policy.md
+++ b/docs/docs/privacy-policy.md
@@ -12,6 +12,11 @@ The terms used in this Privacy Policy have the same meanings as in our Terms and
For a better experience, while using our Service, I may require you to provide us with certain personally identifiable information. The information that I request will be retained on your device and is not collected by me in any way.
+Additonal information which are required to provide app functionality:
+
+- Jay App collects location data to show analytics about your taken path, like your speed on your route and display where you have been while you were driving. Location data is collected in the background while a Session is running, even when Jay App is in the background or closed.
+- Jay App collects client IDs, which are generated on first startup. This enables Jay to help filter data (e.g. Sessions) based on a client it is recorded on.
+
The app does use third-party services that may collect information used to identify you.
Link to the privacy policy of third-party service providers used by the app
@@ -59,7 +64,7 @@ These Services do not address anyone under the age of 13. I do not knowingly col
I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on this page.
-This policy is effective as of 2023-03-20
+This policy is effective as of 2023-04-06
## Contact Us