Browse Source
Android chatgui: - New MemberListScreen with member list, detail card, edit dialog - Link status dots (green/red/grey) per address in detail card (1s poll) - Self-node name coloured green - ChatScreen: people icon in TopAppBar IPv6 autoconnect fixes (3 bugs in conn_mgr/topo): - topo_node_sqlite_node_load: load v6 addresses from DB - topo_node_registry_store: merge v6_addrs into existing node - cm_sock_v6_classify: wildcard [::] treated as CM_V6_DIR - cm_add_v6_link: htons(port) was missing Memory fixes: - strdup→u_strdup in bridge (7 instances) - u_realloc cap update before success check (3 instances) - char sql[256]→[512] for truncated SELECT Chat member hash now includes addresses for change detection Linux chatgui: - Link status dots in AccountList detail panel (1s poll) - Self-node name in green in member listtopo_upd
19 changed files with 1022 additions and 23 deletions
@ -0,0 +1,319 @@
|
||||
package com.utun.chat.ui.screens |
||||
|
||||
import androidx.compose.animation.AnimatedVisibility |
||||
import androidx.compose.animation.slideInVertically |
||||
import androidx.compose.animation.slideOutVertically |
||||
import androidx.compose.foundation.background |
||||
import androidx.compose.foundation.clickable |
||||
import androidx.compose.foundation.layout.* |
||||
import androidx.compose.foundation.lazy.LazyColumn |
||||
import androidx.compose.foundation.lazy.items |
||||
import androidx.compose.foundation.shape.CircleShape |
||||
import androidx.compose.foundation.shape.RoundedCornerShape |
||||
import androidx.compose.material.icons.Icons |
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack |
||||
import androidx.compose.material3.* |
||||
import androidx.compose.runtime.* |
||||
import androidx.compose.ui.Alignment |
||||
import androidx.compose.ui.Modifier |
||||
import androidx.compose.ui.draw.clip |
||||
import androidx.compose.ui.graphics.Color |
||||
import androidx.compose.ui.text.font.FontFamily |
||||
import androidx.compose.ui.text.font.FontWeight |
||||
import androidx.compose.ui.text.style.TextOverflow |
||||
import androidx.compose.ui.unit.dp |
||||
import androidx.compose.ui.unit.sp |
||||
import com.utun.chat.data.Channel |
||||
import com.utun.chat.data.ChatMember |
||||
import com.utun.chat.data.MemberDetail |
||||
import com.utun.chat.data.MemberAddr |
||||
import com.utun.chat.data.MemberLinks |
||||
import com.utun.chat.data.LinkStatus |
||||
import com.utun.chat.viewmodel.ChatViewModel |
||||
import kotlinx.coroutines.delay |
||||
import kotlinx.coroutines.isActive |
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class) |
||||
@Composable |
||||
fun MemberListScreen( |
||||
channel: Channel, |
||||
members: List<ChatMember>, |
||||
selectedMember: MemberDetail?, |
||||
viewModel: ChatViewModel, |
||||
onBack: () -> Unit |
||||
) { |
||||
var showEditDialog by remember { mutableStateOf(false) } |
||||
var editMember by remember { mutableStateOf<ChatMember?>(null) } |
||||
|
||||
LaunchedEffect(channel.id) { viewModel.refreshMembers(channel.id) } |
||||
|
||||
val memberLinks by viewModel.memberLinks.collectAsState() |
||||
|
||||
LaunchedEffect(selectedMember?.nodeId) { |
||||
val nodeId = selectedMember?.nodeId |
||||
if (nodeId == null) { viewModel.refreshMemberLinks(0); return@LaunchedEffect } |
||||
while (isActive) { |
||||
viewModel.refreshMemberLinks(nodeId) |
||||
delay(1000) |
||||
} |
||||
} |
||||
|
||||
Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) { |
||||
TopAppBar( |
||||
title = { Text("Members (${members.size})") }, |
||||
navigationIcon = { |
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } |
||||
} |
||||
) |
||||
|
||||
LazyColumn( |
||||
modifier = Modifier |
||||
.weight(1f) |
||||
.fillMaxWidth() |
||||
) { |
||||
items(members.size) { idx -> |
||||
val m = members[idx] |
||||
MemberListItem( |
||||
member = m, |
||||
isSelected = selectedMember?.nodeId == m.nodeId, |
||||
onClick = { viewModel.showMemberDetail(channel.id, m.nodeId) }, |
||||
onEdit = { |
||||
editMember = m |
||||
showEditDialog = true |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
AnimatedVisibility( |
||||
visible = selectedMember != null, |
||||
enter = slideInVertically(initialOffsetY = { it }), |
||||
exit = slideOutVertically(targetOffsetY = { it }) |
||||
) { |
||||
selectedMember?.let { detail -> |
||||
MemberDetailCard( |
||||
detail = detail, |
||||
memberLinks = memberLinks, |
||||
onDismiss = { } |
||||
) |
||||
} |
||||
} |
||||
} |
||||
|
||||
if (showEditDialog && editMember != null) { |
||||
MemberEditDialog( |
||||
member = editMember!!, |
||||
onDismiss = { showEditDialog = false }, |
||||
onSave = { nick, admin, supernode, storage -> |
||||
if (nick != editMember!!.name) viewModel.setMemberNick(channel.id, editMember!!.nodeId, nick) |
||||
if (admin != editMember!!.isAdmin || supernode != editMember!!.isSuper || storage != editMember!!.isStorage) |
||||
viewModel.setMemberFlags(channel.id, editMember!!.nodeId, admin, supernode, storage) |
||||
showEditDialog = false |
||||
} |
||||
) |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun MemberListItem( |
||||
member: ChatMember, |
||||
isSelected: Boolean = false, |
||||
onClick: () -> Unit, |
||||
onEdit: () -> Unit |
||||
) { |
||||
val onlineColor = if (member.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E) |
||||
val bgColor = if (isSelected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent |
||||
|
||||
Surface( |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.clickable(onClick = onClick), |
||||
color = bgColor, |
||||
tonalElevation = 0.dp |
||||
) { |
||||
Row( |
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), |
||||
verticalAlignment = Alignment.CenterVertically |
||||
) { |
||||
Box( |
||||
modifier = Modifier |
||||
.size(10.dp) |
||||
.clip(CircleShape) |
||||
.background(onlineColor) |
||||
) |
||||
Spacer(Modifier.width(10.dp)) |
||||
|
||||
Column(modifier = Modifier.weight(1f)) { |
||||
Text( |
||||
text = member.name, |
||||
fontWeight = FontWeight.Medium, |
||||
fontSize = 15.sp, |
||||
maxLines = 1, |
||||
overflow = TextOverflow.Ellipsis, |
||||
color = if (member.isSelf) Color(0xFF4CAF50) else Color.Unspecified |
||||
) |
||||
val roles = buildList { |
||||
if (member.isAdmin) add("Admin") |
||||
if (member.isSuper) add("Super") |
||||
if (member.isStorage) add("Storage") |
||||
} |
||||
if (roles.isNotEmpty() || member.rtt > 0) { |
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
if (roles.isNotEmpty()) |
||||
Text( |
||||
text = roles.joinToString(", "), |
||||
fontSize = 11.sp, |
||||
color = MaterialTheme.colorScheme.primary |
||||
) |
||||
if (member.rtt > 0) { |
||||
if (roles.isNotEmpty()) Text(" ", fontSize = 11.sp) |
||||
Text( |
||||
text = "${member.rtt / 10}ms", |
||||
fontSize = 11.sp, |
||||
color = Color.Gray |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
TextButton(onClick = onEdit) { Text("Edit") } |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun MemberDetailCard( |
||||
detail: MemberDetail, |
||||
memberLinks: MemberLinks, |
||||
onDismiss: () -> Unit |
||||
) { |
||||
fun linkDotColor(addr: MemberAddr): Color { |
||||
val lk = memberLinks.links.find { |
||||
it.family == addr.family && it.address == addr.address && it.port == addr.port |
||||
} ?: return Color(0xFF9E9E9E) |
||||
return if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF4CAF50) else Color(0xFFE53935) |
||||
} |
||||
|
||||
Card( |
||||
modifier = Modifier |
||||
.fillMaxWidth() |
||||
.padding(8.dp), |
||||
shape = RoundedCornerShape(12.dp), |
||||
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) |
||||
) { |
||||
Column(modifier = Modifier.padding(12.dp)) { |
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
val dotColor = if (detail.online) Color(0xFF4CAF50) else Color(0xFF9E9E9E) |
||||
Box( |
||||
modifier = Modifier |
||||
.size(10.dp) |
||||
.clip(CircleShape) |
||||
.background(dotColor) |
||||
) |
||||
Spacer(Modifier.width(8.dp)) |
||||
Text(detail.name, fontWeight = FontWeight.Bold, fontSize = 15.sp) |
||||
} |
||||
|
||||
Spacer(Modifier.height(4.dp)) |
||||
Text( |
||||
text = "ID: 0x${detail.nodeId.toString(16).padStart(16, '0')}", |
||||
fontFamily = FontFamily.Monospace, |
||||
fontSize = 12.sp, |
||||
color = Color.Gray |
||||
) |
||||
|
||||
if (detail.lastSeen > 0) { |
||||
val sec = System.currentTimeMillis() / 1000 - detail.lastSeen |
||||
val ago = when { |
||||
sec < 60 -> "${sec}s ago" |
||||
sec < 3600 -> "${sec / 60}m ago" |
||||
sec < 86400 -> "${sec / 3600}h ago" |
||||
else -> "${sec / 86400}d ago" |
||||
} |
||||
Text("Last seen: $ago", fontSize = 12.sp, color = Color.Gray) |
||||
} |
||||
|
||||
if (detail.created > 0) { |
||||
val sec = System.currentTimeMillis() / 1000 - detail.created |
||||
val ago = when { |
||||
sec < 604800 -> "${sec / 86400}d ago" |
||||
else -> "${sec / 2592000}mo ago" |
||||
} |
||||
Text("Created: $ago", fontSize = 12.sp, color = Color.Gray) |
||||
} |
||||
|
||||
if (detail.addresses.isNotEmpty()) { |
||||
Spacer(Modifier.height(6.dp)) |
||||
Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium) |
||||
detail.addresses.forEach { a -> |
||||
val dcol = linkDotColor(a) |
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
Box( |
||||
modifier = Modifier |
||||
.size(8.dp) |
||||
.clip(CircleShape) |
||||
.background(dcol) |
||||
) |
||||
Spacer(Modifier.width(6.dp)) |
||||
Text( |
||||
text = "${a.protocol} ${a.address}:${a.port}" + |
||||
(if (a.rtt > 0) " (${a.rtt / 10}ms)" else ""), |
||||
fontFamily = FontFamily.Monospace, |
||||
fontSize = 11.sp, |
||||
color = Color.DarkGray |
||||
) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@Composable |
||||
private fun MemberEditDialog( |
||||
member: ChatMember, |
||||
onDismiss: () -> Unit, |
||||
onSave: (nick: String, admin: Boolean, supernode: Boolean, storage: Boolean) -> Unit |
||||
) { |
||||
var nick by remember { mutableStateOf(member.name) } |
||||
var admin by remember { mutableStateOf(member.isAdmin) } |
||||
var supernode by remember { mutableStateOf(member.isSuper) } |
||||
var storage by remember { mutableStateOf(member.isStorage) } |
||||
|
||||
AlertDialog( |
||||
onDismissRequest = onDismiss, |
||||
title = { Text("Edit ${member.name}") }, |
||||
text = { |
||||
Column { |
||||
OutlinedTextField( |
||||
value = nick, |
||||
onValueChange = { nick = it }, |
||||
label = { Text("Nickname") }, |
||||
enabled = !member.isSelf, |
||||
modifier = Modifier.fillMaxWidth(), |
||||
singleLine = true |
||||
) |
||||
Spacer(Modifier.height(8.dp)) |
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
Checkbox(checked = admin, onCheckedChange = { admin = it }) |
||||
Text("Admin", modifier = Modifier.clickable { admin = !admin }) |
||||
} |
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
Checkbox(checked = supernode, onCheckedChange = { supernode = it }) |
||||
Text("Supernode", modifier = Modifier.clickable { supernode = !supernode }) |
||||
} |
||||
Row(verticalAlignment = Alignment.CenterVertically) { |
||||
Checkbox(checked = storage, onCheckedChange = { storage = it }) |
||||
Text("Storage", modifier = Modifier.clickable { storage = !storage }) |
||||
} |
||||
} |
||||
}, |
||||
confirmButton = { |
||||
TextButton(onClick = { onSave(nick, admin, supernode, storage) }) { Text("Save") } |
||||
}, |
||||
dismissButton = { |
||||
TextButton(onClick = onDismiss) { Text("Cancel") } |
||||
} |
||||
) |
||||
} |
||||
Loading…
Reference in new issue