mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-11-08 08:01:09 +00:00
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:
commit
c7db79fa37
@ -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) }
|
||||
|
||||
153
wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt
Normal file
153
wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt
Normal 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"
|
||||
@ -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() }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user