Design pass with the screens actually in front of me
The JVM harness renders whole screens now, not just parts. The screens take their state as `phase` and only touch the view model when something is tapped, so an unstarted model is enough — it opens the Keystore vault in `init`, which fails inside a runCatching on a background dispatcher under Robolectric. That is why this works rather than by design; if it starts hanging, look there first. With the screens visible at the kit's own 375x812 canvas, four things were obvious that were not from inside the code: 🔴 The large title said "fnreviewalpha" over a relay and a device id. The kit's title names the SCREEN — "Messages". Three lines about who you are, at the top of a list about other people, and it dominated because a handle is longer than a title. Identity belongs on Account, where it already was. Contact rows read "fnreviewbeta / fnreviewbeta · online" — the name twice and a status — because the handle is usually the same string as the display name. It is dropped when it matches. "No groups yet. Start one with your buddies." was a line of prose in the middle of a list, with a section label above it, for the ordinary state of a new account: two lines of furniture for zero rows. Both go when the section is empty AND loaded. The failure case still speaks, because the reason that message existed is real — an empty list rendering nothing gave no way to tell "you have none" from "it did not load". Alerts offered "Mark all read" on an empty screen, where its only possible effect was nothing, and it was the most prominent thing there. "Nothing new." sat against the heading like a subtitle of it. Proper empty state now, and the button appears only when something is unread. Alert rows drew initials of the KIND, so "contact request" became "CR" — two letters that look like a person's initials and name nobody. Address card: the QR was the largest thing on the Account screen and pushed everything below the fold, and the address wrapped mid-hostname as "…@rela / y.flamenet.io". A relay name split across a line break is the kind of thing somebody retypes wrong, so the host is its own line now. Two fields whose placeholder repeated the label above them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d8b8d63687
commit
5a592e9858
@@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -79,16 +81,34 @@ fun AddressCard(fnid: String?, relay: String) {
|
|||||||
// stops scanning — the default filtering is smooth.
|
// stops scanning — the default filtering is smooth.
|
||||||
filterQuality = FilterQuality.None,
|
filterQuality = FilterQuality.None,
|
||||||
contentScale = ContentScale.FillBounds,
|
contentScale = ContentScale.FillBounds,
|
||||||
modifier = Modifier.size(180.dp).testTag("address-qr"),
|
// ⚠️ 140, not 180. At 180 the code was the largest thing on
|
||||||
|
// the screen and pushed everything else below the fold —
|
||||||
|
// and a QR only has to be big enough for a phone camera a
|
||||||
|
// foot away, which 140dp at this density is.
|
||||||
|
modifier = Modifier.size(140.dp).testTag("address-qr"),
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
}
|
}
|
||||||
|
// 🔴 **Broken at the `@`, not wherever the line runs out.** As one
|
||||||
|
// string it wrapped mid-hostname — "…@rela / y.flamenet.io" — and a
|
||||||
|
// relay name split across a line break is the kind of thing
|
||||||
|
// somebody retypes wrong. The fnid is a derived key and wraps
|
||||||
|
// anywhere without harm; the host must not.
|
||||||
Text(
|
Text(
|
||||||
address,
|
fnid,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.testTag("address-text"),
|
modifier = Modifier.testTag("address-text"),
|
||||||
)
|
)
|
||||||
|
Text(
|
||||||
|
"@$relay",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = Palette.SecondaryText,
|
||||||
|
maxLines = 1,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
Spacer(Modifier.height(Space.sm))
|
Spacer(Modifier.height(Space.sm))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(Space.sm)) {
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|||||||
@@ -232,15 +232,22 @@ fun ColumnScope.BuddyList(
|
|||||||
fontWeight = if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread)
|
fontWeight = if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread)
|
||||||
FontWeight.Bold else FontWeight.Medium,
|
FontWeight.Bold else FontWeight.Medium,
|
||||||
)
|
)
|
||||||
// The handle beside the display name, because the handle is
|
// ⚠️ The handle is dropped when it is the same string as
|
||||||
// what you tell someone else to type.
|
// the display name, which is the common case — it rendered
|
||||||
|
// as "fnreviewbeta / fnreviewbeta · online", the name twice
|
||||||
|
// and a status. The kit's second line is the last message;
|
||||||
|
// until the relay gives us one, presence alone is honest
|
||||||
|
// and the handle only earns its place when it differs.
|
||||||
Text(
|
Text(
|
||||||
listOfNotNull(
|
listOfNotNull(
|
||||||
buddy.handle.takeIf { it.isNotBlank() },
|
buddy.handle.takeIf {
|
||||||
|
it.isNotBlank() && !it.equals(name, ignoreCase = true)
|
||||||
|
},
|
||||||
buddy.presence.takeIf { it.isNotBlank() },
|
buddy.presence.takeIf { it.isNotBlank() },
|
||||||
).joinToString(" · "),
|
).joinToString(" · "),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) {
|
if (io.flamenet.e2e.Vault.threadForUser(buddy.userId) in phase.unread) {
|
||||||
@@ -354,7 +361,9 @@ fun ColumnScope.GroupsSection(
|
|||||||
var name by remember { mutableStateOf("") }
|
var name by remember { mutableStateOf("") }
|
||||||
var chosen by remember { mutableStateOf(setOf<Long>()) }
|
var chosen by remember { mutableStateOf(setOf<Long>()) }
|
||||||
|
|
||||||
SectionLabel("Groups")
|
// ⚠️ No label for a section with nothing in it. Two words of furniture
|
||||||
|
// above zero rows is worse than the gap it fills.
|
||||||
|
if (shownGroupsExist(phase, query) || creating) SectionLabel("Groups")
|
||||||
|
|
||||||
if (creating) {
|
if (creating) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -397,16 +406,17 @@ fun ColumnScope.GroupsSection(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⚠️ An empty list rendered NOTHING here — no guidance, and no way to tell
|
// 🔴 **Loaded-and-empty says nothing; failed still speaks.** The two used
|
||||||
// "you have no groups" from "the list did not load". Say which.
|
// to share one sentence, and the reason is worth keeping: an empty list
|
||||||
if (phase.groups.isEmpty()) {
|
// rendering NOTHING gave no way to tell "you have no groups" from "the
|
||||||
|
// list did not load". But a line of prose in the middle of a list, for the
|
||||||
|
// ordinary state of a new account, is chrome — and the section label above
|
||||||
|
// it made two lines of furniture for zero rows. A failure is still worth a
|
||||||
|
// sentence, because that one IS news.
|
||||||
|
if (phase.groups.isEmpty() && !phase.groupsLoaded) {
|
||||||
Text(
|
Text(
|
||||||
if (phase.groupsLoaded) {
|
"Couldn't load your groups. Check the connection and sign in again.",
|
||||||
"No groups yet. Start one with your buddies."
|
fontSize = 14.sp,
|
||||||
} else {
|
|
||||||
"Couldn't load your groups. Check the connection and sign in again."
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Palette.SecondaryText,
|
color = Palette.SecondaryText,
|
||||||
modifier = Modifier.testTag("groups-empty"),
|
modifier = Modifier.testTag("groups-empty"),
|
||||||
)
|
)
|
||||||
@@ -493,22 +503,48 @@ fun ColumnScope.NotificationsScreen(
|
|||||||
) {
|
) {
|
||||||
// Same bar grammar as the list screen: title at 24 bold, the one action on
|
// Same bar grammar as the list screen: title at 24 bold, the one action on
|
||||||
// the trailing edge.
|
// the trailing edge.
|
||||||
|
val unread = phase.notifications.count { !it.read }
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Text("Alerts", style = MaterialTheme.typography.headlineMedium)
|
Text("Alerts", style = MaterialTheme.typography.headlineMedium)
|
||||||
KitChip(
|
// ⚠️ Only when there is something to mark. It was offered
|
||||||
text = "Mark all read",
|
// unconditionally, so an empty screen showed a control whose only
|
||||||
selected = false,
|
// possible effect was nothing — and it was the most prominent thing on
|
||||||
onClick = model::markAllNotificationsRead,
|
// that screen.
|
||||||
tag = "mark-all-read",
|
if (unread > 0) {
|
||||||
)
|
KitChip(
|
||||||
|
text = "Mark all read",
|
||||||
|
selected = false,
|
||||||
|
onClick = model::markAllNotificationsRead,
|
||||||
|
tag = "mark-all-read",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phase.notifications.isEmpty()) {
|
if (phase.notifications.isEmpty()) {
|
||||||
Text("Nothing new.", fontSize = 14.sp, color = Palette.SecondaryText)
|
// 🔴 **An empty state, not a stray sentence under the title.**
|
||||||
|
// "Nothing new." tucked against the heading read as a subtitle of the
|
||||||
|
// heading rather than as the content of the screen, and left the other
|
||||||
|
// nine tenths of it blank with no explanation of what would ever
|
||||||
|
// appear there.
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xl * 2),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||||
|
) {
|
||||||
|
Text("🔔", fontSize = 32.sp)
|
||||||
|
Text("Nothing new", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||||
|
Text(
|
||||||
|
"Contact requests and things that happened while you were away "
|
||||||
|
+ "show up here.",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = Palette.SecondaryText,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -531,7 +567,11 @@ fun ColumnScope.NotificationsScreen(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Avatar(name = note.kind, online = false)
|
// ⚠️ A glyph for the kind, not initials OF the kind — that
|
||||||
|
// rendered "contact request" as "CR" and "missed call" as
|
||||||
|
// "MC", two letters that look like a person's initials and
|
||||||
|
// name nobody.
|
||||||
|
AlertGlyph(note.kind)
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
note.body.ifBlank { note.kind },
|
note.body.ifBlank { note.kind },
|
||||||
@@ -597,11 +637,14 @@ fun ColumnScope.AccountScreen(
|
|||||||
SettingsSection {
|
SettingsSection {
|
||||||
PresencePicker(model, phase.presence)
|
PresencePicker(model, phase.presence)
|
||||||
|
|
||||||
|
// ⚠️ The row is the label; the field must not repeat it. "Display
|
||||||
|
// name" over a box whose placeholder also said "Display name" is the
|
||||||
|
// same duplication the contact rows had.
|
||||||
SettingsRow(label = "Display name") {}
|
SettingsRow(label = "Display name") {}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = displayName,
|
value = displayName,
|
||||||
onValueChange = { displayName = it },
|
onValueChange = { displayName = it },
|
||||||
placeholder = { Text("Display name", fontSize = 14.sp) },
|
placeholder = { Text("How others see you", fontSize = 14.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth().testTag("display-name"),
|
modifier = Modifier.fillMaxWidth().testTag("display-name"),
|
||||||
)
|
)
|
||||||
@@ -622,7 +665,7 @@ fun ColumnScope.AccountScreen(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = password,
|
value = password,
|
||||||
onValueChange = { password = it },
|
onValueChange = { password = it },
|
||||||
placeholder = { Text("New password", fontSize = 14.sp) },
|
placeholder = { Text("At least 8 characters", fontSize = 14.sp) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = PasswordVisualTransformation(),
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
modifier = Modifier.fillMaxWidth().testTag("new-password"),
|
modifier = Modifier.fillMaxWidth().testTag("new-password"),
|
||||||
@@ -865,6 +908,29 @@ private fun IconAction(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun shownGroupsExist(
|
||||||
|
phase: MessengerViewModel.Phase.SignedIn,
|
||||||
|
query: String,
|
||||||
|
): Boolean = phase.groups.any { query.isBlank() || it.name.contains(query, true) }
|
||||||
|
|
||||||
|
/** One circle per kind of alert, in the same 48dp footprint as an avatar. */
|
||||||
|
@Composable
|
||||||
|
private fun AlertGlyph(kind: String) {
|
||||||
|
val symbol = when {
|
||||||
|
kind.contains("call", true) -> "☎"
|
||||||
|
kind.contains("contact", true) || kind.contains("request", true) -> "+"
|
||||||
|
kind.contains("group", true) -> "⊞"
|
||||||
|
kind.contains("story", true) -> "◎"
|
||||||
|
else -> "•"
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(Kit.avatar).clip(CircleShape).background(Palette.AvatarFill),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(symbol, fontSize = 20.sp, color = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SectionLabel(text: String) {
|
private fun SectionLabel(text: String) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -413,8 +413,13 @@ private fun NavButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ Internal rather than private ONLY so the JVM screenshot suite can render
|
||||||
|
* it. `MainActivity` is its one real caller — the alternative was a screenshot
|
||||||
|
* harness that could photograph the app's parts and never a whole screen.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun ColumnScope.SignedIn(
|
internal fun ColumnScope.SignedIn(
|
||||||
model: MessengerViewModel,
|
model: MessengerViewModel,
|
||||||
phase: MessengerViewModel.Phase.SignedIn,
|
phase: MessengerViewModel.Phase.SignedIn,
|
||||||
) {
|
) {
|
||||||
@@ -443,19 +448,18 @@ private fun ColumnScope.SignedIn(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
// 🔴 **The screen's name, not the account's.** The kit's large title
|
||||||
Text(
|
// says "Messages" — it names where you are. This said "fnreviewalpha"
|
||||||
phase.handle.ifBlank { "Signed in" },
|
// over the relay and a device id, which is three lines about who you
|
||||||
fontSize = 24.sp,
|
// are at the top of a list about other people, and it dominated the
|
||||||
fontWeight = FontWeight.Bold,
|
// screen because a handle is longer than a title. Identity belongs on
|
||||||
)
|
// Account, which is where it now is.
|
||||||
Text(
|
Text(
|
||||||
"${phase.relay} · ${phase.deviceId.take(8)}",
|
"Messages",
|
||||||
fontSize = 12.sp,
|
fontSize = 24.sp,
|
||||||
color = Palette.SecondaryText,
|
fontWeight = FontWeight.Bold,
|
||||||
modifier = Modifier.testTag("device-id"),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
BarAction(
|
BarAction(
|
||||||
symbol = if (creatingGroup) "×" else "⊞",
|
symbol = if (creatingGroup) "×" else "⊞",
|
||||||
label = "New group",
|
label = "New group",
|
||||||
|
|||||||
@@ -69,6 +69,28 @@ class ScreenshotTest {
|
|||||||
AddressCard(fnid = null, relay = "relay.flamenet.io")
|
AddressCard(fnid = null, relay = "relay.flamenet.io")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole screen, with a fabricated phase and an idle view model.
|
||||||
|
*
|
||||||
|
* ⚠️ The screens take their state as `phase` and only touch the model when
|
||||||
|
* something is tapped, so an unstarted model is enough to render them. It
|
||||||
|
* does open the Keystore vault in `init` — under Robolectric that fails
|
||||||
|
* inside a `runCatching` on a background dispatcher, which is why this
|
||||||
|
* works at all rather than by design. If it starts hanging, that is the
|
||||||
|
* first place to look.
|
||||||
|
*/
|
||||||
|
@Test fun contactsScreen() = shootFullScreen("screen-contacts") { model, phase ->
|
||||||
|
androidx.compose.foundation.layout.Column { SignedIn(model, phase) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun accountScreen() = shootFullScreen("screen-account") { model, phase ->
|
||||||
|
androidx.compose.foundation.layout.Column { AccountScreen(model, phase) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase ->
|
||||||
|
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
|
||||||
|
}
|
||||||
|
|
||||||
/** The kit's buttons, so a change to one is visible without hunting. */
|
/** The kit's buttons, so a change to one is visible without hunting. */
|
||||||
@Test fun buttons() = shoot("buttons") {
|
@Test fun buttons() = shoot("buttons") {
|
||||||
Column {
|
Column {
|
||||||
@@ -86,6 +108,51 @@ class ScreenshotTest {
|
|||||||
* background the PNG has a transparent ground and every colour judgement
|
* background the PNG has a transparent ground and every colour judgement
|
||||||
* made from it is wrong.
|
* made from it is wrong.
|
||||||
*/
|
*/
|
||||||
|
/** A phase with enough in it to exercise every row shape on a screen. */
|
||||||
|
private fun samplePhase() = MessengerViewModel.Phase.SignedIn(
|
||||||
|
handle = "fnreviewalpha",
|
||||||
|
userId = 31,
|
||||||
|
deviceId = "74d869d7-4667-4d97-854b-80ee684aaacc",
|
||||||
|
relay = "relay.flamenet.io",
|
||||||
|
fnid = "fnid:kx7q2m4n8p3r5t7v9wxyz0123456789abcdefghjkmn",
|
||||||
|
status = "Signed in on this device.",
|
||||||
|
buddies = listOf(
|
||||||
|
MessengerApi.Buddy(userId = 32, handle = "fnreviewbeta",
|
||||||
|
displayName = "fnreviewbeta", presence = "online"),
|
||||||
|
MessengerApi.Buddy(userId = 33, handle = "hoang",
|
||||||
|
displayName = "Hoang Nguyen", presence = "away"),
|
||||||
|
),
|
||||||
|
buddiesLoaded = true,
|
||||||
|
groupsLoaded = true,
|
||||||
|
notifications = listOf(
|
||||||
|
MessengerApi.Notification(1, "contact request",
|
||||||
|
"Hoang Nguyen wants to add you", read = false),
|
||||||
|
MessengerApi.Notification(2, "missed call",
|
||||||
|
"You missed a call from fnreviewbeta", read = true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The same screen with nothing on it — the state a new account sees. */
|
||||||
|
@Test fun alertsEmpty() = shoot("screen-alerts-empty") {
|
||||||
|
val app = androidx.test.core.app.ApplicationProvider
|
||||||
|
.getApplicationContext<android.app.Application>()
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
NotificationsScreen(MessengerViewModel(app),
|
||||||
|
samplePhase().copy(notifications = emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shootFullScreen(
|
||||||
|
name: String,
|
||||||
|
content: @Composable (MessengerViewModel, MessengerViewModel.Phase.SignedIn) -> Unit,
|
||||||
|
) {
|
||||||
|
val app = androidx.test.core.app.ApplicationProvider
|
||||||
|
.getApplicationContext<android.app.Application>()
|
||||||
|
val model = MessengerViewModel(app)
|
||||||
|
val phase = samplePhase()
|
||||||
|
shoot(name) { content(model, phase) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun shoot(name: String, content: @Composable () -> Unit) {
|
private fun shoot(name: String, content: @Composable () -> Unit) {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
MessengerTheme {
|
MessengerTheme {
|
||||||
|
|||||||
Reference in New Issue
Block a user