fix: video call UI per-peer video activity and layout improvements
- Track per-peer video activity in CallController so each peer's video state is independent (fixes frozen frames and incorrect fallback to phone-call UI when one peer disables video) - Replace single RemoteVideoGrid with PeerVideoGrid that shows video for active peers and avatar/name for inactive peers - Use call type (VIDEO vs VOICE) to determine layout instead of relying solely on isRemoteVideoActive boolean - Remove extra spacing in AddParticipantDialog between search field and user list - Fix group video monitor using separate job to avoid conflict with P2P monitor https://claude.ai/code/session_01EFDCu97SLYp3TCeBSAttAe
This commit is contained in:
@@ -141,6 +141,11 @@ class CallController(
|
|||||||
// Per-peer video activity monitoring for group calls
|
// Per-peer video activity monitoring for group calls
|
||||||
private val perPeerFrameSinks = ConcurrentHashMap<HexKey, VideoSink>()
|
private val perPeerFrameSinks = ConcurrentHashMap<HexKey, VideoSink>()
|
||||||
private val perPeerLastFrameTimeMs = ConcurrentHashMap<HexKey, AtomicLong>()
|
private val perPeerLastFrameTimeMs = ConcurrentHashMap<HexKey, AtomicLong>()
|
||||||
|
private var groupVideoMonitorJob: kotlinx.coroutines.Job? = null
|
||||||
|
|
||||||
|
// Set of peer pubkeys that are actively sending video frames
|
||||||
|
private val _activePeerVideos = MutableStateFlow<Set<HexKey>>(emptySet())
|
||||||
|
val activePeerVideos: StateFlow<Set<HexKey>> = _activePeerVideos.asStateFlow()
|
||||||
|
|
||||||
private val _isAudioMuted = MutableStateFlow(false)
|
private val _isAudioMuted = MutableStateFlow(false)
|
||||||
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
|
val isAudioMuted: StateFlow<Boolean> = _isAudioMuted.asStateFlow()
|
||||||
@@ -762,13 +767,20 @@ class CallController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureGroupVideoMonitorRunning() {
|
private fun ensureGroupVideoMonitorRunning() {
|
||||||
if (remoteVideoMonitorJob != null) return
|
if (groupVideoMonitorJob != null) return
|
||||||
remoteVideoMonitorJob =
|
groupVideoMonitorJob =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(1500)
|
delay(1500)
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val anyActive = perPeerLastFrameTimeMs.values.any { now - it.get() < 2000 }
|
val activePeers = mutableSetOf<HexKey>()
|
||||||
|
for ((peerKey, lastFrame) in perPeerLastFrameTimeMs) {
|
||||||
|
if (now - lastFrame.get() < 2000) {
|
||||||
|
activePeers.add(peerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_activePeerVideos.value = activePeers
|
||||||
|
val anyActive = activePeers.isNotEmpty()
|
||||||
_isRemoteVideoActive.value = anyActive || (now - lastRemoteFrameTimeMs.get() < 2000)
|
_isRemoteVideoActive.value = anyActive || (now - lastRemoteFrameTimeMs.get() < 2000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -925,6 +937,7 @@ class CallController(
|
|||||||
_isVideoEnabled.value = false
|
_isVideoEnabled.value = false
|
||||||
_isRemoteVideoActive.value = false
|
_isRemoteVideoActive.value = false
|
||||||
_remoteVideoAspectRatio.value = null
|
_remoteVideoAspectRatio.value = null
|
||||||
|
_activePeerVideos.value = emptySet()
|
||||||
videoPausedByProximity = false
|
videoPausedByProximity = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -947,6 +960,8 @@ class CallController(
|
|||||||
private fun stopRemoteVideoMonitor() {
|
private fun stopRemoteVideoMonitor() {
|
||||||
remoteVideoMonitorJob?.cancel()
|
remoteVideoMonitorJob?.cancel()
|
||||||
remoteVideoMonitorJob = null
|
remoteVideoMonitorJob = null
|
||||||
|
groupVideoMonitorJob?.cancel()
|
||||||
|
groupVideoMonitorJob = null
|
||||||
try {
|
try {
|
||||||
_remoteVideoTrack.value?.removeSink(remoteFrameSink)
|
_remoteVideoTrack.value?.removeSink(remoteFrameSink)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -401,7 +401,9 @@ private fun ConnectedCallUI(
|
|||||||
|
|
||||||
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
|
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
|
||||||
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
|
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
|
||||||
|
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
|
||||||
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
|
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
|
||||||
|
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
|
||||||
val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState()
|
val localVideoTrack by (callController?.localVideoTrack ?: emptyVideoFlow).collectAsState()
|
||||||
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
|
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
|
||||||
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
|
val defaultTrue = remember { kotlinx.coroutines.flow.MutableStateFlow(true) }
|
||||||
@@ -410,6 +412,7 @@ private fun ConnectedCallUI(
|
|||||||
val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
|
val isAudioMuted by (callController?.isAudioMuted ?: defaultFalse).collectAsState()
|
||||||
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
|
val isVideoEnabled by (callController?.isVideoEnabled ?: defaultTrue).collectAsState()
|
||||||
val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState()
|
val currentAudioRoute by (callController?.audioRoute ?: defaultRoute).collectAsState()
|
||||||
|
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
|
||||||
|
|
||||||
var showAddParticipant by remember { mutableStateOf(false) }
|
var showAddParticipant by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -431,38 +434,65 @@ private fun ConnectedCallUI(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black),
|
.background(Color.Black),
|
||||||
) {
|
) {
|
||||||
// Remote video(s) — render all peers in a grid when actively sending
|
|
||||||
if (isRemoteVideoActive && remoteVideoTracks.isNotEmpty()) {
|
|
||||||
RemoteVideoGrid(
|
|
||||||
remoteVideoTracks = remoteVideoTracks,
|
|
||||||
eglBase = callController?.getEglBase(),
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Local video (small pip in corner) — only when camera is active
|
|
||||||
if (isVideoEnabled) {
|
|
||||||
localVideoTrack?.let { track ->
|
|
||||||
VideoRenderer(
|
|
||||||
videoTrack = track,
|
|
||||||
eglBase = callController?.getEglBase(),
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.size(120.dp, 160.dp)
|
|
||||||
.align(Alignment.TopEnd)
|
|
||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
|
||||||
.padding(16.dp),
|
|
||||||
mirror = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no video or peer stopped sharing, show avatar
|
|
||||||
val otherMembers =
|
val otherMembers =
|
||||||
remember(state.allPeerPubKeys) {
|
remember(state.allPeerPubKeys) {
|
||||||
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
|
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
|
||||||
}
|
}
|
||||||
if (!isRemoteVideoActive) {
|
|
||||||
|
if (isVideoCall) {
|
||||||
|
// Video call: always show the peer grid with video for active peers, avatar for inactive
|
||||||
|
PeerVideoGrid(
|
||||||
|
peerPubKeys = otherMembers,
|
||||||
|
remoteVideoTracks = remoteVideoTracks,
|
||||||
|
activePeerVideos = activePeerVideos,
|
||||||
|
eglBase = callController?.getEglBase(),
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Local video (small pip in corner) — only when camera is active
|
||||||
|
if (isVideoEnabled) {
|
||||||
|
localVideoTrack?.let { track ->
|
||||||
|
VideoRenderer(
|
||||||
|
videoTrack = track,
|
||||||
|
eglBase = callController?.getEglBase(),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.size(120.dp, 160.dp)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
.padding(16.dp),
|
||||||
|
mirror = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timer overlay
|
||||||
|
Text(
|
||||||
|
text = formatDuration(elapsed),
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
.padding(top = 16.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.pendingPeerPubKeys.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringRes(R.string.call_waiting_for_others),
|
||||||
|
color = Color.White.copy(alpha = 0.5f),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.windowInsetsPadding(WindowInsets.statusBars)
|
||||||
|
.padding(top = 38.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Voice call: show avatars and names
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -494,18 +524,6 @@ private fun ConnectedCallUI(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Timer overlay
|
|
||||||
Text(
|
|
||||||
text = formatDuration(elapsed),
|
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.windowInsetsPadding(WindowInsets.statusBars)
|
|
||||||
.padding(top = 16.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Controls at bottom
|
// Controls at bottom
|
||||||
@@ -602,43 +620,62 @@ private fun ConnectedCallUI(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RemoteVideoGrid(
|
private fun PeerVideoGrid(
|
||||||
|
peerPubKeys: Set<String>,
|
||||||
remoteVideoTracks: Map<String, VideoTrack>,
|
remoteVideoTracks: Map<String, VideoTrack>,
|
||||||
|
activePeerVideos: Set<String>,
|
||||||
eglBase: org.webrtc.EglBase?,
|
eglBase: org.webrtc.EglBase?,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val tracks = remember(remoteVideoTracks) { remoteVideoTracks.entries.toList() }
|
val peers = remember(peerPubKeys) { peerPubKeys.toList() }
|
||||||
|
|
||||||
if (tracks.size == 1) {
|
if (peers.size == 1) {
|
||||||
// Single peer: full screen
|
val peerKey = peers[0]
|
||||||
VideoRenderer(
|
val track = remoteVideoTracks[peerKey]
|
||||||
videoTrack = tracks[0].value,
|
if (track != null && peerKey in activePeerVideos) {
|
||||||
eglBase = eglBase,
|
VideoRenderer(
|
||||||
modifier = modifier,
|
videoTrack = track,
|
||||||
mirror = false,
|
eglBase = eglBase,
|
||||||
)
|
modifier = modifier,
|
||||||
|
mirror = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PeerAvatarCell(
|
||||||
|
peerPubKey = peerKey,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
val columns =
|
val columns =
|
||||||
when {
|
when {
|
||||||
tracks.size <= 2 -> 1
|
peers.size <= 2 -> 1
|
||||||
tracks.size <= 4 -> 2
|
|
||||||
else -> 2
|
else -> 2
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
tracks.chunked(columns).forEach { row ->
|
peers.chunked(columns).forEach { row ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
row.forEach { (_, track) ->
|
row.forEach { peerKey ->
|
||||||
VideoRenderer(
|
val track = remoteVideoTracks[peerKey]
|
||||||
videoTrack = track,
|
if (track != null && peerKey in activePeerVideos) {
|
||||||
eglBase = eglBase,
|
VideoRenderer(
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
videoTrack = track,
|
||||||
mirror = false,
|
eglBase = eglBase,
|
||||||
)
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
mirror = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PeerAvatarCell(
|
||||||
|
peerPubKey = peerKey,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Fill empty cells in the last row
|
|
||||||
repeat(columns - row.size) {
|
repeat(columns - row.size) {
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
@@ -648,6 +685,40 @@ private fun RemoteVideoGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PeerAvatarCell(
|
||||||
|
peerPubKey: String,
|
||||||
|
accountViewModel: AccountViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier.background(Color.DarkGray),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
LoadUser(baseUserHex = peerPubKey, accountViewModel = accountViewModel) { user ->
|
||||||
|
if (user != null) {
|
||||||
|
ClickableUserPicture(
|
||||||
|
baseUser = user,
|
||||||
|
size = 80.dp,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
UsernameDisplay(
|
||||||
|
baseUser = user,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textColor = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VideoRenderer(
|
private fun VideoRenderer(
|
||||||
videoTrack: VideoTrack,
|
videoTrack: VideoTrack,
|
||||||
@@ -725,10 +796,18 @@ private fun PipConnectedCallUI(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val emptyVideoFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<VideoTrack?>(null) }
|
val emptyTracksFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Map<String, VideoTrack>>(emptyMap()) }
|
||||||
val remoteVideoTrack by (callController?.remoteVideoTrack ?: emptyVideoFlow).collectAsState()
|
val emptySetFlow = remember { kotlinx.coroutines.flow.MutableStateFlow<Set<String>>(emptySet()) }
|
||||||
|
val remoteVideoTracks by (callController?.remoteVideoTracks ?: emptyTracksFlow).collectAsState()
|
||||||
|
val activePeerVideos by (callController?.activePeerVideos ?: emptySetFlow).collectAsState()
|
||||||
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
|
val defaultFalse = remember { kotlinx.coroutines.flow.MutableStateFlow(false) }
|
||||||
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
|
val isRemoteVideoActive by (callController?.isRemoteVideoActive ?: defaultFalse).collectAsState()
|
||||||
|
val isVideoCall = state.callType == com.vitorpamplona.quartz.nipACWebRtcCalls.tags.CallType.VIDEO
|
||||||
|
|
||||||
|
val otherMembers =
|
||||||
|
remember(state.allPeerPubKeys) {
|
||||||
|
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
|
||||||
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
@@ -736,24 +815,31 @@ private fun PipConnectedCallUI(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black),
|
.background(Color.Black),
|
||||||
) {
|
) {
|
||||||
// Remote video full screen in PiP
|
if (isVideoCall) {
|
||||||
if (isRemoteVideoActive) {
|
// Video call: show first active peer's video or avatar
|
||||||
remoteVideoTrack?.let { track ->
|
val firstActivePeer = otherMembers.firstOrNull { it in activePeerVideos }
|
||||||
|
val activeTrack = firstActivePeer?.let { remoteVideoTracks[it] }
|
||||||
|
if (activeTrack != null) {
|
||||||
VideoRenderer(
|
VideoRenderer(
|
||||||
videoTrack = track,
|
videoTrack = activeTrack,
|
||||||
eglBase = callController?.getEglBase(),
|
eglBase = callController?.getEglBase(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
mirror = false,
|
mirror = false,
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
GroupCallPictures(
|
||||||
|
peerPubKeys = otherMembers,
|
||||||
|
size = 48.dp,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if (!isRemoteVideoActive) {
|
||||||
|
|
||||||
val otherMembers =
|
|
||||||
remember(state.allPeerPubKeys) {
|
|
||||||
state.allPeerPubKeys - accountViewModel.account.signer.pubKey
|
|
||||||
}
|
|
||||||
if (!isRemoteVideoActive) {
|
|
||||||
// Show small avatar + timer in PiP
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
@@ -771,18 +857,18 @@ private fun PipConnectedCallUI(
|
|||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Timer overlay
|
|
||||||
Text(
|
|
||||||
text = formatDuration(elapsed),
|
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
|
||||||
fontSize = 10.sp,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.padding(top = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timer overlay
|
||||||
|
Text(
|
||||||
|
text = formatDuration(elapsed),
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 4.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,7 +900,6 @@ private fun AddParticipantDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Box(modifier = Modifier.height(300.dp)) {
|
Box(modifier = Modifier.height(300.dp)) {
|
||||||
ShowUserSuggestionList(
|
ShowUserSuggestionList(
|
||||||
userSuggestions = userSuggestions,
|
userSuggestions = userSuggestions,
|
||||||
|
|||||||
Reference in New Issue
Block a user