From 9701e88bcd721dabc9f2f87da14e35cd460d7ec2 Mon Sep 17 00:00:00 2001 From: Stefano Sansone Date: Sat, 27 Apr 2024 00:29:41 +0200 Subject: [PATCH] add nested navigation for Home screens --- .../app/omnivore/omnivore/MainActivity.kt | 23 +- .../designsystem/motion/MaterialSharedAxis.kt | 146 ++++ .../designsystem/motion/MotionConstants.kt | 11 + .../omnivore/core/designsystem/util/Motion.kt | 30 - .../omnivore/feature/auth/LoginViewModel.kt | 697 +++++++++--------- .../feature/following/FollowingScreen.kt | 6 +- .../omnivore/feature/library/LibraryView.kt | 6 +- .../omnivore/feature/library/SearchView.kt | 5 +- .../omnivore/feature/profile/ProfileScreen.kt | 5 +- .../omnivore/feature/root/RootView.kt | 153 ++-- .../omnivore/omnivore/navigation/Routes.kt | 1 + 11 files changed, 573 insertions(+), 510 deletions(-) create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MaterialSharedAxis.kt create mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MotionConstants.kt delete mode 100644 android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Motion.kt diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/MainActivity.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/MainActivity.kt index 13ac179a2..108df99ea 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/MainActivity.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/MainActivity.kt @@ -5,7 +5,6 @@ import android.view.View import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier @@ -13,12 +12,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat -import app.omnivore.omnivore.feature.auth.LoginViewModel -import app.omnivore.omnivore.feature.components.LabelsViewModel -import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel -import app.omnivore.omnivore.feature.library.SearchViewModel import app.omnivore.omnivore.feature.root.RootView -import app.omnivore.omnivore.feature.save.SaveViewModel import app.omnivore.omnivore.feature.theme.OmnivoreTheme import com.pspdfkit.PSPDFKit import dagger.hilt.android.AndroidEntryPoint @@ -36,12 +30,6 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) - val loginViewModel: LoginViewModel by viewModels() - val searchViewModel: SearchViewModel by viewModels() - val labelsViewModel: LabelsViewModel by viewModels() - val saveViewModel: SaveViewModel by viewModels() - val editInfoViewModel: EditInfoViewModel by viewModels() - val context = this GlobalScope.launch(Dispatchers.IO) { @@ -59,16 +47,9 @@ class MainActivity : ComponentActivity() { setContent { OmnivoreTheme { Box( - modifier = Modifier - .fillMaxSize() + modifier = Modifier.fillMaxSize() ) { - RootView( - loginViewModel, - searchViewModel, - labelsViewModel, - saveViewModel, - editInfoViewModel - ) + RootView() } } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MaterialSharedAxis.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MaterialSharedAxis.kt new file mode 100644 index 000000000..ff1bd94db --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MaterialSharedAxis.kt @@ -0,0 +1,146 @@ +package app.omnivore.omnivore.core.designsystem.motion + +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp + + +/** + * Returns the provided [Dp] as an [Int] value by the [LocalDensity]. + * + * @param slideDistance Value to the slide distance dimension, 30dp by default. + */ +@Composable +fun rememberSlideDistance( + slideDistance: Dp = MotionConstants.DefaultSlideDistance, +): Int { + val density = LocalDensity.current + return remember(density, slideDistance) { + with(density) { slideDistance.roundToPx() } + } +} + +private const val ProgressThreshold = 0.35f + +private val Int.ForOutgoing: Int + get() = (this * ProgressThreshold).toInt() + +private val Int.ForIncoming: Int + get() = this - this.ForOutgoing + +/** + * [materialSharedAxisXIn] allows to switch a layout with shared X-axis enter transition. + */ +fun materialSharedAxisXIn( + initialOffsetX: (fullWidth: Int) -> Int, + durationMillis: Int = MotionConstants.DefaultMotionDuration, +): EnterTransition = slideInHorizontally( + animationSpec = tween( + durationMillis = durationMillis, + easing = FastOutSlowInEasing + ), + initialOffsetX = initialOffsetX +) + fadeIn( + animationSpec = tween( + durationMillis = durationMillis.ForIncoming, + delayMillis = durationMillis.ForOutgoing, + easing = LinearOutSlowInEasing + ) +) + +/** + * [materialSharedAxisXOut] allows to switch a layout with shared X-axis exit transition. + * + */ +fun materialSharedAxisXOut( + targetOffsetX: (fullWidth: Int) -> Int, + durationMillis: Int = MotionConstants.DefaultMotionDuration, +): ExitTransition = slideOutHorizontally( + animationSpec = tween( + durationMillis = durationMillis, + easing = FastOutSlowInEasing + ), + targetOffsetX = targetOffsetX +) + fadeOut( + animationSpec = tween( + durationMillis = durationMillis.ForOutgoing, + delayMillis = 0, + easing = FastOutLinearInEasing + ) +) + + +/** + * [materialSharedAxisY] allows to switch a layout with shared Y-axis transition. + * + */ +@OptIn(ExperimentalAnimationApi::class) +public fun materialSharedAxisY( + initialOffsetY: (fullWidth: Int) -> Int, + targetOffsetY: (fullWidth: Int) -> Int, + durationMillis: Int = MotionConstants.DefaultMotionDuration, +): ContentTransform = ContentTransform( + materialSharedAxisYIn( + initialOffsetY = initialOffsetY, + durationMillis = durationMillis +), materialSharedAxisYOut( + targetOffsetY = targetOffsetY, + durationMillis = durationMillis +) +) + +/** + * [materialSharedAxisYIn] allows to switch a layout with shared Y-axis enter transition. + */ +public fun materialSharedAxisYIn( + initialOffsetY: (fullWidth: Int) -> Int, + durationMillis: Int = MotionConstants.DefaultMotionDuration, +): EnterTransition = slideInVertically( + animationSpec = tween( + durationMillis = durationMillis, + easing = FastOutSlowInEasing + ), + initialOffsetY = initialOffsetY +) + fadeIn( + animationSpec = tween( + durationMillis = durationMillis.ForIncoming, + delayMillis = durationMillis.ForOutgoing, + easing = LinearOutSlowInEasing + ) +) + +/** + * [materialSharedAxisYOut] allows to switch a layout with shared X-axis exit transition. + * + */ +public fun materialSharedAxisYOut( + targetOffsetY: (fullWidth: Int) -> Int, + durationMillis: Int = MotionConstants.DefaultMotionDuration, +): ExitTransition = slideOutVertically ( + animationSpec = tween( + durationMillis = durationMillis, + easing = FastOutSlowInEasing + ), + targetOffsetY = targetOffsetY +) + fadeOut( + animationSpec = tween( + durationMillis = durationMillis.ForOutgoing, + delayMillis = 0, + easing = FastOutLinearInEasing + ) +) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MotionConstants.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MotionConstants.kt new file mode 100644 index 000000000..60c43882b --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/motion/MotionConstants.kt @@ -0,0 +1,11 @@ +package app.omnivore.omnivore.core.designsystem.motion + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +object MotionConstants { + const val DefaultMotionDuration: Int = 300 + const val DefaultFadeInDuration: Int = 150 + const val DefaultFadeOutDuration: Int = 75 + val DefaultSlideDistance: Dp = 30.dp +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Motion.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Motion.kt deleted file mode 100644 index d0a0ec079..000000000 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/core/designsystem/util/Motion.kt +++ /dev/null @@ -1,30 +0,0 @@ -package app.omnivore.omnivore.core.designsystem.util - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -@Composable -fun rememberSlideDistance( - slideDistance: Dp = MotionConstants.DefaultSlideDistance, -): Int { - val density = LocalDensity.current - return remember(density, slideDistance) { - with(density) { slideDistance.roundToPx() } - } -} - -object MotionConstants { - const val DEFAULT_MOTION_DURATION: Int = 300 - val DefaultSlideDistance: Dp = 30.dp -} - -const val ProgressThreshold = 0.35f - -val Int.ForOutgoing: Int - get() = (this * ProgressThreshold).toInt() - -val Int.ForIncoming: Int - get() = this - this.ForOutgoing diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt index 454b13c7d..7c10ed550 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/auth/LoginViewModel.kt @@ -15,6 +15,7 @@ import app.omnivore.omnivore.R import app.omnivore.omnivore.core.analytics.EventTracker import app.omnivore.omnivore.core.data.DataService import app.omnivore.omnivore.core.datastore.DatastoreRepository +import app.omnivore.omnivore.core.datastore.followingTabActive import app.omnivore.omnivore.core.datastore.omnivoreAuthCookieString import app.omnivore.omnivore.core.datastore.omnivoreAuthToken import app.omnivore.omnivore.core.datastore.omnivorePendingUserToken @@ -44,389 +45,405 @@ import dagger.hilt.android.lifecycle.HiltViewModel import io.intercom.android.sdk.Intercom import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import java.util.regex.Pattern import javax.inject.Inject enum class RegistrationState { - SocialLogin, - EmailSignIn, - EmailSignUp, - PendingUser, - SelfHosted + SocialLogin, EmailSignIn, EmailSignUp, PendingUser, SelfHosted } data class PendingEmailUserCreds( - val email: String, - val password: String + val email: String, val password: String ) @HiltViewModel class LoginViewModel @Inject constructor( - private val datastoreRepo: DatastoreRepository, - private val eventTracker: EventTracker, - private val networker: Networker, - private val dataService: DataService, - private val resourceProvider: ResourceProvider -): ViewModel() { - private var validateUsernameJob: Job? = null + private val datastoreRepository: DatastoreRepository, + private val eventTracker: EventTracker, + private val networker: Networker, + private val dataService: DataService, + private val resourceProvider: ResourceProvider +) : ViewModel() { + private var validateUsernameJob: Job? = null - var isLoading by mutableStateOf(false) - private set + var isLoading by mutableStateOf(false) + private set - var errorMessage by mutableStateOf(null) - private set + var errorMessage by mutableStateOf(null) + private set - var hasValidUsername by mutableStateOf(false) - private set + var hasValidUsername by mutableStateOf(false) + private set - var usernameValidationErrorMessage by mutableStateOf(null) - private set + var usernameValidationErrorMessage by mutableStateOf(null) + private set - var pendingEmailUserCreds by mutableStateOf(null) - private set + var pendingEmailUserCreds by mutableStateOf(null) + private set - val hasAuthTokenLiveData: LiveData = datastoreRepo - .hasAuthTokenFlow - .distinctUntilChanged() - .asLiveData() + val hasAuthTokenLiveData: LiveData = + datastoreRepository.hasAuthTokenFlow.distinctUntilChanged().asLiveData() - val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin) + val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin) - fun getAuthCookieString(): String? = runBlocking { - datastoreRepo.getString(omnivoreAuthCookieString) - } + val followingTabActiveState: StateFlow = datastoreRepository.getBoolean( + followingTabActive + ).stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false + ) - fun setSelfHostingDetails(context: Context, apiServer: String, webServer: String) { - viewModelScope.launch { - datastoreRepo.putString(omnivoreSelfHostedApiServer, apiServer) - datastoreRepo.putString(omnivoreSelfHostedWebServer, webServer) - Toast.makeText( - context, - context.getString(R.string.login_view_model_self_hosting_settings_updated), - Toast.LENGTH_SHORT - ).show() - } - } - - fun resetSelfHostingDetails(context: Context) { - viewModelScope.launch { - datastoreRepo.clearValue(omnivoreSelfHostedApiServer) - datastoreRepo.clearValue(omnivoreSelfHostedWebServer) - Toast.makeText( - context, - context.getString(R.string.login_view_model_self_hosting_settings_reset), - Toast.LENGTH_SHORT - ).show() + fun setSelfHostingDetails(context: Context, apiServer: String, webServer: String) { + viewModelScope.launch { + datastoreRepository.putString(omnivoreSelfHostedApiServer, apiServer) + datastoreRepository.putString(omnivoreSelfHostedWebServer, webServer) + Toast.makeText( + context, + context.getString(R.string.login_view_model_self_hosting_settings_updated), + Toast.LENGTH_SHORT + ).show() + } } + fun resetSelfHostingDetails(context: Context) { + viewModelScope.launch { + datastoreRepository.clearValue(omnivoreSelfHostedApiServer) + datastoreRepository.clearValue(omnivoreSelfHostedWebServer) + Toast.makeText( + context, + context.getString(R.string.login_view_model_self_hosting_settings_reset), + Toast.LENGTH_SHORT + ).show() + } - } - fun showSocialLogin() { - resetState() - registrationStateLiveData.value = RegistrationState.SocialLogin - } - fun showEmailSignIn() { - resetState() - registrationStateLiveData.value = RegistrationState.EmailSignIn - } - - fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) { - resetState() - pendingEmailUserCreds = pendingCreds - registrationStateLiveData.value = RegistrationState.EmailSignUp - } - - fun showSelfHostedSettings() { - resetState() - registrationStateLiveData.value = RegistrationState.SelfHosted - } - - fun cancelNewUserSignUp() { - resetState() - viewModelScope.launch { - datastoreRepo.clearValue(omnivorePendingUserToken) } - showSocialLogin() - } - fun registerUser() { - viewModelScope.launch { - val viewer = networker.viewer() - viewer?.let { - eventTracker.registerUser(viewer.userID, viewer.intercomHash, BuildConfig.DEBUG) - } + fun showSocialLogin() { + resetState() + registrationStateLiveData.value = RegistrationState.SocialLogin } - } - private fun resetState() { - validateUsernameJob = null - isLoading = false - errorMessage = null - hasValidUsername = false - usernameValidationErrorMessage = null - pendingEmailUserCreds = null - } + fun showEmailSignIn() { + resetState() + registrationStateLiveData.value = RegistrationState.EmailSignIn + } - fun validateUsername(potentialUsername: String) { - validateUsernameJob?.cancel() + fun showEmailSignUp(pendingCreds: PendingEmailUserCreds? = null) { + resetState() + pendingEmailUserCreds = pendingCreds + registrationStateLiveData.value = RegistrationState.EmailSignUp + } - validateUsernameJob = viewModelScope.launch { - delay(2000) + fun showSelfHostedSettings() { + resetState() + registrationStateLiveData.value = RegistrationState.SelfHosted + } - // Check the username requirements first - if (potentialUsername.isEmpty()) { + fun cancelNewUserSignUp() { + resetState() + viewModelScope.launch { + datastoreRepository.clearValue(omnivorePendingUserToken) + } + showSocialLogin() + } + + fun registerUser() { + viewModelScope.launch { + val viewer = networker.viewer() + viewer?.let { + eventTracker.registerUser(viewer.userID, viewer.intercomHash, BuildConfig.DEBUG) + } + } + } + + private fun resetState() { + validateUsernameJob = null + isLoading = false + errorMessage = null + hasValidUsername = false usernameValidationErrorMessage = null - hasValidUsername = false - return@launch - } + pendingEmailUserCreds = null + } - if (potentialUsername.length < 4 || potentialUsername.length > 15) { - usernameValidationErrorMessage = resourceProvider.getString( - R.string.login_view_model_username_validation_length_error_msg) - hasValidUsername = false - return@launch - } + fun validateUsername(potentialUsername: String) { + validateUsernameJob?.cancel() - val isValidPattern = Pattern.compile("^[a-z0-9][a-z0-9_]+[a-z0-9]$") - .matcher(potentialUsername) - .matches() + validateUsernameJob = viewModelScope.launch { + delay(2000) - if (!isValidPattern) { - usernameValidationErrorMessage = resourceProvider.getString( - R.string.login_view_model_username_validation_alphanumeric_error_msg) - hasValidUsername = false - return@launch - } + // Check the username requirements first + if (potentialUsername.isEmpty()) { + usernameValidationErrorMessage = null + hasValidUsername = false + return@launch + } - val apolloClient = ApolloClient.Builder() - .serverUrl("${Constants.apiURL}/api/graphql") - .build() + if (potentialUsername.length < 4 || potentialUsername.length > 15) { + usernameValidationErrorMessage = resourceProvider.getString( + R.string.login_view_model_username_validation_length_error_msg + ) + hasValidUsername = false + return@launch + } - try { - val response = apolloClient.query( - ValidateUsernameQuery(username = potentialUsername) - ).execute() + val isValidPattern = + Pattern.compile("^[a-z0-9][a-z0-9_]+[a-z0-9]$").matcher(potentialUsername).matches() - if (response.data?.validateUsername == true) { - usernameValidationErrorMessage = null - hasValidUsername = true + if (!isValidPattern) { + usernameValidationErrorMessage = resourceProvider.getString( + R.string.login_view_model_username_validation_alphanumeric_error_msg + ) + hasValidUsername = false + return@launch + } + + val apolloClient = + ApolloClient.Builder().serverUrl("${Constants.apiURL}/api/graphql").build() + + try { + val response = apolloClient.query( + ValidateUsernameQuery(username = potentialUsername) + ).execute() + + if (response.data?.validateUsername == true) { + usernameValidationErrorMessage = null + hasValidUsername = true + } else { + hasValidUsername = false + usernameValidationErrorMessage = resourceProvider.getString( + R.string.login_view_model_username_not_available_error_msg + ) + } + } catch (e: java.lang.Exception) { + hasValidUsername = false + usernameValidationErrorMessage = resourceProvider.getString( + R.string.login_view_model_connection_error_msg + ) + } + } + } + + fun login(email: String, password: String) { + + viewModelScope.launch { + val emailLogin = + RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java) + + isLoading = true + errorMessage = null + + val result = emailLogin.submitEmailLogin( + EmailLoginCredentials(email = email, password = password) + ) + + isLoading = false + + if (result.body()?.pendingEmailVerification == true) { + showEmailSignUp( + pendingCreds = PendingEmailUserCreds( + email = email, password = password + ) + ) + return@launch + } + + if (result.body()?.authToken != null) { + datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) + } else { + errorMessage = resourceProvider.getString( + R.string.login_view_model_something_went_wrong_error_msg + ) + } + + if (result.body()?.authCookieString != null) { + datastoreRepository.putString( + omnivoreAuthCookieString, result.body()?.authCookieString!! + ) + } + } + } + + fun submitEmailSignUp( + email: String, + password: String, + username: String, + name: String, + ) { + viewModelScope.launch { + val request = + RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java) + + isLoading = true + errorMessage = null + + val params = EmailSignUpParams( + email = email, password = password, name = name, username = username + ) + + val result = request.submitCreateEmailAccount(params) + + isLoading = false + + if (result.errorBody() != null) { + errorMessage = resourceProvider.getString( + R.string.login_view_model_something_went_wrong_two_error_msg + ) + } else { + pendingEmailUserCreds = PendingEmailUserCreds(email, password) + } + } + } + + private fun getPendingAuthToken(): String? = runBlocking { + datastoreRepository.getString(omnivorePendingUserToken) + } + + fun submitProfile(username: String, name: String) { + viewModelScope.launch { + val request = + RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java) + + isLoading = true + errorMessage = null + + val pendingUserToken = getPendingAuthToken() ?: "" + + val userProfile = UserProfile(name = name, username = username) + val params = CreateAccountParams( + pendingUserToken = pendingUserToken, userProfile = userProfile + ) + + val result = request.submitCreateAccount(params) + + isLoading = false + + if (result.body()?.authToken != null) { + datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) + } else { + errorMessage = resourceProvider.getString( + R.string.login_view_model_something_went_wrong_error_msg + ) + } + + if (result.body()?.authCookieString != null) { + datastoreRepository.putString( + omnivoreAuthCookieString, result.body()?.authCookieString!! + ) + } + } + } + + fun handleAppleToken(authToken: String) { + submitAuthProviderPayload( + params = SignInParams(token = authToken, provider = "APPLE") + ) + } + + fun logout() { + viewModelScope.launch { + datastoreRepository.clear() + dataService.clearDatabase() + Intercom.client().logout() + eventTracker.logout() + } + } + + fun resetErrorMessage() { + errorMessage = null + } + + fun showGoogleErrorMessage() { + errorMessage = resourceProvider.getString(R.string.login_view_model_google_auth_error_msg) + } + + fun handleGoogleAuthTask(task: Task) { + val result = task.getResult(ApiException::class.java) + val googleIdToken = result?.idToken ?: "" + + // If token is missing then set the error message + if (googleIdToken.isEmpty()) { + errorMessage = resourceProvider.getString( + R.string.login_view_model_missing_auth_token_error_msg + ) + return + } + + submitAuthProviderPayload( + params = SignInParams(token = googleIdToken, provider = "GOOGLE") + ) + } + + private fun submitAuthProviderPayload(params: SignInParams) { + + viewModelScope.launch { + val login = + RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java) + + isLoading = true + errorMessage = null + + val result = login.submitAuthProviderLogin(params) + + isLoading = false + + if (result.body()?.authToken != null) { + datastoreRepository.putString(omnivoreAuthToken, result.body()?.authToken!!) + + if (result.body()?.authCookieString != null) { + datastoreRepository.putString( + omnivoreAuthCookieString, result.body()?.authCookieString!! + ) + } + } else { + when (result.code()) { + 401, 403 -> { + // This is a new user so they should go through the new user flow + submitAuthProviderPayloadForPendingToken(params = params) + } + + 418 -> { + // Show pending email state + errorMessage = resourceProvider.getString( + R.string.login_view_model_something_went_wrong_two_error_msg + ) + } + + else -> { + errorMessage = resourceProvider.getString( + R.string.login_view_model_something_went_wrong_two_error_msg + ) + } + } + } + } + } + + private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) { + isLoading = true + errorMessage = null + + val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java) + val result = request.submitPendingUser(params) + + isLoading = false + + if (result.body()?.pendingUserToken != null) { + datastoreRepository.putString( + omnivorePendingUserToken, result.body()?.pendingUserToken!! + ) + registrationStateLiveData.value = RegistrationState.PendingUser } else { - hasValidUsername = false - usernameValidationErrorMessage = resourceProvider.getString( - R.string.login_view_model_username_not_available_error_msg) - } - } catch (e: java.lang.Exception) { - hasValidUsername = false - usernameValidationErrorMessage = resourceProvider.getString( - R.string.login_view_model_connection_error_msg) - } - } - } - - fun login(email: String, password: String) { - - viewModelScope.launch { - val emailLogin = RetrofitHelper.getInstance(networker).create(EmailLoginSubmit::class.java) - - isLoading = true - errorMessage = null - - val result = emailLogin.submitEmailLogin( - EmailLoginCredentials(email = email, password = password) - ) - - isLoading = false - - if (result.body()?.pendingEmailVerification == true) { - showEmailSignUp(pendingCreds = PendingEmailUserCreds(email = email, password = password)) - return@launch - } - - if (result.body()?.authToken != null) { - datastoreRepo.putString(omnivoreAuthToken, result.body()?.authToken!!) - } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_error_msg) - } - - if (result.body()?.authCookieString != null) { - datastoreRepo.putString( - omnivoreAuthCookieString, result.body()?.authCookieString!! - ) - } - } - } - - fun submitEmailSignUp( - email: String, - password: String, - username: String, - name: String, - ) { - viewModelScope.launch { - val request = RetrofitHelper.getInstance(networker).create(CreateEmailAccountSubmit::class.java) - - isLoading = true - errorMessage = null - - val params = EmailSignUpParams( - email = email, - password = password, - name = name, - username = username - ) - - val result = request.submitCreateEmailAccount(params) - - isLoading = false - - if (result.errorBody() != null) { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg) - } else { - pendingEmailUserCreds = PendingEmailUserCreds(email, password) - } - } - } - - private fun getPendingAuthToken(): String? = runBlocking { - datastoreRepo.getString(omnivorePendingUserToken) - } - - fun submitProfile(username: String, name: String) { - viewModelScope.launch { - val request = RetrofitHelper.getInstance(networker).create(CreateAccountSubmit::class.java) - - isLoading = true - errorMessage = null - - val pendingUserToken = getPendingAuthToken() ?: "" - - val userProfile = UserProfile(name = name, username = username) - val params = CreateAccountParams( - pendingUserToken = pendingUserToken, - userProfile = userProfile - ) - - val result = request.submitCreateAccount(params) - - isLoading = false - - if (result.body()?.authToken != null) { - datastoreRepo.putString(omnivoreAuthToken, result.body()?.authToken!!) - } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_error_msg) - } - - if (result.body()?.authCookieString != null) { - datastoreRepo.putString( - omnivoreAuthCookieString, result.body()?.authCookieString!! - ) - } - } - } - - fun handleAppleToken(authToken: String) { - submitAuthProviderPayload( - params = SignInParams(token = authToken, provider = "APPLE") - ) - } - - fun logout() { - viewModelScope.launch { - datastoreRepo.clear() - dataService.clearDatabase() - Intercom.client().logout() - eventTracker.logout() - } - } - - fun resetErrorMessage() { - errorMessage = null - } - - fun showGoogleErrorMessage() { - errorMessage = resourceProvider.getString(R.string.login_view_model_google_auth_error_msg) - } - - fun handleGoogleAuthTask(task: Task) { - val result = task.getResult(ApiException::class.java) - val googleIdToken = result?.idToken ?: "" - - // If token is missing then set the error message - if (googleIdToken.isEmpty()) { - errorMessage = resourceProvider.getString( - R.string.login_view_model_missing_auth_token_error_msg) - return - } - - submitAuthProviderPayload( - params = SignInParams(token = googleIdToken, provider = "GOOGLE") - ) - } - - private fun submitAuthProviderPayload(params: SignInParams) { - - viewModelScope.launch { - val login = RetrofitHelper.getInstance(networker).create(AuthProviderLoginSubmit::class.java) - - isLoading = true - errorMessage = null - - val result = login.submitAuthProviderLogin(params) - - isLoading = false - - if (result.body()?.authToken != null) { - datastoreRepo.putString(omnivoreAuthToken, result.body()?.authToken!!) - - if (result.body()?.authCookieString != null) { - datastoreRepo.putString( - omnivoreAuthCookieString, result.body()?.authCookieString!! - ) - } - } else { - when (result.code()) { - 401, 403 -> { - // This is a new user so they should go through the new user flow - submitAuthProviderPayloadForPendingToken(params = params) - } - 418 -> { - // Show pending email state errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg) - } - else -> { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg) - } + R.string.login_view_model_something_went_wrong_two_error_msg + ) } - } } - } - - private suspend fun submitAuthProviderPayloadForPendingToken(params: SignInParams) { - isLoading = true - errorMessage = null - - val request = RetrofitHelper.getInstance(networker).create(PendingUserSubmit::class.java) - val result = request.submitPendingUser(params) - - isLoading = false - - if (result.body()?.pendingUserToken != null) { - datastoreRepo.putString( - omnivorePendingUserToken, result.body()?.pendingUserToken!! - ) - registrationStateLiveData.value = RegistrationState.PendingUser - } else { - errorMessage = resourceProvider.getString( - R.string.login_view_model_something_went_wrong_two_error_msg) - } - } } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt index f4d36100f..0ad2fe432 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/following/FollowingScreen.kt @@ -35,10 +35,10 @@ import kotlinx.coroutines.launch @Composable internal fun FollowingScreen( - labelsViewModel: LabelsViewModel, - saveViewModel: SaveViewModel, - editInfoViewModel: EditInfoViewModel, navController: NavHostController, + labelsViewModel: LabelsViewModel = hiltViewModel(), + saveViewModel: SaveViewModel = hiltViewModel(), + editInfoViewModel: EditInfoViewModel = hiltViewModel(), viewModel: FollowingViewModel = hiltViewModel() ) { val snackbarHostState = remember { SnackbarHostState() } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt index cfeb458a0..b2c0c0b37 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/LibraryView.kt @@ -79,10 +79,10 @@ import kotlinx.coroutines.launch @Composable internal fun LibraryView( - labelsViewModel: LabelsViewModel, - saveViewModel: SaveViewModel, - editInfoViewModel: EditInfoViewModel, navController: NavHostController, + labelsViewModel: LabelsViewModel = hiltViewModel(), + saveViewModel: SaveViewModel = hiltViewModel(), + editInfoViewModel: EditInfoViewModel = hiltViewModel(), viewModel: LibraryViewModel = hiltViewModel() ) { val snackbarHostState = remember { SnackbarHostState() } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt index 3df01dea5..9a77a9cca 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/library/SearchView.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import app.omnivore.omnivore.R import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights @@ -46,8 +47,8 @@ import app.omnivore.omnivore.feature.savedItemViews.TypeaheadSearchCard @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchView( - viewModel: SearchViewModel, - navController: NavHostController + navController: NavHostController, + viewModel: SearchViewModel = hiltViewModel() ) { val isRefreshing: Boolean by viewModel.isRefreshing.observeAsState(false) val typeaheadMode: Boolean by viewModel.typeaheadMode.observeAsState(true) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt index d97b1f614..3f1d9977b 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/profile/ProfileScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import app.omnivore.omnivore.R import app.omnivore.omnivore.core.designsystem.component.TextPreferenceWidget @@ -25,8 +26,8 @@ internal const val RELEASE_URL = "https://github.com/omnivore-app/omnivore/relea @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun SettingsScreen( - loginViewModel: LoginViewModel, - navController: NavHostController + navController: NavHostController, + loginViewModel: LoginViewModel = hiltViewModel() ) { Scaffold(topBar = { TopAppBar( diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt index 7723651c2..c8a61afd9 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/feature/root/RootView.kt @@ -1,13 +1,7 @@ package app.omnivore.omnivore.feature.root -import androidx.compose.animation.core.FastOutLinearInEasing -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets @@ -35,6 +29,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination @@ -42,48 +38,43 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController -import app.omnivore.omnivore.core.designsystem.util.ForIncoming -import app.omnivore.omnivore.core.designsystem.util.ForOutgoing -import app.omnivore.omnivore.core.designsystem.util.MotionConstants.DEFAULT_MOTION_DURATION -import app.omnivore.omnivore.core.designsystem.util.rememberSlideDistance +import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXIn +import app.omnivore.omnivore.core.designsystem.motion.materialSharedAxisXOut import app.omnivore.omnivore.feature.auth.LoginViewModel import app.omnivore.omnivore.feature.auth.WelcomeScreen -import app.omnivore.omnivore.feature.components.LabelsViewModel -import app.omnivore.omnivore.feature.editinfo.EditInfoViewModel import app.omnivore.omnivore.feature.following.FollowingScreen import app.omnivore.omnivore.feature.library.LibraryView import app.omnivore.omnivore.feature.library.SearchView -import app.omnivore.omnivore.feature.library.SearchViewModel import app.omnivore.omnivore.feature.profile.SettingsScreen import app.omnivore.omnivore.feature.profile.about.AboutScreen import app.omnivore.omnivore.feature.profile.account.AccountScreen import app.omnivore.omnivore.feature.profile.filters.FiltersScreen -import app.omnivore.omnivore.feature.save.SaveViewModel import app.omnivore.omnivore.feature.web.WebViewScreen import app.omnivore.omnivore.navigation.Routes import app.omnivore.omnivore.navigation.TopLevelDestination @Composable fun RootView( - loginViewModel: LoginViewModel, - searchViewModel: SearchViewModel, - labelsViewModel: LabelsViewModel, - saveViewModel: SaveViewModel, - editInfoViewModel: EditInfoViewModel, + loginViewModel: LoginViewModel = hiltViewModel() ) { val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false) val snackbarHostState = remember { SnackbarHostState() } val navController = rememberNavController() + val followingTabActive by loginViewModel.followingTabActiveState.collectAsStateWithLifecycle() + + val destinations = if (!followingTabActive) { + TopLevelDestination.entries + } else { + TopLevelDestination.entries.filter { it.route != Routes.Following.route } + } Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { - if ( - navController.currentBackStackEntryAsState().value?.destination?.route in - TopLevelDestination.entries.map { it.route } - ) { + if (navController.currentBackStackEntryAsState().value?.destination?.route in TopLevelDestination.entries.map { it.route }) { OmnivoreBottomBar( navController, - TopLevelDestination.entries, + destinations, navController.currentBackStackEntryAsState().value?.destination ) } @@ -102,13 +93,7 @@ fun RootView( if (hasAuthToken) { PrimaryNavigator( navController = navController, - loginViewModel = loginViewModel, - searchViewModel = searchViewModel, - labelsViewModel = labelsViewModel, - saveViewModel = saveViewModel, - editInfoViewModel = editInfoViewModel, snackbarHostState = snackbarHostState - ) } else { WelcomeScreen(viewModel = loginViewModel) @@ -124,100 +109,51 @@ fun RootView( } } +private const val INITIAL_OFFSET_FACTOR = 0.10f + @Composable fun PrimaryNavigator( navController: NavHostController, - loginViewModel: LoginViewModel, - searchViewModel: SearchViewModel, - labelsViewModel: LabelsViewModel, - saveViewModel: SaveViewModel, - editInfoViewModel: EditInfoViewModel, snackbarHostState: SnackbarHostState ) { - val slideDistance = rememberSlideDistance() - val duration = DEFAULT_MOTION_DURATION + NavHost(navController = navController, + startDestination = Routes.Home.route, + enterTransition = { materialSharedAxisXIn(initialOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }, + exitTransition = { materialSharedAxisXOut(targetOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) }, + popEnterTransition = { materialSharedAxisXIn(initialOffsetX = { -(it * INITIAL_OFFSET_FACTOR).toInt() }) }, + popExitTransition = { materialSharedAxisXOut(targetOffsetX = { (it * INITIAL_OFFSET_FACTOR).toInt() }) }) { - val enterTransition = slideInHorizontally( - animationSpec = tween( - durationMillis = duration, - easing = FastOutSlowInEasing - ), - initialOffsetX = { - slideDistance - } - ) + fadeIn( - animationSpec = tween( - durationMillis = duration.ForIncoming, - delayMillis = duration.ForOutgoing, - easing = LinearOutSlowInEasing - ) - ) + navigation(startDestination = Routes.Inbox.route, + route = Routes.Home.route, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + popEnterTransition = { EnterTransition.None }, + popExitTransition = { ExitTransition.None }) { - val exitTransition = slideOutHorizontally( - animationSpec = tween( - durationMillis = duration, - easing = FastOutSlowInEasing - ), - targetOffsetX = { - -slideDistance - } - ) + fadeOut( - animationSpec = tween( - durationMillis = duration.ForOutgoing, - delayMillis = 0, - easing = FastOutLinearInEasing - ) - ) + composable(Routes.Inbox.route) { + LibraryView(navController = navController) + } - NavHost( - navController = navController, startDestination = Routes.Inbox.route, - enterTransition = { enterTransition }, - popEnterTransition = { enterTransition }, - exitTransition = { exitTransition }, - popExitTransition = { exitTransition } + composable(Routes.Following.route) { + FollowingScreen(navController = navController) + } - ) { - composable(Routes.Inbox.route) { - LibraryView( - navController = navController, - labelsViewModel = labelsViewModel, - saveViewModel = saveViewModel, - editInfoViewModel = editInfoViewModel, - ) - } - - composable(Routes.Following.route) { - FollowingScreen( - navController = navController, - labelsViewModel = labelsViewModel, - saveViewModel = saveViewModel, - editInfoViewModel = editInfoViewModel, - ) + composable(Routes.Settings.route) { + SettingsScreen(navController = navController) + } } composable(Routes.Search.route) { - SearchView( - viewModel = searchViewModel, navController = navController - ) - } - - composable(Routes.Settings.route) { - SettingsScreen( - loginViewModel = loginViewModel, navController = navController - ) + SearchView(navController = navController) } composable(Routes.About.route) { - AboutScreen( - navController = navController - ) + AboutScreen(navController = navController) } composable(Routes.Filters.route) { - FiltersScreen( - navController = navController - ) + FiltersScreen(navController = navController) } composable(Routes.Account.route) { @@ -259,8 +195,7 @@ private fun OmnivoreBottomBar( } NavigationBarItem(icon = { Icon( - icon, - contentDescription = stringResource(id = screen.iconTextId) + icon, contentDescription = stringResource(id = screen.iconTextId) ) }, selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt index d7a4eb90f..d317fdc37 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/navigation/Routes.kt @@ -1,6 +1,7 @@ package app.omnivore.omnivore.navigation sealed class Routes(val route: String) { + data object Home : Routes("Home") data object Following : Routes("Following") data object Inbox : Routes("Inbox") data object Settings : Routes("Settings")