Merge pull request #1008 from permissionlesstech/claude/wear-os-swipe-menu-d7f87d

feat(wear): add swipe-right app menu with quit option
This commit is contained in:
callebtc 2026-10-26 12:37:46 +01:00 committed by GitHub
commit c7db79fa37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 191 additions and 5 deletions

View File

@ -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) }

View File

@ -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"

View File

@ -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() }