First implementation of using Node/Presenter/UI on RoomList (no DI)
This commit is contained in:
parent
e176a41ecf
commit
1509d82f3f
13 changed files with 326 additions and 230 deletions
|
|
@ -41,7 +41,7 @@ class MainActivity : NodeComponentActivity(), DaggerComponentOwner {
|
|||
NodeHost(integrationPoint = appyxIntegrationPoint) {
|
||||
RootFlowNode(
|
||||
buildContext = it,
|
||||
daggerComponentOwner = this,
|
||||
appComponentOwner = this,
|
||||
matrix = appBindings.matrix(),
|
||||
sessionComponentsOwner = appBindings.sessionComponentsOwner()
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.os.Parcelable
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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.node.Node
|
||||
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 io.element.android.x.core.di.viewModelSupportNode
|
||||
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.SessionId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
class LoggedInFlowNode(
|
||||
buildContext: BuildContext,
|
||||
val sessionId: SessionId,
|
||||
private val matrixClient: MatrixClient,
|
||||
private val backstack: BackStack<NavTarget> = BackStack(
|
||||
initialElement = NavTarget.RoomList,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
|
|
@ -29,6 +34,13 @@ class LoggedInFlowNode(
|
|||
buildContext = buildContext
|
||||
) {
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onCreate = { Timber.v("OnCreate") },
|
||||
onDestroy = { Timber.v("OnDestroy") }
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
object RoomList : NavTarget
|
||||
|
|
@ -39,11 +51,12 @@ class LoggedInFlowNode(
|
|||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.RoomList -> viewModelSupportNode(buildContext) {
|
||||
RoomListScreen(
|
||||
onRoomClicked = { backstack.push(NavTarget.Messages(it)) }
|
||||
)
|
||||
}
|
||||
NavTarget.RoomList -> RoomListNode(
|
||||
buildContext = buildContext,
|
||||
presenter = RoomListPresenter(matrixClient),
|
||||
onRoomClicked = {
|
||||
backstack.push(NavTarget.Messages(it))
|
||||
})
|
||||
is NavTarget.Messages -> viewModelSupportNode(buildContext) {
|
||||
MessagesScreen(
|
||||
roomId = navTarget.roomId.value,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.os.Parcelable
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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.node.Node
|
||||
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.onboarding.OnBoardingScreen
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
class NotLoggedInFlowNode(
|
||||
buildContext: BuildContext,
|
||||
|
|
@ -25,6 +27,13 @@ class NotLoggedInFlowNode(
|
|||
buildContext = buildContext
|
||||
) {
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onCreate = { Timber.v("OnCreate") },
|
||||
onDestroy = { Timber.v("OnDestroy") }
|
||||
)
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
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.node
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.newRoot
|
||||
import com.bumble.appyx.navmodel.backstack.operation.replace
|
||||
import io.element.android.x.BuildConfig
|
||||
import io.element.android.x.component.ShowkaseButton
|
||||
|
|
@ -61,7 +62,7 @@ class RootFlowNode(
|
|||
initialElement = NavTarget.SplashScreen,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
private val daggerComponentOwner: DaggerComponentOwner,
|
||||
private val appComponentOwner: DaggerComponentOwner,
|
||||
private val matrix: Matrix,
|
||||
private val sessionComponentsOwner: SessionComponentsOwner,
|
||||
) :
|
||||
|
|
@ -71,23 +72,32 @@ class RootFlowNode(
|
|||
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 {
|
||||
matrix.isLoggedIn()
|
||||
.distinctUntilChanged()
|
||||
.onEach { isLoggedIn ->
|
||||
Timber.v("IsLoggedIn")
|
||||
if (isLoggedIn) {
|
||||
val matrixClient = matrix.restoreSession()
|
||||
if (matrixClient == null) {
|
||||
backstack.replace(NavTarget.NotLoggedInFlow)
|
||||
backstack.newRoot(NavTarget.NotLoggedInFlow)
|
||||
} else {
|
||||
matrixClient.startSync()
|
||||
sessionComponentsOwner.create(matrixClient)
|
||||
backstack.replace(NavTarget.LoggedInFlow(matrixClient.sessionId))
|
||||
backstack.newRoot(NavTarget.LoggedInFlow(matrixClient.sessionId))
|
||||
}
|
||||
} else {
|
||||
backstack.replace(NavTarget.NotLoggedInFlow)
|
||||
backstack.newRoot(NavTarget.NotLoggedInFlow)
|
||||
}
|
||||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
|
@ -124,7 +134,10 @@ class RootFlowNode(
|
|||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
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.SplashScreen -> node(buildContext) {
|
||||
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.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.Instant
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
|
|
@ -14,8 +10,13 @@ import kotlinx.datetime.toInstant
|
|||
import kotlinx.datetime.toJavaLocalDate
|
||||
import kotlinx.datetime.toJavaLocalDateTime
|
||||
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 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.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.tooling.preview.Preview
|
||||
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.designsystem.ElementXTheme
|
||||
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.model.MatrixUser
|
||||
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.matrix.core.RoomId
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@Composable
|
||||
fun RoomListScreen(
|
||||
viewModel: RoomListViewModel = mavericksViewModel(),
|
||||
onSuccessLogout: () -> Unit = { },
|
||||
onRoomClicked: (RoomId) -> Unit = { }
|
||||
fun RoomListView(
|
||||
state: RoomListScreen.State,
|
||||
modifier: Modifier = Modifier,
|
||||
onRoomClicked: (RoomId) -> Unit = {},
|
||||
onFilterChanged: (String) -> Unit = {},
|
||||
onLogoutClicked: () -> Unit = {},
|
||||
onScrollOver: (IntRange) -> Unit = {},
|
||||
) {
|
||||
val logoutAction by viewModel.collectAsState(RoomListViewState::logoutAction)
|
||||
val filter by viewModel.collectAsState(RoomListViewState::filter)
|
||||
if (logoutAction is Success) {
|
||||
onSuccessLogout()
|
||||
return
|
||||
}
|
||||
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(),
|
||||
RoomListView(
|
||||
roomSummaries = state.roomList,
|
||||
matrixUser = state.matrixUser,
|
||||
filter = state.filter,
|
||||
isLoginOut = state.isLoginOut,
|
||||
modifier = modifier,
|
||||
onRoomClicked = onRoomClicked,
|
||||
onLogoutClicked = viewModel::logout,
|
||||
isLoginOut = logoutAction is Loading,
|
||||
filter = filter,
|
||||
onFilterChanged = viewModel::filterRoom,
|
||||
onScrollOver = viewModel::updateVisibleRange
|
||||
onFilterChanged = onFilterChanged,
|
||||
onLogoutClicked = onLogoutClicked,
|
||||
onScrollOver = onScrollOver
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RoomListContent(
|
||||
fun RoomListView(
|
||||
roomSummaries: ImmutableList<RoomListRoomSummary>,
|
||||
matrixUser: MatrixUser?,
|
||||
filter: String,
|
||||
|
|
@ -141,15 +130,11 @@ fun RoomListContent(
|
|||
|
||||
private fun RoomListRoomSummary.contentType() = isPlaceholder
|
||||
|
||||
private fun LazyListState.isScrolled(): Boolean {
|
||||
return firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewableRoomListContent() {
|
||||
fun PreviewableRoomListView() {
|
||||
ElementXTheme(darkTheme = false) {
|
||||
RoomListContent(
|
||||
RoomListView(
|
||||
roomSummaries = stubbedRoomSummaries(),
|
||||
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
||||
onRoomClicked = {},
|
||||
|
|
@ -164,9 +149,9 @@ fun PreviewableRoomListContent() {
|
|||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewableDarkRoomListContent() {
|
||||
fun PreviewableDarkRoomListView() {
|
||||
ElementXTheme(darkTheme = true) {
|
||||
RoomListContent(
|
||||
RoomListView(
|
||||
roomSummaries = stubbedRoomSummaries(),
|
||||
matrixUser = MatrixUser("User#1", avatarData = AvatarData("U")),
|
||||
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