mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 03:45:21 +00:00
Merge pull request #109 from permissionlesstech/ui-select-text
UI select text + track RSSI + unread indicator
This commit is contained in:
@@ -41,12 +41,23 @@ class BluetoothConnectionManager(
|
|||||||
// Delegate for component managers to call back to main manager
|
// Delegate for component managers to call back to main manager
|
||||||
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
|
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
|
||||||
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) {
|
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) {
|
||||||
|
device?.let { bluetoothDevice ->
|
||||||
|
// Get current RSSI for this device and update if available
|
||||||
|
val currentRSSI = connectionTracker.getBestRSSI(bluetoothDevice.address)
|
||||||
|
if (currentRSSI != null) {
|
||||||
|
delegate?.onRSSIUpdated(bluetoothDevice.address, currentRSSI)
|
||||||
|
}
|
||||||
|
}
|
||||||
delegate?.onPacketReceived(packet, peerID, device)
|
delegate?.onPacketReceived(packet, peerID, device)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDeviceConnected(device: BluetoothDevice) {
|
override fun onDeviceConnected(device: BluetoothDevice) {
|
||||||
delegate?.onDeviceConnected(device)
|
delegate?.onDeviceConnected(device)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
|
||||||
|
delegate?.onRSSIUpdated(deviceAddress, rssi)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private val serverManager = BluetoothGattServerManager(
|
private val serverManager = BluetoothGattServerManager(
|
||||||
@@ -231,4 +242,5 @@ class BluetoothConnectionManager(
|
|||||||
interface BluetoothConnectionManagerDelegate {
|
interface BluetoothConnectionManagerDelegate {
|
||||||
fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?)
|
fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?)
|
||||||
fun onDeviceConnected(device: BluetoothDevice)
|
fun onDeviceConnected(device: BluetoothDevice)
|
||||||
|
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ class BluetoothConnectionTracker(
|
|||||||
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
|
private val subscribedDevices = CopyOnWriteArrayList<BluetoothDevice>()
|
||||||
val addressPeerMap = ConcurrentHashMap<String, String>()
|
val addressPeerMap = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
// RSSI tracking from scan results (for devices we discover but may connect as servers)
|
||||||
|
private val scanRSSI = ConcurrentHashMap<String, Int>()
|
||||||
|
|
||||||
// Connection attempt tracking with automatic cleanup
|
// Connection attempt tracking with automatic cleanup
|
||||||
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
|
private val pendingConnections = ConcurrentHashMap<String, ConnectionAttempt>()
|
||||||
|
|
||||||
@@ -117,6 +120,31 @@ class BluetoothConnectionTracker(
|
|||||||
return subscribedDevices.toList()
|
return subscribedDevices.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current RSSI for a device address
|
||||||
|
*/
|
||||||
|
fun getDeviceRSSI(deviceAddress: String): Int? {
|
||||||
|
return connectedDevices[deviceAddress]?.rssi?.takeIf { it != Int.MIN_VALUE }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store RSSI from scan results
|
||||||
|
*/
|
||||||
|
fun updateScanRSSI(deviceAddress: String, rssi: Int) {
|
||||||
|
scanRSSI[deviceAddress] = rssi
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get best available RSSI for a device (connection RSSI preferred, then scan RSSI)
|
||||||
|
*/
|
||||||
|
fun getBestRSSI(deviceAddress: String): Int? {
|
||||||
|
// Prefer connection RSSI if available and valid
|
||||||
|
connectedDevices[deviceAddress]?.rssi?.takeIf { it != Int.MIN_VALUE }?.let { return it }
|
||||||
|
|
||||||
|
// Fall back to scan RSSI
|
||||||
|
return scanRSSI[deviceAddress]
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add a subscribed device
|
* Add a subscribed device
|
||||||
*/
|
*/
|
||||||
@@ -249,6 +277,7 @@ class BluetoothConnectionTracker(
|
|||||||
subscribedDevices.clear()
|
subscribedDevices.clear()
|
||||||
addressPeerMap.clear()
|
addressPeerMap.clear()
|
||||||
pendingConnections.clear()
|
pendingConnections.clear()
|
||||||
|
scanRSSI.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,6 +329,11 @@ class BluetoothConnectionTracker(
|
|||||||
val elapsed = (now - attempt.lastAttempt) / 1000
|
val elapsed = (now - attempt.lastAttempt) / 1000
|
||||||
appendLine(" - $address: ${attempt.attempts} attempts, last ${elapsed}s ago")
|
appendLine(" - $address: ${attempt.attempts} attempts, last ${elapsed}s ago")
|
||||||
}
|
}
|
||||||
|
appendLine()
|
||||||
|
appendLine("Scan RSSI Cache: ${scanRSSI.size}")
|
||||||
|
scanRSSI.forEach { (address, rssi) ->
|
||||||
|
appendLine(" - $address: $rssi dBm")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages GATT client operations, scanning, and client-side connections
|
* Manages GATT client operations, scanning, and client-side connections
|
||||||
@@ -31,6 +32,9 @@ class BluetoothGattClientManager(
|
|||||||
// Use exact same UUIDs as iOS version
|
// Use exact same UUIDs as iOS version
|
||||||
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
|
private val SERVICE_UUID = UUID.fromString("F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
|
||||||
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
private val CHARACTERISTIC_UUID = UUID.fromString("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
|
||||||
|
|
||||||
|
// RSSI monitoring constants
|
||||||
|
private const val RSSI_UPDATE_INTERVAL = 5000L // 5 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core Bluetooth components
|
// Core Bluetooth components
|
||||||
@@ -48,6 +52,9 @@ class BluetoothGattClientManager(
|
|||||||
private var isCurrentlyScanning = false
|
private var isCurrentlyScanning = false
|
||||||
private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts
|
private val scanRateLimit = 5000L // Minimum 5 seconds between scan start attempts
|
||||||
|
|
||||||
|
// RSSI monitoring state
|
||||||
|
private var rssiMonitoringJob: Job? = null
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
private var isActive = false
|
private var isActive = false
|
||||||
|
|
||||||
@@ -78,6 +85,9 @@ class BluetoothGattClientManager(
|
|||||||
} else {
|
} else {
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start RSSI monitoring
|
||||||
|
startRSSIMonitoring()
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
@@ -91,6 +101,7 @@ class BluetoothGattClientManager(
|
|||||||
|
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
stopScanning()
|
stopScanning()
|
||||||
|
stopRSSIMonitoring()
|
||||||
Log.i(TAG, "GATT client manager stopped")
|
Log.i(TAG, "GATT client manager stopped")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,6 +117,41 @@ class BluetoothGattClientManager(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic RSSI monitoring for all client connections
|
||||||
|
*/
|
||||||
|
private fun startRSSIMonitoring() {
|
||||||
|
rssiMonitoringJob?.cancel()
|
||||||
|
rssiMonitoringJob = connectionScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
try {
|
||||||
|
// Request RSSI from all client connections
|
||||||
|
val connectedDevices = connectionTracker.getConnectedDevices()
|
||||||
|
connectedDevices.values.filter { it.isClient && it.gatt != null }.forEach { deviceConn ->
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Requesting RSSI from ${deviceConn.device.address}")
|
||||||
|
deviceConn.gatt?.readRemoteRssi()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to request RSSI from ${deviceConn.device.address}: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(RSSI_UPDATE_INTERVAL)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error in RSSI monitoring: ${e.message}")
|
||||||
|
delay(RSSI_UPDATE_INTERVAL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop RSSI monitoring
|
||||||
|
*/
|
||||||
|
private fun stopRSSIMonitoring() {
|
||||||
|
rssiMonitoringJob?.cancel()
|
||||||
|
rssiMonitoringJob = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start scanning with rate limiting
|
* Start scanning with rate limiting
|
||||||
*/
|
*/
|
||||||
@@ -230,6 +276,9 @@ class BluetoothGattClientManager(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store RSSI from scan results for later use (especially for server connections)
|
||||||
|
connectionTracker.updateScanRSSI(deviceAddress, rssi)
|
||||||
|
|
||||||
// Power-aware RSSI filtering
|
// Power-aware RSSI filtering
|
||||||
if (rssi < powerManager.getRSSIThreshold()) {
|
if (rssi < powerManager.getRSSIThreshold()) {
|
||||||
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
|
Log.d(TAG, "Skipping device $deviceAddress due to weak signal: $rssi < ${powerManager.getRSSIThreshold()}")
|
||||||
@@ -381,6 +430,21 @@ class BluetoothGattClientManager(
|
|||||||
Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
|
Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onReadRemoteRssi(gatt: BluetoothGatt, rssi: Int, status: Int) {
|
||||||
|
val deviceAddress = gatt.device.address
|
||||||
|
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
Log.d(TAG, "Client: RSSI updated for $deviceAddress: $rssi dBm")
|
||||||
|
|
||||||
|
// Update the connection tracker with new RSSI value
|
||||||
|
connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn ->
|
||||||
|
val updatedConn = deviceConn.copy(rssi = rssi)
|
||||||
|
connectionTracker.updateDeviceConnection(deviceAddress, updatedConn)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Client: Failed to read RSSI for $deviceAddress, status: $status")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class BluetoothGattServerManager(
|
|||||||
isActive = true
|
isActive = true
|
||||||
|
|
||||||
connectionScope.launch {
|
connectionScope.launch {
|
||||||
setupGattServer()
|
// setupGattServer()
|
||||||
delay(300) // Brief delay to ensure GATT server is ready
|
delay(300) // Brief delay to ensure GATT server is ready
|
||||||
startAdvertising()
|
startAdvertising()
|
||||||
}
|
}
|
||||||
@@ -122,8 +122,13 @@ class BluetoothGattServerManager(
|
|||||||
when (newState) {
|
when (newState) {
|
||||||
BluetoothProfile.STATE_CONNECTED -> {
|
BluetoothProfile.STATE_CONNECTED -> {
|
||||||
Log.i(TAG, "Server: Device connected ${device.address}")
|
Log.i(TAG, "Server: Device connected ${device.address}")
|
||||||
|
|
||||||
|
// Get best available RSSI (scan RSSI for server connections)
|
||||||
|
val rssi = connectionTracker.getBestRSSI(device.address) ?: Int.MIN_VALUE
|
||||||
|
|
||||||
val deviceConn = BluetoothConnectionTracker.DeviceConnection(
|
val deviceConn = BluetoothConnectionTracker.DeviceConnection(
|
||||||
device = device,
|
device = device,
|
||||||
|
rssi = rssi,
|
||||||
isClient = false
|
isClient = false
|
||||||
)
|
)
|
||||||
connectionTracker.addDeviceConnection(device.address, deviceConn)
|
connectionTracker.addDeviceConnection(device.address, deviceConn)
|
||||||
|
|||||||
@@ -280,6 +280,13 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
sendKeyExchangeToDevice()
|
sendKeyExchangeToDevice()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
|
||||||
|
// Find the peer ID for this device address and update RSSI in PeerManager
|
||||||
|
connectionManager.addressPeerMap[deviceAddress]?.let { peerID ->
|
||||||
|
peerManager.updatePeerRSSI(peerID, rssi)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class MeshDelegateHandler(
|
|||||||
|
|
||||||
// Show notification with enhanced information - now includes senderPeerID
|
// Show notification with enhanced information - now includes senderPeerID
|
||||||
message.senderPeerID?.let { senderPeerID ->
|
message.senderPeerID?.let { senderPeerID ->
|
||||||
if (state.getSelectedPrivateChatPeerValue() != senderPeerID) {
|
|
||||||
// Use nickname if available, fall back to sender or senderPeerID
|
// Use nickname if available, fall back to sender or senderPeerID
|
||||||
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
|
val senderNickname = message.sender.takeIf { it != senderPeerID } ?: senderPeerID
|
||||||
notificationManager.showPrivateMessageNotification(
|
notificationManager.showPrivateMessageNotification(
|
||||||
@@ -58,7 +57,6 @@ class MeshDelegateHandler(
|
|||||||
messageContent = message.content
|
messageContent = message.content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if (message.channel != null) {
|
} else if (message.channel != null) {
|
||||||
// Channel message
|
// Channel message
|
||||||
if (state.getJoinedChannelsValue().contains(message.channel)) {
|
if (state.getJoinedChannelsValue().contains(message.channel)) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -41,6 +42,7 @@ fun MessagesList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SelectionContainer {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
@@ -54,6 +56,7 @@ fun MessagesList(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class NotificationManager(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) {
|
fun showPrivateMessageNotification(senderPeerID: String, senderNickname: String, messageContent: String) {
|
||||||
// Only show notifications if app is in background OR user is not viewing this specific chat
|
// Only show notifications if app is in background OR user is not viewing this specific chat
|
||||||
val shouldNotify = isAppInBackground || currentPrivateChatPeer != senderPeerID
|
val shouldNotify = isAppInBackground || (!isAppInBackground && currentPrivateChatPeer != senderPeerID)
|
||||||
|
|
||||||
if (!shouldNotify) {
|
if (!shouldNotify) {
|
||||||
Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname")
|
Log.d(TAG, "Skipping notification - app in foreground and viewing chat with $senderNickname")
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ fun SidebarOverlay(
|
|||||||
val currentChannel by viewModel.currentChannel.observeAsState()
|
val currentChannel by viewModel.currentChannel.observeAsState()
|
||||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.observeAsState()
|
||||||
val nickname by viewModel.nickname.observeAsState("")
|
val nickname by viewModel.nickname.observeAsState("")
|
||||||
|
val unreadChannelMessages by viewModel.unreadChannelMessages.observeAsState(emptyMap())
|
||||||
|
|
||||||
// Get peer data from mesh service
|
// Get peer data from mesh service
|
||||||
val peerNicknames = viewModel.meshService.getPeerNicknames()
|
val peerNicknames = viewModel.meshService.getPeerNicknames()
|
||||||
@@ -94,7 +95,8 @@ fun SidebarOverlay(
|
|||||||
},
|
},
|
||||||
onLeaveChannel = { channel ->
|
onLeaveChannel = { channel ->
|
||||||
viewModel.leaveChannel(channel)
|
viewModel.leaveChannel(channel)
|
||||||
}
|
},
|
||||||
|
unreadChannelMessages = unreadChannelMessages
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +157,8 @@ fun ChannelsSection(
|
|||||||
currentChannel: String?,
|
currentChannel: String?,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
onChannelClick: (String) -> Unit,
|
onChannelClick: (String) -> Unit,
|
||||||
onLeaveChannel: (String) -> Unit
|
onLeaveChannel: (String) -> Unit,
|
||||||
|
unreadChannelMessages: Map<String, Int> = emptyMap()
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Row(
|
Row(
|
||||||
@@ -181,6 +184,7 @@ fun ChannelsSection(
|
|||||||
|
|
||||||
channels.forEach { channel ->
|
channels.forEach { channel ->
|
||||||
val isSelected = channel == currentChannel
|
val isSelected = channel == currentChannel
|
||||||
|
val unreadCount = unreadChannelMessages[channel] ?: 0
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -193,6 +197,13 @@ fun ChannelsSection(
|
|||||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
// Unread badge for channels
|
||||||
|
UnreadBadge(
|
||||||
|
count = unreadCount,
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = channel, // Channel already contains the # prefix
|
text = channel, // Channel already contains the # prefix
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -288,7 +299,7 @@ fun PeopleSection(
|
|||||||
PeerItem(
|
PeerItem(
|
||||||
peerID = peerID,
|
peerID = peerID,
|
||||||
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
|
displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: peerID),
|
||||||
signalStrength = peerRSSI[peerID] ?: 0,
|
signalStrength = convertRSSIToSignalStrength(peerRSSI[peerID]),
|
||||||
isSelected = peerID == selectedPrivatePeer,
|
isSelected = peerID == selectedPrivatePeer,
|
||||||
isFavorite = isFavorite,
|
isFavorite = isFavorite,
|
||||||
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
|
hasUnreadDM = hasUnreadPrivateMessages.contains(peerID),
|
||||||
@@ -297,7 +308,11 @@ fun PeopleSection(
|
|||||||
onToggleFavorite = {
|
onToggleFavorite = {
|
||||||
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
||||||
viewModel.toggleFavorite(peerID)
|
viewModel.toggleFavorite(peerID)
|
||||||
}
|
},
|
||||||
|
unreadCount = privateChats[peerID]?.count { msg ->
|
||||||
|
// Count unread messages from this peer (messages not from the current user)
|
||||||
|
msg.sender != nickname && hasUnreadPrivateMessages.contains(peerID)
|
||||||
|
} ?: if (hasUnreadPrivateMessages.contains(peerID)) 1 else 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -314,7 +329,8 @@ private fun PeerItem(
|
|||||||
hasUnreadDM: Boolean,
|
hasUnreadDM: Boolean,
|
||||||
colorScheme: ColorScheme,
|
colorScheme: ColorScheme,
|
||||||
onItemClick: () -> Unit,
|
onItemClick: () -> Unit,
|
||||||
onToggleFavorite: () -> Unit
|
onToggleFavorite: () -> Unit,
|
||||||
|
unreadCount: Int = 0
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -327,13 +343,11 @@ private fun PeerItem(
|
|||||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
.padding(horizontal = 24.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
// Show filled mail icon instead of signal strength when user has unread DMs
|
// Show unread badge or signal strength
|
||||||
if (hasUnreadDM) {
|
if (hasUnreadDM) {
|
||||||
Icon(
|
UnreadBadge(
|
||||||
imageVector = Icons.Filled.Email,
|
count = unreadCount,
|
||||||
contentDescription = "Unread messages",
|
colorScheme = colorScheme
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color(0xFFFF9500) // Orange to match private message theme
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Signal strength indicators
|
// Signal strength indicators
|
||||||
@@ -391,3 +405,57 @@ private fun SignalStrengthIndicator(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable unread badge component for both channels and private messages
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun UnreadBadge(
|
||||||
|
count: Int,
|
||||||
|
colorScheme: ColorScheme,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (count > 0) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(
|
||||||
|
color = Color(0xFFFFD700), // Yellow color
|
||||||
|
shape = RoundedCornerShape(10.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 2.dp, vertical = 0.dp)
|
||||||
|
.defaultMinSize(minWidth = 14.dp, minHeight = 14.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (count > 99) "99+" else count.toString(),
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
),
|
||||||
|
color = Color.Black // Black text on yellow background
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert RSSI value (dBm) to signal strength percentage (0-100)
|
||||||
|
* RSSI typically ranges from -30 (excellent) to -100 (very poor)
|
||||||
|
* Maps to 0-100 scale where:
|
||||||
|
* - 0-32: No signal (0 bars)
|
||||||
|
* - 33-65: Weak (1 bar)
|
||||||
|
* - 66-98: Good (2 bars)
|
||||||
|
* - 99-100: Excellent (3 bars)
|
||||||
|
*/
|
||||||
|
private fun convertRSSIToSignalStrength(rssi: Int?): Int {
|
||||||
|
if (rssi == null) return 0
|
||||||
|
|
||||||
|
return when {
|
||||||
|
rssi >= -40 -> 100 // Excellent signal
|
||||||
|
rssi >= -55 -> 85 // Very good signal
|
||||||
|
rssi >= -70 -> 70 // Good signal
|
||||||
|
rssi >= -85 -> 50 // Fair signal
|
||||||
|
rssi >= -100 -> 25 // Poor signal
|
||||||
|
else -> 0 // Very poor or no signal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user