Conversation Settings Screen: Java/XML to Kotlin/Compose migration#112
Conversation Settings Screen: Java/XML to Kotlin/Compose migration#112m4pl wants to merge 34 commits into
Conversation
| internal val ScreenContentPadding = 16.dp | ||
| internal val SectionSpacing = 12.dp | ||
| internal val GroupedItemSpacing = 4.dp | ||
|
|
||
| private val SettingsCardCornerRadius = 20.dp | ||
| private val GroupedItemInnerCornerRadius = 4.dp | ||
|
|
||
| internal val SettingsCardShape = RoundedCornerShape( | ||
| size = SettingsCardCornerRadius, | ||
| ) | ||
|
|
||
| internal val GroupedTopItemShape = RoundedCornerShape( | ||
| topStart = SettingsCardCornerRadius, | ||
| topEnd = SettingsCardCornerRadius, | ||
| bottomStart = GroupedItemInnerCornerRadius, | ||
| bottomEnd = GroupedItemInnerCornerRadius, | ||
| ) | ||
|
|
||
| internal val GroupedMiddleItemShape = RoundedCornerShape( | ||
| size = GroupedItemInnerCornerRadius, | ||
| ) | ||
|
|
||
| internal val GroupedBottomItemShape = RoundedCornerShape( | ||
| topStart = GroupedItemInnerCornerRadius, | ||
| topEnd = GroupedItemInnerCornerRadius, | ||
| bottomStart = SettingsCardCornerRadius, | ||
| bottomEnd = SettingsCardCornerRadius, | ||
| ) |
There was a problem hiding this comment.
I think it would be better to derive these from MaterialTheme shapes, something like (check the default dps of the shapes)
val MaterialTheme.settingsCardShape: CornerBasedShape
@Composable @ReadOnlyComposable
get() = shapes.extraLarge
val MaterialTheme.groupedMiddleItemShape: CornerBasedShape
@Composable @ReadOnlyComposable
get() = shapes.extraSmall
val MaterialTheme.groupedTopItemShape: CornerBasedShape
@Composable @ReadOnlyComposable
get() = shapes.extraLarge.copy(
bottomStart = shapes.extraSmall.bottomStart,
bottomEnd = shapes.extraSmall.bottomEnd,
)
val MaterialTheme.groupedBottomItemShape: CornerBasedShape
@Composable @ReadOnlyComposable
get() = shapes.extraLarge.copy(
topStart = shapes.extraSmall.topStart,
topEnd = shapes.extraSmall.topEnd,
)
| fun closeAfterBlock_fromRoot_navigatesBackWithFinishResult() { | ||
| renderScreen() | ||
|
|
||
| emitNavEvent(ConversationSettingsNavEvent.CloseAfterBlock) |
There was a problem hiding this comment.
Compile error Unresolved reference 'CloseAfterBlock'
| package com.android.messaging.data.conversationsettings.model | ||
|
|
||
| import java.util.concurrent.TimeUnit | ||
|
|
||
| private const val ONE_HOUR = 1L | ||
| private const val EIGHT_HOURS = 8L | ||
| private const val TWENTY_FOUR_HOURS = 24L | ||
|
|
||
| internal enum class SnoozeOption( | ||
| val durationMillis: Long, | ||
| ) { | ||
| OneHour(TimeUnit.HOURS.toMillis(ONE_HOUR)), | ||
| EightHours(TimeUnit.HOURS.toMillis(EIGHT_HOURS)), | ||
| TwentyFourHours(TimeUnit.HOURS.toMillis(TWENTY_FOUR_HOURS)), | ||
| Always(Long.MAX_VALUE), | ||
| } |
There was a problem hiding this comment.
For Kotlin, I think Kotlin Duration is more idomatic than Java TimeUnit
| is Action.ArchiveClicked -> { | ||
| delegate.setArchived(true) | ||
| emitNavigationEvent(NavEvent.CloseAfterArchive) | ||
| } | ||
|
|
||
| is Action.UnblockClicked -> { | ||
| delegate.setDestinationBlocked(false) | ||
| } | ||
|
|
||
| is Action.BlockConfirmed -> { | ||
| delegate.setDestinationBlocked(true) | ||
| } |
There was a problem hiding this comment.
Going by the test compilation failure, for Action.BlockConfirmed, are we missing a CloseAfterBlock here? It seems old Messaging fragment does close after block:
There was a problem hiding this comment.
Ah, yes, exactly. Thanks for pointing that out, I forgot to fix the test in the last commit. I used Google's Messages app as a reference: it doesn't go back after a block, but it does after an archive. I decided to replicate that behavior, so I'll fix the test to match it.
| <activity | ||
| android:name=".ui.conversationsettings.PeopleAndOptionsActivity" | ||
| android:name=".ui.conversationsettings.ConversationSettingsActivity" | ||
| android:allowEmbedded="true" | ||
| android:configChanges="orientation|screenSize|keyboardHidden" | ||
| android:screenOrientation="user" | ||
| android:label="@string/people_and_options_activity_title" | ||
| android:theme="@style/BugleTheme" | ||
| android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity"> | ||
| android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity" | ||
| android:resizeableActivity="true" | ||
| android:screenOrientation="user" | ||
| android:theme="@style/Theme.Compose"> | ||
| <meta-data | ||
| android:name="android.support.PARENT_ACTIVITY" | ||
| android:value="com.android.messaging.ui.conversation.ConversationActivity" /> | ||
| </activity> | ||
|
|
||
| <activity-alias | ||
| android:name=".ui.conversationsettings.PeopleAndOptionsActivity" | ||
| android:exported="true" | ||
| android:targetActivity=".ui.conversationsettings.ConversationSettingsActivity" /> |
There was a problem hiding this comment.
I think we don't need this activity-alias. We needed it for the app settings activity because it originally had android:exported="true" so it could've potentially broken external apps that used that activity,
For here, the original PeopleAndOptionsActivity had no android:exported attribute (and no intent-filter), so it was effectively not exported
| conversationId = conversationId, | ||
| conversationTitle = metadata?.conversationName.orEmpty(), | ||
| isArchived = metadata?.isArchived ?: false, | ||
| isSnoozed = notificationRepository.isSnoozed(conversationId), |
There was a problem hiding this comment.
This seems to compute isSnoozed once and snapshots it based on System.currentTimeMillis(). Technically, snooze time can run out while the screen is active, and it won't update the UI
There was a problem hiding this comment.
I agree. It's unlikely, but it could happen. I'll correct it.
| is Action.ParticipantPressed -> { | ||
| resolveConversation(action.destination, shouldOpenChat = true) | ||
| } |
There was a problem hiding this comment.
Old Messages opened the contact card on participant press
There was a problem hiding this comment.
Ah okay, that makes sense. But from a consistency point of view, a lot of other messaging apps will show some sort of contact info or options in this area instead of opening the chat. e.g. the old Messaging app open contact info, Google Messages has a popup with options, Signal and WhatsApp show a bottom sheet with actions
Also, opening chat screen from here can have other implications. e.g., someone might want to leave a message for a person in a group unread because they might not want to read it at that time but still want the unread status so that they know to come back later. If we make this default open the chat screen, it will mark those messages as read. If they are used to other messaging apps showing contact options in this area, then this will be a surprise.
Generally, for actual groups, I think it's better to make contact info or at least some options as the primary action instead of as the secondary action on the side, especially for people they don't have in their contacts yet.
Google Messages: Contact
Google Messages: Non-contact
Signal
There was a problem hiding this comment.
Thanks for ideas, I'll refine the approach
There was a problem hiding this comment.
That looks pretty good! (I don't know why this comment showed up down here but it's for #112 (comment))
|
|
||
| return isVoiceCapable && | ||
| !phoneNumber.isNullOrBlank() && | ||
| !PhoneNumberUtils.isEmergencyNumber(phoneNumber) |
There was a problem hiding this comment.
marked as deprecated:
Please use TelephonyManager.isEmergencyNumber(String) instead.
| participant: ParticipantUiState?, | ||
| isVoiceCapable: Boolean, | ||
| ): Boolean { | ||
| val phoneNumber = participant?.normalizedDestination |
There was a problem hiding this comment.
This can be an email
Messaging/src/com/android/messaging/datamodel/data/ParticipantData.java
Lines 179 to 182 in 97fc0e8
Can use something like PhoneNumberUtils.isWellFormedSmsAddress to make sure
| LaunchedEffect(screenModel, effectHandler) { | ||
| screenModel.effects.collect(effectHandler::handle) | ||
| } |
There was a problem hiding this comment.
Technically effectHandler is only created in Activity onCreate so it's only created once (and recreating activity would recompose everything anyway). But we generally shouldn't use what's effectively a lambda as a key to a LaunchedEffect; it's better to use https://developer.android.com/develop/ui/compose/side-effects#rememberupdatedstate
d9a14e5 to
1ed081e
Compare
|
I've approved the conversation screen rewrite (#109), so we'll be able to properly test this more after that |
4309022 to
a00c959
Compare
| private val AppShapes = Shapes( | ||
| extraSmall = RoundedCornerShape(size = 12.dp), | ||
| small = RoundedCornerShape(size = 16.dp), | ||
| medium = RoundedCornerShape(size = 20.dp), | ||
| large = RoundedCornerShape(size = 28.dp), | ||
| extraLarge = RoundedCornerShape(size = 36.dp), | ||
| ) | ||
|
|
||
| @Composable | ||
| fun AppTheme( | ||
| content: @Composable () -> Unit, | ||
| ) { | ||
| val context = LocalContext.current | ||
| val colorScheme = when { | ||
| isSystemInDarkTheme() -> dynamicDarkColorScheme(context = context) | ||
| else -> dynamicLightColorScheme(context = context) | ||
| } | ||
|
|
||
| MaterialTheme( | ||
| colorScheme = colorScheme, | ||
| shapes = AppShapes, | ||
| content = content, |
| if (onAction != null) { | ||
| ParticipantInfoButton(onClick = onAction) | ||
| } |
There was a problem hiding this comment.
I think this info button might be redundant now that clicking the row has that quick actions popup
There was a problem hiding this comment.
They actually lead to different screens - the popup's contact action opens the system contact screen, while the info button navigates to our in-app participant info screen. I'd like to keep both behaviors, but happy to hear your thoughts. Maybe we should surface the in-app one differently?
There was a problem hiding this comment.
Ah I see, that seems fine then. Google Messages does seem to do it the opposite way: their in-app button is in the popup but the side button (a vertical 3-dot icon) is the one that opens system contact screen
| is Action.ParticipantPressed -> { | ||
| resolveConversation(action.destination, shouldOpenChat = true) | ||
| } |
There was a problem hiding this comment.
That looks pretty good! (I don't know why this comment showed up down here but it's for #112 (comment))
| internal class AnchorRelativePositionProvider( | ||
| private val anchorBoundsPx: IntRect, | ||
| private val gapPx: Int, | ||
| private val transformOriginState: MutableState<TransformOrigin>, | ||
| ) : PopupPositionProvider { | ||
|
|
||
| override fun calculatePosition( | ||
| anchorBounds: IntRect, | ||
| windowSize: IntSize, | ||
| layoutDirection: LayoutDirection, | ||
| popupContentSize: IntSize, | ||
| ): IntOffset { | ||
| val popupWidth = popupContentSize.width | ||
| val popupHeight = popupContentSize.height | ||
|
|
||
| val rightX = anchorBoundsPx.left |
There was a problem hiding this comment.
AnchorRelativePositionProvider seems to already receive an anchorBounds parameter the calculatePosition function, so probably don't need to store anchorBoundsPx in the class
There was a problem hiding this comment.
The anchorBounds param Compose passes is the bounds of the Popup's parent layout, not the avatar's. Since the popup should be anchored to the avatar specifically, those bounds are measured explicitly via onGloballyPositioned. Let me know if you see a cleaner way!
There was a problem hiding this comment.
Maybe we could just put the avatar + quick actions inside of an avatar-sized Box inside of ParticipantAvatarSlot, then the ParticipantAvatar can just use Modifer.matchParentSize()
| participants.forEach { participant -> | ||
| val destination = participant.normalizedDestination.orEmpty() | ||
| val hasDestination = destination.isNotEmpty() | ||
|
|
||
| ParticipantItem( | ||
| participant = participant, | ||
| onClick = { | ||
| if (hasDestination) { | ||
| onAction(ParticipantAction.ParticipantPressed(destination)) | ||
| } | ||
| }, | ||
| onLongClick = { | ||
| val details = participant.details | ||
| if (!details.isNullOrEmpty()) { | ||
| onAction(ParticipantAction.ParticipantLongPressed(details)) | ||
| } | ||
| }, | ||
| onCallClick = { | ||
| onAction(ParticipantAction.ParticipantCallClicked(destination)) | ||
| }.takeIf { participant.canCall }, | ||
| onContactClick = { | ||
| onAction(ParticipantAction.ParticipantContactInfoClicked(participant)) | ||
| }.takeIf { hasDestination }, | ||
| onAction = { | ||
| onAction(ParticipantAction.ParticipantActionPressed(destination)) | ||
| }.takeIf { hasDestination && isGroup }, | ||
| ) | ||
| } |
There was a problem hiding this comment.
Better for us surround this ParticipantItem with key(some stable id from participant) especially since this is in a Column and group participants could technically change, each participant holds its own state + has side effects, etc.
From https://developer.android.com/develop/ui/compose/lifecycle#add-info-smart-recomposition:
Calling a composable multiple times will add it to Composition multiple times as well. When calling a composable multiple times from the same call site, Compose doesn’t have any information to uniquely identify each call to that composable, so the execution order is used in addition to the call site in order to keep the instances distinct. This behavior is sometimes all that is needed, but in some cases it can cause unwanted behavior.
There was a problem hiding this comment.
Done. Added a stable id: String to ParticipantUiState.
751fc18 to
defa8fe
Compare
defa8fe to
16b6b2d
Compare


Migrated the Conversation Settings screen from Java + XML to Kotlin + Compose. Partially reused code from PR to avoid conflicts and code duplication. Additionally, implemented logic for blocking, archiving, and snooze settings.
Note: This PR is partially blocked by Rewrite Conversation screen PR, as the SIM card switching logic in chat settings cannot be properly tested until it is merged.
Screenshots
Video
Screen_recording_20260520_012114.webm