add nested navigation for Home screens

This commit is contained in:
Stefano Sansone
2024-04-27 00:29:41 +02:00
parent 2101815f36
commit 9701e88bcd
11 changed files with 573 additions and 510 deletions

View File

@ -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()
}
}
}

View File

@ -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
)
)

View File

@ -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
}

View File

@ -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

View File

@ -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<String?>(null)
private set
var errorMessage by mutableStateOf<String?>(null)
private set
var hasValidUsername by mutableStateOf<Boolean>(false)
private set
var hasValidUsername by mutableStateOf<Boolean>(false)
private set
var usernameValidationErrorMessage by mutableStateOf<String?>(null)
private set
var usernameValidationErrorMessage by mutableStateOf<String?>(null)
private set
var pendingEmailUserCreds by mutableStateOf<PendingEmailUserCreds?>(null)
private set
var pendingEmailUserCreds by mutableStateOf<PendingEmailUserCreds?>(null)
private set
val hasAuthTokenLiveData: LiveData<Boolean> = datastoreRepo
.hasAuthTokenFlow
.distinctUntilChanged()
.asLiveData()
val hasAuthTokenLiveData: LiveData<Boolean> =
datastoreRepository.hasAuthTokenFlow.distinctUntilChanged().asLiveData()
val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin)
val registrationStateLiveData = MutableLiveData(RegistrationState.SocialLogin)
fun getAuthCookieString(): String? = runBlocking {
datastoreRepo.getString(omnivoreAuthCookieString)
}
val followingTabActiveState: StateFlow<Boolean> = 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<GoogleSignInAccount>) {
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<GoogleSignInAccount>) {
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)
}
}
}

View File

@ -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() }

View File

@ -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() }

View File

@ -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)

View File

@ -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(

View File

@ -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,

View File

@ -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")