gonna do settings now

This commit is contained in:
2026-02-24 17:51:39 +00:00
parent 6cdcbbfd4c
commit 1c8c306652
5 changed files with 177 additions and 62 deletions

View File

@@ -28,6 +28,7 @@ class PreferencesManager(private val context: Context) {
private val KEY_PASSWORD = stringPreferencesKey("password") private val KEY_PASSWORD = stringPreferencesKey("password")
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in") private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order") private val KEY_CHANNEL_ORDER = stringPreferencesKey("channel_order")
private val KEY_CHILD_SPACE_ORDER = stringPreferencesKey("child_space_order")
} }
val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs -> val isLoggedIn: Flow<Boolean> = context.dataStore.data.map { prefs ->
@@ -98,6 +99,24 @@ class PreferencesManager(private val context: Context) {
} }
} }
val childSpaceOrder: Flow<Map<String, List<String>>> = context.dataStore.data.map { prefs ->
val raw = prefs[KEY_CHILD_SPACE_ORDER] ?: return@map emptyMap()
try {
Json.decodeFromString<Map<String, List<String>>>(raw)
} catch (_: Exception) {
emptyMap()
}
}
suspend fun saveChildSpaceOrder(parentSpaceId: String, childSpaceIds: List<String>) {
context.dataStore.edit { prefs ->
val existing = prefs[KEY_CHILD_SPACE_ORDER]?.let {
try { Json.decodeFromString<Map<String, List<String>>>(it) } catch (_: Exception) { emptyMap() }
} ?: emptyMap()
prefs[KEY_CHILD_SPACE_ORDER] = Json.encodeToString(existing + (parentSpaceId to childSpaceIds))
}
}
suspend fun clearSession() { suspend fun clearSession() {
context.dataStore.edit { it.clear() } context.dataStore.edit { it.clear() }
} }

View File

@@ -42,6 +42,7 @@ fun MainScreen(
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState() val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState()
val channelSections by viewModel.channelSections.collectAsState() val channelSections by viewModel.channelSections.collectAsState()
val unreadMarkerIndex by viewModel.unreadMarkerIndex.collectAsState() val unreadMarkerIndex by viewModel.unreadMarkerIndex.collectAsState()
val listState = viewModel.channelListState
// Back button opens channel list when in a chat, or does nothing if already open // Back button opens channel list when in a chat, or does nothing if already open
BackHandler(enabled = selectedChannel != null && !showChannelList) { BackHandler(enabled = selectedChannel != null && !showChannelList) {
@@ -124,9 +125,11 @@ fun MainScreen(
onLogout() onLogout()
}, },
contentPadding = padding, contentPadding = padding,
listState = listState,
isReorderMode = isReorderMode, isReorderMode = isReorderMode,
onToggleReorderMode = { viewModel.toggleReorderMode() }, onToggleReorderMode = { viewModel.toggleReorderMode() },
onMoveChannel = { from, to -> viewModel.moveChannel(from, to) }, onMoveChannel = { from, to -> viewModel.moveChannel(from, to) },
onMoveChildSpace = { from, to -> viewModel.moveChildSpace(from, to) },
) )
} }
} }

View File

@@ -12,7 +12,9 @@ import com.example.fluffytrix.data.repository.AuthRepository
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import androidx.compose.foundation.lazy.LazyListState
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@@ -114,10 +116,13 @@ class MainViewModel(
private val _channelName = MutableStateFlow<String?>(null) private val _channelName = MutableStateFlow<String?>(null)
val channelName: StateFlow<String?> = _channelName val channelName: StateFlow<String?> = _channelName
val channelListState = LazyListState()
private val _isReorderMode = MutableStateFlow(false) private val _isReorderMode = MutableStateFlow(false)
val isReorderMode: StateFlow<Boolean> = _isReorderMode val isReorderMode: StateFlow<Boolean> = _isReorderMode
private val _channelOrderMap = MutableStateFlow<Map<String, List<String>>>(emptyMap()) private val _channelOrderMap = MutableStateFlow<Map<String, List<String>>>(emptyMap())
private val _childSpaceOrderMap = MutableStateFlow<Map<String, List<String>>>(emptyMap())
private val _allChannelRooms = MutableStateFlow<List<ChannelItem>>(emptyList()) private val _allChannelRooms = MutableStateFlow<List<ChannelItem>>(emptyList())
private val _spaceChildren = MutableStateFlow<Set<String>?>(emptySet()) private val _spaceChildren = MutableStateFlow<Set<String>?>(emptySet())
@@ -175,6 +180,9 @@ class MainViewModel(
viewModelScope.launch { viewModelScope.launch {
preferencesManager.channelOrder.collect { _channelOrderMap.value = it } preferencesManager.channelOrder.collect { _channelOrderMap.value = it }
} }
viewModelScope.launch {
preferencesManager.childSpaceOrder.collect { _childSpaceOrderMap.value = it }
}
} }
private fun loadRooms() { private fun loadRooms() {
@@ -289,7 +297,8 @@ class MainViewModel(
combine( combine(
combine(_allChannelRooms, _spaceChildren, _selectedSpace) { a, b, c -> Triple(a, b, c) }, combine(_allChannelRooms, _spaceChildren, _selectedSpace) { a, b, c -> Triple(a, b, c) },
combine(_channelOrderMap, _roomUnreadStatus, _directChildSpaces) { a, b, c -> Triple(a, b, c) }, combine(_channelOrderMap, _roomUnreadStatus, _directChildSpaces) { a, b, c -> Triple(a, b, c) },
) { (allChannels, children, spaceId), (orderMap, unreadMap, directChildren) -> _childSpaceOrderMap,
) { (allChannels, children, spaceId), (orderMap, unreadMap, directChildren), childSpaceOrderMap ->
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 ->
@@ -303,7 +312,14 @@ class MainViewModel(
} else withUnread } else withUnread
// Build sections // Build sections
val childSpaces = spaceId?.let { directChildren[it] } val rawChildSpaces = spaceId?.let { directChildren[it] }
val childSpaces = if (!rawChildSpaces.isNullOrEmpty() && spaceId != null) {
val savedCsOrder = childSpaceOrderMap[spaceId]
if (savedCsOrder != null) {
val csIndexMap = savedCsOrder.withIndex().associate { (i, id) -> id to i }
rawChildSpaces.sortedBy { csIndexMap[it.first] ?: Int.MAX_VALUE }
} else rawChildSpaces
} else rawChildSpaces
val sections = if (childSpaces.isNullOrEmpty()) { val sections = if (childSpaces.isNullOrEmpty()) {
// Home view or space with no child spaces — single flat section // Home view or space with no child spaces — single flat section
listOf(ChannelSection(channels = sorted)) listOf(ChannelSection(channels = sorted))
@@ -393,59 +409,66 @@ class MainViewModel(
val cached = messageCache.getOrPut(roomId) { mutableListOf() } val cached = messageCache.getOrPut(roomId) { mutableListOf() }
val ids = messageIds.getOrPut(roomId) { mutableSetOf() } val ids = messageIds.getOrPut(roomId) { mutableSetOf() }
// SDK timeline items in SDK order — we apply diffs to this, then rebuild our cache
val sdkItems = mutableListOf<org.matrix.rustcomponents.sdk.TimelineItem>()
val mutex = kotlinx.coroutines.sync.Mutex()
// Register listener for live updates — must keep handle to prevent GC // Register listener for live updates — must keep handle to prevent GC
timelineListenerHandle = timeline.addListener(object : TimelineListener { timelineListenerHandle = timeline.addListener(object : TimelineListener {
override fun onUpdate(diff: List<TimelineDiff>) { override fun onUpdate(diff: List<TimelineDiff>) {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
var dirty = false mutex.withLock {
for (d in diff) { for (d in diff) {
when (d) { when (d) {
is TimelineDiff.Reset -> { is TimelineDiff.Reset -> {
// Incrementally merge rather than full replace to avoid UI flash sdkItems.clear()
val newIds = mutableSetOf<String>() sdkItems.addAll(d.values)
for (item in d.values) {
val eventItem = item.asEvent() ?: continue
val eid = when (val eot = eventItem.eventOrTransactionId) {
is EventOrTransactionId.EventId -> eot.eventId
is EventOrTransactionId.TransactionId -> eot.transactionId
} }
newIds.add(eid) is TimelineDiff.Clear -> sdkItems.clear()
if (eid !in ids) { is TimelineDiff.Append -> sdkItems.addAll(d.values)
processEventItem(roomId, eventItem, cached, ids) is TimelineDiff.PushBack -> sdkItems.add(d.value)
dirty = true is TimelineDiff.PushFront -> sdkItems.add(0, d.value)
is TimelineDiff.Insert -> {
val idx = d.index.toInt()
if (idx in 0..sdkItems.size) sdkItems.add(idx, d.value)
}
is TimelineDiff.Set -> {
val idx = d.index.toInt()
if (idx in sdkItems.indices) sdkItems[idx] = d.value
}
is TimelineDiff.Remove -> {
val idx = d.index.toInt()
if (idx in sdkItems.indices) sdkItems.removeAt(idx)
}
is TimelineDiff.Truncate -> {
val len = d.length.toInt()
while (sdkItems.size > len) sdkItems.removeAt(sdkItems.lastIndex)
}
is TimelineDiff.PopBack -> {
if (sdkItems.isNotEmpty()) sdkItems.removeAt(sdkItems.lastIndex)
}
is TimelineDiff.PopFront -> {
if (sdkItems.isNotEmpty()) sdkItems.removeAt(0)
} }
} }
// Remove messages no longer in timeline
val removed = ids.filter { it !in newIds }
if (removed.isNotEmpty()) {
for (rid in removed) ids.remove(rid)
cached.removeAll { it.eventId in removed.toSet() }
dirty = true
} }
}
is TimelineDiff.Clear -> { // Rebuild cache from SDK items
cached.clear() cached.clear()
ids.clear() ids.clear()
dirty = true for (item in sdkItems) {
}
else -> {
val items = when (d) {
is TimelineDiff.Append -> d.values
is TimelineDiff.PushBack -> listOf(d.value)
is TimelineDiff.PushFront -> listOf(d.value)
is TimelineDiff.Insert -> listOf(d.value)
is TimelineDiff.Set -> listOf(d.value)
else -> emptyList()
}
for (item in items) {
val eventItem = item.asEvent() ?: continue val eventItem = item.asEvent() ?: continue
if (processEventItem(roomId, eventItem, cached, ids)) dirty = true processEventItem(roomId, eventItem, cached, ids)
} }
} if (_selectedChannel.value == roomId) {
}
}
if (dirty && _selectedChannel.value == roomId) {
_messages.value = ArrayList(cached) _messages.value = ArrayList(cached)
// Clamp unread marker to valid range — hide if it would be at/beyond the end
val marker = _unreadMarkerIndex.value
if (marker >= 0 && marker >= cached.size - 1) {
_unreadMarkerIndex.value = -1
}
sendReadReceipt(roomId)
}
} }
} }
} }
@@ -467,6 +490,7 @@ class MainViewModel(
is EventOrTransactionId.EventId -> eot.eventId is EventOrTransactionId.EventId -> eot.eventId
is EventOrTransactionId.TransactionId -> eot.transactionId is EventOrTransactionId.TransactionId -> eot.transactionId
} }
if (eventId in ids) return false if (eventId in ids) return false
val content = eventItem.content val content = eventItem.content
@@ -516,7 +540,7 @@ class MainViewModel(
) )
ids.add(eventId) ids.add(eventId)
// Descending order (newest first at index 0) for reverse layout // Descending order (newest at index 0) reverseLayout shows index 0 at bottom
val insertIdx = cached.binarySearch { val insertIdx = cached.binarySearch {
msg.timestamp.compareTo(it.timestamp) msg.timestamp.compareTo(it.timestamp)
}.let { if (it < 0) -(it + 1) else it } }.let { if (it < 0) -(it + 1) else it }
@@ -657,20 +681,16 @@ class MainViewModel(
} }
fun sendMessage(body: String) { fun sendMessage(body: String) {
val roomId = _selectedChannel.value ?: return val timeline = activeTimeline ?: return
val client = authRepository.getClient() ?: return
viewModelScope.launch { viewModelScope.launch {
try { try {
val room = client.getRoom(roomId) ?: return@launch
val timeline = room.timeline()
timeline.send(messageEventContentFromMarkdown(body)) timeline.send(messageEventContentFromMarkdown(body))
} catch (_: Exception) { } } catch (_: Exception) { }
} }
} }
fun sendFile(uri: Uri) { fun sendFile(uri: Uri) {
val roomId = _selectedChannel.value ?: return val timeline = activeTimeline ?: return
val client = authRepository.getClient() ?: return
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val contentResolver = application.contentResolver val contentResolver = application.contentResolver
@@ -682,9 +702,6 @@ class MainViewModel(
} else null } else null
} ?: "file" } ?: "file"
val room = client.getRoom(roomId) ?: return@launch
val timeline = room.timeline()
// Copy file to a temp path // Copy file to a temp path
val tempFile = java.io.File(application.cacheDir, "upload_$fileName") val tempFile = java.io.File(application.cacheDir, "upload_$fileName")
contentResolver.openInputStream(uri)?.use { input -> contentResolver.openInputStream(uri)?.use { input ->
@@ -986,8 +1003,10 @@ class MainViewModel(
} }
fun selectChannel(channelId: String) { fun selectChannel(channelId: String) {
// Capture unread count before clearing // Place unread marker: in descending list (newest=0), marker at index (count-1)
// means it appears visually above the block of unread messages
val unreadCount = _roomUnreadCount.value[channelId]?.toInt() ?: 0 val unreadCount = _roomUnreadCount.value[channelId]?.toInt() ?: 0
// Marker goes at the last unread message, divider renders above it
_unreadMarkerIndex.value = if (unreadCount > 0) unreadCount - 1 else -1 _unreadMarkerIndex.value = if (unreadCount > 0) unreadCount - 1 else -1
_selectedChannel.value = channelId _selectedChannel.value = channelId
@@ -1074,6 +1093,20 @@ class MainViewModel(
} }
} }
fun moveChildSpace(from: Int, to: Int) {
val spaceId = _selectedSpace.value ?: return
val current = _directChildSpaces.value[spaceId]?.toMutableList() ?: return
if (from !in current.indices || to !in current.indices) return
val item = current.removeAt(from)
current.add(to, item)
_directChildSpaces.value = _directChildSpaces.value + (spaceId to current)
val childSpaceIds = current.map { it.first }
_childSpaceOrderMap.value = _childSpaceOrderMap.value + (spaceId to childSpaceIds)
viewModelScope.launch {
preferencesManager.saveChildSpaceOrder(spaceId, childSpaceIds)
}
}
fun logout() { fun logout() {
viewModelScope.launch { viewModelScope.launch {
try { syncService?.stop() } catch (_: Exception) { } try { syncService?.stop() } catch (_: Exception) { }

View File

@@ -70,17 +70,22 @@ fun ChannelList(
onLogout: () -> Unit, onLogout: () -> Unit,
onDismiss: () -> Unit = {}, onDismiss: () -> Unit = {},
contentPadding: PaddingValues, contentPadding: PaddingValues,
listState: androidx.compose.foundation.lazy.LazyListState,
isReorderMode: Boolean = false, isReorderMode: Boolean = false,
onToggleReorderMode: () -> Unit = {}, onToggleReorderMode: () -> Unit = {},
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> }, onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
onMoveChildSpace: (from: Int, to: Int) -> Unit = { _, _ -> },
) { ) {
var showLogoutDialog by remember { mutableStateOf(false) } var showLogoutDialog by remember { mutableStateOf(false) }
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() } val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
// Drag state // Channel drag state
var draggingIndex by remember { mutableIntStateOf(-1) } var draggingIndex by remember { mutableIntStateOf(-1) }
var dragOffsetY by remember { mutableFloatStateOf(0f) } var dragOffsetY by remember { mutableFloatStateOf(0f) }
val listState = rememberLazyListState()
// Section drag state
var draggingSectionIndex by remember { mutableIntStateOf(-1) }
var sectionDragOffsetY by remember { mutableFloatStateOf(0f) }
if (showLogoutDialog) { if (showLogoutDialog) {
AlertDialog( AlertDialog(
@@ -157,12 +162,20 @@ fun ChannelList(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
) { ) {
// Build index of child space sections (excluding the direct-rooms section which has no spaceId)
val childSpaceSections = sections.withIndex().filter { it.value.spaceId != null }
val childSpaceSectionIndexMap = childSpaceSections.withIndex().associate { (csIdx, iv) -> iv.index to csIdx }
for ((sectionIdx, section) in sections.withIndex()) { for ((sectionIdx, section) in sections.withIndex()) {
// Section header for child spaces // Section header for child spaces
if (section.spaceName != null && section.spaceId != null) { if (section.spaceName != null && section.spaceId != null) {
item(key = "section_${section.spaceId}") { item(key = "section_${section.spaceId}") {
val isCollapsed = collapsedSections[section.spaceId] ?: false val isCollapsed = collapsedSections[section.spaceId] ?: false
if (sectionIdx > 0) { val csIndex = childSpaceSectionIndexMap[sectionIdx] ?: -1
val isSectionDragging = draggingSectionIndex == csIndex
val sectionElevation by animateDpAsState(if (isSectionDragging) 8.dp else 0.dp, label = "sectionElevation")
if (sectionIdx > 0 && !isSectionDragging) {
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(horizontal = 4.dp), modifier = Modifier.padding(horizontal = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
@@ -171,11 +184,58 @@ fun ChannelList(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(
if (isSectionDragging) Modifier
.zIndex(1f)
.offset { IntOffset(0, sectionDragOffsetY.roundToInt()) }
.shadow(sectionElevation, RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
else Modifier
)
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable { collapsedSections[section.spaceId] = !isCollapsed } .clickable { collapsedSections[section.spaceId] = !isCollapsed }
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isReorderMode) {
val currentCsIndex by rememberUpdatedState(csIndex)
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "Drag to reorder section",
modifier = Modifier
.size(20.dp)
.pointerInput(Unit) {
detectDragGesturesAfterLongPress(
onDragStart = {
draggingSectionIndex = currentCsIndex
sectionDragOffsetY = 0f
},
onDrag = { change, dragAmount ->
change.consume()
sectionDragOffsetY += dragAmount.y
val itemHeight = 40.dp.toPx()
val draggedPositions = (sectionDragOffsetY / itemHeight).roundToInt()
val targetIndex = (draggingSectionIndex + draggedPositions).coerceIn(0, childSpaceSections.lastIndex)
if (targetIndex != draggingSectionIndex) {
onMoveChildSpace(draggingSectionIndex, targetIndex)
sectionDragOffsetY -= (targetIndex - draggingSectionIndex) * itemHeight
draggingSectionIndex = targetIndex
}
},
onDragEnd = {
draggingSectionIndex = -1
sectionDragOffsetY = 0f
},
onDragCancel = {
draggingSectionIndex = -1
sectionDragOffsetY = 0f
},
)
},
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(6.dp))
}
Icon( Icon(
imageVector = if (isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess, imageVector = if (isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess,
contentDescription = if (isCollapsed) "Expand" else "Collapse", contentDescription = if (isCollapsed) "Expand" else "Collapse",

View File

@@ -219,12 +219,6 @@ fun MessageTimeline(
// The divider goes after index unreadMarkerIndex (visually above unread block) // The divider goes after index unreadMarkerIndex (visually above unread block)
if (index == unreadMarkerIndex) { if (index == unreadMarkerIndex) {
Column { Column {
if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp))
FullMessage(message)
} else {
CompactMessage(message.content)
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@@ -247,6 +241,12 @@ fun MessageTimeline(
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
if (isFirstInGroup) {
Spacer(modifier = Modifier.height(12.dp))
FullMessage(message)
} else {
CompactMessage(message.content)
}
} }
} else { } else {
if (isFirstInGroup) { if (isFirstInGroup) {