mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 10:25:19 +00:00
Location notes (#482)
* try 1 * works * equalize UI with ios * ui cleanup * geohash chats: no building * load notes in background * insta * simplify and tor icon change * icons nice * refactor * unify location enabled / disabled * cooler * simplify, doesnt subscribe right away * load when clicked * plus minus location notes * load when tor is available * translations * fix transalations * implement review comments
This commit is contained in:
@@ -28,6 +28,9 @@ 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
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
|
||||
/**
|
||||
* Header components for ChatScreen
|
||||
@@ -53,23 +56,27 @@ fun isFavoriteReactive(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TorStatusIcon(
|
||||
fun TorStatusDot(
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val torStatus by com.bitchat.android.net.TorManager.statusFlow.collectAsState()
|
||||
|
||||
if (torStatus.mode != com.bitchat.android.net.TorMode.OFF) {
|
||||
val cableColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500)
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851)
|
||||
else -> Color.Red
|
||||
val dotColor = when {
|
||||
torStatus.running && torStatus.bootstrapPercent < 100 -> Color(0xFFFF9500) // Orange - bootstrapping
|
||||
torStatus.running && torStatus.bootstrapPercent >= 100 -> Color(0xFF00C851) // Green - connected
|
||||
else -> Color.Red // Red - error/disconnected
|
||||
}
|
||||
Canvas(
|
||||
modifier = modifier
|
||||
) {
|
||||
val radius = size.minDimension / 2
|
||||
drawCircle(
|
||||
color = dotColor,
|
||||
radius = radius,
|
||||
center = Offset(size.width / 2, size.height / 2)
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Cable,
|
||||
contentDescription = stringResource(R.string.cd_tor_status),
|
||||
modifier = modifier,
|
||||
tint = cableColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +240,8 @@ fun ChatHeaderContent(
|
||||
onSidebarClick: () -> Unit,
|
||||
onTripleClick: () -> Unit,
|
||||
onShowAppInfo: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit
|
||||
onLocationChannelsClick: () -> Unit,
|
||||
onLocationNotesClick: () -> Unit
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
@@ -289,6 +297,7 @@ fun ChatHeaderContent(
|
||||
onTripleTitleClick = onTripleClick,
|
||||
onSidebarClick = onSidebarClick,
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = onLocationNotesClick,
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
@@ -510,6 +519,7 @@ private fun MainHeader(
|
||||
onTripleTitleClick: () -> Unit,
|
||||
onSidebarClick: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit,
|
||||
onLocationNotesClick: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
@@ -573,7 +583,7 @@ private fun MainHeader(
|
||||
}
|
||||
|
||||
// Location channels button (matching iOS implementation) and bookmark grouped tightly
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 14.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 4.dp)) {
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationChannelsClick
|
||||
@@ -588,7 +598,7 @@ private fun MainHeader(
|
||||
val isBookmarked = bookmarks.contains(currentGeohash)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 1.dp) // minimal gap between geohash and bookmark
|
||||
.padding(start = 2.dp) // minimal gap between geohash and bookmark
|
||||
.size(20.dp)
|
||||
.clickable { bookmarksStore.toggle(currentGeohash) },
|
||||
contentAlignment = Alignment.Center
|
||||
@@ -603,15 +613,25 @@ private fun MainHeader(
|
||||
}
|
||||
}
|
||||
|
||||
// Tor status cable icon when Tor is enabled
|
||||
TorStatusIcon(modifier = Modifier.size(14.dp))
|
||||
// Location Notes button (extracted to separate component)
|
||||
LocationNotesButton(
|
||||
viewModel = viewModel,
|
||||
onClick = onLocationNotesClick
|
||||
)
|
||||
|
||||
// Tor status dot when Tor is enabled
|
||||
TorStatusDot(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.padding(start = 0.dp, end = 2.dp)
|
||||
)
|
||||
|
||||
// PoW status indicator
|
||||
PoWStatusIndicator(
|
||||
modifier = Modifier,
|
||||
style = PoWIndicatorStyle.COMPACT
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
PeerCounter(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
|
||||
@@ -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,8 +439,12 @@ private fun ChatFloatingHeader(
|
||||
onSidebarToggle: () -> Unit,
|
||||
onShowAppInfo: () -> Unit,
|
||||
onPanicClear: () -> Unit,
|
||||
onLocationChannelsClick: () -> Unit
|
||||
onLocationChannelsClick: () -> Unit,
|
||||
onLocationNotesClick: () -> Unit
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val locationManager = remember { com.bitchat.android.geohash.LocationChannelManager.getInstance(context) }
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -460,7 +468,12 @@ private fun ChatFloatingHeader(
|
||||
onSidebarClick = onSidebarToggle,
|
||||
onTripleClick = onPanicClear,
|
||||
onShowAppInfo = onShowAppInfo,
|
||||
onLocationChannelsClick = onLocationChannelsClick
|
||||
onLocationChannelsClick = onLocationChannelsClick,
|
||||
onLocationNotesClick = {
|
||||
// Ensure location is loaded before showing sheet
|
||||
locationManager.refreshChannels()
|
||||
onLocationNotesClick()
|
||||
}
|
||||
)
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
@@ -471,6 +484,7 @@ private fun ChatFloatingHeader(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ChatDialogs(
|
||||
showPasswordDialog: Boolean,
|
||||
@@ -483,6 +497,8 @@ private fun ChatDialogs(
|
||||
onAppInfoDismiss: () -> Unit,
|
||||
showLocationChannelsSheet: Boolean,
|
||||
onLocationChannelsSheetDismiss: () -> Unit,
|
||||
showLocationNotesSheet: Boolean,
|
||||
onLocationNotesSheetDismiss: () -> Unit,
|
||||
showUserSheet: Boolean,
|
||||
onUserSheetDismiss: () -> Unit,
|
||||
selectedUserForSheet: String,
|
||||
@@ -523,6 +539,14 @@ private fun ChatDialogs(
|
||||
)
|
||||
}
|
||||
|
||||
// Location notes sheet (extracted to separate presenter)
|
||||
if (showLocationNotesSheet) {
|
||||
LocationNotesSheetPresenter(
|
||||
viewModel = viewModel,
|
||||
onDismiss = onLocationNotesSheetDismiss
|
||||
)
|
||||
}
|
||||
|
||||
// User action sheet
|
||||
if (showUserSheet) {
|
||||
ChatUserSheet(
|
||||
|
||||
@@ -147,6 +147,8 @@ class ChatViewModel(
|
||||
mediaSendingManager.handleTransferProgressEvent(evt)
|
||||
}
|
||||
}
|
||||
|
||||
// Removed background location notes subscription. Notes now load only when sheet opens.
|
||||
}
|
||||
|
||||
fun cancelMediaSend(messageId: String) {
|
||||
@@ -591,6 +593,8 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Location notes subscription management moved to LocationNotesViewModelExtensions.kt
|
||||
|
||||
/**
|
||||
* Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI)
|
||||
*/
|
||||
|
||||
@@ -247,8 +247,11 @@ fun LocationChannelsSheet(
|
||||
}
|
||||
|
||||
// Nearby options (only show if location services are enabled)
|
||||
// CRITICAL: Filter out .building level (precision 8) - iOS pattern
|
||||
// iOS: let nearby = manager.availableChannels.filter { $0.level != .building }
|
||||
if (availableChannels.isNotEmpty() && locationServicesEnabled) {
|
||||
items(availableChannels) { channel ->
|
||||
val nearbyChannels = availableChannels.filter { it.level != GeohashChannelLevel.BUILDING }
|
||||
items(nearbyChannels) { channel ->
|
||||
val coverage = coverageString(channel.geohash.length)
|
||||
val nameBase = locationNames[channel.level]
|
||||
val namePart = nameBase?.let { formattedNamePrefix(channel.level) + it }
|
||||
@@ -570,8 +573,6 @@ fun LocationChannelsSheet(
|
||||
if (isPresented) {
|
||||
if (permissionState == LocationChannelManager.PermissionState.AUTHORIZED && locationServicesEnabled) {
|
||||
locationManager.refreshChannels()
|
||||
}
|
||||
if (locationServicesEnabled) {
|
||||
locationManager.beginLiveRefresh()
|
||||
}
|
||||
val geohashes = (availableChannels.map { it.geohash } + bookmarks).toSet().toList()
|
||||
@@ -713,6 +714,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 +751,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,67 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Description
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.R
|
||||
import com.bitchat.android.geohash.ChannelID
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
import com.bitchat.android.nostr.LocationNotesManager
|
||||
|
||||
/**
|
||||
* Location Notes button component for MainHeader
|
||||
* Shows in mesh mode when location permission granted AND services enabled
|
||||
* Icon turns primary color when notes exist, gray otherwise
|
||||
*/
|
||||
@Composable
|
||||
fun LocationNotesButton(
|
||||
viewModel: ChatViewModel,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val context = LocalContext.current
|
||||
|
||||
// Get channel and permission state
|
||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val permissionState by locationManager.permissionState.observeAsState()
|
||||
val locationServicesEnabled by locationManager.locationServicesEnabled.observeAsState(false)
|
||||
|
||||
// Check both permission AND location services enabled
|
||||
val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED
|
||||
val locationEnabled = locationPermissionGranted && locationServicesEnabled
|
||||
|
||||
// Get notes count from LocationNotesManager
|
||||
val notesManager = remember { LocationNotesManager.getInstance() }
|
||||
val notes by notesManager.notes.observeAsState(emptyList())
|
||||
val notesCount = notes.size
|
||||
|
||||
// Only show in mesh mode when location is authorized (iOS pattern)
|
||||
if (selectedLocationChannel is ChannelID.Mesh && locationEnabled) {
|
||||
val hasNotes = notesCount > 0
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import com.bitchat.android.R
|
||||
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
|
||||
) {
|
||||
// Localized title with ±1 and note count
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
id = R.plurals.location_notes_title,
|
||||
count = count,
|
||||
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))
|
||||
|
||||
// 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
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_description),
|
||||
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 = stringResource(R.string.location_notes_relays_unavailable),
|
||||
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 = stringResource(R.string.location_notes_no_relays_title),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_no_relays_desc),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.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 = stringResource(R.string.loading_location_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 = stringResource(R.string.location_notes_empty_title),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.location_notes_empty_desc),
|
||||
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 = stringResource(R.string.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 = stringResource(R.string.location_notes_input_placeholder),
|
||||
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 = stringResource(R.string.send_message),
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.livedata.observeAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||
import com.bitchat.android.geohash.LocationChannelManager
|
||||
|
||||
/**
|
||||
* Presenter component for LocationNotesSheet
|
||||
* Handles sheet presentation logic with proper error states
|
||||
* Extracts this logic from ChatScreen for better separation of concerns
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LocationNotesSheetPresenter(
|
||||
viewModel: ChatViewModel,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val locationManager = remember { LocationChannelManager.getInstance(context) }
|
||||
val availableChannels by locationManager.availableChannels.observeAsState(emptyList())
|
||||
val nickname by viewModel.nickname.observeAsState("")
|
||||
|
||||
// iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
val buildingGeohash = availableChannels.firstOrNull { it.level == GeohashChannelLevel.BUILDING }?.geohash
|
||||
|
||||
if (buildingGeohash != null) {
|
||||
// Get location name from locationManager
|
||||
val locationNames by locationManager.locationNames.observeAsState(emptyMap())
|
||||
val locationName = locationNames[GeohashChannelLevel.BUILDING]
|
||||
?: locationNames[GeohashChannelLevel.BLOCK]
|
||||
|
||||
LocationNotesSheet(
|
||||
geohash = buildingGeohash,
|
||||
locationName = locationName,
|
||||
nickname = nickname,
|
||||
onDismiss = onDismiss
|
||||
)
|
||||
} else {
|
||||
// No building geohash available - show error state (matches iOS)
|
||||
LocationNotesErrorSheet(
|
||||
onDismiss = onDismiss,
|
||||
locationManager = locationManager
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error sheet when location is unavailable
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun LocationNotesErrorSheet(
|
||||
onDismiss: () -> Unit,
|
||||
locationManager: LocationChannelManager
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
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 = {
|
||||
// UNIFIED FIX: Enable location services first (user toggle)
|
||||
locationManager.enableLocationServices()
|
||||
// Then request location channels (which will also request permission if needed)
|
||||
locationManager.enableLocationChannels()
|
||||
locationManager.refreshChannels()
|
||||
}) {
|
||||
Text("Enable Location")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user