package com.bitchat.android.ui import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme 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.material.icons.Icons import androidx.compose.material.icons.filled.ArrowUpward 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.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bitchat.android.geohash.GeohashChannelLevel import com.bitchat.android.geohash.LocationChannelManager import com.bitchat.android.nostr.LocationNotesManager import java.text.SimpleDateFormat import java.util.* import java.util.Calendar /** * Location Notes Sheet - EXACT iOS UI match for bitchat * Matches iOS LocationNotesView.swift exactly in style, colors, fonts, and text */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun LocationNotesSheet( geohash: String, locationName: String?, nickname: String?, onDismiss: () -> Unit, modifier: Modifier = Modifier ) { val context = LocalContext.current val isDark = isSystemInDarkTheme() // iOS color scheme val backgroundColor = if (isDark) Color.Black else Color.White val accentGreen = if (isDark) Color.Green else Color(0xFF008000) // dark: green, light: dark green (0, 0.5, 0) // Managers val notesManager = remember { LocationNotesManager.getInstance() } 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 initialLoadComplete by notesManager.initialLoadComplete.observeAsState(false) // SIMPLIFIED: Get count directly from notes list (no separate counter needed) val count = notes.size // Get location name (building or block) - matches iOS locationNames lookup val locationNames by locationManager.locationNames.observeAsState(emptyMap()) val displayLocationName = locationNames[GeohashChannelLevel.BUILDING]?.takeIf { it.isNotEmpty() } ?: locationNames[GeohashChannelLevel.BLOCK]?.takeIf { it.isNotEmpty() } // Input field state var draft by remember { mutableStateOf("") } val sendButtonEnabled = draft.trim().isNotEmpty() && state != LocationNotesManager.State.NO_RELAYS // Scroll state val listState = rememberLazyListState() // Effect to set geohash when sheet opens LaunchedEffect(geohash) { notesManager.setGeohash(geohash) } // Cleanup when sheet closes DisposableEffect(Unit) { onDispose { notesManager.cancel() } } ModalBottomSheet( onDismissRequest = onDismiss, modifier = modifier, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), containerColor = backgroundColor, contentColor = if (isDark) Color.White else Color.Black ) { Column( modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.9f) ) { // 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( state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) ) { // Notes content (matches iOS notesContent) when { state == LocationNotesManager.State.NO_RELAYS -> { item { NoRelaysRow( onRetry = { notesManager.refresh() } ) } } 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(12.dp)) } } } // Error row (matches iOS errorRow) errorMessage?.let { error -> if (state != LocationNotesManager.State.NO_RELAYS) { item { ErrorRow( message = error, onDismiss = { notesManager.clearError() } ) } } } } } // Divider before input (matches iOS overlay) HorizontalDivider( modifier = Modifier.fillMaxWidth(), color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f), thickness = 1.dp ) // 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 = "" } } ) } } } /** * Header section - matches iOS headerSection exactly * Shows: "#geohash • X notes", location name, description, and close button */ @Composable private fun LocationNotesHeader( geohash: String, count: Int, locationName: String?, state: LocationNotesManager.State, accentGreen: Color, backgroundColor: Color, onClose: () -> Unit ) { Column( modifier = Modifier .fillMaxWidth() .background(backgroundColor) .padding(horizontal = 16.dp) .padding(top = 16.dp, bottom = 12.dp) ) { // Title row with close button Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // "#geohash ± 1 • X notes" to indicate neighboring cells are included Text( text = "#$geohash ± 1 • $count ${if (count == 1) "note" else "notes"}", 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)) // Location name in green (building or block) locationName?.let { name -> if (name.isNotEmpty()) { Text( text = name, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = accentGreen ) Spacer(modifier = Modifier.height(8.dp)) } } // Description - iOS exact text Text( text = "add short permanent notes to this location for other visitors to find.", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) // Relays paused message if no relays if (state == LocationNotesManager.State.NO_RELAYS) { Spacer(modifier = Modifier.height(4.dp)) Text( text = "geo relays unavailable; notes paused", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } } /** * Note row - matches iOS noteRow exactly * Shows @basename then timestamp, then content below */ @Composable private fun NoteRow(note: LocationNotesManager.Note) { // Extract baseName (before #suffix like iOS) val baseName = note.displayName.split("#", limit = 2).firstOrNull() ?: note.displayName val ts = timestampText(note.createdAt) Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) ) { // First row: @nickname and timestamp Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically ) { Text( text = "@$baseName", fontFamily = FontFamily.Monospace, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) if (ts.isNotEmpty()) { Spacer(modifier = Modifier.width(6.dp)) Text( text = ts, fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } Spacer(modifier = Modifier.height(2.dp)) // Second row: content Text( text = note.content, fontFamily = FontFamily.Monospace, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurface ) } } /** * No relays row - matches iOS noRelaysRow */ @Composable private fun NoRelaysRow(onRetry: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp) ) { Text( text = "no geo relays nearby", fontFamily = FontFamily.Monospace, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "notes rely on geo relays. check connection and try again.", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "retry", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onRetry) ) } } /** * Loading row - matches iOS loadingRow */ @Composable private fun LoadingRow() { Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator( modifier = Modifier.size(16.dp), strokeWidth = 2.dp ) Spacer(modifier = Modifier.width(10.dp)) Text( text = "loading notes…", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } /** * Empty row - matches iOS emptyRow */ @Composable private fun EmptyRow() { Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp) ) { Text( text = "no notes yet", fontFamily = FontFamily.Monospace, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "be the first to add one for this spot.", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) ) } } /** * Error row - matches iOS errorRow */ @Composable private fun ErrorRow(message: String, onDismiss: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp) ) { Row( horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically ) { Text( text = "⚠", fontSize = 12.sp ) Spacer(modifier = Modifier.width(6.dp)) Text( text = message, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface ) } Spacer(modifier = Modifier.height(4.dp)) Text( text = "dismiss", fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.clickable(onClick = onDismiss) ) } } /** * Input section - matches main chat input exactly */ @Composable private fun LocationNotesInputSection( draft: String, onDraftChange: (String) -> Unit, sendButtonEnabled: Boolean, accentGreen: Color, backgroundColor: Color, onSend: () -> Unit ) { val isDark = isSystemInDarkTheme() val colorScheme = MaterialTheme.colorScheme Row( modifier = Modifier .fillMaxWidth() .background(backgroundColor) .padding(horizontal = 12.dp, vertical = 8.dp), // Match main chat padding verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) // Match main chat spacing ) { // Text input with placeholder overlay (matches main chat exactly) Box( modifier = Modifier.weight(1f) ) { androidx.compose.foundation.text.BasicTextField( value = draft, onValueChange = onDraftChange, textStyle = MaterialTheme.typography.bodyMedium.copy( color = colorScheme.primary, fontFamily = FontFamily.Monospace ), cursorBrush = androidx.compose.ui.graphics.SolidColor(colorScheme.primary), keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( imeAction = androidx.compose.ui.text.input.ImeAction.Send ), keyboardActions = androidx.compose.foundation.text.KeyboardActions( onSend = { if (sendButtonEnabled) onSend() } ), modifier = Modifier.fillMaxWidth() ) // Placeholder when empty (matches main chat) if (draft.isEmpty()) { Text( text = "add a note for this place", style = MaterialTheme.typography.bodyMedium.copy( fontFamily = FontFamily.Monospace ), color = colorScheme.onSurface.copy(alpha = 0.5f), modifier = Modifier.fillMaxWidth() ) } } // Send button - circular with icon (matches main chat exactly) IconButton( onClick = { if (sendButtonEnabled) onSend() }, enabled = sendButtonEnabled, modifier = Modifier.size(32.dp) ) { Box( modifier = Modifier .size(30.dp) .background( color = if (!sendButtonEnabled) { colorScheme.onSurface.copy(alpha = 0.3f) } else { accentGreen.copy(alpha = 0.75f) }, shape = CircleShape ), contentAlignment = Alignment.Center ) { Icon( imageVector = Icons.Filled.ArrowUpward, contentDescription = "Send", modifier = Modifier.size(20.dp), tint = if (!sendButtonEnabled) { colorScheme.onSurface.copy(alpha = 0.5f) } else if (isDark) { Color.Black // Black arrow on green in dark theme } else { Color.White // White arrow on green in light theme } ) } } } } /** * Timestamp text - matches iOS timestampText exactly * Shows relative time for < 7 days, absolute date otherwise */ private fun timestampText(createdAt: Int): String { val date = Date(createdAt * 1000L) val now = Date() // Calculate days difference val calendar = Calendar.getInstance() calendar.time = date val dateDay = calendar.get(Calendar.DAY_OF_YEAR) val dateYear = calendar.get(Calendar.YEAR) calendar.time = now val nowDay = calendar.get(Calendar.DAY_OF_YEAR) val nowYear = calendar.get(Calendar.YEAR) val daysDiff = if (dateYear == nowYear) { nowDay - dateDay } else { // Simplified: just check if less than 7 days by timestamp val diff = (now.time - date.time) / (1000 * 60 * 60 * 24) diff.toInt() } return if (daysDiff < 7) { // Relative formatting (abbreviated) val diffMillis = now.time - date.time val diffSeconds = diffMillis / 1000 when { diffSeconds < 60 -> "" // Don't show "just now" in iOS diffSeconds < 3600 -> { val minutes = (diffSeconds / 60).toInt() "${minutes}m ago" } diffSeconds < 86400 -> { val hours = (diffSeconds / 3600).toInt() "${hours}h ago" } else -> { val days = (diffSeconds / 86400).toInt() "${days}d ago" } } } else { // Absolute date formatting val sameYear = dateYear == nowYear val formatter = if (sameYear) { SimpleDateFormat("MMM d", Locale.getDefault()) } else { SimpleDateFormat("MMM d, y", Locale.getDefault()) } formatter.format(date) } }