diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 8eb76c44..e5e36fc2 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -416,6 +417,39 @@ fun MessageItem( .padding(top = topSpacing), verticalArrangement = Arrangement.spacedBy(0.dp) ) { + // The content aligns itself (self bubbles right, received bubbles left), so this just + // hands it the full width to place within. + MessageTextWithClickableNicknames( + message = message, + messages = messages, + currentUserNickname = currentUserNickname, + meshService = meshService, + mentionPeerIdentities = mentionPeerIdentities, + colorScheme = colorScheme, + timeFormatter = timeFormatter, + showSender = showSender, + onNicknameClick = onNicknameClick, + onMessageLongPress = onMessageLongPress, + onCancelTransfer = onCancelTransfer, + onImageClick = onImageClick, + modifier = Modifier.fillMaxWidth() + ) + + // Delivery status for private messages: a small right-aligned marker beneath the bubble, + // where it no longer risks overlapping the right-aligned self bubble. + if (message.isPrivate && message.sender == currentUserNickname) { + message.deliveryStatus?.let { status -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp, end = 4.dp), + contentAlignment = Alignment.CenterEnd + ) { + DeliveryStatusIcon(status = status) + } + } + } + Box(modifier = Modifier.fillMaxWidth()) { Row( modifier = Modifier.fillMaxWidth(), @@ -767,13 +801,43 @@ internal fun TextMessageLayout( } // The timestamp trails the body rather than occupying its own column, so a short message + // no longer reserves a full-width row for eight grey characters. The body is built further + // below against the bubble's own content color (see resolvedBodyText). + val isSelf = message.isFromSelf(currentUserNickname, myPeerId) + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val handleLongPress: () -> Unit = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onMessageLongPress?.invoke(message) + } + + // Bubble geometry: three rounded corners, with the corner on the speaker's own side tightened + // into a subtle "tail". Self bubbles sit on the right and tuck their bottom-right corner; + // received bubbles sit on the left and tuck their bottom-left corner. + val corner = ChatVisualTokens.BubbleCornerRadius + val tail = ChatVisualTokens.BubbleTailRadius + val bubbleShape = if (isSelf) { + RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = tail, bottomStart = corner) + } else { + RoundedCornerShape(topStart = corner, topEnd = corner, bottomEnd = corner, bottomStart = tail) + } + val bubbleColor = if (isSelf) { + colorScheme.primaryContainer + } else { + colorScheme.surfaceVariant.copy(alpha = ChatVisualTokens.BubbleSurfaceAlpha) + } + val bubbleContentColor = if (isSelf) colorScheme.onPrimaryContainer else colorScheme.onSurface + + // Rebuild the body against the bubble's own content color so self-message text stays legible + // on the tinted primary container rather than defaulting to the surface's onSurface tone. + val resolvedBodyText = remember( // no longer reserves a full-width row for eight grey characters. Self bubbles leave the // timestamp to their meta cluster (see BubbleTextMessageLayout). val bodyText = remember( displayMessage, currentUserNickname, palette, - colorScheme.onSurface, + bubbleContentColor, colorScheme.secondary, mentionPeerIdentities, timeFormatter, @@ -784,7 +848,7 @@ internal fun TextMessageLayout( message = displayMessage, currentUserNickname = currentUserNickname, palette = palette, - contentColor = colorScheme.onSurface, + contentColor = bubbleContentColor, linkColor = colorScheme.secondary, mentionPeerIdentities = mentionPeerIdentities, timeFormatter = timeFormatter, @@ -810,6 +874,7 @@ internal fun TextMessageLayout( Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(MessageGrouping.SENDER_TO_BODY_SPACING), + horizontalAlignment = if (isSelf) Alignment.End else Alignment.Start, ) { if (showSender) { AnnotatedClickableText( @@ -826,8 +891,12 @@ internal fun TextMessageLayout( }, onLongPress = handleLongPress, modifier = Modifier - .fillMaxWidth() - .padding(top = MessageGrouping.SENDER_TOP_PADDING), + .padding( + top = MessageGrouping.SENDER_TOP_PADDING, + // Nudge the label off the bubble's rounded edge so it lines up with the text. + start = if (isSelf) 0.dp else ChatVisualTokens.BubblePaddingHorizontal, + end = if (isSelf) ChatVisualTokens.BubblePaddingHorizontal else 0.dp, + ), fontFamily = BitchatFontFamily, softWrap = false, overflow = TextOverflow.Ellipsis, @@ -835,32 +904,52 @@ internal fun TextMessageLayout( ) } - AnnotatedClickableText( - text = bodyText, - annotationTags = listOf("geohash_click", "url_click"), - onAnnotationClick = { tag, item -> - when (tag) { - "geohash_click" -> { - navigateToGeohash(context, item) - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - true - } + // Constrain the bubble to a fraction of the available width so short messages hug their + // content while long ones still wrap instead of touching the opposite edge. + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val maxBubbleWidth = maxWidth * ChatVisualTokens.BubbleMaxWidthFraction + Box(modifier = Modifier.fillMaxWidth()) { + Surface( + color = bubbleColor, + contentColor = bubbleContentColor, + shape = bubbleShape, + modifier = Modifier + .align(if (isSelf) Alignment.CenterEnd else Alignment.CenterStart) + .widthIn(max = maxBubbleWidth) + ) { + AnnotatedClickableText( + text = resolvedBodyText, + annotationTags = listOf("geohash_click", "url_click"), + onAnnotationClick = { tag, item -> + when (tag) { + "geohash_click" -> { + navigateToGeohash(context, item) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + true + } - "url_click" -> { - openMessageUrl(context, item) - haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) - true - } + "url_click" -> { + openMessageUrl(context, item) + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + true + } - else -> false + else -> false + } + }, + onLongPress = handleLongPress, + modifier = Modifier.padding( + horizontal = ChatVisualTokens.BubblePaddingHorizontal, + vertical = ChatVisualTokens.BubblePaddingVertical, + ), + fontFamily = BitchatFontFamily, + softWrap = true, + overflow = TextOverflow.Visible, + style = MessageBodyTextStyle.copy(color = bubbleContentColor), + ) } - }, - onLongPress = handleLongPress, - fontFamily = BitchatFontFamily, - softWrap = true, - overflow = TextOverflow.Visible, - style = MessageBodyTextStyle.copy(color = colorScheme.onSurface), - ) + } + } } } diff --git a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt index 8dfb87b0..6a4d3fa9 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/BitchatPalette.kt @@ -49,24 +49,28 @@ data class BitchatPalette( ) val DarkBitchatPalette = BitchatPalette( - inputOutline = Color(0xFF333635), - inputOutlineFocused = Color(0xFF5A605D), - inputSurface = Color(0xFF0B0B0B), - inputSurfaceFocused = Color(0xFF151515), - inputButton = Color(0xFF1E1E1E), - textTertiary = Color(0xFF6B776B), + // Neutral M3 tones aligned to the new dark surfaces (surface 0xFF191C19, + // surfaceVariant/outlineVariant 0xFF43483F, outline 0xFF8D9287). + inputOutline = Color(0xFF43483F), + inputOutlineFocused = Color(0xFF8D9287), + inputSurface = Color(0xFF1E211D), + inputSurfaceFocused = Color(0xFF262A24), + inputButton = Color(0xFF2A2E28), + textTertiary = Color(0xFF8A8F84), accentOrange = Color(0xFFFF9F0A), accentPurple = Color(0xFFBF5AF2), peerColors = PeerColorStyle.Dark, ) val LightBitchatPalette = BitchatPalette( - inputOutline = Color(0xFFCFD3D1), - inputOutlineFocused = Color(0xFF8E9490), - inputSurface = Color(0xFFFAFAFA), - inputSurfaceFocused = Color(0xFFF2F2F2), - inputButton = Color(0xFFE8E8E8), - textTertiary = Color(0xFF757F75), + // Neutral M3 tones aligned to the new light surfaces (surface 0xFFF2F6F2, + // surfaceVariant 0xFFE7EDE7, outline 0xFFCBD6CB). + inputOutline = Color(0xFFCBD6CB), + inputOutlineFocused = Color(0xFF8E948E), + inputSurface = Color(0xFFF2F6F2), + inputSurfaceFocused = Color(0xFFE7EDE7), + inputButton = Color(0xFFE0E6E0), + textTertiary = Color(0xFF6C766C), accentOrange = Color(0xFFFF9500), accentPurple = Color(0xFFAF52DE), peerColors = PeerColorStyle.Light, diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Shapes.kt b/app/src/main/java/com/bitchat/android/ui/theme/Shapes.kt new file mode 100644 index 00000000..87d455a7 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/theme/Shapes.kt @@ -0,0 +1,20 @@ +package com.bitchat.android.ui.theme + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * Rounded Material 3 shape scale for the redesigned surface. + * + * There was previously no [Shapes] definition, so components fell back to Material's stock scale. + * Defining it here is what gives buttons, cards, text fields, chips, sheets, and the FAB their + * softer, modern corners app-wide from a single source — no per-composable shape overrides needed. + */ +val BitchatShapes = Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(10.dp), + medium = RoundedCornerShape(14.dp), + large = RoundedCornerShape(20.dp), + extraLarge = RoundedCornerShape(28.dp), +) diff --git a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt index f2c4b0b7..da4716a7 100644 --- a/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt +++ b/app/src/main/java/com/bitchat/android/ui/theme/Theme.kt @@ -7,6 +7,8 @@ import android.view.WindowInsetsController import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -15,31 +17,35 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView // Standard UI semantics live in Material so stock components and custom Bitchat composables // share one source of truth. LocalBitchatPalette below only supplies app-specific extra colors. +// Fixed fallback schemes for devices below Android 12 (no Material You dynamic color). +// Neutral Material 3 surfaces with a retained green-family accent so the app keeps its identity +// without the old terminal green-on-black surface tint. internal val DarkBitchatColorScheme = darkColorScheme( - primary = Color(0xFF32D74B), - onPrimary = Color.Black, - primaryContainer = Color(0xFF163D1D), + primary = Color(0xFF7CDC8A), + onPrimary = Color(0xFF00390F), + primaryContainer = Color(0xFF1C4A28), onPrimaryContainer = Color(0xFFB8F5C1), - secondary = Color(0xFF0A84FF), - onSecondary = Color.Black, - secondaryContainer = Color(0xFF082E54), - onSecondaryContainer = Color(0xFFC2E0FF), + secondary = Color(0xFF9CCBFF), + onSecondary = Color(0xFF00325A), + secondaryContainer = Color(0xFF1B4870), + onSecondaryContainer = Color(0xFFD3E4FF), tertiary = DarkBitchatPalette.accentOrange, onTertiary = Color.Black, - background = Color(0xFF000000), - onBackground = Color(0xFFF5F5F5), - surface = Color(0xFF0E150E), - onSurface = Color(0xFFF5F5F5), - surfaceVariant = Color(0xFF182118), - onSurfaceVariant = Color(0xFF9AA69A), - outline = Color(0xFF2A3A2A), - outlineVariant = Color(0xFF1C271C), - error = Color(0xFFFF453A), - onError = Color.Black + background = Color(0xFF111311), + onBackground = Color(0xFFE3E3DE), + surface = Color(0xFF191C19), + onSurface = Color(0xFFE3E3DE), + surfaceVariant = Color(0xFF43483F), + onSurfaceVariant = Color(0xFFC3C8BC), + outline = Color(0xFF8D9287), + outlineVariant = Color(0xFF43483F), + error = Color(0xFFFFB4AB), + onError = Color(0xFF690005) ) internal val LightBitchatColorScheme = lightColorScheme( @@ -82,7 +88,15 @@ fun BitchatTheme( } } - val colorScheme = if (shouldUseDark) DarkBitchatColorScheme else LightBitchatColorScheme + val context = LocalContext.current + // Material You: track the wallpaper on Android 12+, else fall back to the fixed modern schemes. + val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + val colorScheme = when { + dynamicColor && shouldUseDark -> dynamicDarkColorScheme(context) + dynamicColor && !shouldUseDark -> dynamicLightColorScheme(context) + shouldUseDark -> DarkBitchatColorScheme + else -> LightBitchatColorScheme + } val palette = if (shouldUseDark) DarkBitchatPalette else LightBitchatPalette val view = LocalView.current @@ -110,6 +124,7 @@ fun BitchatTheme( MaterialTheme( colorScheme = colorScheme, typography = Typography, + shapes = BitchatShapes, content = content ) } diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index b63113ef..0d8b2393 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -6,5 +6,5 @@ android:height="108dp"> + android:fillColor="#248A3D" />