[Room List] Show offline indicator when the device is offline (#239)

* Implement the network status indicator.

* Add `networkmonitor` feature.
This commit is contained in:
Jorge Martin Espinosa 2023-04-17 17:01:16 +02:00 committed by GitHub
parent 1a2007bfc2
commit 798bedcaed
33 changed files with 511 additions and 21 deletions

1
changelog.d/141.feature Normal file
View file

@ -0,0 +1 @@
Add a `NetworkMonitor` component to track the network connection status

View file

@ -40,6 +40,7 @@ dependencies {
implementation(projects.libraries.textcomposer) implementation(projects.libraries.textcomposer)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.features.networkmonitor.api)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.datetime) implementation(libs.datetime)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
@ -53,6 +54,7 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.networkmonitor.test)
androidTestImplementation(libs.test.junitext) androidTestImplementation(libs.test.junitext)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -38,6 +39,8 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.textcomposer.MessageComposerMode import io.element.android.libraries.textcomposer.MessageComposerMode
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -48,6 +51,7 @@ class MessagesPresenter @Inject constructor(
private val composerPresenter: MessageComposerPresenter, private val composerPresenter: MessageComposerPresenter,
private val timelinePresenter: TimelinePresenter, private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter, private val actionListPresenter: ActionListPresenter,
private val networkMonitor: NetworkMonitor,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
@Composable @Composable
@ -64,6 +68,10 @@ class MessagesPresenter @Inject constructor(
val roomAvatar: MutableState<AvatarData?> = remember { val roomAvatar: MutableState<AvatarData?> = remember {
mutableStateOf(null) mutableStateOf(null)
} }
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
println(networkConnectionStatus)
LaunchedEffect(syncUpdateFlow) { LaunchedEffect(syncUpdateFlow) {
roomAvatar.value = roomAvatar.value =
AvatarData( AvatarData(
@ -89,6 +97,7 @@ class MessagesPresenter @Inject constructor(
composerState = composerState, composerState = composerState,
timelineState = timelineState, timelineState = timelineState,
actionListState = actionListState, actionListState = actionListState,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -31,5 +31,6 @@ data class MessagesState(
val composerState: MessageComposerState, val composerState: MessageComposerState,
val timelineState: TimelineState, val timelineState: TimelineState,
val actionListState: ActionListState, val actionListState: ActionListState,
val hasNetworkConnection: Boolean,
val eventSink: (MessagesEvents) -> Unit val eventSink: (MessagesEvents) -> Unit
) )

View file

@ -31,6 +31,7 @@ open class MessagesStateProvider : PreviewParameterProvider<MessagesState> {
override val values: Sequence<MessagesState> override val values: Sequence<MessagesState>
get() = sequenceOf( get() = sequenceOf(
aMessagesState(), aMessagesState(),
aMessagesState().copy(hasNetworkConnection = false),
) )
} }
@ -47,5 +48,6 @@ fun aMessagesState() = MessagesState(
timelineItems = aTimelineItemList(aTimelineItemContent()), timelineItems = aTimelineItemList(aTimelineItemContent()),
), ),
actionListState = anActionListState(), actionListState = anActionListState(),
hasNetworkConnection = true,
eventSink = {} eventSink = {}
) )

View file

@ -70,7 +70,7 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@ -112,12 +112,15 @@ fun MessagesView(
modifier = modifier, modifier = modifier,
contentWindowInsets = WindowInsets.statusBars, contentWindowInsets = WindowInsets.statusBars,
topBar = { topBar = {
MessagesViewTopBar( Column {
roomTitle = state.roomName, ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
roomAvatar = state.roomAvatar, MessagesViewTopBar(
onBackPressed = onBackPressed, roomTitle = state.roomName,
onRoomDetailsClicked = onRoomDetailsClicked, roomAvatar = state.roomAvatar,
) onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
)
}
}, },
content = { padding -> content = { padding ->
MessagesViewContent( MessagesViewContent(
@ -208,7 +211,8 @@ fun MessagesViewTopBar(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} }
} },
windowInsets = WindowInsets(0.dp)
) )
} }

View file

@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter import io.element.android.features.messages.impl.textcomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.timeline.TimelinePresenter import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -141,6 +142,7 @@ class MessagesPresenterTest {
composerPresenter = messageComposerPresenter, composerPresenter = messageComposerPresenter,
timelinePresenter = timelinePresenter, timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter, actionListPresenter = actionListPresenter,
networkMonitor = FakeNetworkMonitor(),
) )
} }
} }

View file

@ -0,0 +1,31 @@
/*
* 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.networkmonitor.api"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.uiStrings)
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 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.features.networkmonitor.api
import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val connectivity: Flow<NetworkStatus>
val currentConnectivityStatus: NetworkStatus
}

View file

@ -0,0 +1,22 @@
/*
* Copyright (c) 2023 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.features.networkmonitor.api
enum class NetworkStatus {
Online,
Offline
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 2023 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.features.networkmonitor.api.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.WifiOff
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun ConnectivityIndicatorView(
isOnline: Boolean,
modifier: Modifier = Modifier
) {
val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline }
val isStatusBarPaddingVisible = remember { MutableTransitionState(isOnline) }.apply { targetState = isOnline }
// Display the network indicator with an animation
AnimatedVisibility(
visibleState = isIndicatorVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Indicator(modifier)
}
// Show missing status bar padding when the indicator is not visible
AnimatedVisibility(
visibleState = isStatusBarPaddingVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
StatusBarPaddingSpacer(modifier)
}
}
@Composable
private fun Indicator(modifier: Modifier = Modifier) {
Row(
modifier
.fillMaxWidth()
.background(LocalColors.current.gray400)
.statusBarsPadding()
.padding(vertical = 6.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.Bottom,
) {
val tint = MaterialTheme.colorScheme.primary
Image(
imageVector = Icons.Outlined.WifiOff,
contentDescription = null,
colorFilter = ColorFilter.tint(tint),
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = stringResource(StringR.string.common_offline), style = ElementTextStyles.Regular.bodyMD, color = tint)
}
}
@Composable
private fun StatusBarPaddingSpacer(modifier: Modifier = Modifier) {
Spacer(modifier = modifier.statusBarsPadding())
}
@Preview
@Composable
internal fun PreviewLightConnectivityIndicatorView() {
ElementPreviewLight {
ConnectivityIndicatorView(isOnline = false)
}
}
@Preview
@Composable
internal fun PreviewDarkConnectivityIndicatorView() {
ElementPreviewDark {
ConnectivityIndicatorView(isOnline = false)
}
}

View file

@ -0,0 +1,38 @@
/*
* 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-library")
alias(libs.plugins.anvil)
}
anvil {
generateDaggerFactories.set(true)
}
android {
namespace = "io.element.android.features.networkmonitor.impl"
}
dependencies {
implementation(libs.coroutines.core)
implementation(libs.dagger)
implementation(projects.libraries.core)
implementation(projects.libraries.di)
api(projects.features.networkmonitor.api)
}

View file

@ -0,0 +1,19 @@
<!--
~ Copyright (c) 2023 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.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

View file

@ -0,0 +1,93 @@
/*
* Copyright (c) 2023 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.features.networkmonitor.impl
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import timber.log.Timber
import javax.inject.Inject
@ContributesBinding(scope = AppScope::class)
@SingleIn(AppScope::class)
class NetworkMonitorImpl @Inject constructor(
@ApplicationContext context: Context
) : NetworkMonitor {
private val connectivityManager: ConnectivityManager = context.getSystemService(ConnectivityManager::class.java)
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_connectivity.value = connectivityManager.currentConnectionStatus()
Timber.v("Connectivity status (available): ${connectivityManager.currentConnectionStatus()}")
}
override fun onLost(network: Network) {
_connectivity.value = connectivityManager.currentConnectionStatus()
Timber.v("Connectivity status (lost): ${connectivityManager.currentConnectionStatus()}")
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
_connectivity.value = connectivityManager.currentConnectionStatus()
Timber.v("Connectivity status (changed): ${connectivityManager.currentConnectionStatus()}")
}
}
private val _connectivity = MutableStateFlow(NetworkStatus.Online)
override val connectivity: Flow<NetworkStatus> = _connectivity
override val currentConnectivityStatus: NetworkStatus get() = _connectivity.value
init {
listenToConnectionChanges()
}
private fun listenToConnectionChanges() {
val request = NetworkRequest.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
connectivityManager.registerNetworkCallback(request, callback)
_connectivity.tryEmit(connectivityManager.currentConnectionStatus())
}
private fun ConnectivityManager.currentConnectionStatus(): NetworkStatus {
val hasInternet = activeNetwork?.let(::getNetworkCapabilities)
?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
?: false
return if (hasInternet) {
NetworkStatus.Online
} else {
NetworkStatus.Offline
}
}
}

View file

@ -0,0 +1,30 @@
/*
* 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.
*/
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.networkmonitor.test"
}
dependencies {
api(projects.features.networkmonitor.api)
api(libs.coroutines.core)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2023 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.features.networkmonitor.test
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeNetworkMonitor(initialStatus: NetworkStatus = NetworkStatus.Online) : NetworkMonitor {
override val currentConnectivityStatus: NetworkStatus
get() = _connectivityStatus.value
private val _connectivityStatus: MutableStateFlow<NetworkStatus> = MutableStateFlow(initialStatus)
override val connectivity: Flow<NetworkStatus> = _connectivityStatus
}

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.features.networkmonitor.api)
implementation(libs.accompanist.placeholder) implementation(libs.accompanist.placeholder)
api(projects.features.roomlist.api) api(projects.features.roomlist.api)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
@ -60,6 +61,7 @@ dependencies {
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.permissions.noop)
androidTestImplementation(libs.test.junitext) androidTestImplementation(libs.test.junitext)

View file

@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryPlaceholders
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
@ -42,6 +43,7 @@ import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.ui.model.MatrixUser import io.element.android.libraries.matrix.ui.model.MatrixUser
import io.element.android.features.networkmonitor.api.NetworkStatus
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
@ -57,6 +59,7 @@ class RoomListPresenter @Inject constructor(
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter, private val roomLastMessageFormatter: RoomLastMessageFormatter,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
@ -71,6 +74,8 @@ class RoomListPresenter @Inject constructor(
.roomSummaries() .roomSummaries()
.collectAsState() .collectAsState()
val networkConnectionStatus by networkMonitor.connectivity.collectAsState(initial = networkMonitor.currentConnectivityStatus)
Timber.v("RoomSummaries size = ${roomSummaries.size}") Timber.v("RoomSummaries size = ${roomSummaries.size}")
val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember { val filteredRoomSummaries: MutableState<ImmutableList<RoomListRoomSummary>> = remember {
@ -108,6 +113,7 @@ class RoomListPresenter @Inject constructor(
filter = filter, filter = filter,
displayVerificationPrompt = displayVerificationPrompt, displayVerificationPrompt = displayVerificationPrompt,
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -28,6 +28,7 @@ data class RoomListState(
val roomList: ImmutableList<RoomListRoomSummary>, val roomList: ImmutableList<RoomListRoomSummary>,
val filter: String, val filter: String,
val displayVerificationPrompt: Boolean, val displayVerificationPrompt: Boolean,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val eventSink: (RoomListEvents) -> Unit val eventSink: (RoomListEvents) -> Unit
) )

View file

@ -33,6 +33,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(), aRoomListState(),
aRoomListState().copy(displayVerificationPrompt = true), aRoomListState().copy(displayVerificationPrompt = true),
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)), aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
aRoomListState().copy(hasNetworkConnection = false),
) )
} }
@ -40,6 +41,7 @@ internal fun aRoomListState() = RoomListState(
matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("@id", "U")), matrixUser = MatrixUser(id = UserId("@id"), username = "User#1", avatarData = AvatarData("@id", "U")),
roomList = aRoomListRoomSummaryList(), roomList = aRoomListRoomSummaryList(),
filter = "filter", filter = "filter",
hasNetworkConnection = true,
snackbarMessage = null, snackbarMessage = null,
displayVerificationPrompt = false, displayVerificationPrompt = false,
eventSink = {} eventSink = {}

View file

@ -68,6 +68,7 @@ import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.LogCompositions import io.element.android.libraries.designsystem.utils.LogCompositions
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.element.android.libraries.designsystem.R as DrawableR import io.element.android.libraries.designsystem.R as DrawableR
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@ -150,14 +151,16 @@ fun RoomListContent(
Scaffold( Scaffold(
modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
RoomListTopBar( Column {
matrixUser = state.matrixUser, ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
filter = state.filter, RoomListTopBar(
onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) }, matrixUser = state.matrixUser,
onOpenSettings = onOpenSettings, filter = state.filter,
scrollBehavior = scrollBehavior, onFilterChanged = { state.eventSink(RoomListEvents.UpdateFilter(it)) },
modifier = Modifier, onOpenSettings = onOpenSettings,
) scrollBehavior = scrollBehavior,
)
}
}, },
content = { padding -> content = { padding ->
Column( Column(

View file

@ -19,6 +19,7 @@
package io.element.android.features.roomlist.impl.components package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.ContentAlpha import androidx.compose.material.ContentAlpha
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@ -46,6 +47,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.roomlist.impl.R import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -172,6 +174,7 @@ fun SearchRoomListTopBar(
) )
} }
}, },
windowInsets = WindowInsets(0.dp)
) )
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
focusRequester.requestFocus() focusRequester.requestFocus()
@ -231,6 +234,7 @@ private fun DefaultRoomListTopBar(
} }
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp),
) )
} }

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter import io.element.android.libraries.dateformatter.api.LastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
@ -49,6 +50,7 @@ class RoomListPresenterTests {
createDateFormatter(), createDateFormatter(),
FakeRoomLastMessageFormatter(), FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -76,6 +78,7 @@ class RoomListPresenterTests {
createDateFormatter(), createDateFormatter(),
FakeRoomLastMessageFormatter(), FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -97,6 +100,7 @@ class RoomListPresenterTests {
createDateFormatter(), createDateFormatter(),
FakeRoomLastMessageFormatter(), FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -122,6 +126,7 @@ class RoomListPresenterTests {
createDateFormatter(), createDateFormatter(),
FakeRoomLastMessageFormatter(), FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -152,6 +157,7 @@ class RoomListPresenterTests {
createDateFormatter(), createDateFormatter(),
FakeRoomLastMessageFormatter(), FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -187,6 +193,7 @@ class RoomListPresenterTests {
createDateFormatter(), createDateFormatter(),
FakeRoomLastMessageFormatter(), FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -236,6 +243,7 @@ class RoomListPresenterTests {
givenIsReady(true) givenIsReady(true)
givenVerifiedStatus(SessionVerifiedStatus.NotVerified) givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
}, },
FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {

View file

@ -70,5 +70,10 @@ val Vermilion = Color(0xFFFF5B55)
val LinkColor = Color(0xFF0086E6) val LinkColor = Color(0xFF0086E6)
// Compound colors
val TextColorCriticalLight = Color(0xFFD51928) val TextColorCriticalLight = Color(0xFFD51928)
val TextColorCriticalDark = Color(0xfffd3e3c) val TextColorCriticalDark = Color(0xfffd3e3c)
val Gray_400_Light = Color(0xFFE1E6EC)
val Gray_400_Dark = Color(0xFF26282D)

View file

@ -26,6 +26,7 @@ import io.element.android.libraries.designsystem.Black_950
import io.element.android.libraries.designsystem.DarkGrey import io.element.android.libraries.designsystem.DarkGrey
import io.element.android.libraries.designsystem.Gray_300 import io.element.android.libraries.designsystem.Gray_300
import io.element.android.libraries.designsystem.Gray_400 import io.element.android.libraries.designsystem.Gray_400
import io.element.android.libraries.designsystem.Gray_400_Dark
import io.element.android.libraries.designsystem.Gray_450 import io.element.android.libraries.designsystem.Gray_450
import io.element.android.libraries.designsystem.SystemGrey5Dark import io.element.android.libraries.designsystem.SystemGrey5Dark
import io.element.android.libraries.designsystem.SystemGrey6Dark import io.element.android.libraries.designsystem.SystemGrey6Dark
@ -38,6 +39,7 @@ fun elementColorsDark() = ElementColors(
messageHighlightedBackground = Azure, messageHighlightedBackground = Azure,
quaternary = Gray_400, quaternary = Gray_400,
quinary = Gray_450, quinary = Gray_450,
gray400 = Gray_400_Dark,
textActionCritical = TextColorCriticalDark, textActionCritical = TextColorCriticalDark,
isLight = false, isLight = false,
) )

View file

@ -26,6 +26,7 @@ import io.element.android.libraries.designsystem.Gray_100
import io.element.android.libraries.designsystem.Gray_150 import io.element.android.libraries.designsystem.Gray_150
import io.element.android.libraries.designsystem.Gray_200 import io.element.android.libraries.designsystem.Gray_200
import io.element.android.libraries.designsystem.Gray_25 import io.element.android.libraries.designsystem.Gray_25
import io.element.android.libraries.designsystem.Gray_400_Light
import io.element.android.libraries.designsystem.Gray_50 import io.element.android.libraries.designsystem.Gray_50
import io.element.android.libraries.designsystem.SystemGrey5Light import io.element.android.libraries.designsystem.SystemGrey5Light
import io.element.android.libraries.designsystem.SystemGrey6Light import io.element.android.libraries.designsystem.SystemGrey6Light
@ -38,6 +39,7 @@ fun elementColorsLight() = ElementColors(
messageHighlightedBackground = Azure, messageHighlightedBackground = Azure,
quaternary = Gray_100, quaternary = Gray_100,
quinary = Gray_50, quinary = Gray_50,
gray400 = Gray_400_Light,
textActionCritical = TextColorCriticalLight, textActionCritical = TextColorCriticalLight,
isLight = true, isLight = true,
) )

View file

@ -29,6 +29,7 @@ class ElementColors(
messageHighlightedBackground: Color, messageHighlightedBackground: Color,
quaternary: Color, quaternary: Color,
quinary: Color, quinary: Color,
gray400: Color,
textActionCritical: Color, textActionCritical: Color,
isLight: Boolean isLight: Boolean
) { ) {
@ -45,6 +46,9 @@ class ElementColors(
var quinary by mutableStateOf(quinary) var quinary by mutableStateOf(quinary)
private set private set
var gray400 by mutableStateOf(gray400)
private set
var textActionCritical by mutableStateOf(textActionCritical) var textActionCritical by mutableStateOf(textActionCritical)
private set private set
@ -57,6 +61,7 @@ class ElementColors(
messageHighlightedBackground: Color = this.messageHighlightedBackground, messageHighlightedBackground: Color = this.messageHighlightedBackground,
quaternary: Color = this.quaternary, quaternary: Color = this.quaternary,
quinary: Color = this.quinary, quinary: Color = this.quinary,
gray400: Color = this.gray400,
textActionCritical: Color = this.textActionCritical, textActionCritical: Color = this.textActionCritical,
isLight: Boolean = this.isLight, isLight: Boolean = this.isLight,
) = ElementColors( ) = ElementColors(
@ -65,6 +70,7 @@ class ElementColors(
messageHighlightedBackground = messageHighlightedBackground, messageHighlightedBackground = messageHighlightedBackground,
quaternary = quaternary, quaternary = quaternary,
quinary = quinary, quinary = quinary,
gray400 = gray400,
textActionCritical = textActionCritical, textActionCritical = textActionCritical,
isLight = isLight, isLight = isLight,
) )
@ -75,6 +81,7 @@ class ElementColors(
messageHighlightedBackground = other.messageHighlightedBackground messageHighlightedBackground = other.messageHighlightedBackground
quaternary = other.quaternary quaternary = other.quaternary
quinary = other.quinary quinary = other.quinary
gray400 = other.gray400
textActionCritical = other.textActionCritical textActionCritical = other.textActionCritical
isLight = other.isLight isLight = other.isLight
} }

View file

@ -56,6 +56,7 @@ dependencies {
implementation(projects.libraries.dateformatter.impl) implementation(projects.libraries.dateformatter.impl)
implementation(projects.features.roomlist.impl) implementation(projects.features.roomlist.impl)
implementation(projects.features.login.impl) implementation(projects.features.login.impl)
implementation(projects.features.networkmonitor.impl)
implementation(libs.coroutines.core) implementation(libs.coroutines.core)
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3")
} }

View file

@ -20,6 +20,7 @@ import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl
import io.element.android.features.roomlist.impl.DefaultRoomLastMessageFormatter import io.element.android.features.roomlist.impl.DefaultRoomLastMessageFormatter
import io.element.android.features.roomlist.impl.RoomListPresenter import io.element.android.features.roomlist.impl.RoomListPresenter
import io.element.android.features.roomlist.impl.RoomListView import io.element.android.features.roomlist.impl.RoomListView
@ -45,11 +46,12 @@ class RoomListScreen(
private val dateFormatters = DateFormatters(locale, clock, timeZone) private val dateFormatters = DateFormatters(locale, clock, timeZone)
private val sessionVerificationService = matrixClient.sessionVerificationService() private val sessionVerificationService = matrixClient.sessionVerificationService()
private val presenter = RoomListPresenter( private val presenter = RoomListPresenter(
matrixClient, client = matrixClient,
DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters), lastMessageTimestampFormatter = DefaultLastMessageTimestampFormatter(dateTimeProvider, dateFormatters),
DefaultRoomLastMessageFormatter(context, matrixClient), roomLastMessageFormatter = DefaultRoomLastMessageFormatter(context, matrixClient),
sessionVerificationService, sessionVerificationService = sessionVerificationService,
SnackbarDispatcher(), networkMonitor = NetworkMonitorImpl(context),
snackbarDispatcher = SnackbarDispatcher(),
) )
@Composable @Composable

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8b1012920df3d9e6754e43464723139ae3fd0c04a518f761afcd54e25105acd6
size 41437

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:59c0a62bf960d150ee111e88a932e9f5c85a82336a787b10db9f3c157ab13a35
size 40520

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cb089bb34d22666fda661f7a9e95eae8f39a39f6ec3d64d45f6a18cb681cf228
size 39817

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:54d8ec83ac35f79bb5d6a3dce30ba38a7e328e914454c98c2fd7a3a7e4a3fb46
size 39315