mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 09:25:19 +00:00
New MeshPeerListSheet as Ios like (#498)
* Automated update of relay data - Sun Sep 21 06:21:05 UTC 2025
* Automated update of relay data - Sun Sep 28 06:20:40 UTC 2025
* refactor: new close button like ios(but not liquid glass)
* Automated update of relay data - Sun Oct 5 06:20:09 UTC 2025
* Automated update of relay data - Sun Oct 12 06:20:12 UTC 2025
* Automated update of relay data - Sun Oct 19 06:21:51 UTC 2025
* Refactor: Redesign peer and channel list as a bottom sheet
Replaced the right-hand sidebar with a Material 3 `ModalBottomSheet` for displaying mesh peers and channels. This modernizes the UI and improves usability.
- Renamed `SidebarComponents.kt` to `MeshPeerListSheet.kt`.
- Replaced the custom sidebar implementation with `ModalBottomSheet`.
- Added a floating top bar to the sheet that appears on scroll, displaying the title and a close button.
- Updated the row layouts for both channels and peers to use `Surface` for better visual grouping and selection state handling.
- Added a checkmark icon to indicate the currently selected channel or private chat peer.
- Improved styling for section headers, unread badges, and empty-state text.
- Removed the `SignalStrengthIndicator` as it was no longer used.
* Refactor: Remove sidebar state management from ViewModel
This commit removes the state management for the sidebar's visibility from `ChatViewModel` and `ChatState`.
The sidebar's visibility is now a purely UI-level concern and is no longer coupled with the ViewModel's logic. This change simplifies the ViewModel by removing unnecessary LiveData and related methods (`showSidebar`, `hideSidebar`). The back navigation handler has also been updated to remove the case for closing the sidebar.
* Refactor: Replace Sidebar with MeshPeerList Bottom Sheet
* feat: Add nested private chat sheet
* Feat: Enhance UI/UX of LocationNotesSheet
This commit refactors the `LocationNotesSheet` to more closely align with its iOS counterpart, improving both its appearance and user experience.
The layout has been updated to use a `Box` with aligned elements instead of a single `Column`, allowing for a floating input section at the bottom and a floating close button at the top right.
**Key Changes:**
- **Floating Top Bar and Input:**
- The main content is now a `LazyColumn` that scrolls underneath a new floating top bar and a floating input section at the bottom.
- The top bar's background animates from transparent to semi-opaque as the user scrolls, providing a "blur" effect.
- **iOS-Style Close Button:**
- The close button is moved from the header row to the top-right corner of the sheet, where it remains fixed.
- **Structural Refinements:**
- Replaced the main `Column` with a `Box` to manage the layout of the scrollable content, top bar, and input section.
- Removed the `onClose` parameter from `LocationNotesHeader` as the close button is now managed separately.
- Added `statusBarsPadding` to the `ModalBottomSheet` to prevent content from rendering under the system status bar.
- Adjusted spacing and padding for better visual consistency.
* refactor: Use collectAsStateWithLifecycle
Migrates LiveData observation from `observeAsState` to `collectAsStateWithLifecycle` for improved lifecycle-aware state collection in `MeshPeerListSheet`.
This also includes the following related changes:
* Removes an unused `showSidebar` state flow from `ChatViewModel`.
* Replaces fully qualified `com.bitchat.android.ui.splitSuffix` calls with a direct `splitSuffix` call.
* Updates resource string access to use the `R` import.
* feat: Add verification status indicators
- Add peer verification status icons to the peer list.
- Add a button to the channel list to show the verification QR code.
- Remove the unused `SidebarComponents.kt` file.
* Refactor: Add state for mesh peer list visibility
* Refactor: Move mesh peer list state to ViewModel
* refactor: Move QR code icon from channel items to footer
The verification (QR code) icon is relocated from being repeated on each channel list item to a single, centralized position in the sheet's footer.
This icon is now only displayed when not in a location-based channel. The `onShowVerification` parameter has been removed from `ChannelListItem` as it's no longer needed there.
* feat: Show verified status for peers
---------
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -5,7 +5,6 @@ package com.bitchat.android.ui
|
|||||||
|
|
||||||
|
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
|
||||||
import androidx.compose.foundation.*
|
import androidx.compose.foundation.*
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
@@ -51,12 +50,12 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
||||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
||||||
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
|
val channelMessages by viewModel.channelMessages.collectAsStateWithLifecycle()
|
||||||
val showSidebar by viewModel.showSidebar.collectAsStateWithLifecycle()
|
|
||||||
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
|
val showCommandSuggestions by viewModel.showCommandSuggestions.collectAsStateWithLifecycle()
|
||||||
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
val commandSuggestions by viewModel.commandSuggestions.collectAsStateWithLifecycle()
|
||||||
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
val showMentionSuggestions by viewModel.showMentionSuggestions.collectAsStateWithLifecycle()
|
||||||
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
val mentionSuggestions by viewModel.mentionSuggestions.collectAsStateWithLifecycle()
|
||||||
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
val showAppInfo by viewModel.showAppInfo.collectAsStateWithLifecycle()
|
||||||
|
val showMeshPeerListSheet by viewModel.showMeshPeerList.collectAsStateWithLifecycle()
|
||||||
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
|
||||||
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@@ -249,7 +248,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
onSidebarToggle = { viewModel.showSidebar() },
|
onSidebarToggle = { viewModel.showMeshPeerList() },
|
||||||
onShowAppInfo = { viewModel.showAppInfo() },
|
onShowAppInfo = { viewModel.showAppInfo() },
|
||||||
onPanicClear = { viewModel.panicClearAllData() },
|
onPanicClear = { viewModel.panicClearAllData() },
|
||||||
onLocationChannelsClick = { showLocationChannelsSheet = true },
|
onLocationChannelsClick = { showLocationChannelsSheet = true },
|
||||||
@@ -266,28 +265,9 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
color = colorScheme.outline.copy(alpha = 0.3f)
|
color = colorScheme.outline.copy(alpha = 0.3f)
|
||||||
)
|
)
|
||||||
|
|
||||||
val alpha by animateFloatAsState(
|
|
||||||
targetValue = if (showSidebar) 0.5f else 0f,
|
|
||||||
animationSpec = tween(
|
|
||||||
durationMillis = 300,
|
|
||||||
easing = EaseOutCubic
|
|
||||||
), label = "overlayAlpha"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Only render the background if it's visible
|
|
||||||
if (alpha > 0f) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = alpha))
|
|
||||||
.clickable { viewModel.hideSidebar() }
|
|
||||||
.zIndex(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll-to-bottom floating button
|
// Scroll-to-bottom floating button
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = isScrolledUp && !showSidebar,
|
visible = isScrolledUp,
|
||||||
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
enter = slideInVertically(initialOffsetY = { it / 2 }) + fadeIn(),
|
||||||
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
exit = slideOutVertically(targetOffsetY = { it / 2 }) + fadeOut(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -313,29 +293,6 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = showSidebar,
|
|
||||||
enter = slideInHorizontally(
|
|
||||||
initialOffsetX = { it },
|
|
||||||
animationSpec = tween(300, easing = EaseOutCubic)
|
|
||||||
) + fadeIn(animationSpec = tween(300)),
|
|
||||||
exit = slideOutHorizontally(
|
|
||||||
targetOffsetX = { it },
|
|
||||||
animationSpec = tween(250, easing = EaseInCubic)
|
|
||||||
) + fadeOut(animationSpec = tween(250)),
|
|
||||||
modifier = Modifier.zIndex(2f)
|
|
||||||
) {
|
|
||||||
SidebarOverlay(
|
|
||||||
viewModel = viewModel,
|
|
||||||
onDismiss = { viewModel.hideSidebar() },
|
|
||||||
onShowVerification = {
|
|
||||||
viewModel.showVerificationSheet(fromSidebar = true)
|
|
||||||
viewModel.hideSidebar()
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxSize()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
|
// Full-screen image viewer - separate from other sheets to allow image browsing without navigation
|
||||||
@@ -383,12 +340,14 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
showVerificationSheet = showVerificationSheet,
|
showVerificationSheet = showVerificationSheet,
|
||||||
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
|
onVerificationSheetDismiss = viewModel::hideVerificationSheet,
|
||||||
showSecurityVerificationSheet = showSecurityVerificationSheet,
|
showSecurityVerificationSheet = showSecurityVerificationSheet,
|
||||||
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet
|
onSecurityVerificationSheetDismiss = viewModel::hideSecurityVerificationSheet,
|
||||||
|
showMeshPeerListSheet = showMeshPeerListSheet,
|
||||||
|
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatInputSection(
|
fun ChatInputSection(
|
||||||
messageText: TextFieldValue,
|
messageText: TextFieldValue,
|
||||||
onMessageTextChange: (TextFieldValue) -> Unit,
|
onMessageTextChange: (TextFieldValue) -> Unit,
|
||||||
onSend: () -> Unit,
|
onSend: () -> Unit,
|
||||||
@@ -527,7 +486,9 @@ private fun ChatDialogs(
|
|||||||
showVerificationSheet: Boolean,
|
showVerificationSheet: Boolean,
|
||||||
onVerificationSheetDismiss: () -> Unit,
|
onVerificationSheetDismiss: () -> Unit,
|
||||||
showSecurityVerificationSheet: Boolean,
|
showSecurityVerificationSheet: Boolean,
|
||||||
onSecurityVerificationSheetDismiss: () -> Unit
|
onSecurityVerificationSheetDismiss: () -> Unit,
|
||||||
|
showMeshPeerListSheet: Boolean,
|
||||||
|
onMeshPeerListDismiss: () -> Unit,
|
||||||
) {
|
) {
|
||||||
// Password dialog
|
// Password dialog
|
||||||
PasswordPromptDialog(
|
PasswordPromptDialog(
|
||||||
@@ -581,6 +542,18 @@ private fun ChatDialogs(
|
|||||||
viewModel = viewModel
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// MeshPeerList sheet (network view)
|
||||||
|
if (showMeshPeerListSheet){
|
||||||
|
MeshPeerListSheet(
|
||||||
|
isPresented = showMeshPeerListSheet,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onDismiss = onMeshPeerListDismiss,
|
||||||
|
onShowVerification = {
|
||||||
|
onMeshPeerListDismiss()
|
||||||
|
viewModel.showVerificationSheet(fromSidebar = true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showVerificationSheet) {
|
if (showVerificationSheet) {
|
||||||
VerificationSheet(
|
VerificationSheet(
|
||||||
|
|||||||
@@ -77,10 +77,6 @@ class ChatState(
|
|||||||
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
|
private val _passwordPromptChannel = MutableStateFlow<String?>(null)
|
||||||
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
|
val passwordPromptChannel: StateFlow<String?> = _passwordPromptChannel.asStateFlow()
|
||||||
|
|
||||||
// Sidebar state
|
|
||||||
private val _showSidebar = MutableStateFlow(false)
|
|
||||||
val showSidebar: StateFlow<Boolean> = _showSidebar.asStateFlow()
|
|
||||||
|
|
||||||
// Command autocomplete
|
// Command autocomplete
|
||||||
private val _showCommandSuggestions = MutableStateFlow(false)
|
private val _showCommandSuggestions = MutableStateFlow(false)
|
||||||
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
|
val showCommandSuggestions: StateFlow<Boolean> = _showCommandSuggestions.asStateFlow()
|
||||||
@@ -123,6 +119,9 @@ class ChatState(
|
|||||||
private val _showAppInfo = MutableStateFlow<Boolean>(false)
|
private val _showAppInfo = MutableStateFlow<Boolean>(false)
|
||||||
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
|
val showAppInfo: StateFlow<Boolean> = _showAppInfo.asStateFlow()
|
||||||
|
|
||||||
|
private val _showMeshPeerList = MutableStateFlow(false)
|
||||||
|
val showMeshPeerList: StateFlow<Boolean> = _showMeshPeerList.asStateFlow()
|
||||||
|
|
||||||
private val _showVerificationSheet = MutableStateFlow(false)
|
private val _showVerificationSheet = MutableStateFlow(false)
|
||||||
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
|
val showVerificationSheet: StateFlow<Boolean> = _showVerificationSheet.asStateFlow()
|
||||||
|
|
||||||
@@ -178,7 +177,6 @@ class ChatState(
|
|||||||
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
|
fun getPasswordProtectedChannelsValue() = _passwordProtectedChannels.value
|
||||||
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
|
fun getShowPasswordPromptValue() = _showPasswordPrompt.value
|
||||||
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
|
fun getPasswordPromptChannelValue() = _passwordPromptChannel.value
|
||||||
fun getShowSidebarValue() = _showSidebar.value
|
|
||||||
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
|
fun getShowCommandSuggestionsValue() = _showCommandSuggestions.value
|
||||||
fun getCommandSuggestionsValue() = _commandSuggestions.value
|
fun getCommandSuggestionsValue() = _commandSuggestions.value
|
||||||
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
|
fun getShowMentionSuggestionsValue() = _showMentionSuggestions.value
|
||||||
@@ -188,6 +186,9 @@ class ChatState(
|
|||||||
fun getPeerFingerprintsValue() = _peerFingerprints.value
|
fun getPeerFingerprintsValue() = _peerFingerprints.value
|
||||||
fun getShowAppInfoValue() = _showAppInfo.value
|
fun getShowAppInfoValue() = _showAppInfo.value
|
||||||
fun getGeohashPeopleValue() = _geohashPeople.value
|
fun getGeohashPeopleValue() = _geohashPeople.value
|
||||||
|
|
||||||
|
fun getShowMeshPeerListValue() = _showMeshPeerList.value
|
||||||
|
|
||||||
fun getTeleportedGeoValue() = _teleportedGeo.value
|
fun getTeleportedGeoValue() = _teleportedGeo.value
|
||||||
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
|
fun getGeohashParticipantCountsValue() = _geohashParticipantCounts.value
|
||||||
|
|
||||||
@@ -252,10 +253,6 @@ class ChatState(
|
|||||||
_passwordPromptChannel.value = channel
|
_passwordPromptChannel.value = channel
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setShowSidebar(show: Boolean) {
|
|
||||||
_showSidebar.value = show
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setShowCommandSuggestions(show: Boolean) {
|
fun setShowCommandSuggestions(show: Boolean) {
|
||||||
_showCommandSuggestions.value = show
|
_showCommandSuggestions.value = show
|
||||||
}
|
}
|
||||||
@@ -337,4 +334,7 @@ class ChatState(
|
|||||||
_geohashParticipantCounts.value = counts
|
_geohashParticipantCounts.value = counts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setShowMeshPeerList(show: Boolean) {
|
||||||
|
_showMeshPeerList.value = show
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ class ChatViewModel(
|
|||||||
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
|
val passwordProtectedChannels: StateFlow<Set<String>> = state.passwordProtectedChannels
|
||||||
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
|
val showPasswordPrompt: StateFlow<Boolean> = state.showPasswordPrompt
|
||||||
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
|
val passwordPromptChannel: StateFlow<String?> = state.passwordPromptChannel
|
||||||
val showSidebar: StateFlow<Boolean> = state.showSidebar
|
|
||||||
val hasUnreadChannels = state.hasUnreadChannels
|
val hasUnreadChannels = state.hasUnreadChannels
|
||||||
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
val hasUnreadPrivateMessages = state.hasUnreadPrivateMessages
|
||||||
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
|
val showCommandSuggestions: StateFlow<Boolean> = state.showCommandSuggestions
|
||||||
@@ -171,6 +170,7 @@ class ChatViewModel(
|
|||||||
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
|
val peerRSSI: StateFlow<Map<String, Int>> = state.peerRSSI
|
||||||
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
|
val peerDirect: StateFlow<Map<String, Boolean>> = state.peerDirect
|
||||||
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
|
val showAppInfo: StateFlow<Boolean> = state.showAppInfo
|
||||||
|
val showMeshPeerList: StateFlow<Boolean> = state.showMeshPeerList
|
||||||
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
|
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
|
||||||
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
|
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
|
||||||
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||||
@@ -452,11 +452,6 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
startPrivateChat(openPeer)
|
startPrivateChat(openPeer)
|
||||||
|
|
||||||
// If sidebar visible, hide it to focus on the private chat
|
|
||||||
if (state.getShowSidebarValue()) {
|
|
||||||
state.setShowSidebar(false)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
|
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -783,7 +778,7 @@ class ChatViewModel(
|
|||||||
state.setShowVerificationSheet(false)
|
state.setShowVerificationSheet(false)
|
||||||
if (reopenSidebarAfterVerification) {
|
if (reopenSidebarAfterVerification) {
|
||||||
reopenSidebarAfterVerification = false
|
reopenSidebarAfterVerification = false
|
||||||
state.setShowSidebar(true)
|
state.setShowMeshPeerList(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -795,6 +790,14 @@ class ChatViewModel(
|
|||||||
state.setShowSecurityVerificationSheet(false)
|
state.setShowSecurityVerificationSheet(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showMeshPeerList() {
|
||||||
|
state.setShowMeshPeerList(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideMeshPeerList() {
|
||||||
|
state.setShowMeshPeerList(false)
|
||||||
|
}
|
||||||
|
|
||||||
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
fun getPeerFingerprintForDisplay(peerID: String): String? {
|
||||||
return verificationHandler.getPeerFingerprintForDisplay(peerID)
|
return verificationHandler.getPeerFingerprintForDisplay(peerID)
|
||||||
}
|
}
|
||||||
@@ -1028,14 +1031,6 @@ class ChatViewModel(
|
|||||||
state.setShowAppInfo(false)
|
state.setShowAppInfo(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun showSidebar() {
|
|
||||||
state.setShowSidebar(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun hideSidebar() {
|
|
||||||
state.setShowSidebar(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle Android back navigation
|
* Handle Android back navigation
|
||||||
* Returns true if the back press was handled, false if it should be passed to the system
|
* Returns true if the back press was handled, false if it should be passed to the system
|
||||||
@@ -1047,11 +1042,6 @@ class ChatViewModel(
|
|||||||
hideAppInfo()
|
hideAppInfo()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
// Close sidebar
|
|
||||||
state.getShowSidebarValue() -> {
|
|
||||||
hideSidebar()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
// Close password dialog
|
// Close password dialog
|
||||||
state.getShowPasswordPromptValue() -> {
|
state.getShowPasswordPromptValue() -> {
|
||||||
state.setShowPasswordPrompt(false)
|
state.setShowPasswordPrompt(false)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
@@ -12,6 +13,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.ArrowUpward
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -76,6 +78,15 @@ fun LocationNotesSheet(
|
|||||||
|
|
||||||
// Scroll state
|
// Scroll state
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val isScrolled by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val topBarAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (isScrolled) 0.95f else 0f,
|
||||||
|
label = "topBarAlpha"
|
||||||
|
)
|
||||||
|
|
||||||
// Effect to set geohash when sheet opens
|
// Effect to set geohash when sheet opens
|
||||||
LaunchedEffect(geohash) {
|
LaunchedEffect(geohash) {
|
||||||
@@ -91,102 +102,118 @@ fun LocationNotesSheet(
|
|||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
modifier = modifier,
|
modifier = modifier.statusBarsPadding(),
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
|
dragHandle = null,
|
||||||
containerColor = backgroundColor,
|
containerColor = backgroundColor,
|
||||||
contentColor = if (isDark) Color.White else Color.Black
|
contentColor = if (isDark) Color.White else Color.Black
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier
|
LazyColumn(
|
||||||
.fillMaxWidth()
|
state = listState,
|
||||||
.fillMaxHeight(0.9f)
|
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||||
) {
|
contentPadding = PaddingValues(top = 64.dp, bottom = 20.dp)
|
||||||
// Header section (matches iOS headerSection)
|
|
||||||
LocationNotesHeader(
|
|
||||||
geohash = geohash,
|
|
||||||
count = count,
|
|
||||||
locationName = displayLocationName,
|
|
||||||
state = state,
|
|
||||||
accentGreen = accentGreen,
|
|
||||||
backgroundColor = backgroundColor,
|
|
||||||
onClose = onDismiss
|
|
||||||
)
|
|
||||||
|
|
||||||
// ScrollView with notes content
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(backgroundColor)
|
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
item(key = "notes_header") {
|
||||||
state = listState,
|
LocationNotesHeader(
|
||||||
modifier = Modifier.fillMaxSize(),
|
geohash = geohash,
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
count = count,
|
||||||
) {
|
locationName = displayLocationName,
|
||||||
// Notes content (matches iOS notesContent)
|
state = state,
|
||||||
when {
|
accentGreen = accentGreen,
|
||||||
state == LocationNotesManager.State.NO_RELAYS -> {
|
backgroundColor = backgroundColor
|
||||||
item {
|
)
|
||||||
NoRelaysRow(
|
}
|
||||||
onRetry = { notesManager.refresh() }
|
|
||||||
)
|
// Notes content (matches iOS notesContent)
|
||||||
}
|
when {
|
||||||
}
|
state == LocationNotesManager.State.NO_RELAYS -> {
|
||||||
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
|
item {
|
||||||
item {
|
NoRelaysRow(
|
||||||
LoadingRow()
|
onRetry = { notesManager.refresh() }
|
||||||
}
|
)
|
||||||
}
|
|
||||||
notes.isEmpty() -> {
|
|
||||||
item {
|
|
||||||
EmptyRow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
items(notes, key = { it.id }) { note ->
|
|
||||||
NoteRow(note = note)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
|
||||||
|
item {
|
||||||
|
LoadingRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notes.isEmpty() -> {
|
||||||
|
item {
|
||||||
|
EmptyRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
items(notes, key = { it.id }) { note ->
|
||||||
|
NoteRow(note = note)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Error row (matches iOS errorRow)
|
// Error row (matches iOS errorRow)
|
||||||
errorMessage?.let { error ->
|
errorMessage?.let { error ->
|
||||||
if (state != LocationNotesManager.State.NO_RELAYS) {
|
if (state != LocationNotesManager.State.NO_RELAYS) {
|
||||||
item {
|
item {
|
||||||
ErrorRow(
|
ErrorRow(
|
||||||
message = error,
|
message = error,
|
||||||
onDismiss = { notesManager.clearError() }
|
onDismiss = { notesManager.clearError() }
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Divider before input (matches iOS overlay)
|
// TopBar (animated)
|
||||||
HorizontalDivider(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
|
.align(Alignment.TopCenter)
|
||||||
thickness = 1.dp
|
.fillMaxWidth()
|
||||||
)
|
.height(64.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.background.copy(alpha = topBarAlpha))
|
||||||
|
) {
|
||||||
|
CloseButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Input section (matches iOS inputSection)
|
Box(
|
||||||
LocationNotesInputSection(
|
modifier = Modifier
|
||||||
draft = draft,
|
.align(Alignment.BottomCenter)
|
||||||
onDraftChange = { draft = it },
|
.fillMaxWidth()
|
||||||
sendButtonEnabled = sendButtonEnabled,
|
){
|
||||||
accentGreen = accentGreen,
|
Column {
|
||||||
backgroundColor = backgroundColor,
|
// Divider before input (matches iOS overlay)
|
||||||
onSend = {
|
HorizontalDivider(
|
||||||
val content = draft.trim()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
if (content.isNotEmpty()) {
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f),
|
||||||
notesManager.send(content, nickname)
|
thickness = 1.dp
|
||||||
draft = ""
|
)
|
||||||
}
|
|
||||||
|
// Input section (matches iOS inputSection)
|
||||||
|
LocationNotesInputSection(
|
||||||
|
draft = draft,
|
||||||
|
onDraftChange = { draft = it },
|
||||||
|
sendButtonEnabled = sendButtonEnabled,
|
||||||
|
accentGreen = accentGreen,
|
||||||
|
backgroundColor = backgroundColor,
|
||||||
|
onSend = {
|
||||||
|
val content = draft.trim()
|
||||||
|
if (content.isNotEmpty()) {
|
||||||
|
notesManager.send(content, nickname)
|
||||||
|
draft = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,51 +230,25 @@ private fun LocationNotesHeader(
|
|||||||
state: LocationNotesManager.State,
|
state: LocationNotesManager.State,
|
||||||
accentGreen: Color,
|
accentGreen: Color,
|
||||||
backgroundColor: Color,
|
backgroundColor: Color,
|
||||||
onClose: () -> Unit
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.padding(horizontal = 16.dp)
|
|
||||||
.padding(top = 16.dp, bottom = 12.dp)
|
.padding(top = 16.dp, bottom = 12.dp)
|
||||||
) {
|
) {
|
||||||
// Title row with close button
|
// Localized title with ±1 and note count
|
||||||
Row(
|
Text(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
text = pluralStringResource(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
id = R.plurals.location_notes_title,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
count = count,
|
||||||
) {
|
geohash,
|
||||||
// Localized title with ±1 and note count
|
count
|
||||||
Text(
|
),
|
||||||
text = pluralStringResource(
|
fontFamily = FontFamily.Monospace,
|
||||||
id = R.plurals.location_notes_title,
|
fontSize = 18.sp,
|
||||||
count = count,
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
geohash,
|
)
|
||||||
count
|
|
||||||
),
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
|
|
||||||
// Close button - iOS style with xmark icon
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(32.dp)
|
|
||||||
.clickable(onClick = onClose),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "✕",
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Location name in green (building or block)
|
// Location name in green (building or block)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,753 +0,0 @@
|
|||||||
package com.bitchat.android.ui
|
|
||||||
|
|
||||||
import com.bitchat.android.R
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.compose.foundation.*
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.*
|
|
||||||
import androidx.compose.material.icons.outlined.*
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
|
||||||
import com.bitchat.android.util.hexEncodedString
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sidebar components for ChatScreen
|
|
||||||
* Extracted from ChatScreen.kt for better organization
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SidebarOverlay(
|
|
||||||
viewModel: ChatViewModel,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onShowVerification: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
|
|
||||||
val connectedPeers by viewModel.connectedPeers.collectAsStateWithLifecycle()
|
|
||||||
val joinedChannels by viewModel.joinedChannels.collectAsStateWithLifecycle()
|
|
||||||
val currentChannel by viewModel.currentChannel.collectAsStateWithLifecycle()
|
|
||||||
val selectedPrivatePeer by viewModel.selectedPrivateChatPeer.collectAsStateWithLifecycle()
|
|
||||||
val nickname by viewModel.nickname.collectAsStateWithLifecycle()
|
|
||||||
val unreadChannelMessages by viewModel.unreadChannelMessages.collectAsStateWithLifecycle()
|
|
||||||
val peerNicknames by viewModel.peerNicknames.collectAsStateWithLifecycle()
|
|
||||||
val peerRSSI by viewModel.peerRSSI.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.background(Color.Black.copy(alpha = 0.5f))
|
|
||||||
.clickable(indication = null, interactionSource = interactionSource) { onDismiss() }
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.width(280.dp)
|
|
||||||
.align(Alignment.CenterEnd)
|
|
||||||
.clickable { /* Prevent dismissing when clicking sidebar */ }
|
|
||||||
) {
|
|
||||||
// Grey vertical bar for visual continuity (matches iOS)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.width(1.dp)
|
|
||||||
.background(Color.Gray.copy(alpha = 0.3f))
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.weight(1f)
|
|
||||||
.background(colorScheme.background.copy(alpha = 0.95f))
|
|
||||||
.windowInsetsPadding(WindowInsets.statusBars) // Add status bar padding
|
|
||||||
) {
|
|
||||||
SidebarHeader()
|
|
||||||
|
|
||||||
HorizontalDivider()
|
|
||||||
|
|
||||||
// Scrollable content
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
// Channels section
|
|
||||||
if (joinedChannels.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
ChannelsSection(
|
|
||||||
channels = joinedChannels.toList(), // Convert Set to List
|
|
||||||
currentChannel = currentChannel,
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
onChannelClick = { channel ->
|
|
||||||
viewModel.switchToChannel(channel)
|
|
||||||
onDismiss()
|
|
||||||
},
|
|
||||||
onLeaveChannel = { channel ->
|
|
||||||
viewModel.leaveChannel(channel)
|
|
||||||
},
|
|
||||||
unreadChannelMessages = unreadChannelMessages
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// People section - switch between mesh and geohash lists (iOS-compatible)
|
|
||||||
item {
|
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsState()
|
|
||||||
|
|
||||||
when (selectedLocationChannel) {
|
|
||||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
|
||||||
// Show geohash people list when in location channel
|
|
||||||
GeohashPeopleList(
|
|
||||||
viewModel = viewModel,
|
|
||||||
onTapPerson = onDismiss
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
// Show mesh peer list when in mesh channel (default)
|
|
||||||
PeopleSection(
|
|
||||||
modifier = modifier.padding(bottom = 16.dp),
|
|
||||||
connectedPeers = connectedPeers,
|
|
||||||
peerNicknames = peerNicknames,
|
|
||||||
peerRSSI = peerRSSI,
|
|
||||||
nickname = nickname,
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
selectedPrivatePeer = selectedPrivatePeer,
|
|
||||||
viewModel = viewModel,
|
|
||||||
onShowVerification = onShowVerification,
|
|
||||||
onPrivateChatStart = { peerID ->
|
|
||||||
viewModel.startPrivateChat(peerID)
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SidebarHeader() {
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.height(42.dp) // Match reduced main header height
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(colorScheme.background.copy(alpha = 0.95f))
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.your_network).uppercase(),
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontFamily = FontFamily.Monospace
|
|
||||||
),
|
|
||||||
color = colorScheme.onSurface
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChannelsSection(
|
|
||||||
channels: List<String>,
|
|
||||||
currentChannel: String?,
|
|
||||||
colorScheme: ColorScheme,
|
|
||||||
onChannelClick: (String) -> Unit,
|
|
||||||
onLeaveChannel: (String) -> Unit,
|
|
||||||
unreadChannelMessages: Map<String, Int> = emptyMap()
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Person, // Using Person icon as placeholder
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(10.dp),
|
|
||||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.channels).uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
channels.forEach { channel ->
|
|
||||||
val isSelected = channel == currentChannel
|
|
||||||
val unreadCount = unreadChannelMessages[channel] ?: 0
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onChannelClick(channel) }
|
|
||||||
.background(
|
|
||||||
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
|
|
||||||
else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Unread badge for channels
|
|
||||||
UnreadBadge(
|
|
||||||
count = unreadCount,
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
modifier = Modifier.padding(end = 8.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = channel, // Channel already contains the # prefix
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (isSelected) colorScheme.primary else colorScheme.onSurface,
|
|
||||||
fontWeight = if (isSelected) FontWeight.Medium else FontWeight.Normal,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Leave channel button
|
|
||||||
IconButton(
|
|
||||||
onClick = { onLeaveChannel(channel) },
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_leave_channel),
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
tint = colorScheme.onSurface.copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PeopleSection(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
connectedPeers: List<String>,
|
|
||||||
peerNicknames: Map<String, String>,
|
|
||||||
peerRSSI: Map<String, Int>,
|
|
||||||
nickname: String,
|
|
||||||
colorScheme: ColorScheme,
|
|
||||||
selectedPrivatePeer: String?,
|
|
||||||
viewModel: ChatViewModel,
|
|
||||||
onShowVerification: () -> Unit,
|
|
||||||
onPrivateChatStart: (String) -> Unit
|
|
||||||
) {
|
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Group, // Using Person icon for people
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(12.dp),
|
|
||||||
tint = colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.people).uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.6f),
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
if (selectedLocationChannel !is com.bitchat.android.geohash.ChannelID.Location) {
|
|
||||||
IconButton(onClick = onShowVerification, modifier = Modifier.size(24.dp)) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Outlined.QrCode,
|
|
||||||
contentDescription = stringResource(R.string.verify_title),
|
|
||||||
tint = colorScheme.onSurface.copy(alpha = 0.8f),
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (connectedPeers.isEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = R.string.no_one_connected),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = colorScheme.onSurface.copy(alpha = 0.5f),
|
|
||||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observe reactive state for favorites and fingerprints
|
|
||||||
val hasUnreadPrivateMessages by viewModel.unreadPrivateMessages.collectAsStateWithLifecycle()
|
|
||||||
val privateChats by viewModel.privateChats.collectAsStateWithLifecycle()
|
|
||||||
val favoritePeers by viewModel.favoritePeers.collectAsStateWithLifecycle()
|
|
||||||
val peerFingerprints by viewModel.peerFingerprints.collectAsStateWithLifecycle()
|
|
||||||
val verifiedFingerprints by viewModel.verifiedFingerprints.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// Reactive favorite computation for all peers
|
|
||||||
val peerFavoriteStates = remember(favoritePeers, peerFingerprints, connectedPeers) {
|
|
||||||
connectedPeers.associateWith { peerID ->
|
|
||||||
// Reactive favorite computation - same as ChatHeader
|
|
||||||
val fingerprint = peerFingerprints[peerID]
|
|
||||||
fingerprint != null && favoritePeers.contains(fingerprint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build mapping of connected peerID -> noise key hex to unify with offline favorites
|
|
||||||
val noiseHexByPeerID: Map<String, String> = connectedPeers.associateWith { pid ->
|
|
||||||
try {
|
|
||||||
viewModel.meshService.getPeerInfo(pid)?.noisePublicKey?.hexEncodedString()
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}.filterValues { it != null }.mapValues { it.value!! }
|
|
||||||
|
|
||||||
val peerVerifiedStates = remember(verifiedFingerprints, peerFingerprints, connectedPeers) {
|
|
||||||
connectedPeers.associateWith { peerID ->
|
|
||||||
viewModel.isPeerVerified(peerID, verifiedFingerprints)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d("SidebarComponents", "Recomposing with ${favoritePeers.size} favorites, peer states: $peerFavoriteStates")
|
|
||||||
|
|
||||||
// Smart sorting: unread DMs first, then by most recent DM, then favorites, then alphabetical
|
|
||||||
val sortedPeers = connectedPeers.sortedWith(
|
|
||||||
compareBy<String> { !hasUnreadPrivateMessages.contains(it) } // Unread DM senders first
|
|
||||||
.thenByDescending { privateChats[it]?.maxByOrNull { msg -> msg.timestamp }?.timestamp?.time ?: 0L } // Most recent DM (convert Date to Long)
|
|
||||||
.thenBy { !(peerFavoriteStates[it] ?: false) } // Favorites first
|
|
||||||
.thenBy { (if (it == nickname) "You" else (peerNicknames[it] ?: it)).lowercase() } // Alphabetical
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build a map of base name counts across all people shown in the list (connected + offline + nostr)
|
|
||||||
val hex64Regex = Regex("^[0-9a-fA-F]{64}$")
|
|
||||||
|
|
||||||
// Helper to compute display name used for a given key
|
|
||||||
fun computeDisplayNameForPeerId(key: String): String {
|
|
||||||
return if (key == nickname) "You" else (peerNicknames[key] ?: (privateChats[key]?.lastOrNull()?.sender ?: key.take(12)))
|
|
||||||
}
|
|
||||||
|
|
||||||
val baseNameCounts = mutableMapOf<String, Int>()
|
|
||||||
|
|
||||||
// Connected peers
|
|
||||||
sortedPeers.forEach { pid ->
|
|
||||||
val dn = computeDisplayNameForPeerId(pid)
|
|
||||||
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
|
|
||||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offline favorites (exclude ones mapped to connected)
|
|
||||||
val offlineFavorites = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites()
|
|
||||||
offlineFavorites.forEach { fav ->
|
|
||||||
val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
|
|
||||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
|
||||||
if (!isMappedToConnected) {
|
|
||||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
|
||||||
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
|
|
||||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nostr-only conversations
|
|
||||||
val connectedIds = sortedPeers.toSet()
|
|
||||||
val appendedOfflineIds = mutableSetOf<String>()
|
|
||||||
privateChats.keys
|
|
||||||
.filter { key ->
|
|
||||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
|
||||||
!connectedIds.contains(key) &&
|
|
||||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
.forEach { convKey ->
|
|
||||||
val dn = peerNicknames[convKey] ?: (privateChats[convKey]?.lastOrNull()?.sender ?: convKey.take(12))
|
|
||||||
val (b, _) = com.bitchat.android.ui.splitSuffix(dn)
|
|
||||||
if (b != "You") baseNameCounts[b] = (baseNameCounts[b] ?: 0) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
sortedPeers.forEach { peerID ->
|
|
||||||
val isFavorite = peerFavoriteStates[peerID] ?: false
|
|
||||||
val isVerified = peerVerifiedStates[peerID] ?: false
|
|
||||||
// fingerprint and favorite relationship resolution not needed here; UI will show Nostr globe for appended offline favorites below
|
|
||||||
|
|
||||||
val noiseHex = noiseHexByPeerID[peerID]
|
|
||||||
val meshUnread = hasUnreadPrivateMessages.contains(peerID)
|
|
||||||
val nostrUnread = if (noiseHex != null) hasUnreadPrivateMessages.contains(noiseHex) else false
|
|
||||||
val combinedHasUnread = meshUnread || nostrUnread
|
|
||||||
val combinedUnreadCount = (
|
|
||||||
privateChats[peerID]?.count { msg -> msg.sender != nickname && meshUnread } ?: 0
|
|
||||||
) + (
|
|
||||||
if (noiseHex != null) privateChats[noiseHex]?.count { msg -> msg.sender != nickname && nostrUnread } ?: 0 else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
val displayName = if (peerID == nickname) "You" else (peerNicknames[peerID] ?: (privateChats[peerID]?.lastOrNull()?.sender ?: peerID.take(12)))
|
|
||||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(displayName)
|
|
||||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
|
||||||
|
|
||||||
val directMap by viewModel.peerDirect.collectAsStateWithLifecycle()
|
|
||||||
val isDirectLive = directMap[peerID] ?: try { viewModel.meshService.getPeerInfo(peerID)?.isDirectConnection == true } catch (_: Exception) { false }
|
|
||||||
PeerItem(
|
|
||||||
peerID = peerID,
|
|
||||||
displayName = displayName,
|
|
||||||
isDirect = isDirectLive,
|
|
||||||
isSelected = peerID == selectedPrivatePeer,
|
|
||||||
isFavorite = isFavorite,
|
|
||||||
isVerified = isVerified,
|
|
||||||
hasUnreadDM = combinedHasUnread,
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
viewModel = viewModel,
|
|
||||||
onItemClick = { onPrivateChatStart(peerID) },
|
|
||||||
onToggleFavorite = {
|
|
||||||
Log.d("SidebarComponents", "Sidebar toggle favorite: peerID=$peerID, currentFavorite=$isFavorite")
|
|
||||||
viewModel.toggleFavorite(peerID)
|
|
||||||
},
|
|
||||||
unreadCount = if (combinedUnreadCount > 0) combinedUnreadCount else if (combinedHasUnread) 1 else 0,
|
|
||||||
showNostrGlobe = false,
|
|
||||||
showHashSuffix = showHash
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append offline favorites we actively favorite (and not currently connected)
|
|
||||||
offlineFavorites.forEach { fav ->
|
|
||||||
val favPeerID = fav.peerNoisePublicKey.hexEncodedString()
|
|
||||||
// If any connected peer maps to this noise key, skip showing the offline entry
|
|
||||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
|
||||||
if (isMappedToConnected) return@forEach
|
|
||||||
|
|
||||||
// Resolve potential Nostr conversation key for this favorite (for unread detection)
|
|
||||||
val nostrConvKey: String? = try {
|
|
||||||
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
|
||||||
if (npubOrHex != null) {
|
|
||||||
val hex = if (npubOrHex.startsWith("npub")) {
|
|
||||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
|
||||||
if (hrp == "npub") data.hexEncodedString() else null
|
|
||||||
} else {
|
|
||||||
npubOrHex.lowercase()
|
|
||||||
}
|
|
||||||
hex?.let { "nostr_${it.take(16)}" }
|
|
||||||
} else null
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
|
|
||||||
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
|
|
||||||
|
|
||||||
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
|
|
||||||
// open chat with the connected peerID instead of the noise hex for a seamless window
|
|
||||||
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
|
|
||||||
val dn = peerNicknames[favPeerID] ?: fav.peerNickname
|
|
||||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
|
||||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
|
||||||
val isVerified = viewModel.isNoisePublicKeyVerified(fav.peerNoisePublicKey, verifiedFingerprints)
|
|
||||||
|
|
||||||
// Compute unreadCount from either noise conversation or Nostr conversation
|
|
||||||
val unreadCount = (
|
|
||||||
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
|
|
||||||
) + (
|
|
||||||
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
PeerItem(
|
|
||||||
peerID = favPeerID,
|
|
||||||
displayName = dn,
|
|
||||||
isDirect = false,
|
|
||||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
|
||||||
isFavorite = true,
|
|
||||||
isVerified = isVerified,
|
|
||||||
hasUnreadDM = hasUnread,
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
viewModel = viewModel,
|
|
||||||
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
|
||||||
onToggleFavorite = {
|
|
||||||
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
|
|
||||||
viewModel.toggleFavorite(favPeerID)
|
|
||||||
},
|
|
||||||
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
|
|
||||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
|
||||||
showHashSuffix = showHash
|
|
||||||
)
|
|
||||||
appendedOfflineIds.add(favPeerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
|
|
||||||
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
|
|
||||||
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
|
|
||||||
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
|
|
||||||
/*
|
|
||||||
val alreadyShownIds = connectedIds + appendedOfflineIds
|
|
||||||
privateChats.keys
|
|
||||||
.filter { key ->
|
|
||||||
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
|
|
||||||
hex64Regex.matches(key) &&
|
|
||||||
!alreadyShownIds.contains(key) &&
|
|
||||||
// Skip if this key maps to a connected peer via noiseHex mapping
|
|
||||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
|
||||||
}
|
|
||||||
.sortedBy { key -> privateChats[key]?.lastOrNull()?.timestamp }
|
|
||||||
.forEach { convKey ->
|
|
||||||
val lastSender = privateChats[convKey]?.lastOrNull()?.sender
|
|
||||||
val dn = peerNicknames[convKey] ?: (lastSender ?: convKey.take(12))
|
|
||||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
|
||||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
|
||||||
|
|
||||||
PeerItem(
|
|
||||||
peerID = convKey,
|
|
||||||
displayName = dn,
|
|
||||||
isDirect = false,
|
|
||||||
isSelected = convKey == selectedPrivatePeer,
|
|
||||||
isFavorite = false,
|
|
||||||
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
viewModel = viewModel,
|
|
||||||
onItemClick = { onPrivateChatStart(convKey) },
|
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
|
|
||||||
unreadCount = privateChats[convKey]?.count { msg ->
|
|
||||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
|
||||||
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
|
||||||
showNostrGlobe = false,
|
|
||||||
showHashSuffix = showHash
|
|
||||||
)
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
// End intentional removal
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PeerItem(
|
|
||||||
peerID: String,
|
|
||||||
displayName: String,
|
|
||||||
isDirect: Boolean,
|
|
||||||
isSelected: Boolean,
|
|
||||||
isFavorite: Boolean,
|
|
||||||
isVerified: Boolean,
|
|
||||||
hasUnreadDM: Boolean,
|
|
||||||
colorScheme: ColorScheme,
|
|
||||||
viewModel: ChatViewModel,
|
|
||||||
onItemClick: () -> Unit,
|
|
||||||
onToggleFavorite: () -> Unit,
|
|
||||||
unreadCount: Int = 0,
|
|
||||||
showNostrGlobe: Boolean = false,
|
|
||||||
showHashSuffix: Boolean = true
|
|
||||||
) {
|
|
||||||
// Split display name for hashtag suffix support (iOS-compatible)
|
|
||||||
val (baseNameRaw, suffixRaw) = com.bitchat.android.ui.splitSuffix(displayName)
|
|
||||||
val baseName = truncateNickname(baseNameRaw)
|
|
||||||
val suffix = if (showHashSuffix) suffixRaw else ""
|
|
||||||
val isMe = displayName == "You" || peerID == viewModel.nickname.value
|
|
||||||
|
|
||||||
// Get consistent peer color (iOS-compatible)
|
|
||||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
|
||||||
val assignedColor = viewModel.colorForMeshPeer(peerID, isDark)
|
|
||||||
val baseColor = if (isMe) Color(0xFFFF9500) else assignedColor
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onItemClick() }
|
|
||||||
.background(
|
|
||||||
if (isSelected) colorScheme.primaryContainer.copy(alpha = 0.3f)
|
|
||||||
else Color.Transparent
|
|
||||||
)
|
|
||||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Show unread badge or signal strength
|
|
||||||
if (hasUnreadDM) {
|
|
||||||
// Show mail icon for unread DMs (iOS orange)
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Email,
|
|
||||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_unread_message),
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color(0xFFFF9500) // iOS orange
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
// Connection indicator icons
|
|
||||||
if (showNostrGlobe) {
|
|
||||||
// Purple globe to indicate Nostr availability
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Public,
|
|
||||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_reachable_via_nostr),
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color(0xFF9C27B0) // Purple
|
|
||||||
)
|
|
||||||
} else if (!isDirect && isFavorite) {
|
|
||||||
// Offline favorited user: show outlined circle icon
|
|
||||||
Icon(
|
|
||||||
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
|
|
||||||
imageVector = Icons.Outlined.Circle,
|
|
||||||
contentDescription = stringResource(com.bitchat.android.R.string.cd_offline_favorite),
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color.Gray
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
|
|
||||||
contentDescription = if (isDirect) "Direct Bluetooth" else "Routed",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = colorScheme.onSurface.copy(alpha = 0.8f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
|
|
||||||
// Display name with iOS-style color and hashtag suffix support
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
// Base name with peer-specific color
|
|
||||||
Text(
|
|
||||||
text = baseName,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = BASE_FONT_SIZE.sp,
|
|
||||||
fontWeight = if (isMe) FontWeight.Bold else FontWeight.Normal
|
|
||||||
),
|
|
||||||
color = baseColor,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
|
|
||||||
// Hashtag suffix in lighter shade (iOS-style)
|
|
||||||
if (suffix.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = suffix,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontSize = BASE_FONT_SIZE.sp
|
|
||||||
),
|
|
||||||
color = baseColor.copy(alpha = 0.6f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isVerified) {
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Verified,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(14.dp),
|
|
||||||
tint = Color(0xFF32D74B)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Favorite star with proper filled/outlined states
|
|
||||||
IconButton(
|
|
||||||
onClick = onToggleFavorite,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isFavorite) Icons.Filled.Star else Icons.Outlined.Star,
|
|
||||||
contentDescription = if (isFavorite) "Remove from favorites" else "Add to favorites",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = if (isFavorite) Color(0xFFFFD700) else Color(0xFF4CAF50)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SignalStrengthIndicator(
|
|
||||||
signalStrength: Int,
|
|
||||||
colorScheme: ColorScheme
|
|
||||||
) {
|
|
||||||
Row(modifier = Modifier.width(24.dp)) {
|
|
||||||
repeat(3) { index ->
|
|
||||||
val opacity = when {
|
|
||||||
signalStrength >= (index + 1) * 33 -> 1f
|
|
||||||
else -> 0.2f
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 3.dp, height = (4 + index * 2).dp)
|
|
||||||
.background(
|
|
||||||
colorScheme.onSurface.copy(alpha = opacity),
|
|
||||||
RoundedCornerShape(1.dp)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (index < 2) Spacer(modifier = Modifier.width(2.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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