diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 39d73a67..4900d2fb 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -598,42 +598,8 @@ class MainActivity : ComponentActivity() { PoWPreferenceManager.init(this@MainActivity) Log.d("MainActivity", "PoW preferences initialized") - // SIMPLIFIED: Initialize Location Notes Manager only (no separate counter) - com.bitchat.android.nostr.LocationNotesManager.getInstance().initialize( - relayManager = { com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity) }, - subscribe = { filter, id, handler -> - // CRITICAL FIX: Extract geohash properly from filter using getGeohash() method - val geohashFromFilter = filter.getGeohash() ?: run { - Log.e("MainActivity", "❌ Cannot extract geohash from filter for location notes") - return@initialize id // Return subscription ID even on error - } - - Log.d("MainActivity", "📍 Location Notes subscribing to geohash: $geohashFromFilter") - - com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).subscribeForGeohash( - geohash = geohashFromFilter, - filter = filter, - id = id, - handler = handler, - includeDefaults = true, - nRelays = 5 - ) - }, - unsubscribe = { id -> - com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).unsubscribe(id) - }, - sendEvent = { event, relayUrls -> - if (relayUrls != null) { - com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).sendEvent(event, relayUrls) - } else { - com.bitchat.android.nostr.NostrRelayManager.getInstance(this@MainActivity).sendEvent(event) - } - }, - deriveIdentity = { geohash -> - com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(geohash, this@MainActivity) - } - ) - Log.d("MainActivity", "✅ Location Notes Manager initialized") + // Initialize Location Notes Manager (extracted to separate file) + com.bitchat.android.nostr.LocationNotesInitializer.initialize(this@MainActivity) // Ensure all permissions are still granted (user might have revoked in settings) if (!permissionManager.areAllPermissionsGranted()) { diff --git a/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt new file mode 100644 index 00000000..bed29902 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/LocationNotesInitializer.kt @@ -0,0 +1,63 @@ +package com.bitchat.android.nostr + +import android.content.Context +import android.util.Log + +/** + * Initializer for LocationNotesManager with all dependencies + * Extracts initialization logic from MainActivity for better separation of concerns + */ +object LocationNotesInitializer { + + private const val TAG = "LocationNotesInitializer" + + /** + * Initialize LocationNotesManager with all required dependencies + * + * @param context Application context + * @return true if initialization succeeded, false otherwise + */ + fun initialize(context: Context): Boolean { + return try { + LocationNotesManager.getInstance().initialize( + relayManager = { NostrRelayManager.getInstance(context) }, + subscribe = { filter, id, handler -> + // CRITICAL FIX: Extract geohash properly from filter using getGeohash() method + val geohashFromFilter = filter.getGeohash() ?: run { + Log.e(TAG, "❌ Cannot extract geohash from filter for location notes") + return@initialize id // Return subscription ID even on error + } + + Log.d(TAG, "📍 Location Notes subscribing to geohash: $geohashFromFilter") + + NostrRelayManager.getInstance(context).subscribeForGeohash( + geohash = geohashFromFilter, + filter = filter, + id = id, + handler = handler, + includeDefaults = true, + nRelays = 5 + ) + }, + unsubscribe = { id -> + NostrRelayManager.getInstance(context).unsubscribe(id) + }, + sendEvent = { event, relayUrls -> + if (relayUrls != null) { + NostrRelayManager.getInstance(context).sendEvent(event, relayUrls) + } else { + NostrRelayManager.getInstance(context).sendEvent(event) + } + }, + deriveIdentity = { geohash -> + NostrIdentityBridge.deriveIdentity(geohash, context) + } + ) + Log.d(TAG, "✅ Location Notes Manager initialized") + true + } catch (e: Exception) { + Log.e(TAG, "❌ Failed to initialize Location Notes Manager: ${e.message}", e) + false + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt index d785bfd8..4b1b22ca 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatHeader.kt @@ -535,16 +535,6 @@ 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()) - - // SIMPLIFIED: Get notes count directly from LocationNotesManager (no separate counter) - val notesManager = remember { com.bitchat.android.nostr.LocationNotesManager.getInstance() } - val notes by notesManager.notes.observeAsState(emptyList()) - val notesCount = notes.size - - // 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(), @@ -623,22 +613,11 @@ private fun MainHeader( } } - // 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 Notes button (extracted to separate component) + LocationNotesButton( + viewModel = viewModel, + onClick = onLocationNotesClick + ) // Tor status dot when Tor is enabled TorStatusDot( diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index fd639dc8..0ecc25a0 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -539,61 +539,12 @@ private fun ChatDialogs( ) } - // Location notes sheet - matches iOS pattern exactly + // Location notes sheet (extracted to separate presenter) 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("") - - // iOS pattern: notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash - 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 - ) - } else { - // No building geohash available - show error state (matches iOS) - 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") - } - } - } - } + LocationNotesSheetPresenter( + viewModel = viewModel, + onDismiss = onLocationNotesSheetDismiss + ) } // User action sheet diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 684d783b..a670aa20 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -594,69 +594,7 @@ class ChatViewModel( } } - /** - * SIMPLIFIED: Initialize location notes manager subscription (no separate counter) - * Subscribes/unsubscribes based on channel selection and location permission - */ - private fun initializeLocationNotesManagerSubscription() { - viewModelScope.launch { - // Observe channel changes - selectedLocationChannel.observeForever { channel -> - updateLocationNotesSubscription(channel) - } - } - } - - /** - * SIMPLIFIED: Update location notes subscription based on current channel and location permission - * iOS pattern: Subscribe when in mesh mode and location is authorized - */ - private fun updateLocationNotesSubscription(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 building-level geohash notes if location authorized - if (locationPermissionGranted) { - // Refresh location to get current building 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.LocationNotesManager.getInstance().setGeohash(buildingGeohash) - } else { - // Cancel if no building geohash available - if (com.bitchat.android.nostr.LocationNotesManager.getInstance().geohash.value == null) { - com.bitchat.android.nostr.LocationNotesManager.getInstance().cancel() - } - } - } else { - com.bitchat.android.nostr.LocationNotesManager.getInstance().cancel() - } - } - is com.bitchat.android.geohash.ChannelID.Location -> { - // Location channel mode: cancel (only show in mesh mode) - com.bitchat.android.nostr.LocationNotesManager.getInstance().cancel() - } - null -> { - // Default to mesh behavior - if (locationPermissionGranted) { - locationManager.refreshChannels() - } - } - } - } catch (e: Exception) { - Log.w(TAG, "updateLocationNotesSubscription failed: ${e.message}") - } - } + // Location notes subscription management moved to LocationNotesViewModelExtensions.kt /** * Update reactive states for all connected peers (session states, fingerprints, nicknames, RSSI) diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt new file mode 100644 index 00000000..01686d2b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesButton.kt @@ -0,0 +1,63 @@ +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 + * Displays when in mesh mode and location permission is granted + * 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 locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED + + // 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 && locationPermissionGranted) { + val hasNotes = (notesCount ?: 0) > 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 + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt new file mode 100644 index 00000000..3971066a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesSheetPresenter.kt @@ -0,0 +1,95 @@ +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 +) { + ModalBottomSheet( + onDismissRequest = onDismiss, + 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") + } + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/LocationNotesViewModelExtensions.kt b/app/src/main/java/com/bitchat/android/ui/LocationNotesViewModelExtensions.kt new file mode 100644 index 00000000..c568e402 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/LocationNotesViewModelExtensions.kt @@ -0,0 +1,81 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.util.Log +import androidx.lifecycle.viewModelScope +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannelLevel +import com.bitchat.android.geohash.LocationChannelManager +import com.bitchat.android.nostr.LocationNotesManager +import kotlinx.coroutines.launch + +/** + * Extension functions for ChatViewModel to manage LocationNotesManager subscription + * Extracts location notes subscription logic for better separation of concerns + */ + +private const val TAG = "LocationNotesVM" + +/** + * Initialize location notes manager subscription (no separate counter) + * Subscribes/unsubscribes based on channel selection and location permission + */ +fun ChatViewModel.initializeLocationNotesManagerSubscription() { + viewModelScope.launch { + // Observe channel changes + selectedLocationChannel.observeForever { channel -> + updateLocationNotesSubscription(channel) + } + } +} + +/** + * Update location notes subscription based on current channel and location permission + * iOS pattern: Subscribe when in mesh mode and location is authorized + */ +private fun ChatViewModel.updateLocationNotesSubscription(channel: ChannelID?) { + try { + val locationManager = LocationChannelManager.getInstance(getApplication()) + val permissionState = locationManager.permissionState.value + val locationPermissionGranted = permissionState == LocationChannelManager.PermissionState.AUTHORIZED + + when (channel) { + is ChannelID.Mesh -> { + // Mesh mode: subscribe to building-level geohash notes if location authorized + if (locationPermissionGranted) { + // Refresh location to get current building geohash + locationManager.refreshChannels() + + // Get building-level geohash (precision 8, same as iOS) + val buildingGeohash = locationManager.availableChannels.value + ?.firstOrNull { it.level == GeohashChannelLevel.BUILDING } + ?.geohash + + if (buildingGeohash != null) { + Log.d(TAG, "📍 Subscribing to location notes for building geohash: $buildingGeohash") + LocationNotesManager.getInstance().setGeohash(buildingGeohash) + } else { + // Cancel if no building geohash available + if (LocationNotesManager.getInstance().geohash.value == null) { + LocationNotesManager.getInstance().cancel() + } + } + } else { + LocationNotesManager.getInstance().cancel() + } + } + is ChannelID.Location -> { + // Location channel mode: cancel (only show in mesh mode) + LocationNotesManager.getInstance().cancel() + } + null -> { + // Default to mesh behavior + if (locationPermissionGranted) { + locationManager.refreshChannels() + } + } + } + } catch (e: Exception) { + Log.w(TAG, "updateLocationNotesSubscription failed: ${e.message}") + } +}