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