diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt index dc07a949c..41de1a11a 100644 --- a/wear/src/main/java/com/bitchat/watch/MainActivity.kt +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable @@ -39,6 +40,8 @@ import androidx.wear.compose.material3.OutlinedButton import com.bitchat.watch.mesh.WearMeshService import com.bitchat.watch.notification.WearNotificationCoordinator import com.bitchat.watch.service.WearMeshForegroundService +import com.bitchat.watch.ui.ChatHomePager +import com.bitchat.watch.ui.ChatMenuScreen import com.bitchat.watch.ui.ChatScreen import com.bitchat.watch.ui.DmScreen import com.bitchat.watch.ui.NicknameSetupScreen @@ -110,6 +113,7 @@ class MainActivity : ComponentActivity() { ) else -> WearNavHost( launchRequest = pendingLaunchRequest, + onQuit = ::quit, onLaunchRequestHandled = { requestID -> if (pendingLaunchRequest?.id == requestID) { pendingLaunchRequest = null @@ -177,6 +181,15 @@ class MainActivity : ComponentActivity() { startForegroundService(Intent(this, WearMeshForegroundService::class.java)) } + /** + * Quit from the in-app menu: stopping the foreground service tears the mesh down (see + * [WearMeshForegroundService.onDestroy]); the next launch starts it again. + */ + private fun quit() { + stopService(Intent(this, WearMeshForegroundService::class.java)) + finishAndRemoveTask() + } + private fun consumePrivateMessagePeer(intent: Intent?): String? { val peerID = privateMessagePeerFromIntent(intent) intent?.removeExtra(WearNotificationCoordinator.EXTRA_OPEN_DM) @@ -370,6 +383,7 @@ internal class WearNavigationState( @Composable internal fun WearNavHost( launchRequest: WearLaunchRequest?, + onQuit: () -> Unit, onLaunchRequestHandled: (Long) -> Unit ) { val navigation = rememberSaveable(saver = WearNavigationState.Saver) { @@ -397,10 +411,20 @@ internal fun WearNavHost( label = "screenTransition" ) { current -> when (current) { - is WearScreen.Chat -> ChatScreen( - onOpenPeople = { navigation.navigate(WearScreen.People) }, - onOpenTextInput = { navigation.navigate(WearScreen.TextInput(null)) } - ) + is WearScreen.Chat -> { + var chatOwnsGesture by remember { mutableStateOf(false) } + ChatHomePager( + swipeEnabled = !chatOwnsGesture, + menu = { ChatMenuScreen(onQuit = onQuit) }, + chat = { + ChatScreen( + onOpenPeople = { navigation.navigate(WearScreen.People) }, + onOpenTextInput = { navigation.navigate(WearScreen.TextInput(null)) }, + onHorizontalGestureActive = { chatOwnsGesture = it } + ) + } + ) + } is WearScreen.People -> PeopleScreen( onOpenDm = { navigation.navigate(WearScreen.Dm(it)) }, onEditNickname = { navigation.navigate(WearScreen.Nickname) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt new file mode 100644 index 000000000..8f3fb7f55 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt @@ -0,0 +1,153 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.foundation.pager.HorizontalPager +import androidx.wear.compose.foundation.pager.rememberPagerState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.AlertDialogDefaults +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.HorizontalPagerScaffold +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +private const val MENU_PAGE = 0 +private const val CHAT_PAGE = 1 + +/** + * Home of the app: the public mesh chat, with an app menu one page to its left. Swiping the + * chat to the right reveals the menu (the Wear pattern used by Fitbit, Health and similar + * apps); swiping right again from the menu's left edge falls through to the system + * swipe-to-dismiss, so leaving the app still works as everywhere else on the watch. + * + * [swipeEnabled] is false while the chat owns a horizontal gesture (push-to-talk drag, + * full-screen image), so the pager never steals the pointer mid-gesture. + */ +@Composable +fun ChatHomePager( + swipeEnabled: Boolean, + menu: @Composable () -> Unit, + chat: @Composable () -> Unit, +) { + val pagerState = rememberPagerState(initialPage = CHAT_PAGE) { 2 } + // No page indicator: the menu is a peek-behind page, not a peer of the chat, and the + // dots would crowd the chat's edge action bar. + HorizontalPagerScaffold(pagerState = pagerState, pageIndicator = null) { + HorizontalPager( + state = pagerState, + userScrollEnabled = swipeEnabled, + // The crown scrolls chat history, never pages. + rotaryScrollableBehavior = null, + ) { page -> + when (page) { + MENU_PAGE -> menu() + CHAT_PAGE -> chat() + } + } + } +} + +@Composable +fun ChatMenuScreen(onQuit: () -> Unit) { + val palette = LocalBitchatPalette.current + val peers by AppStateStore.peers.collectAsState() + val listState = rememberScalingLazyListState() + var confirmQuit by rememberSaveable { mutableStateOf(false) } + + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = scaffoldPadding, + ) { + item { + ListHeader { + Text( + text = "bitchat", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + item { + Text( + text = if (peers.isEmpty()) "Mesh running" else meshPeersText(peers.size), + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + ) + } + item { + FilledTonalButton( + onClick = { confirmQuit = true }, + modifier = Modifier.fillMaxWidth(), + icon = { + Icon( + imageVector = Icons.Filled.PowerSettingsNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + label = { Text("Quit bitchat") }, + secondaryLabel = { + Text( + text = "Stop the mesh", + color = palette.textTertiary, + ) + }, + ) + } + } + } + + AlertDialog( + visible = confirmQuit, + onDismissRequest = { confirmQuit = false }, + icon = { + Icon( + imageVector = Icons.Filled.PowerSettingsNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp), + ) + }, + title = { Text("Quit bitchat?") }, + confirmButton = { + AlertDialogDefaults.ConfirmButton( + onClick = { + confirmQuit = false + onQuit() + }, + ) + }, + dismissButton = { + AlertDialogDefaults.DismissButton(onClick = { confirmQuit = false }) + }, + ) +} + +private fun meshPeersText(count: Int): String = + if (count == 1) "Mesh running · 1 peer" else "Mesh running · $count peers" diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index d576cd03c..1c0dde16b 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -57,7 +57,11 @@ import java.util.Date import java.util.Locale @Composable -fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) { +fun ChatScreen( + onOpenPeople: () -> Unit, + onOpenTextInput: () -> Unit, + onHorizontalGestureActive: (Boolean) -> Unit = {}, +) { val context = LocalContext.current val messages by AppStateStore.publicMessages.collectAsState() val peers by AppStateStore.peers.collectAsState() @@ -82,6 +86,11 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) { } ) { path -> mesh?.let { sendVoiceNote(it, null, path) } } + // Push-to-talk drags and the image viewer own the whole screen; the home pager must not + // turn them into a page swipe. + val horizontalGestureActive = voice.recording || viewerPath != null + LaunchedEffect(horizontalGestureActive) { onHorizontalGestureActive(horizontalGestureActive) } + androidx.compose.runtime.DisposableEffect(liveVoiceManager) { liveVoiceManager.showPublicMesh() onDispose { liveVoiceManager.clearVisibleConversation() }