looking good

This commit is contained in:
2026-02-24 13:34:56 +00:00
parent b159bf6a56
commit 6cdcbbfd4c
4 changed files with 393 additions and 112 deletions

View File

@@ -1,8 +1,10 @@
package com.example.fluffytrix.ui.screens.main package com.example.fluffytrix.ui.screens.main
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -13,6 +15,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.example.fluffytrix.ui.screens.main.components.ChannelList import com.example.fluffytrix.ui.screens.main.components.ChannelList
@@ -37,9 +40,34 @@ fun MainScreen(
val channelName by viewModel.channelName.collectAsState() val channelName by viewModel.channelName.collectAsState()
val isReorderMode by viewModel.isReorderMode.collectAsState() val isReorderMode by viewModel.isReorderMode.collectAsState()
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState() val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState()
val channelSections by viewModel.channelSections.collectAsState()
val unreadMarkerIndex by viewModel.unreadMarkerIndex.collectAsState()
// Back button opens channel list when in a chat, or does nothing if already open
BackHandler(enabled = selectedChannel != null && !showChannelList) {
viewModel.toggleChannelList()
}
Scaffold { padding -> Scaffold { padding ->
Box(modifier = Modifier.fillMaxSize()) { Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(showChannelList) {
if (!showChannelList) {
var totalDrag = 0f
detectHorizontalDragGestures(
onDragStart = { totalDrag = 0f },
onHorizontalDrag = { _, dragAmount ->
totalDrag += dragAmount
if (totalDrag > 60f) {
viewModel.toggleChannelList()
totalDrag = 0f
}
},
)
}
},
) {
// Main content: SpaceList + chat + members // Main content: SpaceList + chat + members
Row(modifier = Modifier.fillMaxSize()) { Row(modifier = Modifier.fillMaxSize()) {
SpaceList( SpaceList(
@@ -60,6 +88,7 @@ fun MainScreen(
onSendMessage = { viewModel.sendMessage(it) }, onSendMessage = { viewModel.sendMessage(it) },
onSendFile = { viewModel.sendFile(it) }, onSendFile = { viewModel.sendFile(it) },
onLoadMore = { viewModel.loadMoreMessages() }, onLoadMore = { viewModel.loadMoreMessages() },
unreadMarkerIndex = unreadMarkerIndex,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = padding, contentPadding = padding,
) )
@@ -83,11 +112,13 @@ fun MainScreen(
Spacer(modifier = Modifier.width(64.dp)) Spacer(modifier = Modifier.width(64.dp))
ChannelList( ChannelList(
channels = channels, channels = channels,
sections = channelSections,
selectedChannel = selectedChannel, selectedChannel = selectedChannel,
onChannelClick = { onChannelClick = {
viewModel.selectChannel(it) viewModel.selectChannel(it)
viewModel.toggleChannelList() viewModel.toggleChannelList()
}, },
onDismiss = { viewModel.toggleChannelList() },
onLogout = { onLogout = {
viewModel.logout() viewModel.logout()
onLogout() onLogout()

View File

@@ -69,6 +69,12 @@ data class MessageItem(
val timestamp: Long, val timestamp: Long,
) )
data class ChannelSection(
val spaceId: String? = null,
val spaceName: String? = null,
val channels: List<ChannelItem> = emptyList(),
)
data class MemberItem( data class MemberItem(
val userId: String, val userId: String,
val displayName: String, val displayName: String,
@@ -120,11 +126,21 @@ class MainViewModel(
private var cachedOrphanIds: Set<String>? = null private var cachedOrphanIds: Set<String>? = null
private val _roomUnreadStatus = MutableStateFlow<Map<String, UnreadStatus>>(emptyMap()) private val _roomUnreadStatus = MutableStateFlow<Map<String, UnreadStatus>>(emptyMap())
private val _roomUnreadCount = MutableStateFlow<Map<String, ULong>>(emptyMap())
private val _unreadMarkerIndex = MutableStateFlow(-1)
val unreadMarkerIndex: StateFlow<Int> = _unreadMarkerIndex
private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE) private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE)
val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus val homeUnreadStatus: StateFlow<UnreadStatus> = _homeUnreadStatus
private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap()) private val _spaceChildrenMap = MutableStateFlow<Map<String, Set<String>>>(emptyMap())
private val _channelSections = MutableStateFlow<List<ChannelSection>>(emptyList())
val channelSections: StateFlow<List<ChannelSection>> = _channelSections
// Maps spaceId -> list of (childSpaceId, childSpaceName, Set<roomIds>)
private val _directChildSpaces = MutableStateFlow<Map<String, List<Triple<String, String, Set<String>>>>>(emptyMap())
// Per-room caches // Per-room caches
private val messageCache = mutableMapOf<String, MutableList<MessageItem>>() private val messageCache = mutableMapOf<String, MutableList<MessageItem>>()
private val messageIds = mutableMapOf<String, MutableSet<String>>() private val messageIds = mutableMapOf<String, MutableSet<String>>()
@@ -188,6 +204,7 @@ class MainViewModel(
} }
val syncUnread = mutableMapOf<String, UnreadStatus>() val syncUnread = mutableMapOf<String, UnreadStatus>()
val syncUnreadCount = mutableMapOf<String, ULong>()
for (room in joinedRooms) { for (room in joinedRooms) {
val roomId = room.id() val roomId = room.id()
if (roomId == _selectedChannel.value) continue if (roomId == _selectedChannel.value) continue
@@ -203,9 +220,12 @@ class MainViewModel(
} }
if (highlight > 0uL || unreadMentions > 0uL) syncUnread[roomId] = UnreadStatus.MENTIONED if (highlight > 0uL || unreadMentions > 0uL) syncUnread[roomId] = UnreadStatus.MENTIONED
else if (notification > 0uL || unreadNotif > 0uL || unread > 0uL) syncUnread[roomId] = UnreadStatus.UNREAD else if (notification > 0uL || unreadNotif > 0uL || unread > 0uL) syncUnread[roomId] = UnreadStatus.UNREAD
if (unread > 0uL) syncUnreadCount[roomId] = unread
else if (notification > 0uL) syncUnreadCount[roomId] = notification
} catch (_: Exception) { } } catch (_: Exception) { }
} }
_roomUnreadStatus.value = syncUnread _roomUnreadStatus.value = syncUnread
_roomUnreadCount.value = syncUnreadCount
// Rebuild spaces list only when the set of space IDs changes // Rebuild spaces list only when the set of space IDs changes
try { try {
@@ -266,13 +286,10 @@ class MainViewModel(
private fun observeSpaceFiltering() { private fun observeSpaceFiltering() {
viewModelScope.launch { viewModelScope.launch {
combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap, _roomUnreadStatus) { args -> combine(
@Suppress("UNCHECKED_CAST") combine(_allChannelRooms, _spaceChildren, _selectedSpace) { a, b, c -> Triple(a, b, c) },
val allChannels = args[0] as List<ChannelItem> combine(_channelOrderMap, _roomUnreadStatus, _directChildSpaces) { a, b, c -> Triple(a, b, c) },
val children = args[1] as Set<String>? ) { (allChannels, children, spaceId), (orderMap, unreadMap, directChildren) ->
val spaceId = args[2] as String?
val orderMap = args[3] as Map<String, List<String>>
val unreadMap = args[4] as Map<String, UnreadStatus>
val filtered = if (children == null) allChannels val filtered = if (children == null) allChannels
else allChannels.filter { it.id in children } else allChannels.filter { it.id in children }
val withUnread = filtered.map { ch -> val withUnread = filtered.map { ch ->
@@ -280,11 +297,47 @@ class MainViewModel(
if (status != ch.unreadStatus) ch.copy(unreadStatus = status) else ch if (status != ch.unreadStatus) ch.copy(unreadStatus = status) else ch
} }
val savedOrder = spaceId?.let { orderMap[it] } val savedOrder = spaceId?.let { orderMap[it] }
if (savedOrder != null) { val sorted = if (savedOrder != null) {
val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i } val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i }
withUnread.sortedBy { indexMap[it.id] ?: Int.MAX_VALUE } withUnread.sortedBy { indexMap[it.id] ?: Int.MAX_VALUE }
} else withUnread } else withUnread
}.collect { _channels.value = it }
// Build sections
val childSpaces = spaceId?.let { directChildren[it] }
val sections = if (childSpaces.isNullOrEmpty()) {
// Home view or space with no child spaces — single flat section
listOf(ChannelSection(channels = sorted))
} else {
val channelMap = sorted.associateBy { it.id }
val usedIds = mutableSetOf<String>()
val sectionList = mutableListOf<ChannelSection>()
// Direct rooms (not in any child space)
val childSpaceRoomIds = childSpaces.flatMap { it.third }.toSet()
val directRooms = sorted.filter { it.id !in childSpaceRoomIds }
if (directRooms.isNotEmpty()) {
sectionList.add(ChannelSection(channels = directRooms))
usedIds.addAll(directRooms.map { it.id })
}
// Child space sections
for ((csId, csName, csRoomIds) in childSpaces) {
val sectionChannels = csRoomIds.mapNotNull { channelMap[it] }
.filter { it.id !in usedIds }
if (sectionChannels.isNotEmpty()) {
sectionList.add(ChannelSection(spaceId = csId, spaceName = csName, channels = sectionChannels))
usedIds.addAll(sectionChannels.map { it.id })
}
}
sectionList
}
sorted to sections
}.collect { (sorted, sections) ->
_channels.value = sorted
_channelSections.value = sections
}
} }
} }
@@ -729,31 +782,62 @@ class MainViewModel(
try { try {
val spaceService = client.spaceService() val spaceService = client.spaceService()
val map = mutableMapOf<String, Set<String>>() val map = mutableMapOf<String, Set<String>>()
val directMap = mutableMapOf<String, List<Triple<String, String, Set<String>>>>()
for (space in _spaces.value) { for (space in _spaces.value) {
if (space.id in _spaceChildrenMap.value) continue if (space.id in _spaceChildrenMap.value) continue
val allRoomIds = mutableSetOf<String>() val allRoomIds = mutableSetOf<String>()
val childSpaceIds = mutableSetOf<String>() val childSpaceIds = mutableSetOf<String>()
val directChildSpaceList = mutableListOf<Pair<String, String>>() // id, name
val visited = mutableSetOf(space.id) val visited = mutableSetOf(space.id)
var currentLevel = listOf(space.id) var currentLevel = listOf(space.id)
var isFirstLevel = true
while (currentLevel.isNotEmpty()) { while (currentLevel.isNotEmpty()) {
val results = currentLevel.map { sid -> val results = currentLevel.map { sid ->
async { paginateSpaceFully(spaceService, sid) } async { sid to paginateSpaceFully(spaceService, sid) }
}.awaitAll() }.awaitAll()
val nextLevel = mutableListOf<String>() val nextLevel = mutableListOf<String>()
for (children in results) { for ((parentSid, children) in results) {
for (child in children) { for (child in children) {
allRoomIds.add(child.roomId) allRoomIds.add(child.roomId)
if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) { if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
childSpaceIds.add(child.roomId) childSpaceIds.add(child.roomId)
if (visited.add(child.roomId)) nextLevel.add(child.roomId) if (visited.add(child.roomId)) nextLevel.add(child.roomId)
if (isFirstLevel && parentSid == space.id) {
directChildSpaceList.add(child.roomId to (child.displayName ?: child.roomId))
}
} }
} }
} }
currentLevel = nextLevel currentLevel = nextLevel
isFirstLevel = false
} }
map[space.id] = allRoomIds - childSpaceIds map[space.id] = allRoomIds - childSpaceIds
// Resolve rooms for each direct child space
val resolved = directChildSpaceList.map { (csId, csName) ->
val csRoomIds = mutableSetOf<String>()
val csVisited = mutableSetOf(csId)
var csLevel = listOf(csId)
while (csLevel.isNotEmpty()) {
val csResults = csLevel.map { sid ->
async { paginateSpaceFully(spaceService, sid) }
}.awaitAll()
val csNext = mutableListOf<String>()
for (ch in csResults.flatten()) {
if (ch.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
if (csVisited.add(ch.roomId)) csNext.add(ch.roomId)
} else {
csRoomIds.add(ch.roomId)
}
}
csLevel = csNext
}
Triple(csId, csName, csRoomIds as Set<String>)
}
directMap[space.id] = resolved
} }
_spaceChildrenMap.value = _spaceChildrenMap.value + map _spaceChildrenMap.value = _spaceChildrenMap.value + map
_directChildSpaces.value = _directChildSpaces.value + directMap
updateSpaceUnreadStatus() updateSpaceUnreadStatus()
// Recompute orphan rooms now that we have all space children // Recompute orphan rooms now that we have all space children
computeOrphanRoomsFromCache() computeOrphanRoomsFromCache()
@@ -826,6 +910,7 @@ class MainViewModel(
// Use cache if available // Use cache if available
_spaceChildrenMap.value[spaceId]?.let { cached -> _spaceChildrenMap.value[spaceId]?.let { cached ->
_spaceChildren.value = cached _spaceChildren.value = cached
// directChildSpaces is already cached too via the combine in observeSpaceFiltering
return return
} }
val client = authRepository.getClient() ?: return val client = authRepository.getClient() ?: return
@@ -834,25 +919,60 @@ class MainViewModel(
val spaceService = client.spaceService() val spaceService = client.spaceService()
val allRoomIds = mutableSetOf<String>() val allRoomIds = mutableSetOf<String>()
val childSpaceIds = mutableSetOf<String>() val childSpaceIds = mutableSetOf<String>()
// Track direct child spaces of the selected space
val directChildSpaceList = mutableListOf<Triple<String, String, Set<String>>>()
// BFS with parallel loading of sibling spaces // BFS with parallel loading of sibling spaces
val visited = mutableSetOf(spaceId) val visited = mutableSetOf(spaceId)
var currentLevel = listOf(spaceId) var currentLevel = listOf(spaceId)
var isFirstLevel = true
while (currentLevel.isNotEmpty()) { while (currentLevel.isNotEmpty()) {
val results = currentLevel.map { sid -> val results = currentLevel.map { sid ->
async { paginateSpaceFully(spaceService, sid) } async { sid to paginateSpaceFully(spaceService, sid) }
}.awaitAll() }.awaitAll()
val nextLevel = mutableListOf<String>() val nextLevel = mutableListOf<String>()
for (children in results) { for ((parentSid, children) in results) {
for (child in children) { for (child in children) {
allRoomIds.add(child.roomId) allRoomIds.add(child.roomId)
if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) { if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
childSpaceIds.add(child.roomId) childSpaceIds.add(child.roomId)
if (visited.add(child.roomId)) nextLevel.add(child.roomId) if (visited.add(child.roomId)) nextLevel.add(child.roomId)
// Track direct child spaces of the selected space
if (isFirstLevel && parentSid == spaceId) {
// Will fill rooms later
directChildSpaceList.add(Triple(child.roomId, child.displayName ?: child.roomId, emptySet()))
}
} }
} }
} }
currentLevel = nextLevel currentLevel = nextLevel
isFirstLevel = false
} }
// Now resolve rooms for each direct child space (recursively)
val resolvedDirectChildren = directChildSpaceList.map { (csId, csName, _) ->
val csRoomIds = mutableSetOf<String>()
val csVisited = mutableSetOf(csId)
var csLevel = listOf(csId)
while (csLevel.isNotEmpty()) {
val csResults = csLevel.map { sid ->
async { paginateSpaceFully(spaceService, sid) }
}.awaitAll()
val csNext = mutableListOf<String>()
for (ch in csResults.flatten()) {
if (ch.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) {
if (csVisited.add(ch.roomId)) csNext.add(ch.roomId)
} else {
csRoomIds.add(ch.roomId)
}
}
csLevel = csNext
}
Triple(csId, csName, csRoomIds as Set<String>)
}
_directChildSpaces.value = _directChildSpaces.value + (spaceId to resolvedDirectChildren)
// Only keep actual rooms, not child spaces // Only keep actual rooms, not child spaces
val roomIds = allRoomIds - childSpaceIds val roomIds = allRoomIds - childSpaceIds
_spaceChildren.value = roomIds _spaceChildren.value = roomIds
@@ -866,11 +986,31 @@ class MainViewModel(
} }
fun selectChannel(channelId: String) { fun selectChannel(channelId: String) {
// Capture unread count before clearing
val unreadCount = _roomUnreadCount.value[channelId]?.toInt() ?: 0
_unreadMarkerIndex.value = if (unreadCount > 0) unreadCount - 1 else -1
_selectedChannel.value = channelId _selectedChannel.value = channelId
if (_roomUnreadStatus.value.containsKey(channelId)) { if (_roomUnreadStatus.value.containsKey(channelId)) {
_roomUnreadStatus.value = _roomUnreadStatus.value - channelId _roomUnreadStatus.value = _roomUnreadStatus.value - channelId
_roomUnreadCount.value = _roomUnreadCount.value - channelId
updateSpaceUnreadStatus() updateSpaceUnreadStatus()
} }
// Send read receipt to the server
sendReadReceipt(channelId)
}
private fun sendReadReceipt(roomId: String) {
val client = authRepository.getClient() ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
val room = client.getRoom(roomId) ?: return@launch
room.markAsRead(org.matrix.rustcomponents.sdk.ReceiptType.READ)
} catch (e: Exception) {
android.util.Log.e("MainVM", "Failed to send read receipt for $roomId", e)
}
}
} }
private fun updateSpaceUnreadStatus() { private fun updateSpaceUnreadStatus() {

View File

@@ -24,10 +24,13 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.SwapVert import androidx.compose.material.icons.filled.SwapVert
import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -37,6 +40,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
@@ -46,26 +50,32 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow import androidx.compose.ui.draw.shadow
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import com.example.fluffytrix.ui.screens.main.ChannelItem import com.example.fluffytrix.ui.screens.main.ChannelItem
import com.example.fluffytrix.ui.screens.main.ChannelSection
import com.example.fluffytrix.ui.screens.main.UnreadStatus import com.example.fluffytrix.ui.screens.main.UnreadStatus
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @Composable
fun ChannelList( fun ChannelList(
channels: List<ChannelItem>, channels: List<ChannelItem>,
sections: List<ChannelSection>,
selectedChannel: String?, selectedChannel: String?,
onChannelClick: (String) -> Unit, onChannelClick: (String) -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onDismiss: () -> Unit = {},
contentPadding: PaddingValues, contentPadding: PaddingValues,
isReorderMode: Boolean = false, isReorderMode: Boolean = false,
onToggleReorderMode: () -> Unit = {}, onToggleReorderMode: () -> Unit = {},
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> }, onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
) { ) {
var showLogoutDialog by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) }
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
// Drag state // Drag state
var draggingIndex by remember { mutableIntStateOf(-1) } var draggingIndex by remember { mutableIntStateOf(-1) }
@@ -92,9 +102,22 @@ fun ChannelList(
Column( Column(
modifier = Modifier modifier = Modifier
.width(240.dp) .fillMaxWidth()
.fillMaxHeight() .fillMaxHeight()
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.pointerInput(Unit) {
var totalDrag = 0f
detectHorizontalDragGestures(
onDragStart = { totalDrag = 0f },
onHorizontalDrag = { _, dragAmount ->
totalDrag += dragAmount
if (totalDrag < -60f) {
onDismiss()
totalDrag = 0f
}
},
)
}
.padding(top = contentPadding.calculateTopPadding()), .padding(top = contentPadding.calculateTopPadding()),
) { ) {
// Header // Header
@@ -133,11 +156,52 @@ fun ChannelList(
state = listState, state = listState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
itemsIndexed(channels, key = { _, ch -> ch.id }) { index, channel -> for ((sectionIdx, section) in sections.withIndex()) {
// Section header for child spaces
if (section.spaceName != null && section.spaceId != null) {
item(key = "section_${section.spaceId}") {
val isCollapsed = collapsedSections[section.spaceId] ?: false
if (sectionIdx > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { collapsedSections[section.spaceId] = !isCollapsed }
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess,
contentDescription = if (isCollapsed) "Expand" else "Collapse",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = section.spaceName.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
val isCollapsed = section.spaceId?.let { collapsedSections[it] ?: false } ?: false
if (!isCollapsed) {
itemsIndexed(section.channels, key = { _, ch -> ch.id }) { index, channel ->
val isSelected = channel.id == selectedChannel val isSelected = channel.id == selectedChannel
val isDragging = draggingIndex == index val hasUnread = channel.unreadStatus != UnreadStatus.NONE
val globalIndex = channels.indexOfFirst { it.id == channel.id }
val isDragging = draggingIndex == globalIndex
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation") val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
Row( Row(
@@ -147,10 +211,10 @@ fun ChannelList(
if (isDragging) Modifier if (isDragging) Modifier
.zIndex(1f) .zIndex(1f)
.offset { IntOffset(0, dragOffsetY.roundToInt()) } .offset { IntOffset(0, dragOffsetY.roundToInt()) }
.shadow(elevation, RoundedCornerShape(4.dp)) .shadow(elevation, RoundedCornerShape(8.dp))
else Modifier else Modifier
) )
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(8.dp))
.background( .background(
when { when {
isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh isDragging -> MaterialTheme.colorScheme.surfaceContainerHigh
@@ -159,11 +223,11 @@ fun ChannelList(
} }
) )
.then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier) .then(if (!isReorderMode) Modifier.clickable { onChannelClick(channel.id) } else Modifier)
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isReorderMode) { if (isReorderMode) {
val currentIndex by rememberUpdatedState(index) val currentGlobalIndex by rememberUpdatedState(globalIndex)
Icon( Icon(
imageVector = Icons.Default.DragHandle, imageVector = Icons.Default.DragHandle,
contentDescription = "Drag to reorder", contentDescription = "Drag to reorder",
@@ -172,13 +236,13 @@ fun ChannelList(
.pointerInput(Unit) { .pointerInput(Unit) {
detectDragGesturesAfterLongPress( detectDragGesturesAfterLongPress(
onDragStart = { onDragStart = {
draggingIndex = currentIndex draggingIndex = currentGlobalIndex
dragOffsetY = 0f dragOffsetY = 0f
}, },
onDrag = { change, dragAmount -> onDrag = { change, dragAmount ->
change.consume() change.consume()
dragOffsetY += dragAmount.y dragOffsetY += dragAmount.y
val itemHeight = 34.dp.toPx() val itemHeight = 40.dp.toPx()
val draggedPositions = (dragOffsetY / itemHeight).roundToInt() val draggedPositions = (dragOffsetY / itemHeight).roundToInt()
val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex) val targetIndex = (draggingIndex + draggedPositions).coerceIn(0, channels.lastIndex)
if (targetIndex != draggingIndex) { if (targetIndex != draggingIndex) {
@@ -199,35 +263,42 @@ fun ChannelList(
}, },
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
} }
if (channel.unreadStatus != UnreadStatus.NONE) { if (hasUnread) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(8.dp)
.background( .background(
if (channel.unreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red if (channel.unreadStatus == UnreadStatus.MENTIONED) MaterialTheme.colorScheme.error
else androidx.compose.ui.graphics.Color.Gray, else MaterialTheme.colorScheme.primary,
androidx.compose.foundation.shape.CircleShape, androidx.compose.foundation.shape.CircleShape,
), ),
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(6.dp))
} else { } else {
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(14.dp))
} }
Icon( Icon(
imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag, imageVector = if (channel.isEncrypted) Icons.Default.Lock else Icons.Default.Tag,
contentDescription = null, contentDescription = null,
modifier = Modifier.height(18.dp), modifier = Modifier.size(20.dp),
tint = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer tint = when {
else MaterialTheme.colorScheme.onSurfaceVariant, isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
hasUnread -> MaterialTheme.colorScheme.onSurface
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
},
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(10.dp))
Text( Text(
text = channel.name, text = channel.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyLarge,
color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer fontWeight = if (hasUnread) FontWeight.SemiBold else FontWeight.Normal,
else MaterialTheme.colorScheme.onSurfaceVariant, color = when {
isSelected -> MaterialTheme.colorScheme.onPrimaryContainer
hasUnread -> MaterialTheme.colorScheme.onSurface
else -> MaterialTheme.colorScheme.onSurfaceVariant
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -236,3 +307,5 @@ fun ChannelList(
} }
} }
} }
}
}

View File

@@ -119,6 +119,7 @@ fun MessageTimeline(
onSendMessage: (String) -> Unit, onSendMessage: (String) -> Unit,
onSendFile: (Uri) -> Unit, onSendFile: (Uri) -> Unit,
onLoadMore: () -> Unit = {}, onLoadMore: () -> Unit = {},
unreadMarkerIndex: Int = -1,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(), contentPadding: PaddingValues = PaddingValues(),
) { ) {
@@ -213,12 +214,48 @@ fun MessageTimeline(
val next = if (index + 1 < count) messages[index + 1] else null val next = if (index + 1 < count) messages[index + 1] else null
val isFirstInGroup = next == null || next.senderName != message.senderName val isFirstInGroup = next == null || next.senderName != message.senderName
// Show "NEW" divider after the last unread message
// In reverse layout, unreadMarkerIndex 0 = newest message
// The divider goes after index unreadMarkerIndex (visually above unread block)
if (index == unreadMarkerIndex) {
Column {
if (isFirstInGroup) { if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
FullMessage(message) FullMessage(message)
} else { } else {
CompactMessage(message.content) CompactMessage(message.content)
} }
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.error.copy(alpha = 0.6f),
)
Text(
text = "NEW",
modifier = Modifier.padding(horizontal = 8.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.error,
)
HorizontalDivider(
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.error.copy(alpha = 0.6f),
)
}
Spacer(modifier = Modifier.height(8.dp))
}
} else {
if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp))
FullMessage(message)
} else {
CompactMessage(message.content)
}
}
} }
} }