From 1c8c306652062dd5755b86e36801bef17f24816b Mon Sep 17 00:00:00 2001 From: mrfluffy Date: Tue, 24 Feb 2026 17:51:39 +0000 Subject: [PATCH] gonna do settings now --- .../data/local/PreferencesManager.kt | 19 +++ .../fluffytrix/ui/screens/main/MainScreen.kt | 3 + .../ui/screens/main/MainViewModel.kt | 139 +++++++++++------- .../ui/screens/main/components/ChannelList.kt | 66 ++++++++- .../main/components/MessageTimeline.kt | 12 +- 5 files changed, 177 insertions(+), 62 deletions(-) diff --git a/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt b/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt index d3603ed..1866171 100644 --- a/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt +++ b/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt @@ -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 = context.dataStore.data.map { prefs -> @@ -98,6 +99,24 @@ class PreferencesManager(private val context: Context) { } } + val childSpaceOrder: Flow>> = context.dataStore.data.map { prefs -> + val raw = prefs[KEY_CHILD_SPACE_ORDER] ?: return@map emptyMap() + try { + Json.decodeFromString>>(raw) + } catch (_: Exception) { + emptyMap() + } + } + + suspend fun saveChildSpaceOrder(parentSpaceId: String, childSpaceIds: List) { + context.dataStore.edit { prefs -> + val existing = prefs[KEY_CHILD_SPACE_ORDER]?.let { + try { Json.decodeFromString>>(it) } catch (_: Exception) { emptyMap() } + } ?: emptyMap() + prefs[KEY_CHILD_SPACE_ORDER] = Json.encodeToString(existing + (parentSpaceId to childSpaceIds)) + } + } + suspend fun clearSession() { context.dataStore.edit { it.clear() } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index d097b7d..39d5c6b 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -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) }, ) } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index c1f7387..904c18f 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -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(null) val channelName: StateFlow = _channelName + val channelListState = LazyListState() + private val _isReorderMode = MutableStateFlow(false) val isReorderMode: StateFlow = _isReorderMode private val _channelOrderMap = MutableStateFlow>>(emptyMap()) + private val _childSpaceOrderMap = MutableStateFlow>>(emptyMap()) private val _allChannelRooms = MutableStateFlow>(emptyList()) private val _spaceChildren = MutableStateFlow?>(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() + 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) { 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() - 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) - if (eid !in ids) { - processEventItem(roomId, eventItem, cached, ids) - dirty = true - } - } - // 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 - } + sdkItems.clear() + sdkItems.addAll(d.values) } - is TimelineDiff.Clear -> { - cached.clear() - ids.clear() - 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) } - 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 - if (processEventItem(roomId, eventItem, cached, ids)) dirty = true - } + 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) } } } - if (dirty && _selectedChannel.value == roomId) { + + // Rebuild cache from SDK items + cached.clear() + ids.clear() + for (item in sdkItems) { + val eventItem = item.asEvent() ?: continue + processEventItem(roomId, eventItem, cached, ids) + } + 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) { } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt index 8d05bdf..d6cb9e7 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt @@ -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() } - // 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", diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 933224c..2bcfdf5 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -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) {