mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 18:45:20 +00:00
try 1
This commit is contained in:
@@ -28,6 +28,7 @@ import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||
import com.bitchat.android.geohash.LocationChannelManager.PermissionState
|
||||
|
||||
/**
|
||||
* Header components for ChatScreen
|
||||
@@ -233,7 +234,8 @@ fun ChatHeaderContent(
|
||||
onSidebarClick: () -> Unit,
|
||||
onTripleClick: () -> Unit,
|
||||
onShowAppInfo: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit
|
||||
onLocationChannelsClick: () -> Unit,
|
||||
onLocationNotesClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
@@ -289,6 +291,7 @@ fun ChatHeaderContent(
|
||||
onTripleTitleClick = onTripleClick,
|
||||
onSidebarClick = onSidebarClick,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = onLocationNotesClick,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
@@ -510,6 +513,7 @@ private fun MainHeader(
|
||||
onTripleTitleClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit,
|
||||
onLocationNotesClick: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@@ -525,6 +529,15 @@ private fun MainHeader(
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val bookmarksStore = remember { com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(context) }
|
||||
val bookmarks by bookmarksStore.bookmarks.observeAsState(emptyList())
|
||||
|
||||
// Location notes counter (iOS parity)
|
||||
val notesCounter = remember { com.bitchat.android.nostr.LocationNotesCounter }
|
||||
val notesCount by notesCounter.count.observeAsState(0)
|
||||
|
||||
// Location channel manager for permission state
|
||||
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val locationPermissionGranted = permissionState == PermissionState.AUTHORIZED
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -571,6 +584,23 @@ private fun MainHeader(
|
||||
tint = Color(0xFFFF9500)
|
||||
)
|
||||
}
|
||||
|
||||
// Location Notes button (mesh only, when location is authorized)
|
||||
// iOS: Shows to the left of #mesh badge when in mesh mode and location permitted
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Mesh && locationPermissionGranted) {
|
||||
val hasNotes = (notesCount ?: 0) > 0
|
||||
IconButton(
|
||||
onClick = onLocationNotesClick,
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Description, // "long.text.page.and.pencil" equivalent
|
||||
contentDescription = stringResource(R.string.cd_location_notes),
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = if (hasNotes) colorScheme.primary else Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Location channels button (matching iOS implementation) and bookmark grouped tightly
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) {
|
||||
|
||||
@@ -63,6 +63,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||
var passwordInput by remember { mutableStateOf("") }
|
||||
var showLocationChannelsSheet by remember { mutableStateOf(false) }
|
||||
var showLocationNotesSheet by remember { mutableStateOf(false) }
|
||||
var showUserSheet by remember { mutableStateOf(false) }
|
||||
var selectedUserForSheet by remember { mutableStateOf("") }
|
||||
var selectedMessageForSheet by remember { mutableStateOf<BitchatMessage?>(null) }
|
||||
@@ -241,7 +242,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
onSidebarToggle = { viewModel.showSidebar() },
|
||||
onShowAppInfo = { viewModel.showAppInfo() },
|
||||
onPanicClear = { viewModel.panicClearAllData() },
|
||||
onLocationChannelsClick = { showLocationChannelsSheet = true }
|
||||
onLocationChannelsClick = { showLocationChannelsSheet = true },
|
||||
onLocationNotesClick = { showLocationNotesSheet = true }
|
||||
)
|
||||
|
||||
// Divider under header - positioned after status bar + header height
|
||||
@@ -354,6 +356,8 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
onAppInfoDismiss = { viewModel.hideAppInfo() },
|
||||
showLocationChannelsSheet = showLocationChannelsSheet,
|
||||
onLocationChannelsSheetDismiss = { showLocationChannelsSheet = false },
|
||||
showLocationNotesSheet = showLocationNotesSheet,
|
||||
onLocationNotesSheetDismiss = { showLocationNotesSheet = false },
|
||||
showUserSheet = showUserSheet,
|
||||
onUserSheetDismiss = {
|
||||
showUserSheet = false
|
||||
@@ -435,7 +439,8 @@ private fun ChatFloatingHeader(
|
||||
onSidebarToggle: () -> Unit,
|
||||
onShowAppInfo: () -> Unit,
|
||||
onPanicClear: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit
|
||||
onLocationChannelsClick: () -> Unit,
|
||||
onLocationNotesClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
@@ -460,7 +465,8 @@ private fun ChatFloatingHeader(
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = onLocationNotesClick
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -471,6 +477,7 @@ private fun ChatFloatingHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChatDialogs(
|
||||
showPasswordDialog: Boolean,
|
||||
@@ -483,6 +490,8 @@ private fun ChatDialogs(
|
||||
onAppInfoDismiss: () -> Unit,
|
||||
showLocationChannelsSheet: Boolean,
|
||||
onLocationChannelsSheetDismiss: () -> Unit,
|
||||
showLocationNotesSheet: Boolean,
|
||||
onLocationNotesSheetDismiss: () -> Unit,
|
||||
showUserSheet: Boolean,
|
||||
onUserSheetDismiss: () -> Unit,
|
||||
selectedUserForSheet: String,
|
||||
@@ -523,6 +532,68 @@ private fun ChatDialogs(
|
||||
)
|
||||
}
|
||||
|
||||
// Location notes sheet
|
||||
if (showLocationNotesSheet) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
|
||||
// Get building-level geohash (precision 8 for iOS compatibility)
|
||||
val buildingGeohash = availableChannels.firstOrNull { it.level == com.bitchat.android.geohash.GeohashChannelLevel.BUILDING }?.geohash
|
||||
|
||||
if (buildingGeohash != null) {
|
||||
// Get location name from locationManager
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationName = locationNames[com.bitchat.android.geohash.GeohashChannelLevel.BUILDING]
|
||||
?: locationNames[com.bitchat.android.geohash.GeohashChannelLevel.BLOCK]
|
||||
|
||||
LocationNotesSheet(
|
||||
geohash = buildingGeohash,
|
||||
locationName = locationName,
|
||||
nickname = nickname,
|
||||
onDismiss = onLocationNotesSheetDismiss
|
||||
)
|
||||
|
||||
// Subscribe to notes counter when sheet is shown
|
||||
LaunchedEffect(buildingGeohash) {
|
||||
com.bitchat.android.nostr.LocationNotesCounter.subscribe(buildingGeohash)
|
||||
}
|
||||
} else {
|
||||
// No block geohash available - show error state
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onLocationNotesSheetDismiss,
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = "Location Unavailable",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Location permission is required for notes",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Button(onClick = {
|
||||
locationManager.enableLocationChannels()
|
||||
locationManager.refreshChannels()
|
||||
}) {
|
||||
Text("Enable Location")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User action sheet
|
||||
if (showUserSheet) {
|
||||
ChatUserSheet(
|
||||
|
||||
@@ -147,6 +147,9 @@ class ChatViewModel(
|
||||
mediaSendingManager.handleTransferProgressEvent(evt)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize location notes counter subscription management (iOS parity)
|
||||
initializeLocationNotesCounterSubscription()
|
||||
}
|
||||
|
||||
fun cancelMediaSend(messageId: String) {
|
||||
@@ -591,6 +594,70 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize location notes counter subscription management (iOS parity)
|
||||
* Subscribes/unsubscribes based on channel selection and location permission
|
||||
*/
|
||||
private fun initializeLocationNotesCounterSubscription() {
|
||||
viewModelScope.launch {
|
||||
// Observe channel changes
|
||||
selectedLocationChannel.observeForever { channel ->
|
||||
updateNotesCounterSubscription(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notes counter subscription based on current channel and location permission
|
||||
* iOS pattern: Subscribe when in mesh mode and location is authorized
|
||||
*/
|
||||
private fun updateNotesCounterSubscription(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||
try {
|
||||
val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||
val permissionState = locationManager.permissionState.value
|
||||
val locationPermissionGranted = permissionState == com.bitchat.android.geohash.LocationChannelManager.PermissionState.AUTHORIZED
|
||||
|
||||
when (channel) {
|
||||
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||
// Mesh mode: subscribe to block-level geohash notes if location authorized
|
||||
if (locationPermissionGranted) {
|
||||
// Refresh location to get current block geohash
|
||||
locationManager.refreshChannels()
|
||||
|
||||
// Get building-level geohash (precision 8, same as iOS)
|
||||
val buildingGeohash = locationManager.availableChannels.value
|
||||
?.firstOrNull { it.level == com.bitchat.android.geohash.GeohashChannelLevel.BUILDING }
|
||||
?.geohash
|
||||
|
||||
if (buildingGeohash != null) {
|
||||
Log.d(TAG, "📍 Subscribing to location notes for building geohash: $buildingGeohash")
|
||||
com.bitchat.android.nostr.LocationNotesCounter.subscribe(buildingGeohash)
|
||||
} else {
|
||||
// Keep existing subscription if we had one (avoid flicker)
|
||||
if (com.bitchat.android.nostr.LocationNotesCounter.geohash.value == null) {
|
||||
com.bitchat.android.nostr.LocationNotesCounter.cancel()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
com.bitchat.android.nostr.LocationNotesCounter.cancel()
|
||||
}
|
||||
}
|
||||
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||
// Location channel mode: cancel counter (only show in mesh mode)
|
||||
com.bitchat.android.nostr.LocationNotesCounter.cancel()
|
||||
}
|
||||
null -> {
|
||||
// Default to mesh behavior
|
||||
if (locationPermissionGranted) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "updateNotesCounterSubscription failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI)
|
||||
*/
|
||||
|
||||
@@ -713,6 +713,7 @@ private fun geohashTitleWithCount(channel: GeohashChannel, participantCount: Int
|
||||
val ctx = androidx.compose.ui.platform.LocalContext.current
|
||||
val peopleText = ctx.resources.getQuantityString(com.bitchat.android.R.plurals.people_count, participantCount, participantCount)
|
||||
val levelName = when (channel.level) {
|
||||
com.bitchat.android.geohash.GeohashChannelLevel.BUILDING -> "Building" // iOS: precision 8 for location notes
|
||||
com.bitchat.android.geohash.GeohashChannelLevel.BLOCK -> stringResource(com.bitchat.android.R.string.location_level_block)
|
||||
com.bitchat.android.geohash.GeohashChannelLevel.NEIGHBORHOOD -> stringResource(com.bitchat.android.R.string.location_level_neighborhood)
|
||||
com.bitchat.android.geohash.GeohashChannelLevel.CITY -> stringResource(com.bitchat.android.R.string.location_level_city)
|
||||
@@ -749,7 +750,8 @@ private fun levelForLength(length: Int): GeohashChannelLevel {
|
||||
5 -> GeohashChannelLevel.CITY
|
||||
6 -> GeohashChannelLevel.NEIGHBORHOOD
|
||||
7 -> GeohashChannelLevel.BLOCK
|
||||
else -> GeohashChannelLevel.BLOCK
|
||||
8 -> GeohashChannelLevel.BUILDING // iOS: precision 8 for building-level
|
||||
else -> if (length > 8) GeohashChannelLevel.BUILDING else GeohashChannelLevel.BLOCK
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
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.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Send
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
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.platform.LocalContext
|
||||
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.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesCounter
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
|
||||
/**
|
||||
* Location Notes Sheet - Material Design bottom sheet for location-based notes
|
||||
* iOS-compatible implementation with Android Material Design
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LocationNotesSheet(
|
||||
geohash: String,
|
||||
locationName: String?,
|
||||
nickname: String?,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
// Managers
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val counter = remember { LocationNotesCounter }
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
|
||||
// State
|
||||
val notes by notesManager.notes.observeAsState(emptyList())
|
||||
val state by notesManager.state.observeAsState(LocationNotesManager.State.IDLE)
|
||||
val errorMessage by notesManager.errorMessage.observeAsState()
|
||||
val count by counter.count.observeAsState(0)
|
||||
val initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false)
|
||||
|
||||
// Input field state
|
||||
var messageText by remember { mutableStateOf("") }
|
||||
val canSend = messageText.isNotBlank()
|
||||
|
||||
// Scroll state
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Effect to set geohash when sheet opens
|
||||
LaunchedEffect(geohash) {
|
||||
notesManager.setGeohash(geohash)
|
||||
counter.subscribe(geohash)
|
||||
}
|
||||
|
||||
// Cleanup when sheet closes
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
// Don't cancel subscriptions here - they'll be managed by the manager lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = modifier,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(0.9f)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
// Header
|
||||
LocationNotesHeader(
|
||||
geohash = geohash,
|
||||
locationName = locationName,
|
||||
count = count,
|
||||
onClose = onDismiss
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Content area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
when {
|
||||
state == LocationNotesManager.State.NO_RELAYS -> {
|
||||
EmptyStateMessage(
|
||||
message = "No relays available",
|
||||
icon = "🌐"
|
||||
)
|
||||
}
|
||||
state == LocationNotesManager.State.LOADING && !initialLoadComplete -> {
|
||||
LoadingState()
|
||||
}
|
||||
notes.isEmpty() && initialLoadComplete -> {
|
||||
EmptyStateMessage(
|
||||
message = "No notes yet.\nBe the first to share!",
|
||||
icon = "📝"
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LocationNotesList(
|
||||
notes = notes,
|
||||
listState = listState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error message
|
||||
errorMessage?.let { error ->
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Input field
|
||||
LocationNotesInputField(
|
||||
text = messageText,
|
||||
onTextChange = { messageText = it },
|
||||
onSend = {
|
||||
if (canSend) {
|
||||
notesManager.send(messageText, nickname)
|
||||
messageText = ""
|
||||
}
|
||||
},
|
||||
enabled = state != LocationNotesManager.State.NO_RELAYS,
|
||||
canSend = canSend
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Header with geohash, count, location name, and close button
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationNotesHeader(
|
||||
geohash: String,
|
||||
locationName: String?,
|
||||
count: Int,
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = geohash,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "($count)",
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
locationName?.let { name ->
|
||||
Text(
|
||||
text = name,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Close",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List of location notes
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationNotesList(
|
||||
notes: List<LocationNotesManager.Note>,
|
||||
listState: androidx.compose.foundation.lazy.LazyListState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier,
|
||||
reverseLayout = false, // Normal order (newest at top since sorted desc)
|
||||
contentPadding = PaddingValues(vertical = 8.dp)
|
||||
) {
|
||||
items(notes, key = { it.id }) { note ->
|
||||
LocationNoteItem(note = note)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual note item
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationNoteItem(note: LocationNotesManager.Note) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = note.displayName,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = formatTimestamp(note.createdAt),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
Text(
|
||||
text = note.content,
|
||||
fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input field with send button
|
||||
*/
|
||||
@Composable
|
||||
private fun LocationNotesInputField(
|
||||
text: String,
|
||||
onTextChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
enabled: Boolean,
|
||||
canSend: Boolean
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = {
|
||||
Text(
|
||||
text = if (enabled) "Share a note..." else "No relays available",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
},
|
||||
enabled = enabled,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent
|
||||
),
|
||||
maxLines = 3
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (canSend && enabled) {
|
||||
Color(0xFFFF8C00) // Orange color when enabled
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
)
|
||||
.clickable(enabled = canSend && enabled) { onSend() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Send,
|
||||
contentDescription = "Send",
|
||||
tint = if (canSend && enabled) Color.White else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state indicator
|
||||
*/
|
||||
@Composable
|
||||
private fun LoadingState() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(48.dp),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Loading notes...",
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty state message
|
||||
*/
|
||||
@Composable
|
||||
private fun EmptyStateMessage(message: String, icon: String) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
fontSize = 48.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = message,
|
||||
fontSize = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp - iOS-compatible formatting
|
||||
* Relative for < 7 days, absolute otherwise
|
||||
*/
|
||||
private fun formatTimestamp(createdAt: Int): String {
|
||||
val now = System.currentTimeMillis() / 1000
|
||||
val diff = now - createdAt
|
||||
|
||||
return when {
|
||||
diff < 60 -> "just now"
|
||||
diff < 3600 -> {
|
||||
val minutes = diff / 60
|
||||
"${minutes}m ago"
|
||||
}
|
||||
diff < 86400 -> {
|
||||
val hours = diff / 3600
|
||||
"${hours}h ago"
|
||||
}
|
||||
diff < 604800 -> { // 7 days
|
||||
val days = diff / 86400
|
||||
"${days}d ago"
|
||||
}
|
||||
else -> {
|
||||
// Absolute date for older than 7 days
|
||||
val date = Date(createdAt * 1000L)
|
||||
val formatter = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
|
||||
formatter.format(date)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user