converting login view model to state/reducer pattern

This commit is contained in:
Adam Brown 2022-12-13 19:03:51 +00:00
parent 78d8d7d591
commit 821c317916
11 changed files with 149 additions and 109 deletions

View File

@ -4,7 +4,7 @@ import app.dapk.st.core.ProvidableModule
import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.domain.StoreModule
import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.LoginViewModel
import app.dapk.st.login.state.LoginState
import app.dapk.st.profile.state.ProfileState
class HomeModule(
@ -13,7 +13,7 @@ class HomeModule(
val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
) : ProvidableModule {
internal fun homeViewModel(directory: DirectoryState, login: LoginViewModel, profile: ProfileState): HomeViewModel {
internal fun homeViewModel(directory: DirectoryState, login: LoginState, profile: ProfileState): HomeViewModel {
return HomeViewModel(
chatEngine,
directory,

View File

@ -7,9 +7,7 @@ import app.dapk.st.directory.state.DirectoryState
import app.dapk.st.domain.StoreCleaner
import app.dapk.st.engine.ChatEngine
import app.dapk.st.home.HomeScreenState.*
import app.dapk.st.login.LoginViewModel
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.isSignedIn
import app.dapk.st.login.state.LoginState
import app.dapk.st.profile.state.ProfileAction
import app.dapk.st.profile.state.ProfileState
import app.dapk.st.viewmodel.DapkViewModel
@ -22,7 +20,7 @@ import kotlinx.coroutines.launch
internal class HomeViewModel(
private val chatEngine: ChatEngine,
private val directoryState: DirectoryState,
private val loginViewModel: LoginViewModel,
private val loginState: LoginState,
private val profileState: ProfileState,
private val cacheCleaner: StoreCleaner,
private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
@ -33,7 +31,7 @@ internal class HomeViewModel(
private var listenForInvitesJob: Job? = null
fun directory() = directoryState
fun login() = loginViewModel
fun login() = loginState
fun profile() = profileState
fun start() {

View File

@ -23,10 +23,10 @@ import kotlinx.coroutines.flow.onEach
class MainActivity : DapkActivity() {
private val directoryViewModel by state { module<DirectoryModule>().directoryState() }
private val loginViewModel by viewModel { module<LoginModule>().loginViewModel() }
private val profileViewModel by state { module<ProfileModule>().profileState() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryViewModel, loginViewModel, profileViewModel) }
private val directoryState by state { module<DirectoryModule>().directoryState() }
private val loginState by state { module<LoginModule>().loginState() }
private val profileState by state { module<ProfileModule>().profileState() }
private val homeViewModel by viewModel { module<HomeModule>().homeViewModel(directoryState, loginState, profileState) }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

View File

@ -8,9 +8,14 @@ android {
dependencies {
implementation "chat-engine:chat-engine"
implementation 'screen-state:screen-android'
implementation project(":domains:android:compose-core")
implementation project(":domains:android:push")
implementation project(":domains:android:viewmodel")
implementation project(":design-library")
implementation project(":core")
testImplementation 'screen-state:state-test'
testImplementation 'chat-engine:chat-engine-test'
}

View File

@ -3,7 +3,10 @@ package app.dapk.st.login
import app.dapk.st.core.ProvidableModule
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.engine.ChatEngine
import app.dapk.st.login.state.LoginState
import app.dapk.st.login.state.loginReducer
import app.dapk.st.push.PushModule
import app.dapk.st.state.createStateViewModel
class LoginModule(
private val chatEngine: ChatEngine,
@ -11,7 +14,9 @@ class LoginModule(
private val errorTracker: ErrorTracker,
) : ProvidableModule {
fun loginViewModel(): LoginViewModel {
return LoginViewModel(chatEngine, pushModule.pushTokenRegistrar(), errorTracker)
fun loginState(): LoginState {
return createStateViewModel {
loginReducer(chatEngine, pushModule.pushTokenRegistrar(), errorTracker, it)
}
}
}

View File

@ -33,15 +33,18 @@ import androidx.compose.ui.unit.sp
import app.dapk.st.core.StartObserving
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.GenericError
import app.dapk.st.login.LoginEvent.LoginComplete
import app.dapk.st.login.LoginScreenState.*
import app.dapk.st.login.state.LoginAction
import app.dapk.st.login.state.LoginEvent.LoginComplete
import app.dapk.st.login.state.LoginEvent.WellKnownMissing
import app.dapk.st.login.state.LoginScreenState
import app.dapk.st.login.state.LoginState
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
loginViewModel.ObserveEvents(onLoggedIn)
fun LoginScreen(loginState: LoginState, onLoggedIn: () -> Unit) {
loginState.ObserveEvents(onLoggedIn)
LaunchedEffect(true) {
loginViewModel.start()
loginState.dispatch(LoginAction.ComponentLifecycle.Visible)
}
var userName by rememberSaveable { mutableStateOf("") }
@ -49,11 +52,12 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
var serverUrl by rememberSaveable { mutableStateOf("") }
val keyboardController = LocalSoftwareKeyboardController.current
when (val state = loginViewModel.state) {
is Error -> GenericError(cause = state.cause, action = { loginViewModel.start() })
Loading -> CenteredLoading()
when (val content = loginState.current.content) {
is LoginScreenState.Content.Error -> GenericError(cause = content.cause, action = { loginState.dispatch(LoginAction.ComponentLifecycle.Visible) })
LoginScreenState.Content.Loading -> CenteredLoading()
is Content ->
is LoginScreenState.Content.Idle -> {
val showServerUrl = loginState.current.showServerUrl
Row {
Spacer(modifier = Modifier.weight(0.1f))
Column(
@ -88,7 +92,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
keyboardOptions = KeyboardOptions(autoCorrect = false, keyboardType = KeyboardType.Email, imeAction = ImeAction.Next)
)
val canDoLoginAttempt = if (state.showServerUrl) {
val canDoLoginAttempt = if (showServerUrl) {
userName.isNotEmpty() && password.isNotEmpty() && serverUrl.isNotEmpty()
} else {
userName.isNotEmpty() && password.isNotEmpty()
@ -106,12 +110,12 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
Icon(imageVector = Icons.Outlined.Lock, contentDescription = null)
},
keyboardActions = KeyboardActions(
onDone = { loginViewModel.login(userName, password, serverUrl) },
onDone = { loginState.dispatch(LoginAction.Login(userName, password, serverUrl)) },
onNext = { focusManager.moveFocus(FocusDirection.Down) },
),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.Next.takeIf { state.showServerUrl } ?: ImeAction.None,
imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.Next.takeIf { showServerUrl } ?: ImeAction.None,
keyboardType = KeyboardType.Password
),
visualTransformation = if (passwordVisibility) VisualTransformation.None else PasswordVisualTransformation(),
@ -123,7 +127,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
}
)
if (state.showServerUrl) {
if (showServerUrl) {
TextField(
modifier = Modifier.fillMaxWidth(),
value = serverUrl,
@ -133,7 +137,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
leadingIcon = {
Icon(imageVector = Icons.Default.Web, contentDescription = null)
},
keyboardActions = KeyboardActions(onDone = { loginViewModel.login(userName, password, serverUrl) }),
keyboardActions = KeyboardActions(onDone = { loginState.dispatch(LoginAction.Login(userName, password, serverUrl)) }),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
imeAction = ImeAction.Done.takeIf { canDoLoginAttempt } ?: ImeAction.None,
@ -148,7 +152,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
modifier = Modifier.fillMaxWidth(),
onClick = {
keyboardController?.hide()
loginViewModel.login(userName, password, serverUrl)
loginState.dispatch(LoginAction.Login(userName, password, serverUrl))
},
enabled = canDoLoginAttempt
) {
@ -157,6 +161,7 @@ fun LoginScreen(loginViewModel: LoginViewModel, onLoggedIn: () -> Unit) {
}
Spacer(modifier = Modifier.weight(0.1f))
}
}
}
}
@ -183,13 +188,13 @@ private fun Modifier.autofill(
}
@Composable
private fun LoginViewModel.ObserveEvents(onLoggedIn: () -> Unit) {
private fun LoginState.ObserveEvents(onLoggedIn: () -> Unit) {
val context = LocalContext.current
StartObserving {
this@ObserveEvents.events.launch {
when (it) {
LoginComplete -> onLoggedIn()
LoginEvent.WellKnownMissing -> {
WellKnownMissing -> {
Toast.makeText(context, "Couldn't find the homeserver, please enter the server URL", Toast.LENGTH_LONG).show()
}
}

View File

@ -1,14 +0,0 @@
package app.dapk.st.login
sealed interface LoginScreenState {
data class Content(val showServerUrl: Boolean) : LoginScreenState
object Loading : LoginScreenState
data class Error(val cause: Throwable) : LoginScreenState
}
sealed interface LoginEvent {
object LoginComplete : LoginEvent
object WellKnownMissing : LoginEvent
}

View File

@ -1,65 +0,0 @@
package app.dapk.st.login
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.logP
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.LoginRequest
import app.dapk.st.engine.LoginResult
import app.dapk.st.login.LoginEvent.LoginComplete
import app.dapk.st.login.LoginScreenState.*
import app.dapk.st.push.PushTokenRegistrar
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
class LoginViewModel(
private val chatEngine: ChatEngine,
private val pushTokenRegistrar: PushTokenRegistrar,
private val errorTracker: ErrorTracker,
) : DapkViewModel<LoginScreenState, LoginEvent>(
initialState = Content(showServerUrl = false)
) {
private var previousState: LoginScreenState? = null
fun login(userName: String, password: String, serverUrl: String?) {
state = Loading
viewModelScope.launch {
logP("login") {
when (val result = chatEngine.login(LoginRequest(userName, password, serverUrl.takeIfNotEmpty()))) {
is LoginResult.Success -> {
runCatching {
listOf(
async { pushTokenRegistrar.registerCurrentToken() },
async { preloadMe() },
).awaitAll()
}
_events.tryEmit(LoginComplete)
}
is LoginResult.Error -> {
errorTracker.track(result.cause)
state = Error(result.cause)
}
LoginResult.MissingWellKnown -> {
_events.tryEmit(LoginEvent.WellKnownMissing)
state = Content(showServerUrl = true)
}
}
}
}
}
private suspend fun preloadMe() = chatEngine.me(forceRefresh = false)
fun start() {
val showServerUrl = previousState?.let { it is Content && it.showServerUrl } ?: false
state = Content(showServerUrl = showServerUrl)
}
}
private fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() }

View File

@ -0,0 +1,15 @@
package app.dapk.st.login.state
import app.dapk.state.Action
sealed interface LoginAction : Action {
sealed interface ComponentLifecycle : LoginAction {
object Visible : ComponentLifecycle
}
data class Login(val userName: String, val password: String, val serverUrl: String?) : LoginAction
data class UpdateContent(val content: LoginScreenState.Content) : LoginAction
data class UpdateState(val state: LoginScreenState) : LoginAction
}

View File

@ -0,0 +1,69 @@
package app.dapk.st.login.state
import app.dapk.st.core.extensions.ErrorTracker
import app.dapk.st.core.logP
import app.dapk.st.engine.ChatEngine
import app.dapk.st.engine.LoginRequest
import app.dapk.st.engine.LoginResult
import app.dapk.st.push.PushTokenRegistrar
import app.dapk.state.async
import app.dapk.state.change
import app.dapk.state.createReducer
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
fun loginReducer(
chatEngine: ChatEngine,
pushTokenRegistrar: PushTokenRegistrar,
errorTracker: ErrorTracker,
eventEmitter: suspend (LoginEvent) -> Unit,
) = createReducer(
initialState = LoginScreenState(showServerUrl = false, content = LoginScreenState.Content.Idle),
change(LoginAction.ComponentLifecycle.Visible::class) { _, state ->
LoginScreenState(state.showServerUrl, content = LoginScreenState.Content.Idle)
},
change(LoginAction.UpdateContent::class) { action, state ->
state.copy(content = action.content)
},
change(LoginAction.UpdateState::class) { action, _ ->
action.state
},
async(LoginAction.Login::class) { action ->
coroutineScope.launch {
logP("login") {
dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Loading))
val request = LoginRequest(action.userName, action.password, action.serverUrl.takeIfNotEmpty())
when (val result = chatEngine.login(request)) {
is LoginResult.Success -> {
runCatching {
listOf(
async { pushTokenRegistrar.registerCurrentToken() },
async { chatEngine.preloadMe() },
).awaitAll()
}
eventEmitter.invoke(LoginEvent.LoginComplete)
}
is LoginResult.Error -> {
errorTracker.track(result.cause)
dispatch(LoginAction.UpdateContent(LoginScreenState.Content.Error(result.cause)))
}
LoginResult.MissingWellKnown -> {
eventEmitter.invoke(LoginEvent.WellKnownMissing)
dispatch(LoginAction.UpdateState(LoginScreenState(showServerUrl = true, content = LoginScreenState.Content.Idle)))
}
}
}
}
},
)
private suspend fun ChatEngine.preloadMe() = this.me(forceRefresh = false)
private fun String?.takeIfNotEmpty() = this?.takeIf { it.isNotEmpty() }

View File

@ -0,0 +1,22 @@
package app.dapk.st.login.state
import app.dapk.st.state.State
typealias LoginState = State<LoginScreenState, LoginEvent>
data class LoginScreenState(
val showServerUrl: Boolean,
val content: Content,
) {
sealed interface Content {
object Idle : Content
object Loading : Content
data class Error(val cause: Throwable) : Content
}
}
sealed interface LoginEvent {
object LoginComplete : LoginEvent
object WellKnownMissing : LoginEvent
}