mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Fix Wear layouts on small round displays
This commit is contained in:
parent
81ba775063
commit
afad3c5513
@ -18,9 +18,6 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@ -30,7 +27,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.listSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@ -39,7 +35,7 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.wear.compose.material3.Button
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.Text
|
||||
import androidx.wear.compose.material3.TextButton
|
||||
import androidx.wear.compose.material3.OutlinedButton
|
||||
import com.bitchat.watch.mesh.WearMeshService
|
||||
import com.bitchat.watch.notification.WearNotificationCoordinator
|
||||
import com.bitchat.watch.service.WearMeshForegroundService
|
||||
@ -50,6 +46,7 @@ import com.bitchat.watch.ui.PeopleScreen
|
||||
import com.bitchat.watch.ui.UserDetailScreen
|
||||
import com.bitchat.watch.ui.VerificationCodeScreen
|
||||
import com.bitchat.watch.ui.WearChatState
|
||||
import com.bitchat.watch.ui.WearFormScreen
|
||||
import com.bitchat.watch.ui.sendPrivateMessage
|
||||
import com.bitchat.watch.ui.sendPublicMessage
|
||||
import com.bitchat.watch.ui.theme.BitchatWearTheme
|
||||
@ -460,104 +457,110 @@ internal fun WearNavHost(
|
||||
|
||||
@Composable
|
||||
fun NotificationPermissionScreen(onResult: (Boolean) -> Unit, onSkip: () -> Unit) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted -> onResult(granted) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Message alerts",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Alerts for encrypted direct messages",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp)
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
onResult(true)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Enable")
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
onResult(granted)
|
||||
}
|
||||
TextButton(onClick = onSkip) {
|
||||
Text("Not now")
|
||||
|
||||
WearFormScreen {
|
||||
item {
|
||||
Text(
|
||||
text = "Message alerts",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = "Alerts for encrypted direct messages",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
onResult(true)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Text("Enable")
|
||||
}
|
||||
}
|
||||
item {
|
||||
OutlinedButton(onClick = onSkip) {
|
||||
Text("Not now")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionRequestScreen(onGranted: () -> Unit) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { onGranted() }
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
|
||||
onGranted()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = "Needs Bluetooth to mesh with nearby devices",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp)
|
||||
)
|
||||
Button(onClick = {
|
||||
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
|
||||
}) {
|
||||
Text("Grant access")
|
||||
WearFormScreen {
|
||||
item {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = "Needs Bluetooth to mesh with nearby devices",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
launcher.launch(MainActivity.requiredPermissions().toTypedArray())
|
||||
}
|
||||
) {
|
||||
Text("Grant access", textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BluetoothEnableScreen(onEnabled: () -> Unit) {
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) { onEnabled() }
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
onEnabled()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Bluetooth is off",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Button(
|
||||
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
) {
|
||||
Text("Turn on")
|
||||
WearFormScreen {
|
||||
item {
|
||||
Text(
|
||||
text = "Bluetooth is off",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { launcher.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) },
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
) {
|
||||
Text("Turn on")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -36,6 +37,7 @@ import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
@ -74,7 +76,7 @@ fun ChatActionBar(
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(palette.inputButton)
|
||||
.clickable { onKeyboard() },
|
||||
@ -89,7 +91,7 @@ fun ChatActionBar(
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
when {
|
||||
@ -150,7 +152,7 @@ fun VoiceRecordOverlay(
|
||||
hoveringCancel: Boolean,
|
||||
proximity: Float,
|
||||
magnetPull: Offset,
|
||||
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit
|
||||
onCancelBounds: (androidx.compose.ui.geometry.Rect) -> Unit,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
// The cancel morph, choreographed for feel:
|
||||
@ -159,101 +161,127 @@ fun VoiceRecordOverlay(
|
||||
// - the button leans toward the approaching finger (magnetic pull), chasing it with a
|
||||
// smooth spring so it lags and settles naturally
|
||||
// - scale blooms with a soft bounce on activation — no rotation, no wobble
|
||||
val cancelScale by androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
|
||||
animationSpec = androidx.compose.animation.core.spring(
|
||||
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
|
||||
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
|
||||
),
|
||||
label = "cancelSnap"
|
||||
)
|
||||
val pull by androidx.compose.animation.core.animateOffsetAsState(
|
||||
targetValue = magnetPull,
|
||||
animationSpec = androidx.compose.animation.core.spring(
|
||||
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
|
||||
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium
|
||||
),
|
||||
label = "magnetPull"
|
||||
)
|
||||
val cancelColor = androidx.compose.ui.graphics.lerp(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.error,
|
||||
if (hoveringCancel) 1f else proximity * 0.85f
|
||||
)
|
||||
val cancelScale by
|
||||
androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (hoveringCancel) 1.32f else 1f + 0.1f * proximity,
|
||||
animationSpec =
|
||||
androidx.compose.animation.core.spring(
|
||||
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
|
||||
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
|
||||
),
|
||||
label = "cancelSnap",
|
||||
)
|
||||
val pull by
|
||||
androidx.compose.animation.core.animateOffsetAsState(
|
||||
targetValue = magnetPull,
|
||||
animationSpec =
|
||||
androidx.compose.animation.core.spring(
|
||||
dampingRatio = androidx.compose.animation.core.Spring.DampingRatioMediumBouncy,
|
||||
stiffness = androidx.compose.animation.core.Spring.StiffnessMedium,
|
||||
),
|
||||
label = "magnetPull",
|
||||
)
|
||||
val cancelColor =
|
||||
androidx.compose.ui.graphics.lerp(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
MaterialTheme.colorScheme.error,
|
||||
if (hoveringCancel) 1f else proximity * 0.85f,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = voice.recording,
|
||||
enter = fadeIn(tween(BitchatMotion.EMPHASIZED_MS)),
|
||||
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS))
|
||||
exit = fadeOut(tween(BitchatMotion.EMPHASIZED_MS)),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.96f))
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.onGloballyPositioned { coords ->
|
||||
onCancelBounds(
|
||||
androidx.compose.ui.geometry.Rect(
|
||||
coords.localToRoot(androidx.compose.ui.geometry.Offset.Zero),
|
||||
coords.size.toSize()
|
||||
)
|
||||
)
|
||||
}
|
||||
.size(52.dp)
|
||||
.graphicsLayer {
|
||||
translationX = pull.x
|
||||
translationY = pull.y
|
||||
scaleX = cancelScale
|
||||
scaleY = cancelScale
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(cancelColor),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
androidx.compose.animation.Crossfade(
|
||||
targetState = hoveringCancel,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS),
|
||||
label = "cancelIcon"
|
||||
) { cancel ->
|
||||
Icon(
|
||||
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
|
||||
contentDescription = if (cancel) "cancel recording" else null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(26.dp)
|
||||
)
|
||||
val side =
|
||||
if (LocalConfiguration.current.isScreenRound) {
|
||||
roundContentSide(maxWidth.value, maxHeight.value).dp
|
||||
} else {
|
||||
minOf(maxWidth, maxHeight) - 24.dp
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.size(side),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
// Text gets its natural height first. The decorative waveform and cancel icon
|
||||
// share the remaining room instead of pushing instructions off the display.
|
||||
BoxWithConstraints(
|
||||
Modifier.weight(1f).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val iconSize = minOf(52.dp, maxHeight / 1.32f, maxWidth / 1.32f)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.onGloballyPositioned { coords ->
|
||||
onCancelBounds(
|
||||
androidx.compose.ui.geometry.Rect(
|
||||
coords.localToRoot(
|
||||
androidx.compose.ui.geometry.Offset.Zero
|
||||
),
|
||||
coords.size.toSize(),
|
||||
)
|
||||
)
|
||||
}
|
||||
.size(iconSize)
|
||||
.graphicsLayer {
|
||||
translationX = pull.x
|
||||
translationY = pull.y
|
||||
scaleX = cancelScale
|
||||
scaleY = cancelScale
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(cancelColor),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.animation.Crossfade(
|
||||
targetState = hoveringCancel,
|
||||
animationSpec = tween(BitchatMotion.STANDARD_MS),
|
||||
label = "cancelIcon",
|
||||
) { cancel ->
|
||||
Icon(
|
||||
imageVector = if (cancel) Icons.Filled.Close else Icons.Filled.Mic,
|
||||
contentDescription = if (cancel) "cancel recording" else null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(iconSize / 2),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
WaveformBars(
|
||||
samples = voice.liveSamples,
|
||||
progress = 1f,
|
||||
activeColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.fillMaxWidth().height(12.dp),
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
(if (voice.isLive) "LIVE " else "") +
|
||||
"%d:%02d"
|
||||
.format(
|
||||
voice.elapsedMs / 1000 / 60,
|
||||
voice.elapsedMs / 1000 % 60,
|
||||
) +
|
||||
"/0:10",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color =
|
||||
if (hoveringCancel) MaterialTheme.colorScheme.error
|
||||
else palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
WaveformBars(
|
||||
samples = voice.liveSamples,
|
||||
progress = 1f,
|
||||
activeColor = MaterialTheme.colorScheme.primary,
|
||||
inactiveColor = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(top = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(44.dp)
|
||||
)
|
||||
Text(
|
||||
text = (if (voice.isLive) "LIVE · " else "") + "%d:%02d".format(
|
||||
voice.elapsedMs / 1000 / 60,
|
||||
voice.elapsedMs / 1000 % 60
|
||||
) + " / 0:10",
|
||||
style = ChatVisualTokens.SenderStyle,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
)
|
||||
Text(
|
||||
text = if (hoveringCancel) "Release to cancel" else "Lift finger to send",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = if (hoveringCancel) MaterialTheme.colorScheme.error
|
||||
else palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -438,7 +438,7 @@ private fun ChatBody(
|
||||
// engages as the finger approaches, not only on exact contact.
|
||||
private const val CANCEL_HOVER_SLANT_PX = 56f
|
||||
private const val CHAT_SCROLL_DIRECTION_THRESHOLD_PX = 24
|
||||
private val CHAT_HEADER_CONTENT_CLEARANCE = 30.dp
|
||||
private val CHAT_HEADER_CONTENT_CLEARANCE = 56.dp
|
||||
private val CHAT_ACTION_BAR_CLEARANCE = 64.dp
|
||||
private val CHAT_HEADER_EDGE_FADE = 36.dp
|
||||
private val CHAT_ACTION_BAR_EDGE_FADE = 72.dp
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -118,77 +116,79 @@ private fun ChatHeader(
|
||||
peerCount: Int,
|
||||
unreadDms: Int,
|
||||
expanded: Boolean,
|
||||
onOpenPeople: () -> Unit
|
||||
onOpenPeople: () -> Unit,
|
||||
) {
|
||||
// Floating title row: full-size at the newest messages, shrinks to its dense form
|
||||
// while scrolling up into history. Rendered as an overlay, so the animation only
|
||||
// relayouts this row, never the message list.
|
||||
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
|
||||
BitchatMotion.STANDARD_MS
|
||||
)
|
||||
val iconSize by androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "hdrIcon"
|
||||
)
|
||||
val titleSize by androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "hdrTitle"
|
||||
)
|
||||
val vPadding by androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "hdrPad"
|
||||
)
|
||||
val spec =
|
||||
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
|
||||
BitchatMotion.STANDARD_MS
|
||||
)
|
||||
val iconSize by
|
||||
androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 16.dp else 11.dp,
|
||||
animationSpec = spec,
|
||||
label = "hdrIcon",
|
||||
)
|
||||
val titleSize by
|
||||
androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (expanded) 15f else 12f,
|
||||
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
|
||||
label = "hdrTitle",
|
||||
)
|
||||
|
||||
// The entire header region opens the People screen. When there are unread DMs the
|
||||
// title gives way so the people and mail icons (with counts) fit side by side on the
|
||||
// round screen instead of clipping at the edges.
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenPeople() }
|
||||
.padding(horizontal = 8.dp, vertical = vPadding),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
WearChatHeader(
|
||||
fontSize = titleSize,
|
||||
onClickLabel = "Open people",
|
||||
onClick = onOpenPeople,
|
||||
) {
|
||||
if (unreadDms == 0) {
|
||||
Text(
|
||||
text = "bitchat",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) { titleSize.toSp() },
|
||||
fontSize = titleSize.sp,
|
||||
lineHeight = (titleSize * 1.3f).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false).padding(end = 8.dp),
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Filled.People,
|
||||
contentDescription = "people",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(iconSize)
|
||||
modifier = Modifier.size(iconSize),
|
||||
)
|
||||
Text(
|
||||
text = "$peerCount",
|
||||
text = if (peerCount > 99) "99+" else "$peerCount",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||
(iconSize.value * 0.85f).dp.toSp()
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
lineHeight = (titleSize * 1.3f).sp,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 2.dp)
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
)
|
||||
if (unreadDms > 0) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.MailOutline,
|
||||
contentDescription = "$unreadDms unread messages",
|
||||
tint = LocalBitchatPalette.current.accentOrange,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(iconSize)
|
||||
modifier = Modifier.padding(start = 6.dp).size(iconSize),
|
||||
)
|
||||
Text(
|
||||
text = "$unreadDms",
|
||||
text = if (unreadDms > 99) "99+" else "$unreadDms",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||
(iconSize.value * 0.85f).dp.toSp()
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
lineHeight = (titleSize * 1.3f).sp,
|
||||
maxLines = 1,
|
||||
color = LocalBitchatPalette.current.accentOrange,
|
||||
modifier = Modifier.padding(start = 2.dp)
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -240,8 +240,7 @@ fun MessageItem(
|
||||
)
|
||||
Text(
|
||||
text = " ${formatTime(message.timestamp)}",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
fontSize = 9.sp,
|
||||
style = ChatVisualTokens.TimestampStyle,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -20,7 +17,6 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
@ -32,6 +28,8 @@ import androidx.wear.compose.foundation.rotary.rotaryScrollable
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
import androidx.wear.compose.material3.Icon
|
||||
@ -140,59 +138,58 @@ private fun DmHeader(
|
||||
expanded: Boolean,
|
||||
isFavorite: Boolean,
|
||||
isVerified: Boolean,
|
||||
onClick: () -> Unit
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
// Floating title row: full-size at the newest messages, shrinks to its dense form
|
||||
// while scrolling up into history. Rendered as an overlay, so the animation only
|
||||
// relayouts this row, never the message list.
|
||||
val spec = androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
|
||||
BitchatMotion.STANDARD_MS
|
||||
)
|
||||
val headerIconSize by androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 16.dp else 11.dp, animationSpec = spec, label = "dmHdrIcon"
|
||||
)
|
||||
val headerTitleSize by androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 15.dp else 11.dp, animationSpec = spec, label = "dmHdrTitle"
|
||||
)
|
||||
val headerVPadding by androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 6.dp else 1.dp, animationSpec = spec, label = "dmHdrPad"
|
||||
)
|
||||
val spec =
|
||||
androidx.compose.animation.core.tween<androidx.compose.ui.unit.Dp>(
|
||||
BitchatMotion.STANDARD_MS
|
||||
)
|
||||
val headerIconSize by
|
||||
androidx.compose.animation.core.animateDpAsState(
|
||||
targetValue = if (expanded) 16.dp else 11.dp,
|
||||
animationSpec = spec,
|
||||
label = "dmHdrIcon",
|
||||
)
|
||||
val headerTitleSize by
|
||||
androidx.compose.animation.core.animateFloatAsState(
|
||||
targetValue = if (expanded) 15f else 12f,
|
||||
animationSpec = androidx.compose.animation.core.tween(BitchatMotion.STANDARD_MS),
|
||||
label = "dmHdrTitle",
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClickLabel = "Open user details",
|
||||
onClick = onClick
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = headerVPadding),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
WearChatHeader(
|
||||
fontSize = headerTitleSize,
|
||||
onClickLabel = "Open user details",
|
||||
onClick = onClick,
|
||||
) {
|
||||
Text(
|
||||
text = nickname,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontSize = with(androidx.compose.ui.platform.LocalDensity.current) {
|
||||
headerTitleSize.toSp()
|
||||
},
|
||||
fontSize = headerTitleSize.sp,
|
||||
lineHeight = (headerTitleSize * 1.3f).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorForPeer(nickname + peerID, palette)
|
||||
color = colorForPeer(nickname + peerID, palette),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
NoiseLockIcon(
|
||||
state = if (sessionEstablished) NoiseSessionUiState.Established
|
||||
else NoiseSessionUiState.Handshaking,
|
||||
state =
|
||||
if (sessionEstablished) NoiseSessionUiState.Established
|
||||
else NoiseSessionUiState.Handshaking,
|
||||
size = headerIconSize,
|
||||
modifier = Modifier.padding(start = 5.dp)
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
if (isFavorite) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_spec_star_filled),
|
||||
contentDescription = "Favorite",
|
||||
tint = palette.accentOrange,
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(headerIconSize)
|
||||
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
|
||||
)
|
||||
}
|
||||
if (isVerified) {
|
||||
@ -200,9 +197,7 @@ private fun DmHeader(
|
||||
imageVector = Icons.Filled.Verified,
|
||||
contentDescription = "Verified",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.padding(start = 4.dp)
|
||||
.size(headerIconSize)
|
||||
modifier = Modifier.padding(start = 4.dp).size(headerIconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@ -47,7 +44,7 @@ fun NicknameSetupScreen(
|
||||
title: String = "bitchat",
|
||||
subtitle: String = "Pick a nickname",
|
||||
confirmLabel: String = "Join the mesh",
|
||||
onConfirm: (String) -> Unit
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
val palette = LocalBitchatPalette.current
|
||||
// Pre-fill with the cursor at the end of the existing name, not the start.
|
||||
@ -55,7 +52,7 @@ fun NicknameSetupScreen(
|
||||
mutableStateOf(
|
||||
TextFieldValue(
|
||||
text = initialNickname,
|
||||
selection = TextRange(initialNickname.length)
|
||||
selection = TextRange(initialNickname.length),
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -64,71 +61,78 @@ fun NicknameSetupScreen(
|
||||
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp)
|
||||
)
|
||||
BasicTextField(
|
||||
value = name,
|
||||
onValueChange = { newValue ->
|
||||
val trimmed = newValue.text.trim().take(24)
|
||||
name = if (trimmed == newValue.text) {
|
||||
newValue
|
||||
} else {
|
||||
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = ChatVisualTokens.MessageBodyStyle.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
keyboardController?.hide()
|
||||
}),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(palette.inputSurface)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (name.text.isEmpty()) {
|
||||
Text(
|
||||
text = "Nickname",
|
||||
style = ChatVisualTokens.MessageBodyStyle,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
WearFormScreen {
|
||||
item {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
item {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = palette.textTertiary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 10.dp),
|
||||
)
|
||||
}
|
||||
item {
|
||||
BasicTextField(
|
||||
value = name,
|
||||
onValueChange = { newValue ->
|
||||
val trimmed = newValue.text.trim().take(24)
|
||||
name =
|
||||
if (trimmed == newValue.text) {
|
||||
newValue
|
||||
} else {
|
||||
newValue.copy(text = trimmed, selection = TextRange(trimmed.length))
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle =
|
||||
ChatVisualTokens.MessageBodyStyle.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions =
|
||||
KeyboardActions(
|
||||
onDone = {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
),
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(palette.inputSurface)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
if (name.text.isEmpty()) {
|
||||
Text(
|
||||
text = "Nickname",
|
||||
style = ChatVisualTokens.MessageBodyStyle,
|
||||
color = palette.textTertiary,
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
item {
|
||||
Button(
|
||||
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
|
||||
enabled = name.text.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
) {
|
||||
Text(confirmLabel, textAlign = TextAlign.Center)
|
||||
}
|
||||
)
|
||||
Button(
|
||||
onClick = { if (name.text.isNotBlank()) onConfirm(name.text.trim()) },
|
||||
enabled = name.text.isNotBlank(),
|
||||
modifier = Modifier.padding(top = 10.dp)
|
||||
) {
|
||||
Text(confirmLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -134,7 +134,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = Modifier.size(38.dp)
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Mic,
|
||||
@ -145,7 +145,7 @@ fun TextInputScreen(onSend: (String) -> Unit) {
|
||||
IconButton(
|
||||
onClick = { send() },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier.size(38.dp)
|
||||
modifier = Modifier.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
|
||||
@ -18,7 +18,6 @@ import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
@ -45,7 +44,7 @@ fun UserDetailScreen(
|
||||
WearPeerIdentityState.snapshot(peerID, mesh)
|
||||
}
|
||||
val nickname = mesh?.getPeerNickname(peerID) ?: peerID.take(8)
|
||||
val listState = rememberScalingLazyListState()
|
||||
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
||||
@ -53,12 +52,13 @@ fun UserDetailScreen(
|
||||
ScalingLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
autoCentering = null,
|
||||
contentPadding = scaffoldPadding
|
||||
.withAdditionalPadding(
|
||||
layoutDirection = layoutDirection,
|
||||
horizontal = 10.dp,
|
||||
vertical = 8.dp
|
||||
horizontal = 10.dp
|
||||
)
|
||||
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
|
||||
) {
|
||||
item {
|
||||
ListHeader {
|
||||
@ -71,8 +71,7 @@ fun UserDetailScreen(
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = colorForPeer(nickname + peerID, palette),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = "User details",
|
||||
@ -174,13 +173,13 @@ fun UserDetailScreen(
|
||||
text = if (identity.isVerified) {
|
||||
"Identity verified"
|
||||
} else {
|
||||
"Verification code"
|
||||
"Identity code"
|
||||
},
|
||||
style = ChatVisualTokens.SenderStyle,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(
|
||||
text = "Compare cryptographic fingerprints",
|
||||
text = "Compare identity codes",
|
||||
style = ChatVisualTokens.SystemActionStyle,
|
||||
color = palette.textTertiary
|
||||
)
|
||||
|
||||
@ -40,7 +40,7 @@ fun VerificationCodeScreen(peerID: String) {
|
||||
WearPeerIdentityState.snapshot(peerID, mesh)
|
||||
}
|
||||
val myFingerprint = WearPeerIdentityState.myFingerprint(mesh)
|
||||
val listState = rememberScalingLazyListState()
|
||||
val listState = rememberScalingLazyListState(initialCenterItemIndex = 0)
|
||||
val palette = LocalBitchatPalette.current
|
||||
|
||||
ScreenScaffold(scrollState = listState) { scaffoldPadding ->
|
||||
@ -48,12 +48,13 @@ fun VerificationCodeScreen(peerID: String) {
|
||||
ScalingLazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
autoCentering = null,
|
||||
contentPadding = scaffoldPadding
|
||||
.withAdditionalPadding(
|
||||
layoutDirection = layoutDirection,
|
||||
horizontal = 10.dp,
|
||||
vertical = 8.dp
|
||||
horizontal = 10.dp
|
||||
)
|
||||
.withVerticalClearance(layoutDirection, top = 28.dp, bottom = 28.dp)
|
||||
) {
|
||||
item {
|
||||
ListHeader {
|
||||
@ -126,7 +127,8 @@ fun VerificationCodeScreen(peerID: String) {
|
||||
"Remove verification"
|
||||
} else {
|
||||
"Mark verified"
|
||||
}
|
||||
},
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -158,8 +160,8 @@ private fun FingerprintCard(
|
||||
text = fingerprint?.let(::formatVerificationCode) ?: "Handshake pending",
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
lineHeight = 13.sp
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp
|
||||
),
|
||||
color = if (fingerprint == null) {
|
||||
palette.accentOrange
|
||||
@ -178,6 +180,6 @@ fun formatVerificationCode(fingerprint: String): String {
|
||||
return fingerprint
|
||||
.uppercase()
|
||||
.chunked(4)
|
||||
.chunked(4)
|
||||
.chunked(2)
|
||||
.joinToString("\n") { line -> line.joinToString(" ") }
|
||||
}
|
||||
|
||||
71
wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt
Normal file
71
wear/src/main/java/com/bitchat/watch/ui/WearChatHeader.kt
Normal file
@ -0,0 +1,71 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.wear.compose.material3.MaterialTheme
|
||||
|
||||
/** The title is outside the scrolling list, so it needs its own physical display bounds. */
|
||||
@Composable
|
||||
internal fun WearChatHeader(
|
||||
fontSize: Float,
|
||||
onClickLabel: String,
|
||||
onClick: () -> Unit,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val lineHeight = with(LocalDensity.current) { (fontSize * 1.3f).sp.toDp() }
|
||||
val rowHeight = maxOf(48.dp, lineHeight)
|
||||
val top = 12.dp + (rowHeight - lineHeight) / 2
|
||||
BoxWithConstraints(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to MaterialTheme.colorScheme.background,
|
||||
0.75f to MaterialTheme.colorScheme.background,
|
||||
1f to Color.Transparent,
|
||||
)
|
||||
),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
val safeWidth =
|
||||
if (configuration.isScreenRound) {
|
||||
roundBandWidth(
|
||||
maxWidth.value,
|
||||
configuration.screenHeightDp.toFloat(),
|
||||
top.value,
|
||||
(top + lineHeight).value,
|
||||
)
|
||||
.dp - 8.dp
|
||||
} else {
|
||||
maxWidth - 16.dp
|
||||
}
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.padding(top = 12.dp)
|
||||
.width(safeWidth.coerceAtLeast(0.dp))
|
||||
.height(rowHeight)
|
||||
.clickable(role = Role.Button, onClickLabel = onClickLabel, onClick = onClick),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/** Width of the narrowest chord across a centered horizontal band in a round display. */
|
||||
internal fun roundBandWidth(width: Float, height: Float, top: Float, bottom: Float): Float {
|
||||
val radius = min(width, height) / 2f
|
||||
val distance = maxOf(abs(top - height / 2f), abs(bottom - height / 2f))
|
||||
return 2f * sqrt((radius * radius - distance * distance).coerceAtLeast(0f))
|
||||
}
|
||||
|
||||
/** A centered square whose four corners fit inside the physical circle, with a small inset. */
|
||||
internal fun roundContentSide(width: Float, height: Float): Float =
|
||||
(min(width, height) / sqrt(2f) - 4f).coerceAtLeast(0f)
|
||||
30
wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt
Normal file
30
wear/src/main/java/com/bitchat/watch/ui/WearFormScreen.kt
Normal file
@ -0,0 +1,30 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyColumn
|
||||
import androidx.wear.compose.foundation.lazy.ScalingLazyListScope
|
||||
import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState
|
||||
import androidx.wear.compose.material3.ScreenScaffold
|
||||
|
||||
/** Independently scrollable form items remain reachable on small watches and at large fonts. */
|
||||
@Composable
|
||||
internal fun WearFormScreen(content: ScalingLazyListScope.() -> Unit) {
|
||||
val state = rememberScalingLazyListState(initialCenterItemIndex = 0)
|
||||
val direction = LocalLayoutDirection.current
|
||||
ScreenScaffold(scrollState = state) { padding ->
|
||||
ScalingLazyColumn(
|
||||
state = state,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
autoCentering = null,
|
||||
contentPadding =
|
||||
padding
|
||||
.withAdditionalPadding(direction, horizontal = 14.dp)
|
||||
.withVerticalClearance(direction, top = 28.dp, bottom = 28.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
@ -43,6 +44,7 @@ import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
@ -53,6 +55,7 @@ import androidx.wear.compose.material3.Text
|
||||
import com.bitchat.android.features.voice.AudioWaveformExtractor
|
||||
import com.bitchat.android.features.voice.VoiceWaveformCache
|
||||
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import com.bitchat.watch.ui.roundContentSide
|
||||
import com.bitchat.watch.ui.theme.LocalBitchatPalette
|
||||
import kotlinx.coroutines.delay
|
||||
import java.io.File
|
||||
@ -92,7 +95,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
|
||||
onDismissRequest = onClose,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Box(
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
@ -101,11 +104,16 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
|
||||
) {
|
||||
val bitmap = remember(path) { BitmapFactory.decodeFile(path) }
|
||||
if (bitmap != null) {
|
||||
val imageModifier = if (LocalConfiguration.current.isScreenRound) {
|
||||
Modifier.size(roundContentSide(maxWidth.value, maxHeight.value).dp)
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
}
|
||||
Image(
|
||||
painter = BitmapPainter(bitmap.asImageBitmap()),
|
||||
contentDescription = "image fullscreen",
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = imageModifier
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
@ -114,7 +122,7 @@ fun FullScreenImageViewer(path: String, onClose: () -> Unit) {
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 24.dp)
|
||||
.padding(top = 8.dp)
|
||||
.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@ -37,7 +37,9 @@ object ChatVisualTokens {
|
||||
val SystemActionStyle = TextStyle(
|
||||
fontFamily = BitchatFontFamily,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 14.sp,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 15.sp,
|
||||
)
|
||||
|
||||
val TimestampStyle = SystemActionStyle.copy(fontSize = 10.sp, lineHeight = 12.sp)
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
package com.bitchat.watch.ui
|
||||
|
||||
import com.bitchat.watch.ui.theme.ChatVisualTokens
|
||||
import kotlin.math.pow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WearDisplayGeometryTest {
|
||||
@Test
|
||||
fun `header band corners stay inside the circle at supported text sizes`() {
|
||||
for (diameter in listOf(192f, 228f, 240f)) {
|
||||
for (scale in listOf(0.94f, 1f, 1.24f, 1.3f)) {
|
||||
val lineHeight = 15f * 1.3f * scale
|
||||
val top = 12f + (maxOf(48f, lineHeight) - lineHeight) / 2f
|
||||
val width = roundBandWidth(diameter, diameter, top, top + lineHeight)
|
||||
val radius = diameter / 2f
|
||||
for (y in listOf(top, top + lineHeight)) {
|
||||
assertTrue((width / 2).pow(2) + (y - radius).pow(2) <= radius.pow(2) + 0.01f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `out of display bands have no usable width`() {
|
||||
assertEquals(0f, roundBandWidth(192f, 192f, -1f, 20f))
|
||||
assertEquals(0f, roundBandWidth(192f, 192f, 180f, 193f))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `image and recording safe square fits all four corners`() {
|
||||
for (diameter in listOf(192f, 228f, 240f)) {
|
||||
val side = roundContentSide(diameter, diameter)
|
||||
assertTrue(side > 0)
|
||||
assertTrue(2 * (side / 2).pow(2) < (diameter / 2).pow(2))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `essential shared text is at least twelve sp`() {
|
||||
assertTrue(ChatVisualTokens.SystemActionStyle.fontSize.value >= 12f)
|
||||
assertTrue(ChatVisualTokens.MessageBodyStyle.fontSize.value >= 12f)
|
||||
assertTrue(ChatVisualTokens.SenderStyle.fontSize.value >= 12f)
|
||||
assertTrue(ChatVisualTokens.TimestampStyle.fontSize.value >= 10f)
|
||||
}
|
||||
}
|
||||
@ -32,7 +32,7 @@ class WearPeerIdentityStateTest {
|
||||
val formatted = formatVerificationCode(fingerprint)
|
||||
|
||||
assertEquals(fingerprint.uppercase(), formatted.filterNot(Char::isWhitespace))
|
||||
assertEquals(4, formatted.lines().size)
|
||||
assertEquals(listOf(4, 4, 4, 4), formatted.lines().map { it.split(" ").size })
|
||||
assertEquals(8, formatted.lines().size)
|
||||
assertEquals(List(8) { 2 }, formatted.lines().map { it.split(" ").size })
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user