gonna do settings now
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user