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_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
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 ->
@@ -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() {
context.dataStore.edit { it.clear() }
}

View File

@@ -42,6 +42,7 @@ fun MainScreen(
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState()
val channelSections by viewModel.channelSections.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
BackHandler(enabled = selectedChannel != null && !showChannelList) {
@@ -124,9 +125,11 @@ fun MainScreen(
onLogout()
},
contentPadding = padding,
listState = listState,
isReorderMode = isReorderMode,
onToggleReorderMode = { viewModel.toggleReorderMode() },
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.Job
import kotlinx.coroutines.async
import androidx.compose.foundation.lazy.LazyListState
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
@@ -114,10 +116,13 @@ class MainViewModel(
private val _channelName = MutableStateFlow<String?>(null)
val channelName: StateFlow<String?> = _channelName
val channelListState = LazyListState()
private val _isReorderMode = MutableStateFlow(false)
val isReorderMode: StateFlow<Boolean> = _isReorderMode
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 _spaceChildren = MutableStateFlow<Set<String>?>(emptySet())
@@ -175,6 +180,9 @@ class MainViewModel(
viewModelScope.launch {
preferencesManager.channelOrder.collect { _channelOrderMap.value = it }
}
viewModelScope.launch {
preferencesManager.childSpaceOrder.collect { _childSpaceOrderMap.value = it }
}
}
private fun loadRooms() {
@@ -289,7 +297,8 @@ class MainViewModel(
combine(
combine(_allChannelRooms, _spaceChildren, _selectedSpace) { 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
else allChannels.filter { it.id in children }
val withUnread = filtered.map { ch ->
@@ -303,7 +312,14 @@ class MainViewModel(
} else withUnread
// 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()) {
// Home view or space with no child spaces — single flat section
listOf(ChannelSection(channels = sorted))
@@ -393,59 +409,66 @@ class MainViewModel(
val cached = messageCache.getOrPut(roomId) { mutableListOf() }
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
timelineListenerHandle = timeline.addListener(object : TimelineListener {
override fun onUpdate(diff: List<TimelineDiff>) {
viewModelScope.launch(Dispatchers.Default) {
var dirty = false
mutex.withLock {
for (d in diff) {
when (d) {
is TimelineDiff.Reset -> {
// Incrementally merge rather than full replace to avoid UI flash
val newIds = mutableSetOf<String>()
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
sdkItems.clear()
sdkItems.addAll(d.values)
}
newIds.add(eid)
if (eid !in ids) {
processEventItem(roomId, eventItem, cached, ids)
dirty = true
is TimelineDiff.Clear -> sdkItems.clear()
is TimelineDiff.Append -> sdkItems.addAll(d.values)
is TimelineDiff.PushBack -> sdkItems.add(d.value)
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()
ids.clear()
dirty = true
}
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) {
for (item in sdkItems) {
val eventItem = item.asEvent() ?: continue
if (processEventItem(roomId, eventItem, cached, ids)) dirty = true
processEventItem(roomId, eventItem, cached, ids)
}
}
}
}
if (dirty && _selectedChannel.value == roomId) {
if (_selectedChannel.value == roomId) {
_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.TransactionId -> eot.transactionId
}
if (eventId in ids) return false
val content = eventItem.content
@@ -516,7 +540,7 @@ class MainViewModel(
)
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 {
msg.timestamp.compareTo(it.timestamp)
}.let { if (it < 0) -(it + 1) else it }
@@ -657,20 +681,16 @@ class MainViewModel(
}
fun sendMessage(body: String) {
val roomId = _selectedChannel.value ?: return
val client = authRepository.getClient() ?: return
val timeline = activeTimeline ?: return
viewModelScope.launch {
try {
val room = client.getRoom(roomId) ?: return@launch
val timeline = room.timeline()
timeline.send(messageEventContentFromMarkdown(body))
} catch (_: Exception) { }
}
}
fun sendFile(uri: Uri) {
val roomId = _selectedChannel.value ?: return
val client = authRepository.getClient() ?: return
val timeline = activeTimeline ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
val contentResolver = application.contentResolver
@@ -682,9 +702,6 @@ class MainViewModel(
} else null
} ?: "file"
val room = client.getRoom(roomId) ?: return@launch
val timeline = room.timeline()
// Copy file to a temp path
val tempFile = java.io.File(application.cacheDir, "upload_$fileName")
contentResolver.openInputStream(uri)?.use { input ->
@@ -986,8 +1003,10 @@ class MainViewModel(
}
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
// Marker goes at the last unread message, divider renders above it
_unreadMarkerIndex.value = if (unreadCount > 0) unreadCount - 1 else -1
_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() {
viewModelScope.launch {
try { syncService?.stop() } catch (_: Exception) { }

View File

@@ -70,17 +70,22 @@ fun ChannelList(
onLogout: () -> Unit,
onDismiss: () -> Unit = {},
contentPadding: PaddingValues,
listState: androidx.compose.foundation.lazy.LazyListState,
isReorderMode: Boolean = false,
onToggleReorderMode: () -> Unit = {},
onMoveChannel: (from: Int, to: Int) -> Unit = { _, _ -> },
onMoveChildSpace: (from: Int, to: Int) -> Unit = { _, _ -> },
) {
var showLogoutDialog by remember { mutableStateOf(false) }
val collapsedSections = remember { mutableStateMapOf<String, Boolean>() }
// Drag state
// Channel drag state
var draggingIndex by remember { mutableIntStateOf(-1) }
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) {
AlertDialog(
@@ -157,12 +162,20 @@ fun ChannelList(
modifier = Modifier.weight(1f),
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()) {
// 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) {
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(
modifier = Modifier.padding(horizontal = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f),
@@ -171,11 +184,58 @@ fun ChannelList(
Row(
modifier = Modifier
.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))
.clickable { collapsedSections[section.spaceId] = !isCollapsed }
.padding(horizontal = 12.dp, vertical = 10.dp),
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(
imageVector = if (isCollapsed) Icons.Default.ExpandMore else Icons.Default.ExpandLess,
contentDescription = if (isCollapsed) "Expand" else "Collapse",

View File

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