Use EventSink lambda in state instead of Flow in Presenter

This commit is contained in:
ganfra 2023-01-11 15:53:52 +01:00
parent e56ba5e315
commit ad7bf21f6d
43 changed files with 277 additions and 490 deletions

View file

@ -25,8 +25,8 @@ import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import io.element.android.x.architecture.createNode
import io.element.android.x.architecture.presenterConnector
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.core.di.DaggerComponentOwner
import io.element.android.x.core.screenshot.ImageResult
import io.element.android.x.di.SessionComponentsOwner
import io.element.android.x.features.rageshake.bugreport.BugReportNode
import io.element.android.x.matrix.Matrix
@ -77,22 +77,6 @@ class RootFlowNode(
private val presenterConnector = presenterConnector(rootPresenter)
init {
Timber.v("Init")
lifecycle.subscribe(
onCreate = { Timber.v("OnCreate") },
onResume = {
Timber.v("OnResume")
presenterConnector.emitEvent(RootEvents.StartRageshakeDetection)
},
onPause = {
Timber.v("OnPause")
presenterConnector.emitEvent(RootEvents.StopRageshakeDetection)
},
onDestroy = { Timber.v("OnDestroy") }
)
}
init {
matrix.isLoggedIn()
.distinctUntilChanged()
@ -114,47 +98,21 @@ class RootFlowNode(
.launchIn(lifecycleScope)
}
private fun hideShowkaseButton() {
presenterConnector.emitEvent(RootEvents.HideShowkaseButton)
}
private fun onOpenBugReport() {
presenterConnector.emitEvent(RootEvents.ResetAppHasCrashed)
backstack.push(NavTarget.BugReport)
}
private fun onCrashDetectedDismissed() {
presenterConnector.emitEvent(RootEvents.ResetAllCrashData)
}
private fun onDismissRageshake() {
presenterConnector.emitEvent(RootEvents.DismissRageshake)
}
private fun onDisableRageshake() {
presenterConnector.emitEvent(RootEvents.DisableRageshake)
}
@Composable
override fun View(modifier: Modifier) {
val state by presenterConnector.stateFlow.collectAsState()
RootView(
state = state,
onHideShowkaseClicked = this::hideShowkaseButton,
onOpenBugReport = this::onOpenBugReport,
onCrashDetectedDismissed = this::onCrashDetectedDismissed,
onDisableRageshake = this::onDisableRageshake,
onDismissRageshake = this::onDismissRageshake,
onScreenshotTaken = this::onScreenshotTaken
) {
Children(navModel = backstack)
}
}
private fun onScreenshotTaken(imageResult: ImageResult) {
presenterConnector.emitEvent(RootEvents.ProcessScreenshot(imageResult))
}
private val bugReportNodeCallback = object : BugReportNode.Callback {
override fun onBugReportSent() {
backstack.pop()

View file

@ -1,14 +1,5 @@
package io.element.android.x.root
import io.element.android.x.core.screenshot.ImageResult
sealed interface RootEvents {
data class ProcessScreenshot(val imageResult: ImageResult) : RootEvents
object HideShowkaseButton: RootEvents
object ResetAllCrashData : RootEvents
object ResetAppHasCrashed: RootEvents
object DisableRageshake: RootEvents
object DismissRageshake: RootEvents
object StartRageshakeDetection: RootEvents
object StopRageshakeDetection: RootEvents
object HideShowkaseButton : RootEvents
}

View file

@ -1,62 +1,45 @@
package io.element.android.x.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.architecture.SharedFlowHolder
import io.element.android.x.features.rageshake.bugreport.BugReportEvents
import io.element.android.x.features.rageshake.bugreport.BugReportPresenter
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionEvents
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionPresenter
import io.element.android.x.features.rageshake.detection.RageshakeDetectionEvents
import io.element.android.x.features.rageshake.detection.RageshakeDetectionPresenter
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class RootPresenter @Inject constructor(
private val bugReportPresenter: BugReportPresenter,
private val crashDetectionPresenter: CrashDetectionPresenter,
private val rageshakeDetectionPresenter: RageshakeDetectionPresenter,
) : Presenter<RootState, RootEvents> {
private val rageshakeDetectionEventsFlow = SharedFlowHolder<RageshakeDetectionEvents>()
private val bugReporterEventsFlow = SharedFlowHolder<BugReportEvents>()
private val crashDetectionEventsFlow = SharedFlowHolder<CrashDetectionEvents>()
) : Presenter<RootState> {
@Composable
override fun present(events: Flow<RootEvents>): RootState {
override fun present(): RootState {
val isBugReportVisible = rememberSaveable {
mutableStateOf(false)
}
val isShowkaseButtonVisible = rememberSaveable {
mutableStateOf(true)
}
val rageshakeDetectionState = rageshakeDetectionPresenter.present(events = rageshakeDetectionEventsFlow.asSharedFlow())
val crashDetectionState = crashDetectionPresenter.present(events = crashDetectionEventsFlow.asSharedFlow())
val bugReportState = bugReportPresenter.present(events = bugReporterEventsFlow.asSharedFlow())
val rageshakeDetectionState = rageshakeDetectionPresenter.present()
val crashDetectionState = crashDetectionPresenter.present()
val bugReportState = bugReportPresenter.present()
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
RootEvents.HideShowkaseButton -> isShowkaseButtonVisible.value = false
RootEvents.ResetAllCrashData -> crashDetectionEventsFlow.emit(CrashDetectionEvents.ResetAllCrashData)
RootEvents.ResetAppHasCrashed -> crashDetectionEventsFlow.emit(CrashDetectionEvents.ResetAppHasCrashed)
RootEvents.DisableRageshake -> rageshakeDetectionEventsFlow.emit(RageshakeDetectionEvents.Disable)
RootEvents.DismissRageshake -> rageshakeDetectionEventsFlow.emit(RageshakeDetectionEvents.Dismiss)
RootEvents.StartRageshakeDetection -> rageshakeDetectionEventsFlow.emit(RageshakeDetectionEvents.StartDetection)
RootEvents.StopRageshakeDetection -> rageshakeDetectionEventsFlow.emit(RageshakeDetectionEvents.StopDetection)
is RootEvents.ProcessScreenshot -> rageshakeDetectionEventsFlow.emit(RageshakeDetectionEvents.ProcessScreenshot(event.imageResult))
}
fun handleEvent(event: RootEvents) {
when (event) {
RootEvents.HideShowkaseButton -> isShowkaseButtonVisible.value = false
}
}
return RootState(
isBugReportVisible = isBugReportVisible.value,
isShowkaseButtonVisible = isShowkaseButtonVisible.value,
rageshakeDetectionState = rageshakeDetectionState,
crashDetectionState = crashDetectionState,
bugReportState = bugReportState
bugReportState = bugReportState,
eventSink = ::handleEvent
)
}
}

View file

@ -11,5 +11,6 @@ data class RootState(
val isShowkaseButtonVisible: Boolean,
val rageshakeDetectionState: RageshakeDetectionState,
val crashDetectionState: CrashDetectionState,
val bugReportState: BugReportState
val bugReportState: BugReportState,
val eventSink: (RootEvents) -> Unit
)

View file

@ -10,8 +10,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.airbnb.android.showkase.models.Showkase
import io.element.android.x.component.ShowkaseButton
import io.element.android.x.core.screenshot.ImageResult
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionEvents
import io.element.android.x.features.rageshake.crash.ui.CrashDetectionView
import io.element.android.x.features.rageshake.detection.RageshakeDetectionEvents
import io.element.android.x.features.rageshake.detection.RageshakeDetectionView
import io.element.android.x.getBrowserIntent
@ -19,12 +20,7 @@ import io.element.android.x.getBrowserIntent
fun RootView(
state: RootState,
modifier: Modifier = Modifier,
onHideShowkaseClicked: () -> Unit = { },
onOpenBugReport: () -> Unit = {},
onCrashDetectedDismissed: () -> Unit = {},
onDisableRageshake: () -> Unit = {},
onDismissRageshake: () -> Unit = {},
onScreenshotTaken: (ImageResult) -> Unit = {},
children: @Composable BoxScope.() -> Unit,
) {
Box(
@ -33,23 +29,27 @@ fun RootView(
contentAlignment = Alignment.TopCenter,
) {
children()
val eventSink = state.eventSink
val context = LocalContext.current
fun onOpenBugReport() {
state.crashDetectionState.eventSink(CrashDetectionEvents.ResetAppHasCrashed)
state.rageshakeDetectionState.eventSink(RageshakeDetectionEvents.Dismiss)
onOpenBugReport.invoke()
}
ShowkaseButton(
isVisible = state.isShowkaseButtonVisible,
onCloseClicked = onHideShowkaseClicked,
onCloseClicked = { eventSink(RootEvents.HideShowkaseButton) },
onClick = { ContextCompat.startActivity(context, Showkase.getBrowserIntent(context), null) }
)
RageshakeDetectionView(
state = state.rageshakeDetectionState,
onOpenBugReport = onOpenBugReport,
onDisableClicked = onDisableRageshake,
onNoClicked = onDismissRageshake,
onScreenshotTaken = onScreenshotTaken
onOpenBugReport = ::onOpenBugReport,
)
CrashDetectionView(
state = state.crashDetectionState,
onOpenBugReport = onOpenBugReport,
onPopupDismissed = onCrashDetectedDismissed
onOpenBugReport = ::onOpenBugReport,
)
}
}

View file

@ -22,14 +22,6 @@ class ChangeServerNode @AssistedInject constructor(
private val presenterConnector = presenterConnector(presenter)
private fun onChangeServer(server: String) {
presenterConnector.emitEvent(ChangeServerEvents.SetServer(server))
}
private fun onSubmit() {
presenterConnector.emitEvent(ChangeServerEvents.Submit)
}
private fun onSuccess() {
navigateUp()
}
@ -39,8 +31,6 @@ class ChangeServerNode @AssistedInject constructor(
val state by presenterConnector.stateFlow.collectAsState()
ChangeServerView(
state = state,
onChangeServer = this::onChangeServer,
onChangeServerSubmit = this::onSubmit,
onChangeServerSuccess = this::onSuccess,
)
}

View file

@ -1,41 +1,42 @@
package io.element.android.x.features.login.changeserver
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Async
import io.element.android.x.architecture.Presenter
import io.element.android.x.architecture.execute
import io.element.android.x.matrix.Matrix
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class ChangeServerPresenter @Inject constructor(private val matrix: Matrix) : Presenter<ChangeServerState, ChangeServerEvents> {
class ChangeServerPresenter @Inject constructor(private val matrix: Matrix) : Presenter<ChangeServerState> {
@Composable
override fun present(events: Flow<ChangeServerEvents>): ChangeServerState {
override fun present(): ChangeServerState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(matrix.getHomeserverOrDefault())
}
val changeServerAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
}
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
is ChangeServerEvents.SetServer -> homeserver.value = event.server
ChangeServerEvents.Submit -> submit(homeserver.value, changeServerAction)
}
fun handleEvents(event: ChangeServerEvents) {
when (event) {
is ChangeServerEvents.SetServer -> homeserver.value = event.server
ChangeServerEvents.Submit -> localCoroutineScope.submit(homeserver.value, changeServerAction)
}
}
return ChangeServerState(
homeserver = homeserver.value,
changeServerAction = changeServerAction.value
changeServerAction = changeServerAction.value,
eventSink = ::handleEvents
)
}

View file

@ -5,6 +5,7 @@ import io.element.android.x.architecture.Async
data class ChangeServerState(
val homeserver: String = "",
val changeServerAction: Async<Unit> = Async.Uninitialized,
val eventSink: (ChangeServerEvents) -> Unit = {},
) {
val submitEnabled = homeserver.isNotEmpty() && changeServerAction !is Async.Loading
}

View file

@ -64,14 +64,13 @@ import io.element.android.x.features.login.error.changeServerError
fun ChangeServerView(
state: ChangeServerState,
modifier: Modifier = Modifier,
onChangeServer: (String) -> Unit = {},
onChangeServerSubmit: () -> Unit = {},
onChangeServerSuccess: () -> Unit = {},
) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
) {
val eventSink = state.eventSink
val scrollState = rememberScrollState()
Box(
modifier = Modifier
@ -135,7 +134,7 @@ fun ChangeServerView(
.padding(top = 200.dp),
onValueChange = {
homeserverFieldState = it
onChangeServer(it)
eventSink(ChangeServerEvents.SetServer(it))
},
label = {
Text(text = "Server")
@ -146,7 +145,7 @@ fun ChangeServerView(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onChangeServerSubmit() }
onDone = { eventSink(ChangeServerEvents.Submit) }
)
)
if (state.changeServerAction is Async.Failure) {
@ -161,7 +160,7 @@ fun ChangeServerView(
)
}
Button(
onClick = onChangeServerSubmit,
onClick = { eventSink(ChangeServerEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()

View file

@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
@ -13,6 +14,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.x.anvilannotations.ContributesNode
import io.element.android.x.architecture.presenterConnector
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.di.AppScope
@ContributesNode(AppScope::class)
@ -24,12 +26,6 @@ class LoginRootNode @AssistedInject constructor(
private val presenterConnector = presenterConnector(presenter)
init {
lifecycle.subscribe(
onResume = { presenterConnector.emitEvent(LoginRootEvents.RefreshHomeServer) }
)
}
interface Callback : Plugin {
fun onChangeHomeServer()
}
@ -38,27 +34,18 @@ class LoginRootNode @AssistedInject constructor(
plugins<Callback>().forEach { it.onChangeHomeServer() }
}
private fun onLoginChanged(login: String) {
presenterConnector.emitEvent(LoginRootEvents.SetLogin(login))
}
private fun onPasswordChanged(password: String) {
presenterConnector.emitEvent(LoginRootEvents.SetPassword(password))
}
private fun onSubmit() {
presenterConnector.emitEvent(LoginRootEvents.Submit)
}
@Composable
override fun View(modifier: Modifier) {
val state by presenterConnector.stateFlow.collectAsState()
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> state.eventSink(LoginRootEvents.RefreshHomeServer)
else -> Unit
}
}
LoginRootScreen(
state = state,
onChangeServer = this::onChangeHomeServer,
onLoginChanged = this::onLoginChanged,
onPasswordChanged = this::onPasswordChanged,
onSubmitClicked = this::onSubmit
)
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.matrix.Matrix
@ -13,10 +14,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class LoginRootPresenter @Inject constructor(private val matrix: Matrix) : Presenter<LoginRootState, LoginRootEvents> {
class LoginRootPresenter @Inject constructor(private val matrix: Matrix) : Presenter<LoginRootState> {
@Composable
override fun present(events: Flow<LoginRootEvents>): LoginRootState {
override fun present(): LoginRootState {
val localCoroutineScope = rememberCoroutineScope()
val homeserver = rememberSaveable {
mutableStateOf(matrix.getHomeserverOrDefault())
}
@ -27,24 +29,24 @@ class LoginRootPresenter @Inject constructor(private val matrix: Matrix) : Prese
mutableStateOf(LoginFormState.Default)
}
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver)
is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> submit(homeserver.value, formState.value, loggedInState)
fun handleEvents(event: LoginRootEvents){
when (event) {
LoginRootEvents.RefreshHomeServer -> refreshHomeServer(homeserver)
is LoginRootEvents.SetLogin -> updateFormState(formState) {
copy(login = event.login)
}
is LoginRootEvents.SetPassword -> updateFormState(formState) {
copy(password = event.password)
}
LoginRootEvents.Submit -> localCoroutineScope.submit(homeserver.value, formState.value, loggedInState)
}
}
return LoginRootState(
homeserver = homeserver.value,
loggedInState = loggedInState.value,
formState = formState.value
formState = formState.value,
eventSink = ::handleEvents
)
}

View file

@ -68,11 +68,9 @@ fun LoginRootScreen(
state: LoginRootState,
modifier: Modifier = Modifier,
onChangeServer: () -> Unit = {},
onLoginChanged: (String) -> Unit = {},
onPasswordChanged: (String) -> Unit = {},
onSubmitClicked: () -> Unit = {},
onLoginWithSuccess: (SessionId) -> Unit = {},
) {
val eventSink = state.eventSink
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.background,
@ -144,7 +142,7 @@ fun LoginRootScreen(
},
onValueChange = {
loginFieldState = it
onLoginChanged(it)
eventSink(LoginRootEvents.SetLogin(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
@ -163,7 +161,7 @@ fun LoginRootScreen(
.padding(top = 24.dp),
onValueChange = {
passwordFieldState = it
onPasswordChanged(it)
eventSink(LoginRootEvents.SetPassword(it))
},
label = {
Text(text = "Password")
@ -185,7 +183,7 @@ fun LoginRootScreen(
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = { onSubmitClicked() }
onDone = { eventSink(LoginRootEvents.Submit) }
),
)
if (state.loggedInState is LoggedInState.ErrorLoggingIn) {
@ -199,7 +197,7 @@ fun LoginRootScreen(
}
// Submit
Button(
onClick = onSubmitClicked,
onClick = { eventSink(LoginRootEvents.Submit) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()

View file

@ -8,6 +8,7 @@ data class LoginRootState(
val homeserver: String = "",
val loggedInState: LoggedInState = LoggedInState.NotLoggedIn,
val formState: LoginFormState = LoginFormState.Default,
val eventSink: (LoginRootEvents) -> Unit = {}
) {
val submitEnabled =
formState.login.isNotEmpty() && formState.password.isNotEmpty() && loggedInState != LoggedInState.LoggingIn

View file

@ -1,35 +1,36 @@
package io.element.android.x.features.logout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.x.architecture.Async
import io.element.android.x.architecture.Presenter
import io.element.android.x.architecture.execute
import io.element.android.x.matrix.MatrixClient
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class LogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) : Presenter<LogoutPreferenceState, LogoutPreferenceEvents> {
class LogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) : Presenter<LogoutPreferenceState> {
@Composable
override fun present(events: Flow<LogoutPreferenceEvents>): LogoutPreferenceState {
override fun present(): LogoutPreferenceState {
val localCoroutineScope = rememberCoroutineScope()
val logoutAction: MutableState<Async<Unit>> = remember {
mutableStateOf(Async.Uninitialized)
}
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
LogoutPreferenceEvents.Logout -> logout(logoutAction)
}
fun handleEvents(event: LogoutPreferenceEvents) {
when (event) {
LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction)
}
}
return LogoutPreferenceState(
logoutAction = logoutAction.value
logoutAction = logoutAction.value,
eventSink = ::handleEvents
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.x.features.logout
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Logout
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
@ -34,14 +35,15 @@ import io.element.android.x.element.resources.R as ElementR
@Composable
fun LogoutPreferenceView(
state: LogoutPreferenceState,
onLogoutClicked: () -> Unit = {},
onSuccessLogout: () -> Unit = {},
onSuccessLogout: () -> Unit = {}
) {
val eventSink = state.eventSink
if (state.logoutAction is Async.Success) {
onSuccessLogout()
LaunchedEffect(state.logoutAction) {
onSuccessLogout()
}
return
}
val openDialog = remember { mutableStateOf(false) }
LogoutPreferenceContent(
@ -61,7 +63,7 @@ fun LogoutPreferenceView(
},
onSubmitClicked = {
openDialog.value = false
onLogoutClicked()
eventSink(LogoutPreferenceEvents.Logout)
},
onDismiss = {
openDialog.value = false

View file

@ -20,4 +20,5 @@ import io.element.android.x.architecture.Async
data class LogoutPreferenceState(
val logoutAction: Async<Unit> = Async.Uninitialized,
val eventSink: (LogoutPreferenceEvents) -> Unit = {},
)

View file

@ -1,31 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.features.onboarding
import com.airbnb.mvrx.MavericksViewModel
class OnBoardingViewModel(initialState: OnBoardingViewState) :
MavericksViewModel<OnBoardingViewState>(initialState) {
fun onPageChanged(page: Int) {
setState {
copy(
currentPage = page,
)
}
}
}

View file

@ -1,23 +0,0 @@
/*
* Copyright (c) 2022 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.x.features.onboarding
import com.airbnb.mvrx.MavericksState
data class OnBoardingViewState(
val currentPage: Int = 0,
) : MavericksState

View file

@ -1,7 +0,0 @@
package io.element.android.x.features.preferences.root
sealed interface PreferencesRootEvents {
object Logout : PreferencesRootEvents
data class SetRageshakeSensitivity(val sensitivity: Float) : PreferencesRootEvents
data class SetRageshakeEnabled(val enabled: Boolean) : PreferencesRootEvents
}

View file

@ -21,24 +21,12 @@ class PreferencesRootNode @AssistedInject constructor(
private val presenter: PreferencesRootPresenter,
) : Node(buildContext, plugins = plugins) {
public interface Callback : Plugin {
interface Callback : Plugin {
fun onOpenBugReport()
}
private val presenterConnector = presenterConnector(presenter)
private fun onLogoutClicked() {
presenterConnector.emitEvent(PreferencesRootEvents.Logout)
}
private fun onRageshakeEnabledChanged(isEnabled: Boolean) {
presenterConnector.emitEvent(PreferencesRootEvents.SetRageshakeEnabled(isEnabled))
}
private fun onRageshakeSensitivityChanged(sensitivity: Float) {
presenterConnector.emitEvent(PreferencesRootEvents.SetRageshakeSensitivity(sensitivity))
}
private fun onOpenBugReport() {
plugins<Callback>().forEach { it.onOpenBugReport() }
}
@ -48,10 +36,7 @@ class PreferencesRootNode @AssistedInject constructor(
val state by presenterConnector.stateFlow.collectAsState()
PreferencesRootView(
state = state,
onLogoutClicked = this::onLogoutClicked,
onBackPressed = this::navigateUp,
onRageshakeEnabledChanged = this::onRageshakeEnabledChanged,
onRageshakeSensitivityChanged = this::onRageshakeSensitivityChanged,
onOpenRageShake = this::onOpenBugReport
)
}

View file

@ -1,42 +1,26 @@
package io.element.android.x.features.preferences.root
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import io.element.android.x.architecture.Async
import io.element.android.x.architecture.Presenter
import io.element.android.x.architecture.SharedFlowHolder
import io.element.android.x.features.logout.LogoutPreferenceEvents
import io.element.android.x.features.logout.LogoutPreferencePresenter
import io.element.android.x.features.rageshake.preferences.RageshakePreferencesEvents
import io.element.android.x.features.rageshake.preferences.RageshakePreferencesPresenter
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class PreferencesRootPresenter @Inject constructor(
private val logoutPresenter: LogoutPreferencePresenter,
private val rageshakePresenter: RageshakePreferencesPresenter,
) : Presenter<PreferencesRootState, PreferencesRootEvents> {
private val logoutEventsFlow = SharedFlowHolder<LogoutPreferenceEvents>()
private val rageshakeEventsFlow = SharedFlowHolder<RageshakePreferencesEvents>()
) : Presenter<PreferencesRootState> {
@Composable
override fun present(events: Flow<PreferencesRootEvents>): PreferencesRootState {
val logoutState = logoutPresenter.present(events = logoutEventsFlow.asSharedFlow())
val rageshakeState = rageshakePresenter.present(events = rageshakeEventsFlow.asSharedFlow())
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
PreferencesRootEvents.Logout -> logoutEventsFlow.emit(LogoutPreferenceEvents.Logout)
is PreferencesRootEvents.SetRageshakeEnabled -> rageshakeEventsFlow.emit(RageshakePreferencesEvents.SetIsEnabled(event.enabled))
is PreferencesRootEvents.SetRageshakeSensitivity -> rageshakeEventsFlow.emit(RageshakePreferencesEvents.SetSensitivity(event.sensitivity))
}
}
}
override fun present(): PreferencesRootState {
val logoutState = logoutPresenter.present()
val rageshakeState = rageshakePresenter.present()
return PreferencesRootState(
logoutState = logoutState,
rageshakeState = rageshakeState,
myUser = Async.Uninitialized
myUser = Async.Uninitialized,
)
}
}

View file

@ -10,6 +10,7 @@ import io.element.android.x.element.resources.R
import io.element.android.x.features.logout.LogoutPreferenceState
import io.element.android.x.features.logout.LogoutPreferenceView
import io.element.android.x.features.preferences.user.UserPreferences
import io.element.android.x.features.rageshake.preferences.RageshakePreferencesEvents
import io.element.android.x.features.rageshake.preferences.RageshakePreferencesState
import io.element.android.x.features.rageshake.preferences.RageshakePreferencesView
@ -18,10 +19,7 @@ fun PreferencesRootView(
state: PreferencesRootState,
modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {},
onLogoutClicked: () -> Unit = {},
onOpenRageShake: () -> Unit = {},
onRageshakeEnabledChanged: (Boolean) -> Unit = {},
onRageshakeSensitivityChanged: (Float) -> Unit = {},
) {
// TODO Hierarchy!
// Include pref from other modules
@ -34,12 +32,9 @@ fun PreferencesRootView(
RageshakePreferencesView(
state = state.rageshakeState,
onOpenRageshake = onOpenRageShake,
onSensitivityChanged = onRageshakeSensitivityChanged,
onIsEnabledChanged = onRageshakeEnabledChanged,
)
LogoutPreferenceView(
state = state.logoutState,
onLogoutClicked = onLogoutClicked,
)
}
}

View file

@ -33,43 +33,12 @@ class BugReportNode @AssistedInject constructor(
BugReportView(
state = state,
modifier = modifier,
onDescriptionChanged = this::onDescriptionChanged,
onSetSendLog = this::onSetSendLog,
onSetSendCrashLog = this::onSetSendCrashLog,
onSetCanContact = this::onSetCanContact,
onSetSendScreenshot = this::onSetSendScreenshot,
onSubmit = this::onSubmit,
onDone = this::onDone
)
}
private fun onDone() {
presenterConnector.emitEvent(BugReportEvents.ResetAll)
plugins<Callback>().forEach { it.onBugReportSent() }
}
private fun onSubmit() {
presenterConnector.emitEvent(BugReportEvents.SendBugReport)
}
private fun onSetSendLog(sendLog: Boolean) {
presenterConnector.emitEvent(BugReportEvents.SetSendLog(sendLog))
}
private fun onSetSendCrashLog(sendCrashLog: Boolean) {
presenterConnector.emitEvent(BugReportEvents.SetSendCrashLog(sendCrashLog))
}
private fun onSetSendScreenshot(sendScreenshot: Boolean) {
presenterConnector.emitEvent(BugReportEvents.SetSendScreenshot(sendScreenshot))
}
private fun onSetCanContact(canContact: Boolean) {
presenterConnector.emitEvent(BugReportEvents.SetCanContact(canContact))
}
private fun onDescriptionChanged(description: String) {
presenterConnector.emitEvent(BugReportEvents.SetDescription(description))
}
}

View file

@ -1,10 +1,8 @@
package io.element.android.x.features.rageshake.bugreport
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -18,7 +16,6 @@ import io.element.android.x.features.rageshake.reporter.BugReporter
import io.element.android.x.features.rageshake.reporter.ReportType
import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
@ -27,7 +24,7 @@ class BugReportPresenter @Inject constructor(
private val crashDataStore: CrashDataStore,
private val screenshotHolder: ScreenshotHolder,
private val appCoroutineScope: CoroutineScope,
) : Presenter<BugReportState, BugReportEvents> {
) : Presenter<BugReportState> {
private class BugReporterUploadListener(
private val sendingProgress: MutableState<Float>,
@ -56,7 +53,7 @@ class BugReportPresenter @Inject constructor(
}
@Composable
override fun present(events: Flow<BugReportEvents>): BugReportState {
override fun present(): BugReportState {
val screenshotUri = rememberSaveable {
mutableStateOf(
screenshotHolder.getFile()?.toUri()?.toString()
@ -76,58 +73,54 @@ class BugReportPresenter @Inject constructor(
mutableStateOf(BugReportFormState.Default)
}
val uploadListener = BugReporterUploadListener(sendingProgress, sendingAction)
val state by remember {
derivedStateOf {
BugReportState(
hasCrashLogs = crashInfo.isNotEmpty(),
sendingProgress = sendingProgress.value,
sending = sendingAction.value,
formState = formState.value,
screenshotUri = screenshotUri.value
)
}
}
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
BugReportEvents.SendBugReport -> appCoroutineScope.sendBugReport(state, uploadListener)
BugReportEvents.ResetAll -> appCoroutineScope.resetAll()
is BugReportEvents.SetDescription -> updateFormState(formState) {
copy(description = event.description)
}
is BugReportEvents.SetCanContact -> updateFormState(formState) {
copy(canContact = event.canContact)
}
is BugReportEvents.SetSendCrashLog -> updateFormState(formState) {
copy(sendCrashLogs = event.sendCrashlog)
}
is BugReportEvents.SetSendLog -> updateFormState(formState) {
copy(sendLogs = event.sendLog)
}
is BugReportEvents.SetSendScreenshot -> updateFormState(formState) {
copy(sendScreenshot = event.sendScreenshot)
}
fun handleEvents(event: BugReportEvents) {
when (event) {
BugReportEvents.SendBugReport -> appCoroutineScope.sendBugReport(formState.value, crashInfo.isNotEmpty(), uploadListener)
BugReportEvents.ResetAll -> appCoroutineScope.resetAll()
is BugReportEvents.SetDescription -> updateFormState(formState) {
copy(description = event.description)
}
is BugReportEvents.SetCanContact -> updateFormState(formState) {
copy(canContact = event.canContact)
}
is BugReportEvents.SetSendCrashLog -> updateFormState(formState) {
copy(sendCrashLogs = event.sendCrashlog)
}
is BugReportEvents.SetSendLog -> updateFormState(formState) {
copy(sendLogs = event.sendLog)
}
is BugReportEvents.SetSendScreenshot -> updateFormState(formState) {
copy(sendScreenshot = event.sendScreenshot)
}
}
}
return state
return BugReportState(
hasCrashLogs = crashInfo.isNotEmpty(),
sendingProgress = sendingProgress.value,
sending = sendingAction.value,
formState = formState.value,
screenshotUri = screenshotUri.value,
eventSink = ::handleEvents
)
}
private fun updateFormState(formState: MutableState<BugReportFormState>, operation: BugReportFormState.() -> BugReportFormState) {
formState.value = operation(formState.value)
}
private fun CoroutineScope.sendBugReport(state: BugReportState, listener: BugReporter.IMXBugReportListener) = launch {
private fun CoroutineScope.sendBugReport(formState: BugReportFormState, hasCrashLogs: Boolean, listener: BugReporter.IMXBugReportListener) = launch {
bugReporter.sendBugReport(
coroutineScope = this,
reportType = ReportType.BUG_REPORT,
withDevicesLogs = state.formState.sendLogs,
withCrashLogs = state.hasCrashLogs && state.formState.sendCrashLogs,
withDevicesLogs = formState.sendLogs,
withCrashLogs = hasCrashLogs && formState.sendCrashLogs,
withKeyRequestHistory = false,
withScreenshot = state.formState.sendScreenshot,
theBugDescription = state.formState.description,
withScreenshot = formState.sendScreenshot,
theBugDescription = formState.description,
serverVersion = "",
canContact = state.formState.canContact,
canContact = formState.canContact,
customFields = emptyMap(),
listener = listener
)

View file

@ -26,6 +26,7 @@ data class BugReportState(
val screenshotUri: String? = null,
val sendingProgress: Float = 0F,
val sending: Async<Unit> = Async.Uninitialized,
val eventSink: (BugReportEvents) -> Unit = {}
) {
val submitEnabled =
formState.description.length > 10 && sending !is Async.Loading

View file

@ -14,7 +14,6 @@
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.x.features.rageshake.bugreport
@ -36,6 +35,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@ -59,22 +59,21 @@ import io.element.android.x.designsystem.components.LabelledCheckbox
import io.element.android.x.designsystem.components.dialogs.ErrorDialog
import io.element.android.x.element.resources.R as ElementR
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BugReportView(
state: BugReportState,
modifier: Modifier = Modifier,
onDescriptionChanged: (String) -> Unit = {},
onSetSendLog: (Boolean) -> Unit = {},
onSetSendCrashLog: (Boolean) -> Unit = {},
onSetCanContact: (Boolean) -> Unit = {},
onSetSendScreenshot: (Boolean) -> Unit = {},
onSubmit: () -> Unit = {},
onFailureDialogClosed: () -> Unit = { },
onDone: () -> Unit = { },
) {
LogCompositions(tag = "Rageshake", msg = "Root")
val eventSink = state.eventSink
if (state.sending is Async.Success) {
onDone()
LaunchedEffect(state.sending) {
eventSink(BugReportEvents.ResetAll)
onDone()
}
return
}
Surface(
modifier = modifier,
@ -132,7 +131,7 @@ fun BugReportView(
},
onValueChange = {
descriptionFieldState = it
onDescriptionChanged(it)
eventSink(BugReportEvents.SetDescription(it))
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
@ -143,28 +142,28 @@ fun BugReportView(
}
LabelledCheckbox(
checked = state.formState.sendLogs,
onCheckedChange = onSetSendLog,
onCheckedChange = { eventSink(BugReportEvents.SetSendLog(it)) },
enabled = isFormEnabled,
text = stringResource(id = ElementR.string.send_bug_report_include_logs)
)
if (state.hasCrashLogs) {
LabelledCheckbox(
checked = state.formState.sendCrashLogs,
onCheckedChange = onSetSendCrashLog,
onCheckedChange = { eventSink(BugReportEvents.SetSendCrashLog(it)) },
enabled = isFormEnabled,
text = stringResource(id = ElementR.string.send_bug_report_include_crash_logs)
)
}
LabelledCheckbox(
checked = state.formState.canContact,
onCheckedChange = onSetCanContact,
onCheckedChange = { eventSink(BugReportEvents.SetCanContact(it)) },
enabled = isFormEnabled,
text = stringResource(id = ElementR.string.you_may_contact_me)
)
if (state.screenshotUri != null) {
LabelledCheckbox(
checked = state.formState.sendScreenshot,
onCheckedChange = onSetSendScreenshot,
onCheckedChange = { eventSink(BugReportEvents.SetSendScreenshot(it)) },
enabled = isFormEnabled,
text = stringResource(id = ElementR.string.send_bug_report_include_screenshot)
)
@ -187,7 +186,7 @@ fun BugReportView(
}
// Submit
Button(
onClick = onSubmit,
onClick = { eventSink(BugReportEvents.SendBugReport) },
enabled = state.submitEnabled,
modifier = Modifier
.fillMaxWidth()
@ -197,7 +196,6 @@ fun BugReportView(
}
}
when (state.sending) {
Async.Uninitialized -> Unit
is Async.Loading -> {
CircularProgressIndicator(
progress = state.sendingProgress,
@ -206,9 +204,8 @@ fun BugReportView(
}
is Async.Failure -> ErrorDialog(
content = state.sending.error.toString(),
onDismiss = onFailureDialogClosed,
)
is Async.Success -> onDone()
else -> Unit
}
}
}

View file

@ -1,30 +1,31 @@
package io.element.android.x.features.rageshake.crash.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.x.architecture.Presenter
import io.element.android.x.features.rageshake.crash.CrashDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class CrashDetectionPresenter @Inject constructor(private val crashDataStore: CrashDataStore) : Presenter<CrashDetectionState, CrashDetectionEvents> {
class CrashDetectionPresenter @Inject constructor(private val crashDataStore: CrashDataStore) : Presenter<CrashDetectionState> {
@Composable
override fun present(events: Flow<CrashDetectionEvents>): CrashDetectionState {
override fun present(): CrashDetectionState {
val localCoroutineScope = rememberCoroutineScope()
val crashDetected = crashDataStore.appHasCrashed().collectAsState(initial = false)
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
CrashDetectionEvents.ResetAllCrashData -> resetAll()
CrashDetectionEvents.ResetAppHasCrashed -> resetAppHasCrashed()
}
fun handleEvents(event: CrashDetectionEvents) {
when (event) {
CrashDetectionEvents.ResetAllCrashData -> localCoroutineScope.resetAll()
CrashDetectionEvents.ResetAppHasCrashed -> localCoroutineScope.resetAppHasCrashed()
}
}
return CrashDetectionState(
crashDetected = crashDetected.value
crashDetected = crashDetected.value,
eventSink = ::handleEvents
)
}

View file

@ -28,15 +28,19 @@ import io.element.android.x.element.resources.R as ElementR
fun CrashDetectionView(
state: CrashDetectionState,
onOpenBugReport: () -> Unit = { },
onPopupDismissed: () -> Unit = {}
) {
LogCompositions(tag = "Crash", msg = "CrashDetectionScreen")
fun onPopupDismissed(){
state.eventSink(CrashDetectionEvents.ResetAllCrashData)
}
if (state.crashDetected) {
CrashDetectionContent(
state,
onYesClicked = onOpenBugReport,
onNoClicked = onPopupDismissed,
onDismiss = onPopupDismissed,
onNoClicked = ::onPopupDismissed,
onDismiss = ::onPopupDismissed,
)
}
}

View file

@ -18,4 +18,5 @@ package io.element.android.x.features.rageshake.crash.ui
data class CrashDetectionState(
val crashDetected: Boolean = false,
val eventSink: (CrashDetectionEvents) -> Unit = {}
)

View file

@ -3,19 +3,17 @@ package io.element.android.x.features.rageshake.detection
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.architecture.SharedFlowHolder
import io.element.android.x.core.screenshot.ImageResult
import io.element.android.x.features.rageshake.preferences.RageshakePreferencesEvents
import io.element.android.x.features.rageshake.preferences.RageshakePreferencesPresenter
import io.element.android.x.features.rageshake.rageshake.RageShake
import io.element.android.x.features.rageshake.screenshot.ScreenshotHolder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@ -24,13 +22,12 @@ class RageshakeDetectionPresenter @Inject constructor(
private val screenshotHolder: ScreenshotHolder,
private val rageShake: RageShake,
private val preferencesPresenter: RageshakePreferencesPresenter,
) : Presenter<RageshakeDetectionState, RageshakeDetectionEvents> {
private val preferencesEventsFlow = SharedFlowHolder<RageshakePreferencesEvents>()
) : Presenter<RageshakeDetectionState> {
@Composable
override fun present(events: Flow<RageshakeDetectionEvents>): RageshakeDetectionState {
val preferencesState = preferencesPresenter.present(events = preferencesEventsFlow.asSharedFlow())
override fun present(): RageshakeDetectionState {
val localCoroutineScope = rememberCoroutineScope()
val preferencesState = preferencesPresenter.present()
val isStarted = rememberSaveable {
mutableStateOf(false)
}
@ -40,33 +37,38 @@ class RageshakeDetectionPresenter @Inject constructor(
val showDialog = rememberSaveable {
mutableStateOf(false)
}
fun handleEvents(event: RageshakeDetectionEvents) {
when (event) {
RageshakeDetectionEvents.Disable -> {
preferencesState.eventSink(RageshakePreferencesEvents.SetIsEnabled(false))
showDialog.value = false
}
RageshakeDetectionEvents.StartDetection -> isStarted.value = true
RageshakeDetectionEvents.StopDetection -> isStarted.value = false
is RageshakeDetectionEvents.ProcessScreenshot -> localCoroutineScope.processScreenshot(takeScreenshot, showDialog, event.imageResult)
RageshakeDetectionEvents.Dismiss -> showDialog.value = false
}
}
val state = remember(preferencesState, isStarted.value, takeScreenshot.value, showDialog.value) {
RageshakeDetectionState(
isStarted = isStarted.value,
takeScreenshot = takeScreenshot.value,
showDialog = showDialog.value,
preferenceState = preferencesState
preferenceState = preferencesState,
eventSink = ::handleEvents
)
}
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
RageshakeDetectionEvents.Disable -> preferencesEventsFlow.emit(RageshakePreferencesEvents.SetIsEnabled(false))
RageshakeDetectionEvents.StartDetection -> isStarted.value = true
RageshakeDetectionEvents.StopDetection -> isStarted.value = false
is RageshakeDetectionEvents.ProcessScreenshot -> processScreenshot(takeScreenshot, showDialog, event.imageResult)
RageshakeDetectionEvents.Dismiss -> showDialog.value = false
}
}
}
LaunchedEffect(preferencesState.sensitivity) {
rageShake.setSensitivity(preferencesState.sensitivity)
}
val shouldStart = preferencesState.isEnabled &&
preferencesState.isSupported &&
isStarted.value &&
!takeScreenshot.value &&
!showDialog.value
preferencesState.isSupported &&
isStarted.value &&
!takeScreenshot.value &&
!showDialog.value
LaunchedEffect(shouldStart) {
handleRageShake(shouldStart, state, takeScreenshot)

View file

@ -24,5 +24,6 @@ data class RageshakeDetectionState(
val takeScreenshot: Boolean = false,
val showDialog: Boolean = false,
val isStarted: Boolean = false,
val preferenceState: RageshakePreferencesState = RageshakePreferencesState()
val preferenceState: RageshakePreferencesState = RageshakePreferencesState(),
val eventSink: (RageshakeDetectionEvents) -> Unit = {}
)

View file

@ -22,7 +22,9 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.Lifecycle
import io.element.android.x.core.compose.LogCompositions
import io.element.android.x.core.compose.OnLifecycleEvent
import io.element.android.x.core.hardware.vibrate
import io.element.android.x.core.screenshot.ImageResult
import io.element.android.x.core.screenshot.screenshot
@ -34,24 +36,28 @@ import io.element.android.x.element.resources.R as ElementR
fun RageshakeDetectionView(
state: RageshakeDetectionState,
onOpenBugReport: () -> Unit = { },
onScreenshotTaken: (ImageResult) -> Unit = {},
onDisableClicked: () -> Unit = {},
onNoClicked: () -> Unit = {}
) {
LogCompositions(tag = "Rageshake", msg = "RageshakeDetectionScreen")
val eventSink = state.eventSink
val context = LocalContext.current
OnLifecycleEvent { _, event ->
when (event) {
Lifecycle.Event.ON_RESUME -> eventSink(RageshakeDetectionEvents.StartDetection)
Lifecycle.Event.ON_PAUSE -> eventSink(RageshakeDetectionEvents.StopDetection)
else -> Unit
}
}
when {
state.takeScreenshot -> TakeScreenshot(
onScreenshotTaken = onScreenshotTaken
onScreenshotTaken = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) }
)
state.showDialog -> {
LaunchedEffect(key1 = "RS_diag") {
LaunchedEffect(Unit) {
context.vibrate()
}
RageshakeDialogContent(
state,
onNoClicked = onNoClicked,
onDisableClicked = onDisableClicked,
onNoClicked = { eventSink(RageshakeDetectionEvents.Dismiss) },
onDisableClicked = { eventSink(RageshakeDetectionEvents.Disable) },
onYesClicked = onOpenBugReport
)
}
@ -72,7 +78,6 @@ private fun TakeScreenshot(
@Composable
fun RageshakeDialogContent(
state: RageshakeDetectionState,
onNoClicked: () -> Unit = { },
onDisableClicked: () -> Unit = { },
onYesClicked: () -> Unit = { },
@ -83,6 +88,7 @@ fun RageshakeDialogContent(
thirdButtonText = stringResource(id = ElementR.string.action_disable),
submitText = stringResource(id = ElementR.string.yes),
cancelText = stringResource(id = ElementR.string.no),
onCancelClicked = onNoClicked,
onThirdButtonClicked = onDisableClicked,
onSubmitClicked = onYesClicked,
onDismiss = onNoClicked,
@ -93,8 +99,6 @@ fun RageshakeDialogContent(
@Composable
fun RageshakeDialogContentPreview() {
ElementXTheme {
RageshakeDialogContent(
state = RageshakeDetectionState()
)
RageshakeDialogContent()
}
}

View file

@ -1,26 +1,26 @@
package io.element.android.x.features.rageshake.preferences
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.x.architecture.Presenter
import io.element.android.x.features.rageshake.rageshake.RageShake
import io.element.android.x.features.rageshake.rageshake.RageshakeDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import javax.inject.Inject
class RageshakePreferencesPresenter @Inject constructor(
private val rageshake: RageShake,
private val rageshakeDataStore: RageshakeDataStore,
) : Presenter<RageshakePreferencesState, RageshakePreferencesEvents> {
) : Presenter<RageshakePreferencesState> {
@Composable
override fun present(events: Flow<RageshakePreferencesEvents>): RageshakePreferencesState {
override fun present(): RageshakePreferencesState {
val localCoroutineScope = rememberCoroutineScope()
val isSupported: MutableState<Boolean> = rememberSaveable {
mutableStateOf(rageshake.isAvailable())
}
@ -32,19 +32,18 @@ class RageshakePreferencesPresenter @Inject constructor(
.sensitivity()
.collectAsState(initial = 0f)
LaunchedEffect(Unit) {
events.collect { event ->
when (event) {
is RageshakePreferencesEvents.SetIsEnabled -> setIsEnabled(event.isEnabled)
is RageshakePreferencesEvents.SetSensitivity -> setSensitivity(event.sensitivity)
}
fun handleEvents(event: RageshakePreferencesEvents) {
when (event) {
is RageshakePreferencesEvents.SetIsEnabled -> localCoroutineScope.setIsEnabled(event.isEnabled)
is RageshakePreferencesEvents.SetSensitivity -> localCoroutineScope.setSensitivity(event.sensitivity)
}
}
return RageshakePreferencesState(
isEnabled = isEnabled.value,
isSupported = isSupported.value,
sensitivity = sensitivity.value
sensitivity = sensitivity.value,
eventSink = ::handleEvents
)
}

View file

@ -4,4 +4,5 @@ data class RageshakePreferencesState(
val isEnabled: Boolean = false,
val isSupported: Boolean = true,
val sensitivity: Float = 0.3f,
val eventSink: (RageshakePreferencesEvents) -> Unit = {},
)

View file

@ -34,9 +34,16 @@ fun RageshakePreferencesView(
state: RageshakePreferencesState,
modifier: Modifier = Modifier,
onOpenRageshake: () -> Unit = {},
onIsEnabledChanged: (Boolean) -> Unit = {},
onSensitivityChanged: (Float) -> Unit = {}
) {
fun onSensitivityChanged(sensitivity: Float){
state.eventSink(RageshakePreferencesEvents.SetSensitivity(sensitivity = sensitivity))
}
fun onEnabledChanged(isEnabled: Boolean){
state.eventSink(RageshakePreferencesEvents.SetIsEnabled(isEnabled = isEnabled))
}
Column(modifier = modifier) {
PreferenceCategory(title = stringResource(id = ElementR.string.send_bug_report)) {
PreferenceText(
@ -45,12 +52,13 @@ fun RageshakePreferencesView(
onClick = onOpenRageshake
)
}
PreferenceCategory(title = stringResource(id = ElementR.string.settings_rageshake)) {
if (state.isSupported) {
PreferenceSwitch(
title = stringResource(id = ElementR.string.send_bug_report_rage_shake),
isChecked = state.isEnabled,
onCheckedChange = onIsEnabledChanged
onCheckedChange = ::onEnabledChanged
)
PreferenceSlide(
title = stringResource(id = ElementR.string.settings_rageshake_detection_threshold),
@ -58,7 +66,7 @@ fun RageshakePreferencesView(
value = state.sensitivity,
enabled = state.isEnabled,
steps = 3 /* 5 possible values - steps are in ]0, 1[ */,
onValueChange = onSensitivityChanged
onValueChange = ::onSensitivityChanged
)
} else {
PreferenceText(title = "Rageshaking is not supported by your device")

View file

@ -30,18 +30,6 @@ class RoomListNode @AssistedInject constructor(
private val connector = presenterConnector(presenter)
private fun updateFilter(filter: String) {
connector.emitEvent(RoomListEvents.UpdateFilter(filter))
}
private fun updateVisibleRange(range: IntRange) {
connector.emitEvent((RoomListEvents.UpdateVisibleRange(range)))
}
private fun logout() {
connector.emitEvent(RoomListEvents.Logout)
}
private fun onRoomClicked(roomId: RoomId) {
plugins<Callback>().forEach { it.onRoomClicked(roomId) }
}
@ -56,8 +44,6 @@ class RoomListNode @AssistedInject constructor(
RoomListView(
state = state,
onRoomClicked = this::onRoomClicked,
onFilterChanged = this::updateFilter,
onScrollOver = this::updateVisibleRange,
onOpenSettings = this::onOpenSettings
)
}

View file

@ -7,8 +7,10 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import io.element.android.x.architecture.Presenter
import io.element.android.x.core.coroutine.parallelMap
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.designsystem.components.avatar.AvatarSize
@ -19,13 +21,12 @@ import io.element.android.x.features.roomlist.model.RoomListState
import io.element.android.x.matrix.MatrixClient
import io.element.android.x.matrix.media.MediaResolver
import io.element.android.x.matrix.room.RoomSummary
import io.element.android.x.architecture.Presenter
import io.element.android.x.matrix.ui.model.MatrixUser
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import javax.inject.Inject
private const val extendedRangeSize = 40
@ -33,10 +34,10 @@ private const val extendedRangeSize = 40
class RoomListPresenter @Inject constructor(
private val client: MatrixClient,
private val lastMessageFormatter: LastMessageFormatter,
) : Presenter<RoomListState, RoomListEvents> {
) : Presenter<RoomListState> {
@Composable
override fun present(events: Flow<RoomListEvents>): RoomListState {
override fun present(): RoomListState {
val matrixUser: MutableState<MatrixUser?> = remember {
mutableStateOf(null)
}
@ -52,14 +53,15 @@ class RoomListPresenter @Inject constructor(
}
LaunchedEffect(Unit) {
initialLoad(matrixUser)
events.collect { event ->
when (event) {
RoomListEvents.Logout -> logout(isLoginOut)
is RoomListEvents.UpdateFilter -> filter = event.newFilter
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
}
}
fun handleEvents(event: RoomListEvents) {
when (event) {
is RoomListEvents.UpdateFilter -> filter = event.newFilter
is RoomListEvents.UpdateVisibleRange -> updateVisibleRange(event.range)
}
}
LaunchedEffect(roomSummaries, filter) {
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
}
@ -67,7 +69,8 @@ class RoomListPresenter @Inject constructor(
matrixUser = matrixUser.value,
roomList = filteredRoomSummaries.value,
filter = filter,
isLoginOut = isLoginOut.value
isLoginOut = isLoginOut.value,
eventSink = ::handleEvents
)
}
@ -83,7 +86,7 @@ class RoomListPresenter @Inject constructor(
}.toImmutableList()
}
private suspend fun initialLoad(matrixUser: MutableState<MatrixUser?>) {
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
val userDisplayName = client.loadUserDisplayName().getOrNull()
val avatarData =
@ -100,13 +103,6 @@ class RoomListPresenter @Inject constructor(
)
}
private suspend fun logout(isLoginOut: MutableState<Boolean>) {
isLoginOut.value = true
delay(2000)
client.logout()
isLoginOut.value = false
}
private fun updateVisibleRange(range: IntRange) {
if (range.isEmpty()) return
val midExtendedRangeSize = extendedRangeSize / 2

View file

@ -41,6 +41,7 @@ import io.element.android.x.designsystem.ElementXTheme
import io.element.android.x.designsystem.components.avatar.AvatarData
import io.element.android.x.features.roomlist.components.RoomListTopBar
import io.element.android.x.features.roomlist.components.RoomSummaryRow
import io.element.android.x.features.roomlist.model.RoomListEvents
import io.element.android.x.features.roomlist.model.RoomListRoomSummary
import io.element.android.x.features.roomlist.model.RoomListState
import io.element.android.x.features.roomlist.model.stubbedRoomSummaries
@ -54,19 +55,26 @@ fun RoomListView(
state: RoomListState,
modifier: Modifier = Modifier,
onRoomClicked: (RoomId) -> Unit = {},
onFilterChanged: (String) -> Unit = {},
onOpenSettings: () -> Unit = {},
onScrollOver: (IntRange) -> Unit = {},
) {
fun onFilterChanged(filter: String){
state.eventSink(RoomListEvents.UpdateFilter(filter))
}
fun onVisibleRangedChanged(range: IntRange){
state.eventSink(RoomListEvents.UpdateVisibleRange(range))
}
RoomListView(
roomSummaries = state.roomList,
matrixUser = state.matrixUser,
filter = state.filter,
modifier = modifier,
onRoomClicked = onRoomClicked,
onFilterChanged = onFilterChanged,
onFilterChanged = ::onFilterChanged,
onOpenSettings = onOpenSettings,
onScrollOver = onScrollOver,
onScrollOver = ::onVisibleRangedChanged,
)
}

View file

@ -1,7 +1,6 @@
package io.element.android.x.features.roomlist.model
sealed interface RoomListEvents {
object Logout : RoomListEvents
data class UpdateFilter(val newFilter: String) : RoomListEvents
data class UpdateVisibleRange(val range: IntRange): RoomListEvents
}

View file

@ -10,4 +10,5 @@ data class RoomListState(
val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String,
val isLoginOut: Boolean,
val eventSink: (RoomListEvents) -> Unit = {}
)

View file

@ -3,7 +3,7 @@ package io.element.android.x.architecture
import androidx.compose.runtime.Composable
import kotlinx.coroutines.flow.Flow
interface Presenter<State, Event> {
interface Presenter<State> {
@Composable
fun present(events: Flow<Event>): State
fun present(): State
}

View file

@ -8,19 +8,14 @@ import app.cash.molecule.launchMolecule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
inline fun <reified State, reified Event> LifecycleOwner.presenterConnector(presenter: Presenter<State, Event>): LifecyclePresenterConnector<State, Event> =
inline fun <reified State> LifecycleOwner.presenterConnector(presenter: Presenter<State>): LifecyclePresenterConnector<State> =
LifecyclePresenterConnector(lifecycleOwner = this, presenter = presenter)
class LifecyclePresenterConnector<State, Event>(lifecycleOwner: LifecycleOwner, presenter: Presenter<State, Event>) {
class LifecyclePresenterConnector<State>(lifecycleOwner: LifecycleOwner, presenter: Presenter<State>) {
private val moleculeScope = CoroutineScope(lifecycleOwner.lifecycleScope.coroutineContext + AndroidUiDispatcher.Main)
private val eventFlow = SharedFlowHolder<Event>()
val stateFlow: StateFlow<State> = moleculeScope.launchMolecule(RecompositionClock.Immediate) {
presenter.present(events = eventFlow.asSharedFlow())
}
fun emitEvent(event: Event) {
eventFlow.emit(event)
presenter.present()
}
}

View file

@ -9,4 +9,6 @@ class SharedFlowHolder<Data>(capacity: Int = 64) {
fun asSharedFlow() = mutableFlow.asSharedFlow()
fun emit(data: Data) = mutableFlow.tryEmit(data)
suspend fun awaitEmit(data: Data) = mutableFlow.emit(data)
}