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

View File

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

View File

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

View File

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