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