First implementation of using Node/Presenter/UI on RoomList (no DI)
This commit is contained in:
parent
df13ec7445
commit
9153231a65
13 changed files with 326 additions and 230 deletions
|
|
@ -41,7 +41,7 @@ class MainActivity : NodeComponentActivity(), DaggerComponentOwner {
|
||||||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||||
RootFlowNode(
|
RootFlowNode(
|
||||||
buildContext = it,
|
buildContext = it,
|
||||||
daggerComponentOwner = this,
|
appComponentOwner = this,
|
||||||
matrix = appBindings.matrix(),
|
matrix = appBindings.matrix(),
|
||||||
sessionComponentsOwner = appBindings.sessionComponentsOwner()
|
sessionComponentsOwner = appBindings.sessionComponentsOwner()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.composable.Children
|
import com.bumble.appyx.core.composable.Children
|
||||||
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.node.ParentNode
|
import com.bumble.appyx.core.node.ParentNode
|
||||||
|
|
@ -12,14 +13,18 @@ import com.bumble.appyx.navmodel.backstack.operation.pop
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import io.element.android.x.core.di.viewModelSupportNode
|
import io.element.android.x.core.di.viewModelSupportNode
|
||||||
import io.element.android.x.features.messages.MessagesScreen
|
import io.element.android.x.features.messages.MessagesScreen
|
||||||
import io.element.android.x.features.roomlist.RoomListScreen
|
import io.element.android.x.features.roomlist.RoomListNode
|
||||||
|
import io.element.android.x.features.roomlist.RoomListPresenter
|
||||||
|
import io.element.android.x.matrix.MatrixClient
|
||||||
import io.element.android.x.matrix.core.RoomId
|
import io.element.android.x.matrix.core.RoomId
|
||||||
import io.element.android.x.matrix.core.SessionId
|
import io.element.android.x.matrix.core.SessionId
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
class LoggedInFlowNode(
|
class LoggedInFlowNode(
|
||||||
buildContext: BuildContext,
|
buildContext: BuildContext,
|
||||||
val sessionId: SessionId,
|
val sessionId: SessionId,
|
||||||
|
private val matrixClient: MatrixClient,
|
||||||
private val backstack: BackStack<NavTarget> = BackStack(
|
private val backstack: BackStack<NavTarget> = BackStack(
|
||||||
initialElement = NavTarget.RoomList,
|
initialElement = NavTarget.RoomList,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
|
|
@ -29,6 +34,13 @@ class LoggedInFlowNode(
|
||||||
buildContext = buildContext
|
buildContext = buildContext
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onCreate = { Timber.v("OnCreate") },
|
||||||
|
onDestroy = { Timber.v("OnDestroy") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object RoomList : NavTarget
|
object RoomList : NavTarget
|
||||||
|
|
@ -39,11 +51,12 @@ class LoggedInFlowNode(
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.RoomList -> viewModelSupportNode(buildContext) {
|
NavTarget.RoomList -> RoomListNode(
|
||||||
RoomListScreen(
|
buildContext = buildContext,
|
||||||
onRoomClicked = { backstack.push(NavTarget.Messages(it)) }
|
presenter = RoomListPresenter(matrixClient),
|
||||||
)
|
onRoomClicked = {
|
||||||
}
|
backstack.push(NavTarget.Messages(it))
|
||||||
|
})
|
||||||
is NavTarget.Messages -> viewModelSupportNode(buildContext) {
|
is NavTarget.Messages -> viewModelSupportNode(buildContext) {
|
||||||
MessagesScreen(
|
MessagesScreen(
|
||||||
roomId = navTarget.roomId.value,
|
roomId = navTarget.roomId.value,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.composable.Children
|
import com.bumble.appyx.core.composable.Children
|
||||||
|
import com.bumble.appyx.core.lifecycle.subscribe
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.node.ParentNode
|
import com.bumble.appyx.core.node.ParentNode
|
||||||
|
|
@ -13,6 +14,7 @@ import io.element.android.x.core.di.viewModelSupportNode
|
||||||
import io.element.android.x.features.login.node.LoginFlowNode
|
import io.element.android.x.features.login.node.LoginFlowNode
|
||||||
import io.element.android.x.features.onboarding.OnBoardingScreen
|
import io.element.android.x.features.onboarding.OnBoardingScreen
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
class NotLoggedInFlowNode(
|
class NotLoggedInFlowNode(
|
||||||
buildContext: BuildContext,
|
buildContext: BuildContext,
|
||||||
|
|
@ -25,6 +27,13 @@ class NotLoggedInFlowNode(
|
||||||
buildContext = buildContext
|
buildContext = buildContext
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
init {
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onCreate = { Timber.v("OnCreate") },
|
||||||
|
onDestroy = { Timber.v("OnDestroy") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface NavTarget : Parcelable {
|
sealed interface NavTarget : Parcelable {
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object OnBoarding : NavTarget
|
object OnBoarding : NavTarget
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.node.ParentNode
|
import com.bumble.appyx.core.node.ParentNode
|
||||||
import com.bumble.appyx.core.node.node
|
import com.bumble.appyx.core.node.node
|
||||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||||
|
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||||
import io.element.android.x.BuildConfig
|
import io.element.android.x.BuildConfig
|
||||||
import io.element.android.x.component.ShowkaseButton
|
import io.element.android.x.component.ShowkaseButton
|
||||||
|
|
@ -61,7 +62,7 @@ class RootFlowNode(
|
||||||
initialElement = NavTarget.SplashScreen,
|
initialElement = NavTarget.SplashScreen,
|
||||||
savedStateMap = buildContext.savedStateMap,
|
savedStateMap = buildContext.savedStateMap,
|
||||||
),
|
),
|
||||||
private val daggerComponentOwner: DaggerComponentOwner,
|
private val appComponentOwner: DaggerComponentOwner,
|
||||||
private val matrix: Matrix,
|
private val matrix: Matrix,
|
||||||
private val sessionComponentsOwner: SessionComponentsOwner,
|
private val sessionComponentsOwner: SessionComponentsOwner,
|
||||||
) :
|
) :
|
||||||
|
|
@ -71,23 +72,32 @@ class RootFlowNode(
|
||||||
plugins = listOf(SessionComponentsOwnerInteractor(sessionComponentsOwner)),
|
plugins = listOf(SessionComponentsOwnerInteractor(sessionComponentsOwner)),
|
||||||
),
|
),
|
||||||
|
|
||||||
DaggerComponentOwner by daggerComponentOwner {
|
DaggerComponentOwner by appComponentOwner {
|
||||||
|
|
||||||
|
init {
|
||||||
|
Timber.v("Init")
|
||||||
|
lifecycle.subscribe(
|
||||||
|
onCreate = { Timber.v("OnCreate") },
|
||||||
|
onDestroy = { Timber.v("OnDestroy") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
matrix.isLoggedIn()
|
matrix.isLoggedIn()
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.onEach { isLoggedIn ->
|
.onEach { isLoggedIn ->
|
||||||
|
Timber.v("IsLoggedIn")
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
val matrixClient = matrix.restoreSession()
|
val matrixClient = matrix.restoreSession()
|
||||||
if (matrixClient == null) {
|
if (matrixClient == null) {
|
||||||
backstack.replace(NavTarget.NotLoggedInFlow)
|
backstack.newRoot(NavTarget.NotLoggedInFlow)
|
||||||
} else {
|
} else {
|
||||||
matrixClient.startSync()
|
matrixClient.startSync()
|
||||||
sessionComponentsOwner.create(matrixClient)
|
sessionComponentsOwner.create(matrixClient)
|
||||||
backstack.replace(NavTarget.LoggedInFlow(matrixClient.sessionId))
|
backstack.newRoot(NavTarget.LoggedInFlow(matrixClient.sessionId))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
backstack.replace(NavTarget.NotLoggedInFlow)
|
backstack.newRoot(NavTarget.NotLoggedInFlow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(lifecycleScope)
|
.launchIn(lifecycleScope)
|
||||||
|
|
@ -124,7 +134,10 @@ class RootFlowNode(
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
is NavTarget.LoggedInFlow -> LoggedInFlowNode(buildContext, navTarget.sessionId)
|
is NavTarget.LoggedInFlow -> {
|
||||||
|
val matrixClient = sessionComponentsOwner.activeSessionComponent!!.matrixClient()
|
||||||
|
LoggedInFlowNode(buildContext, navTarget.sessionId, matrixClient)
|
||||||
|
}
|
||||||
NavTarget.NotLoggedInFlow -> NotLoggedInFlowNode(buildContext)
|
NavTarget.NotLoggedInFlow -> NotLoggedInFlowNode(buildContext)
|
||||||
NavTarget.SplashScreen -> node(buildContext) {
|
NavTarget.SplashScreen -> node(buildContext) {
|
||||||
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,6 @@ package io.element.android.x.features.roomlist
|
||||||
|
|
||||||
import android.text.format.DateFormat
|
import android.text.format.DateFormat
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import java.time.Period
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.util.Locale
|
|
||||||
import kotlin.math.absoluteValue
|
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
import kotlinx.datetime.LocalDateTime
|
import kotlinx.datetime.LocalDateTime
|
||||||
|
|
@ -14,8 +10,13 @@ import kotlinx.datetime.toInstant
|
||||||
import kotlinx.datetime.toJavaLocalDate
|
import kotlinx.datetime.toJavaLocalDate
|
||||||
import kotlinx.datetime.toJavaLocalDateTime
|
import kotlinx.datetime.toJavaLocalDateTime
|
||||||
import kotlinx.datetime.toLocalDateTime
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import java.time.Period
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlin.math.absoluteValue
|
||||||
|
|
||||||
class LastMessageFormatter(
|
class LastMessageFormatter @Inject constructor(
|
||||||
private val clock: Clock = Clock.System,
|
private val clock: Clock = Clock.System,
|
||||||
private val locale: Locale = Locale.getDefault()
|
private val locale: Locale = Locale.getDefault()
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package io.element.android.x.features.roomlist
|
||||||
|
|
||||||
|
import androidx.compose.ui.platform.AndroidUiDispatcher
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import app.cash.molecule.RecompositionClock
|
||||||
|
import app.cash.molecule.launchMolecule
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import io.element.android.x.core.architecture.Presenter
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
inline fun <reified State, reified Event> Node.presenterConnector(presenter: Presenter<State, Event>): NodePresenterConnector<State, Event> {
|
||||||
|
return NodePresenterConnector(node = this, presenter = presenter)
|
||||||
|
}
|
||||||
|
|
||||||
|
class NodePresenterConnector<State, Event>(private val node: Node, presenter: Presenter<State, Event>) {
|
||||||
|
|
||||||
|
private val moleculeScope = CoroutineScope(node.lifecycleScope.coroutineContext + AndroidUiDispatcher.Main)
|
||||||
|
private val eventFlow: MutableSharedFlow<Event> = MutableSharedFlow(extraBufferCapacity = 64)
|
||||||
|
|
||||||
|
val stateFlow: StateFlow<State> = moleculeScope.launchMolecule(RecompositionClock.ContextClock) {
|
||||||
|
presenter.present(events = eventFlow)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitEvent(event: Event) {
|
||||||
|
eventFlow.tryEmit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package io.element.android.x.features.roomlist
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
|
import com.bumble.appyx.core.node.Node
|
||||||
|
import io.element.android.x.features.roomlist.model.RoomListScreen
|
||||||
|
import io.element.android.x.matrix.core.RoomId
|
||||||
|
|
||||||
|
class RoomListNode(
|
||||||
|
buildContext: BuildContext,
|
||||||
|
presenter: RoomListPresenter,
|
||||||
|
private val onRoomClicked: (RoomId) -> Unit
|
||||||
|
) : Node(buildContext) {
|
||||||
|
|
||||||
|
private val connector = presenterConnector(presenter)
|
||||||
|
|
||||||
|
private fun updateFilter(filter: String) {
|
||||||
|
connector.emitEvent(RoomListScreen.Event.UpdateFilter(filter))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateVisibleRange(range: IntRange) {
|
||||||
|
connector.emitEvent((RoomListScreen.Event.UpdateVisibleRange(range)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logout() {
|
||||||
|
connector.emitEvent(RoomListScreen.Event.Logout)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun View(modifier: Modifier) {
|
||||||
|
val state by connector.stateFlow.collectAsState()
|
||||||
|
RoomListView(
|
||||||
|
state = state,
|
||||||
|
onRoomClicked = onRoomClicked,
|
||||||
|
onFilterChanged = this::updateFilter,
|
||||||
|
onScrollOver = this::updateVisibleRange,
|
||||||
|
onLogoutClicked = this::logout
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,148 @@
|
||||||
|
package io.element.android.x.features.roomlist
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import io.element.android.x.core.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
|
||||||
|
import io.element.android.x.features.roomlist.model.MatrixUser
|
||||||
|
import io.element.android.x.features.roomlist.model.RoomListRoomSummary
|
||||||
|
import io.element.android.x.features.roomlist.model.RoomListRoomSummaryPlaceholders
|
||||||
|
import io.element.android.x.features.roomlist.model.RoomListScreen
|
||||||
|
import io.element.android.x.matrix.MatrixClient
|
||||||
|
import io.element.android.x.matrix.media.MediaResolver
|
||||||
|
import io.element.android.x.matrix.room.RoomSummary
|
||||||
|
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 javax.inject.Inject
|
||||||
|
|
||||||
|
private const val extendedRangeSize = 40
|
||||||
|
|
||||||
|
class RoomListPresenter @Inject constructor(
|
||||||
|
private val client: MatrixClient,
|
||||||
|
private val lastMessageFormatter: LastMessageFormatter = LastMessageFormatter(),
|
||||||
|
) : Presenter<RoomListScreen.State, RoomListScreen.Event> {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun present(events: Flow<RoomListScreen.Event>): RoomListScreen.State {
|
||||||
|
val matrixUser: MutableState<MatrixUser?> = remember {
|
||||||
|
mutableStateOf(null)
|
||||||
|
}
|
||||||
|
var filter by rememberSaveable { mutableStateOf("") }
|
||||||
|
val isLoginOut = rememberSaveable { mutableStateOf(false) }
|
||||||
|
val roomSummaries by client
|
||||||
|
.roomSummaryDataSource()
|
||||||
|
.roomSummaries()
|
||||||
|
.collectAsState(initial = null)
|
||||||
|
|
||||||
|
val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember {
|
||||||
|
mutableStateOf(persistentListOf())
|
||||||
|
}
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
initialLoad(matrixUser)
|
||||||
|
events.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
RoomListScreen.Event.Logout -> logout(isLoginOut)
|
||||||
|
is RoomListScreen.Event.UpdateFilter -> filter = event.newFilter
|
||||||
|
is RoomListScreen.Event.UpdateVisibleRange -> updateVisibleRange(event.range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(roomSummaries, filter) {
|
||||||
|
filteredRoomSummaries.value = updateFilteredRoomSummaries(roomSummaries, filter)
|
||||||
|
}
|
||||||
|
return RoomListScreen.State(
|
||||||
|
matrixUser = matrixUser.value,
|
||||||
|
roomList = filteredRoomSummaries.value,
|
||||||
|
filter = filter,
|
||||||
|
isLoginOut = isLoginOut.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateFilteredRoomSummaries(roomSummaries: List<RoomSummary>?, filter: String): ImmutableList<RoomListRoomSummary> {
|
||||||
|
val mappedRoomSummaries = mapRoomSummaries(roomSummaries.orEmpty())
|
||||||
|
return if (filter.isEmpty()) {
|
||||||
|
mappedRoomSummaries
|
||||||
|
} else {
|
||||||
|
mappedRoomSummaries.filter { it.name.contains(filter, ignoreCase = true) }
|
||||||
|
}.toImmutableList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun initialLoad(matrixUser: MutableState<MatrixUser?>) {
|
||||||
|
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
|
||||||
|
val userDisplayName = client.loadUserDisplayName().getOrNull()
|
||||||
|
val avatarData =
|
||||||
|
loadAvatarData(
|
||||||
|
userDisplayName ?: client.userId().value,
|
||||||
|
userAvatarUrl,
|
||||||
|
AvatarSize.SMALL
|
||||||
|
)
|
||||||
|
matrixUser.value = MatrixUser(
|
||||||
|
username = userDisplayName ?: client.userId().value,
|
||||||
|
avatarUrl = userAvatarUrl,
|
||||||
|
avatarData = avatarData,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0)
|
||||||
|
// Safe to give bigger size than room list
|
||||||
|
val extendedRangeEnd = range.last + midExtendedRangeSize
|
||||||
|
val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd)
|
||||||
|
client.roomSummaryDataSource().setSlidingSyncRange(extendedRange)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mapRoomSummaries(
|
||||||
|
roomSummaries: List<RoomSummary>
|
||||||
|
): List<RoomListRoomSummary> {
|
||||||
|
return roomSummaries.parallelMap { roomSummary ->
|
||||||
|
when (roomSummary) {
|
||||||
|
is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier)
|
||||||
|
is RoomSummary.Filled -> {
|
||||||
|
val avatarData = loadAvatarData(
|
||||||
|
roomSummary.details.name,
|
||||||
|
roomSummary.details.avatarURLString
|
||||||
|
)
|
||||||
|
RoomListRoomSummary(
|
||||||
|
id = roomSummary.identifier(),
|
||||||
|
name = roomSummary.details.name,
|
||||||
|
hasUnread = roomSummary.details.unreadNotificationCount > 0,
|
||||||
|
timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp),
|
||||||
|
lastMessage = roomSummary.details.lastMessage,
|
||||||
|
avatarData = avatarData,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadAvatarData(
|
||||||
|
name: String,
|
||||||
|
url: String?,
|
||||||
|
size: AvatarSize = AvatarSize.MEDIUM
|
||||||
|
): AvatarData {
|
||||||
|
val model = client.mediaResolver()
|
||||||
|
.resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value))
|
||||||
|
return AvatarData(name, model, size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ package io.element.android.x.features.roomlist
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -21,10 +20,6 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Velocity
|
import androidx.compose.ui.unit.Velocity
|
||||||
import com.airbnb.mvrx.Loading
|
|
||||||
import com.airbnb.mvrx.Success
|
|
||||||
import com.airbnb.mvrx.compose.collectAsState
|
|
||||||
import com.airbnb.mvrx.compose.mavericksViewModel
|
|
||||||
import io.element.android.x.core.compose.LogCompositions
|
import io.element.android.x.core.compose.LogCompositions
|
||||||
import io.element.android.x.designsystem.ElementXTheme
|
import io.element.android.x.designsystem.ElementXTheme
|
||||||
import io.element.android.x.designsystem.components.ProgressDialog
|
import io.element.android.x.designsystem.components.ProgressDialog
|
||||||
|
|
@ -33,41 +28,35 @@ 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.components.RoomSummaryRow
|
||||||
import io.element.android.x.features.roomlist.model.MatrixUser
|
import io.element.android.x.features.roomlist.model.MatrixUser
|
||||||
import io.element.android.x.features.roomlist.model.RoomListRoomSummary
|
import io.element.android.x.features.roomlist.model.RoomListRoomSummary
|
||||||
import io.element.android.x.features.roomlist.model.RoomListViewState
|
import io.element.android.x.features.roomlist.model.RoomListScreen
|
||||||
import io.element.android.x.features.roomlist.model.stubbedRoomSummaries
|
import io.element.android.x.features.roomlist.model.stubbedRoomSummaries
|
||||||
import io.element.android.x.matrix.core.RoomId
|
import io.element.android.x.matrix.core.RoomId
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomListScreen(
|
fun RoomListView(
|
||||||
viewModel: RoomListViewModel = mavericksViewModel(),
|
state: RoomListScreen.State,
|
||||||
onSuccessLogout: () -> Unit = { },
|
modifier: Modifier = Modifier,
|
||||||
onRoomClicked: (RoomId) -> Unit = { }
|
onRoomClicked: (RoomId) -> Unit = {},
|
||||||
|
onFilterChanged: (String) -> Unit = {},
|
||||||
|
onLogoutClicked: () -> Unit = {},
|
||||||
|
onScrollOver: (IntRange) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val logoutAction by viewModel.collectAsState(RoomListViewState::logoutAction)
|
RoomListView(
|
||||||
val filter by viewModel.collectAsState(RoomListViewState::filter)
|
roomSummaries = state.roomList,
|
||||||
if (logoutAction is Success) {
|
matrixUser = state.matrixUser,
|
||||||
onSuccessLogout()
|
filter = state.filter,
|
||||||
return
|
isLoginOut = state.isLoginOut,
|
||||||
}
|
modifier = modifier,
|
||||||
LogCompositions(tag = "RoomListScreen", msg = "Root")
|
|
||||||
val roomSummaries by viewModel.collectAsState(RoomListViewState::rooms)
|
|
||||||
val matrixUser by viewModel.collectAsState(RoomListViewState::user)
|
|
||||||
RoomListContent(
|
|
||||||
roomSummaries = roomSummaries().orEmpty().toImmutableList(),
|
|
||||||
matrixUser = matrixUser(),
|
|
||||||
onRoomClicked = onRoomClicked,
|
onRoomClicked = onRoomClicked,
|
||||||
onLogoutClicked = viewModel::logout,
|
onFilterChanged = onFilterChanged,
|
||||||
isLoginOut = logoutAction is Loading,
|
onLogoutClicked = onLogoutClicked,
|
||||||
filter = filter,
|
onScrollOver = onScrollOver
|
||||||
onFilterChanged = viewModel::filterRoom,
|
|
||||||
onScrollOver = viewModel::updateVisibleRange
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RoomListContent(
|
fun RoomListView(
|
||||||
roomSummaries: ImmutableList<RoomListRoomSummary>,
|
roomSummaries: ImmutableList<RoomListRoomSummary>,
|
||||||
matrixUser: MatrixUser?,
|
matrixUser: MatrixUser?,
|
||||||
filter: String,
|
filter: String,
|
||||||
|
|
@ -141,15 +130,11 @@ fun RoomListContent(
|
||||||
|
|
||||||
private fun RoomListRoomSummary.contentType() = isPlaceholder
|
private fun RoomListRoomSummary.contentType() = isPlaceholder
|
||||||
|
|
||||||
private fun LazyListState.isScrolled(): Boolean {
|
|
||||||
return firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewableRoomListContent() {
|
fun PreviewableRoomListView() {
|
||||||
ElementXTheme(darkTheme = false) {
|
ElementXTheme(darkTheme = false) {
|
||||||
RoomListContent(
|
RoomListView(
|
||||||
roomSummaries = stubbedRoomSummaries(),
|
roomSummaries = stubbedRoomSummaries(),
|
||||||
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
||||||
onRoomClicked = {},
|
onRoomClicked = {},
|
||||||
|
|
@ -164,9 +149,9 @@ fun PreviewableRoomListContent() {
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviewableDarkRoomListContent() {
|
fun PreviewableDarkRoomListView() {
|
||||||
ElementXTheme(darkTheme = true) {
|
ElementXTheme(darkTheme = true) {
|
||||||
RoomListContent(
|
RoomListView(
|
||||||
roomSummaries = stubbedRoomSummaries(),
|
roomSummaries = stubbedRoomSummaries(),
|
||||||
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
||||||
onRoomClicked = {},
|
onRoomClicked = {},
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
package io.element.android.x.features.roomlist
|
|
||||||
|
|
||||||
import com.airbnb.mvrx.*
|
|
||||||
import dagger.assisted.Assisted
|
|
||||||
import dagger.assisted.AssistedInject
|
|
||||||
import io.element.android.x.anvilannotations.ContributesViewModel
|
|
||||||
import io.element.android.x.core.coroutine.parallelMap
|
|
||||||
import io.element.android.x.core.di.daggerMavericksViewModelFactory
|
|
||||||
import io.element.android.x.designsystem.components.avatar.AvatarData
|
|
||||||
import io.element.android.x.designsystem.components.avatar.AvatarSize
|
|
||||||
import io.element.android.x.di.SessionScope
|
|
||||||
import io.element.android.x.features.roomlist.model.MatrixUser
|
|
||||||
import io.element.android.x.features.roomlist.model.RoomListRoomSummary
|
|
||||||
import io.element.android.x.features.roomlist.model.RoomListRoomSummaryPlaceholders
|
|
||||||
import io.element.android.x.features.roomlist.model.RoomListViewState
|
|
||||||
import io.element.android.x.matrix.MatrixClient
|
|
||||||
import io.element.android.x.matrix.media.MediaResolver
|
|
||||||
import io.element.android.x.matrix.room.RoomSummary
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
|
||||||
import kotlinx.coroutines.flow.flowOn
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
private const val extendedRangeSize = 40
|
|
||||||
|
|
||||||
@ContributesViewModel(SessionScope::class)
|
|
||||||
class RoomListViewModel @AssistedInject constructor(
|
|
||||||
private val client: MatrixClient,
|
|
||||||
@Assisted initialState: RoomListViewState
|
|
||||||
) :
|
|
||||||
MavericksViewModel<RoomListViewState>(initialState) {
|
|
||||||
|
|
||||||
companion object : MavericksViewModelFactory<RoomListViewModel, RoomListViewState> by daggerMavericksViewModelFactory()
|
|
||||||
|
|
||||||
private val lastMessageFormatter = LastMessageFormatter()
|
|
||||||
|
|
||||||
init {
|
|
||||||
handleInit()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun logout() {
|
|
||||||
viewModelScope.launch {
|
|
||||||
suspend {
|
|
||||||
delay(2000)
|
|
||||||
client.logout()
|
|
||||||
}.execute {
|
|
||||||
copy(logoutAction = it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun filterRoom(filter: String) {
|
|
||||||
setState {
|
|
||||||
copy(
|
|
||||||
filter = filter
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateVisibleRange(range: IntRange) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
if (range.isEmpty()) return@launch
|
|
||||||
val midExtendedRangeSize = extendedRangeSize / 2
|
|
||||||
val extendedRangeStart = (range.first - midExtendedRangeSize).coerceAtLeast(0)
|
|
||||||
// Safe to give bigger size than room list
|
|
||||||
val extendedRangeEnd = range.last + midExtendedRangeSize
|
|
||||||
val extendedRange = IntRange(extendedRangeStart, extendedRangeEnd)
|
|
||||||
client.roomSummaryDataSource().setSlidingSyncRange(extendedRange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleInit() {
|
|
||||||
suspend {
|
|
||||||
val userAvatarUrl = client.loadUserAvatarURLString().getOrNull()
|
|
||||||
val userDisplayName = client.loadUserDisplayName().getOrNull()
|
|
||||||
val avatarData =
|
|
||||||
loadAvatarData(
|
|
||||||
userDisplayName ?: client.userId().value,
|
|
||||||
userAvatarUrl,
|
|
||||||
AvatarSize.SMALL
|
|
||||||
)
|
|
||||||
MatrixUser(
|
|
||||||
username = userDisplayName ?: client.userId().value,
|
|
||||||
avatarUrl = userAvatarUrl,
|
|
||||||
avatarData = avatarData,
|
|
||||||
)
|
|
||||||
}.execute {
|
|
||||||
copy(user = it)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe the room list and the filter
|
|
||||||
combine(
|
|
||||||
client.roomSummaryDataSource().roomSummaries()
|
|
||||||
.map(::mapRoomSummaries)
|
|
||||||
.flowOn(Dispatchers.Default),
|
|
||||||
stateFlow
|
|
||||||
.map { it.filter }
|
|
||||||
.distinctUntilChanged(),
|
|
||||||
) { list, filter ->
|
|
||||||
if (filter.isEmpty()) {
|
|
||||||
list
|
|
||||||
} else {
|
|
||||||
list.filter { it.name.contains(filter, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.execute {
|
|
||||||
copy(
|
|
||||||
rooms = when {
|
|
||||||
it is Loading ||
|
|
||||||
// Note: this second case will prevent to handle correctly the empty case
|
|
||||||
(it is Success && it().isEmpty() && filter.isEmpty()) -> {
|
|
||||||
// Show fake placeholders to avoid having empty screen
|
|
||||||
Loading(RoomListRoomSummaryPlaceholders.createFakeList(size = 16))
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun mapRoomSummaries(
|
|
||||||
roomSummaries: List<RoomSummary>
|
|
||||||
): List<RoomListRoomSummary> {
|
|
||||||
return roomSummaries.parallelMap { roomSummary ->
|
|
||||||
when (roomSummary) {
|
|
||||||
is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier)
|
|
||||||
is RoomSummary.Filled -> {
|
|
||||||
val avatarData = loadAvatarData(
|
|
||||||
roomSummary.details.name,
|
|
||||||
roomSummary.details.avatarURLString
|
|
||||||
)
|
|
||||||
RoomListRoomSummary(
|
|
||||||
id = roomSummary.identifier(),
|
|
||||||
name = roomSummary.details.name,
|
|
||||||
hasUnread = roomSummary.details.unreadNotificationCount > 0,
|
|
||||||
timestamp = lastMessageFormatter.format(roomSummary.details.lastMessageTimestamp),
|
|
||||||
lastMessage = roomSummary.details.lastMessage,
|
|
||||||
avatarData = avatarData,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadAvatarData(
|
|
||||||
name: String,
|
|
||||||
url: String?,
|
|
||||||
size: AvatarSize = AvatarSize.MEDIUM
|
|
||||||
): AvatarData {
|
|
||||||
val model = client.mediaResolver()
|
|
||||||
.resolve(url, kind = MediaResolver.Kind.Thumbnail(size.value))
|
|
||||||
return AvatarData(name, model, size)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package io.element.android.x.features.roomlist.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
object RoomListScreen {
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class State(
|
||||||
|
val matrixUser: MatrixUser?,
|
||||||
|
val roomList: ImmutableList<RoomListRoomSummary>,
|
||||||
|
val filter: String,
|
||||||
|
val isLoginOut: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface Event {
|
||||||
|
object Logout : Event
|
||||||
|
data class UpdateFilter(val newFilter: String) : Event
|
||||||
|
data class UpdateVisibleRange(val range: IntRange): Event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
package io.element.android.x.features.roomlist.model
|
|
||||||
|
|
||||||
import com.airbnb.mvrx.Async
|
|
||||||
import com.airbnb.mvrx.MavericksState
|
|
||||||
import com.airbnb.mvrx.Uninitialized
|
|
||||||
import io.element.android.x.matrix.core.RoomId
|
|
||||||
|
|
||||||
data class RoomListViewState(
|
|
||||||
val user: Async<MatrixUser> = Uninitialized,
|
|
||||||
// Will contain the filtered rooms, using ::filter (if filter is not empty)
|
|
||||||
val rooms: Async<List<RoomListRoomSummary>> = Uninitialized,
|
|
||||||
val filter: String = "",
|
|
||||||
val canLoadMore: Boolean = false,
|
|
||||||
val logoutAction: Async<Unit> = Uninitialized,
|
|
||||||
val roomsById: Map<RoomId, RoomListRoomSummary> = emptyMap()
|
|
||||||
) : MavericksState
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package io.element.android.x.core.architecture
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface Presenter<State, Event> {
|
||||||
|
@Composable
|
||||||
|
fun present(events: Flow<Event>): State
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue