The portal identity reaches the screens, not just the tokens
0.26 carried the palette, the 2dp corners and the gradient helpers, but every screen still drew a plain headline and sat inside a 20dp margin, so the app looked like the old one in new colours. This is the structural half. PortalChrome.kt is the new furniture: the navy screen header with the FLAMENET eyebrow, the maroon header a conversation wears because a conversation is the Messenger channel, the ruled section band, the squared avatar tile, the gold unread count, the full width list row, and the raised control face. Signed in, the screen is now flush to its edges. Chrome, bands and rows are full bleed on the portal; a margin around them turns each one into a card, which is the single change that would undo the rest. Prose asks for its margin back with PortalBody. Buttons and chips are drawn rather than configured. Material takes a flat container colour, and a flat fill beside a beveled face is the seam that gives a half done restyle away. Three defects found while looking at the renders: Mute and Report on a story were painted Palette.Mist, the pale page ground used as ink, so they were nearly invisible on white. The story action row overflowed and broke Report across two lines mid-word. It wraps now. The Stories tab icon was Icons.Outlined.Circle, a placeholder that drew an empty ring naming nothing, and the one round shape left in a squared interface. The screenshot harness no longer pads its full screen shots, because the app does not, and photographing a layout the app does not have is how a suite goes green while proving nothing. A shot of the tab bar is new: it was rewritten for the portal chrome and nothing rendered it. 1474 unit tests green across engine, client, obfs4 and app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
b1073125ba
commit
5bbc328076
@@ -43,8 +43,8 @@ android {
|
||||
// is a label. Code 24 with name 0.24 closes it by skipping the name
|
||||
// 0.23, which costs nothing because a name is a label and may skip.
|
||||
// From here they move together and match: 25 with 0.25, 26 with 0.26.
|
||||
versionCode = 26
|
||||
versionName = "0.26"
|
||||
versionCode = 27
|
||||
versionName = "0.27"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -81,7 +80,7 @@ fun CallScreenBody(
|
||||
// No rings.
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Box(
|
||||
modifier = Modifier.size(128.dp).clip(CircleShape).background(Palette.AvatarFill),
|
||||
modifier = Modifier.size(128.dp).clip(RoundedCornerShape(Radius.small)).background(Palette.AvatarFill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
@@ -134,7 +133,7 @@ fun CallScreenBody(
|
||||
fontSize = 26.sp,
|
||||
modifier =
|
||||
Modifier
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { react(emoji) }
|
||||
.padding(6.dp)
|
||||
.testTag("call-react"),
|
||||
@@ -183,7 +182,7 @@ private fun CallButton(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(diameter)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(action.tint ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(onClick = action.run)
|
||||
.semantics { contentDescription = action.label }
|
||||
@@ -400,7 +399,7 @@ private fun VideoCallBody(
|
||||
.size(width = 108.dp, height = 144.dp)
|
||||
.clip(
|
||||
androidx.compose.foundation.shape
|
||||
.RoundedCornerShape(12.dp),
|
||||
.RoundedCornerShape(Radius.small),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -41,26 +41,13 @@ fun ColumnScope.CallsScreen(
|
||||
androidx.compose.runtime.mutableStateOf(false)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Calls",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
PortalHeader(title = "Calls") {
|
||||
// The way IN.
|
||||
Icon(
|
||||
KitIcon.Call,
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Call,
|
||||
contentDescription = "New call",
|
||||
tint = Palette.Accent,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clickable { showNewCall.value = true }
|
||||
.testTag("new-call"),
|
||||
modifier = Modifier.testTag("new-call"),
|
||||
onClick = { showNewCall.value = true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -210,7 +197,7 @@ fun ColumnScope.CallsScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.AccentSoft)
|
||||
.clickable { videoCall(entry.peerId) }
|
||||
.testTag("video-back-${entry.peerId}"),
|
||||
@@ -228,7 +215,7 @@ fun ColumnScope.CallsScreen(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.AccentSoft)
|
||||
.clickable { model.requestCall(entry.peerId) }
|
||||
.testTag("call-back-${entry.peerId}"),
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
@@ -34,7 +35,6 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
@@ -253,7 +253,7 @@ fun ColumnScope.BuddyList(
|
||||
Modifier
|
||||
.heightIn(min = 16.dp)
|
||||
.widthIn(min = 16.dp)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.testTag("unread-${buddy.userId}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -356,7 +356,7 @@ internal fun AttachedImage(
|
||||
Modifier
|
||||
.widthIn(max = 260.dp)
|
||||
.aspectRatio(ratio)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -530,7 +530,7 @@ fun ColumnScope.GroupsSection(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
.testTag("unread-group-${group.id}"),
|
||||
)
|
||||
@@ -547,7 +547,8 @@ fun PresencePicker(
|
||||
current: String,
|
||||
) {
|
||||
// A heading, because without one this was four unlabelled chips.
|
||||
Text("Your status", style = MaterialTheme.typography.titleMedium)
|
||||
// No heading here: the band above already names the section, and printing
|
||||
// it twice is what made every settings block look doubled.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
@@ -580,21 +581,12 @@ fun ColumnScope.NotificationsScreen(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
// Same bar grammar as the list screen: title at 24 bold, the one action on
|
||||
// the trailing edge.
|
||||
// Same chrome as every other screen, with the one action in it.
|
||||
val unread = phase.notifications.count { !it.read }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Alerts",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.testTag("alerts-heading"),
|
||||
)
|
||||
// Only when there is something to mark.
|
||||
if (unread > 0) {
|
||||
PortalHeader(title = "Alerts", modifier = Modifier.testTag("alerts-heading"))
|
||||
// Only when there is something to mark.
|
||||
if (unread > 0) {
|
||||
PortalBody {
|
||||
KitChip(
|
||||
text = "Mark all read",
|
||||
selected = false,
|
||||
@@ -607,7 +599,10 @@ fun ColumnScope.NotificationsScreen(
|
||||
if (phase.notifications.isEmpty()) {
|
||||
// An empty state, not a stray sentence under the title.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xl * 2),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Space.lg, vertical = Space.xl * 2),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
@@ -633,14 +628,9 @@ fun ColumnScope.NotificationsScreen(
|
||||
// app.
|
||||
Column(modifier = Modifier.fillMaxWidth().testTag("notifications")) {
|
||||
phase.notifications.forEach { note ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.markNotificationRead(note.id) }
|
||||
.padding(vertical = Kit.rowGap / 2),
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
PortalRow(
|
||||
modifier = Modifier.clickable { model.markNotificationRead(note.id) },
|
||||
height = 72.dp,
|
||||
) {
|
||||
// A glyph for the kind, not initials OF the kind, that rendered
|
||||
// "contact request" as "CR" and "missed call" as "MC", two
|
||||
@@ -655,22 +645,10 @@ fun ColumnScope.NotificationsScreen(
|
||||
)
|
||||
Text(note.kind, fontSize = 14.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
// The same unread marker the contact list uses, at the same
|
||||
// size, on the same edge.
|
||||
if (!note.read) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.presenceDot)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
// The same unread marker the conversation list uses, on the
|
||||
// same edge: the gold count, squared.
|
||||
if (!note.read) PortalUnreadBadge()
|
||||
}
|
||||
HorizontalDivider(
|
||||
color = Palette.SecondaryText.copy(alpha = 0.16f),
|
||||
modifier = Modifier.padding(start = Kit.avatar + Kit.gutter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -688,47 +666,43 @@ fun ColumnScope.AccountScreen(
|
||||
var confirmingDelete by remember { mutableStateOf(false) }
|
||||
var confirmingErase by remember { mutableStateOf(false) }
|
||||
|
||||
Text("Account", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"${phase.handle} · ${phase.relay}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
)
|
||||
Text(
|
||||
"Device ${phase.deviceId}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("device-id"),
|
||||
)
|
||||
PortalHeader(title = "Account", subtitle = "${phase.handle} · ${phase.relay}")
|
||||
|
||||
Spacer(Modifier.height(Space.md))
|
||||
|
||||
SettingsSection { ProfilePicture(model, phase) }
|
||||
SettingsSection {
|
||||
Text(
|
||||
"Device ${phase.deviceId}",
|
||||
fontSize = 12.sp,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.testTag("device-id"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
ProfilePicture(model, phase)
|
||||
}
|
||||
|
||||
// Directly under the picture: status is the other thing people expect to
|
||||
// find first on an account screen, and it was last.
|
||||
SectionBand()
|
||||
SectionBand("Your status")
|
||||
SettingsSection { PresencePicker(model, phase.presence) }
|
||||
|
||||
SectionBand()
|
||||
SectionBand("Stories")
|
||||
SettingsSection { StoryAudienceSection(model, phase) }
|
||||
|
||||
// Wallet and Files are bar destinations, in the same place iOS puts them.
|
||||
|
||||
SectionBand()
|
||||
SectionBand("App lock")
|
||||
SettingsSection { AppLockSection() }
|
||||
|
||||
SectionBand()
|
||||
SectionBand("Media")
|
||||
SettingsSection { MediaQualitySection() }
|
||||
|
||||
SectionBand()
|
||||
SectionBand("Your devices")
|
||||
SettingsSection { RegisteredDevicesSection(model, phase) }
|
||||
|
||||
SectionBand()
|
||||
SectionBand("History backup")
|
||||
SettingsSection { HistoryBackupSection(model) }
|
||||
|
||||
// Sits with the other settings and not under anything privacy-shaped.
|
||||
SectionBand()
|
||||
SectionBand("Display")
|
||||
SettingsSection { DisplaySection() }
|
||||
|
||||
SectionBand()
|
||||
@@ -750,13 +724,13 @@ fun ColumnScope.AccountScreen(
|
||||
}
|
||||
SettingsSection { NotificationSection() }
|
||||
|
||||
SectionBand()
|
||||
SectionBand("Network")
|
||||
SettingsSection { NetworkSection(model, phase.buddies) }
|
||||
|
||||
SectionBand()
|
||||
SectionBand("Parental controls")
|
||||
SettingsSection { ParentalControlsSection() }
|
||||
|
||||
SectionBand()
|
||||
SectionBand("Contact list")
|
||||
SettingsSection { ContactListPrivacySection(model) }
|
||||
|
||||
SectionBand()
|
||||
@@ -1126,7 +1100,7 @@ private fun ColumnScope.StoryAudienceSection(
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val audience = phase.storyAudience
|
||||
Text("Who can see your stories", style = MaterialTheme.typography.titleMedium)
|
||||
// Named by the band above, not again here.
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
@@ -1332,7 +1306,13 @@ private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) {
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(vertical = Space.sm), content = content)
|
||||
// Horizontal padding lives here rather than on the screen, so the
|
||||
// white panel runs to the edges the way the bands above it do while
|
||||
// its contents keep the portal's 12px gutter.
|
||||
Column(
|
||||
Modifier.padding(horizontal = Space.md, vertical = Space.sm),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,15 +1325,29 @@ private fun SettingsSection(content: @Composable ColumnScope.() -> Unit) {
|
||||
internal fun SectionBandPublic() = SectionBand()
|
||||
|
||||
@Composable
|
||||
private fun SectionBand() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 0.dp)
|
||||
.height(16.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
)
|
||||
private fun SectionBand(label: String? = null) {
|
||||
// The portal's own section furniture: a blue band ruled top and bottom.
|
||||
// A bare gap reads as an app with nothing between its sections; this
|
||||
// reads as a page with headings.
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Portal.rule()))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Portal.band())
|
||||
.padding(horizontal = Space.md, vertical = if (label == null) Space.sm else 7.dp),
|
||||
) {
|
||||
if (label != null) {
|
||||
Text(
|
||||
label.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Portal.rule()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1417,7 +1411,7 @@ internal fun Avatar(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.AvatarFill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -1447,10 +1441,10 @@ internal fun Avatar(
|
||||
Modifier
|
||||
.size(Kit.presenceDot)
|
||||
.align(Alignment.BottomEnd)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.padding(2.dp)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.Teal),
|
||||
)
|
||||
}
|
||||
@@ -1478,7 +1472,7 @@ private fun IconAction(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = label }
|
||||
.testTag(tag),
|
||||
@@ -1515,7 +1509,7 @@ private fun AlertGlyph(kind: String) {
|
||||
else -> KitIcon.Dot
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(Kit.avatar).clip(CircleShape).background(Palette.AvatarFill),
|
||||
modifier = Modifier.size(Kit.avatar).clip(RoundedCornerShape(Radius.small)).background(Palette.AvatarFill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
@@ -1550,7 +1544,7 @@ private fun PresenceDot(status: String) {
|
||||
"busy" -> Palette.Pink
|
||||
else -> Palette.SecondaryText
|
||||
}
|
||||
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(colour))
|
||||
Box(modifier = Modifier.size(10.dp).clip(RoundedCornerShape(Radius.small)).background(colour))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1672,68 +1666,56 @@ fun ColumnScope.ChatScreen(
|
||||
if (chat.lines.isNotEmpty()) listState.scrollToItem(chat.lines.lastIndex)
|
||||
}
|
||||
|
||||
// One bar, not three stacked rows.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
IconAction(icon = KitIcon.Back, label = "Back", tag = "back", onClick = model::closeChat)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
chat.title.ifBlank { "Conversation" },
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
// The kit's second line: a presence dot, then a 12sp fact.
|
||||
val presence =
|
||||
chat.peerId?.let { id ->
|
||||
buddies.firstOrNull { it.userId == id }?.presence
|
||||
}
|
||||
val subtitle =
|
||||
chat.group?.let { "${it.memberIds.size} members" }
|
||||
?: presence?.takeIf { it.isNotBlank() }
|
||||
subtitle?.let { line ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chat.group == null && presence.equals("online", true)) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(Palette.Teal))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(line, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
}
|
||||
// One bar, not three stacked rows, and it wears the channel's own colour.
|
||||
val chatTitle = chat.title.ifBlank { "Conversation" }
|
||||
val presence =
|
||||
chat.peerId?.let { id ->
|
||||
buddies.firstOrNull { it.userId == id }?.presence
|
||||
}
|
||||
ChannelHeader(
|
||||
title = chatTitle,
|
||||
onBack = model::closeChat,
|
||||
subtitle =
|
||||
chat.group?.let { "${it.memberIds.size} members" }
|
||||
?: presence?.takeIf { it.isNotBlank() },
|
||||
leading = {
|
||||
PortalAvatarTile(
|
||||
letter = chatTitle,
|
||||
size = 36.dp,
|
||||
fill = Palette.ChannelTint,
|
||||
edge = Palette.ChannelTint,
|
||||
ink = Palette.ChannelDark,
|
||||
)
|
||||
},
|
||||
) {
|
||||
// Calling is one-to-one.
|
||||
if (chat.peerId != null) {
|
||||
val videoCall = rememberVideoCall(model)
|
||||
IconAction(
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Video,
|
||||
label = "Video call",
|
||||
tag = "start-video-call",
|
||||
contentDescription = "Video call",
|
||||
modifier = Modifier.testTag("start-video-call"),
|
||||
onClick = { videoCall(chat.peerId) },
|
||||
)
|
||||
// The kit's bar has a video icon here and this does not, and that
|
||||
// is deliberate.
|
||||
IconAction(
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Call,
|
||||
label = "Call",
|
||||
tag = "start-call",
|
||||
contentDescription = "Call",
|
||||
modifier = Modifier.testTag("start-call"),
|
||||
onClick = { model.requestCall(chat.peerId) },
|
||||
)
|
||||
}
|
||||
if (chat.peerId != null) ConversationMenu(model, chat, draft) { setDraft("") }
|
||||
chat.group?.let { group ->
|
||||
IconAction(
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Poll,
|
||||
label = "New poll",
|
||||
tag = "new-poll",
|
||||
contentDescription = "New poll",
|
||||
modifier = Modifier.testTag("new-poll"),
|
||||
onClick = { askingPoll = true },
|
||||
)
|
||||
IconAction(
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.LeaveGroup,
|
||||
label = "Leave group",
|
||||
tag = "leave-group",
|
||||
danger = true,
|
||||
contentDescription = "Leave group",
|
||||
modifier = Modifier.testTag("leave-group"),
|
||||
onClick = { model.leaveGroup(group.id) },
|
||||
)
|
||||
}
|
||||
@@ -1745,20 +1727,28 @@ fun ColumnScope.ChatScreen(
|
||||
// Above everything else in the thread, because it is a statement about who
|
||||
// you are talking to and it has to be read before the messages are, not
|
||||
// after scrolling past them.
|
||||
if (chat.peerId != null) {
|
||||
StrangerBanner(model, chat.peerId, buddies) {
|
||||
reporting = chat.lines.lastOrNull { !it.mine }
|
||||
// Everything between the chrome and the transcript keeps the portal's
|
||||
// gutter. The screen is flush now, so without this the identity line and
|
||||
// the banners run into both edges.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Space.md),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.xs),
|
||||
) {
|
||||
if (chat.peerId != null) {
|
||||
StrangerBanner(model, chat.peerId, buddies) {
|
||||
reporting = chat.lines.lastOrNull { !it.mine }
|
||||
}
|
||||
}
|
||||
|
||||
chat.group?.let { group -> GroupMembers(model, group, buddies) }
|
||||
|
||||
// Identity, directly under the name it belongs to.
|
||||
if (chat.peerId != null) IdentityPanel(model, chat)
|
||||
|
||||
// One line, not three panels.
|
||||
if (chat.peerId != null) ConversationStatusLine(model, chat)
|
||||
}
|
||||
|
||||
chat.group?.let { group -> GroupMembers(model, group, buddies) }
|
||||
|
||||
// Identity, directly under the name it belongs to.
|
||||
if (chat.peerId != null) IdentityPanel(model, chat)
|
||||
|
||||
// One line, not three panels.
|
||||
if (chat.peerId != null) ConversationStatusLine(model, chat)
|
||||
|
||||
// §6.15/§6.19, which message a sheet is currently about.
|
||||
var editing by remember(chat.threadId) { mutableStateOf<Session.Line?>(null) }
|
||||
var forwarding by remember(chat.threadId) { mutableStateOf<Session.Line?>(null) }
|
||||
@@ -1885,6 +1875,11 @@ fun ColumnScope.ChatScreen(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f).testTag("messages"),
|
||||
// The transcript keeps the portal's 12px gutter. The screen around it
|
||||
// is flush, so without this the bubbles touch both edges.
|
||||
contentPadding =
|
||||
androidx.compose.foundation.layout
|
||||
.PaddingValues(horizontal = Space.md, vertical = 14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(chat.lines) { line ->
|
||||
@@ -2229,10 +2224,17 @@ fun ColumnScope.ChatScreen(
|
||||
pendingMentions = emptySet()
|
||||
}
|
||||
|
||||
// The composer sits on its own band, ruled off from the transcript, the
|
||||
// way the site rules off a form from the page it is on.
|
||||
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Portal.rule()))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.sm),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Portal.composerBand())
|
||||
.padding(horizontal = Space.sm, vertical = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
horizontalArrangement = Arrangement.spacedBy(7.dp),
|
||||
) {
|
||||
// A choice, not a shortcut to the photo picker.
|
||||
IconAction(
|
||||
@@ -2258,8 +2260,12 @@ fun ColumnScope.ChatScreen(
|
||||
.weight(1f)
|
||||
.heightIn(min = Kit.pillHeight)
|
||||
.clip(RoundedCornerShape(Kit.pillRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.padding(horizontal = Kit.gutter, vertical = 14.dp),
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(
|
||||
Kit.hairline,
|
||||
Palette.InputEdge,
|
||||
RoundedCornerShape(Kit.pillRadius),
|
||||
).padding(horizontal = 10.dp, vertical = 12.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
if (draft.isEmpty()) {
|
||||
@@ -2335,7 +2341,7 @@ fun ColumnScope.ChatScreen(
|
||||
} else {
|
||||
Button(
|
||||
onClick = sendDraft,
|
||||
shape = CircleShape,
|
||||
shape = RoundedCornerShape(Radius.small),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier.size(Kit.pillHeight).testTag("send"),
|
||||
) {
|
||||
@@ -2565,24 +2571,28 @@ private fun Bubble(
|
||||
return@Column
|
||||
}
|
||||
|
||||
// A sent bubble is the chrome gradient with a highlight along its
|
||||
// top; a received one is plain surface inside a rule. Both are
|
||||
// squared. Flat fills on both sides is what a generic messenger
|
||||
// looks like, and it is the difference that reads first.
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
// One corner is square, and it points at its author.
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = if (line.mine) Kit.bubbleRadius else Kit.bubbleTail,
|
||||
topEnd = Kit.bubbleRadius,
|
||||
bottomEnd = if (line.mine) Kit.bubbleTail else Kit.bubbleRadius,
|
||||
bottomStart = Kit.bubbleRadius,
|
||||
),
|
||||
).background(
|
||||
if (line.mine) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
).padding(Kit.bubblePadding),
|
||||
if (line.mine) {
|
||||
Modifier.portalFace(
|
||||
face = Portal.sentFace(),
|
||||
edge = Palette.NavyDark,
|
||||
radius = Kit.bubbleRadius,
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
.clip(RoundedCornerShape(Kit.bubbleRadius))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(
|
||||
Kit.hairline,
|
||||
Portal.rule(),
|
||||
RoundedCornerShape(Kit.bubbleRadius),
|
||||
)
|
||||
}.padding(Kit.bubblePadding),
|
||||
) {
|
||||
// The kit puts the time ABOVE the bubble, outside it.
|
||||
line.attachment?.let { ref ->
|
||||
@@ -2873,7 +2883,7 @@ fun ColumnScope.StoryStrip(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.avatar)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable { pickStory.launch("*/*") },
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -2902,7 +2912,7 @@ fun ColumnScope.StoryStrip(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.avatar)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
// Unseen is marked rather than styled away: a strip
|
||||
// where read and unread look alike is a strip
|
||||
// nobody comes back to.
|
||||
@@ -2913,7 +2923,7 @@ fun ColumnScope.StoryStrip(
|
||||
MaterialTheme.colorScheme.primary
|
||||
},
|
||||
).padding(if (story.seen) 0.dp else 2.dp)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.AvatarFill)
|
||||
.clickable { model.openStory(story) },
|
||||
contentAlignment = Alignment.Center,
|
||||
@@ -3210,7 +3220,11 @@ fun BuddyCirclesRow(
|
||||
) {
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
modifier = Modifier.fillMaxWidth().testTag("buddy-circles"),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
// The strip is full bleed, so the gutter is content padding rather
|
||||
// than a margin: the tiles scroll under the screen edge instead of
|
||||
// stopping short of it.
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(Space.md),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
items(phase.buddies.size) { i ->
|
||||
val buddy = phase.buddies[i]
|
||||
@@ -4104,7 +4118,7 @@ private fun FileBubble(
|
||||
Modifier
|
||||
.background(
|
||||
onColour.copy(alpha = 0.14f),
|
||||
RoundedCornerShape(4.dp),
|
||||
RoundedCornerShape(Radius.small),
|
||||
).padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
.testTag("chat.file.classification"),
|
||||
)
|
||||
|
||||
@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -70,7 +69,7 @@ fun ColumnScope.ContactDetail(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable(onClick = onClose)
|
||||
.semantics { contentDescription = "Back" }
|
||||
.testTag("contact-back"),
|
||||
@@ -130,7 +129,7 @@ fun ColumnScope.ContactDetail(
|
||||
// anything put there is behind it.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (buddy.presence.equals("online", true)) {
|
||||
Box(Modifier.size(8.dp).clip(CircleShape).background(Palette.Teal))
|
||||
Box(Modifier.size(8.dp).clip(RoundedCornerShape(Radius.small)).background(Palette.Teal))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(
|
||||
@@ -159,7 +158,7 @@ fun ColumnScope.ContactDetail(
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 120.dp, end = Kit.margin)
|
||||
.width(60.dp)
|
||||
.clip(RoundedCornerShape(30.dp))
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(
|
||||
if (isBlocked) Palette.SecondaryText else MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
|
||||
@@ -51,12 +51,7 @@ fun ColumnScope.FilesScreen(
|
||||
) {
|
||||
val files = phase.files ?: MessengerViewModel.FilesState()
|
||||
|
||||
Text(
|
||||
"Files",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp,
|
||||
modifier = Modifier.padding(bottom = Space.xs),
|
||||
)
|
||||
PortalHeader(title = "Files")
|
||||
|
||||
when {
|
||||
!files.hasIdentity -> FilesSetUp(model, files)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -14,9 +19,13 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@@ -24,11 +33,18 @@ import androidx.compose.ui.unit.sp
|
||||
* The kit's buttons.
|
||||
*/
|
||||
object KitButton {
|
||||
val height = 64.dp
|
||||
val radius = 32.dp
|
||||
val height = 44.dp
|
||||
val radius = 2.dp
|
||||
private val padding = PaddingValues(horizontal = 16.dp)
|
||||
}
|
||||
|
||||
/**
|
||||
* The encouraged action: the indigo face, white label, one inset highlight.
|
||||
*
|
||||
* Drawn rather than configured. Material's `Button` takes a flat container
|
||||
* colour, and a flat fill is the single thing that would make this look like
|
||||
* every other app, so the face is a brush and the button is a `Box`.
|
||||
*/
|
||||
@Composable
|
||||
fun KitPrimaryButton(
|
||||
text: String,
|
||||
@@ -37,21 +53,18 @@ fun KitPrimaryButton(
|
||||
enabled: Boolean = true,
|
||||
tag: String? = null,
|
||||
) {
|
||||
Button(
|
||||
PortalFaceButton(
|
||||
text = text,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(KitButton.radius),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.height(KitButton.height)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
) {
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
tag = tag,
|
||||
face = Modifier.portalPrimaryFace(),
|
||||
ink = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
/** Outlined. For an action that is real but not the one being encouraged. */
|
||||
/** The bone face, for an action that is real but not the one being encouraged. */
|
||||
@Composable
|
||||
fun KitSecondaryButton(
|
||||
text: String,
|
||||
@@ -61,21 +74,51 @@ fun KitSecondaryButton(
|
||||
tag: String? = null,
|
||||
danger: Boolean = false,
|
||||
) {
|
||||
val colour =
|
||||
if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
|
||||
OutlinedButton(
|
||||
PortalFaceButton(
|
||||
text = text,
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
shape = RoundedCornerShape(KitButton.radius),
|
||||
border = BorderStroke(1.dp, colour),
|
||||
colors = ButtonDefaults.outlinedButtonColors(contentColor = colour),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
tag = tag,
|
||||
face = Modifier.portalButtonFace(),
|
||||
ink =
|
||||
if (danger) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PortalFaceButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
enabled: Boolean,
|
||||
tag: String?,
|
||||
face: Modifier,
|
||||
ink: Color,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.height(KitButton.height)
|
||||
.then(face)
|
||||
.alpha(if (enabled) 1f else 0.45f)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 16.dp)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,34 +138,39 @@ fun KitChip(
|
||||
*/
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
contentPadding = PaddingValues(horizontal = 14.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentColor =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
),
|
||||
// Chosen wears the chrome navy, the rest wear the bone face. Both are
|
||||
// raised the portal's way: gradient, one highlight along the top, a 1dp
|
||||
// edge. A flat chip beside a beveled button is the seam that gives a
|
||||
// half-done restyle away.
|
||||
val face =
|
||||
if (selected) {
|
||||
Modifier.portalFace(Portal.chrome(), Palette.NavyDark, Color(0x47FFFFFF))
|
||||
} else {
|
||||
Modifier.portalButtonFace()
|
||||
}
|
||||
val ink = if (selected) Color.White else MaterialTheme.colorScheme.onSurface
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.height(36.dp)
|
||||
.height(KitButton.height)
|
||||
.then(face)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp)
|
||||
.then(if (tag != null) Modifier.testTag(tag) else Modifier),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Icon(icon, contentDescription = null, tint = ink, modifier = Modifier.size(18.dp))
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(text, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
text,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import androidx.compose.material.icons.outlined.PersonAdd
|
||||
import androidx.compose.material.icons.outlined.PersonOutline
|
||||
import androidx.compose.material.icons.outlined.PersonRemove
|
||||
import androidx.compose.material.icons.outlined.PhotoCamera
|
||||
import androidx.compose.material.icons.outlined.PhotoLibrary
|
||||
import androidx.compose.material.icons.outlined.QrCodeScanner
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
@@ -62,7 +63,10 @@ object KitIcon {
|
||||
/** The Home tab, roster, stories strip, and the Recent circles. */
|
||||
val Home: ImageVector = Icons.Outlined.Home
|
||||
|
||||
val Stories: ImageVector = Icons.Outlined.Circle
|
||||
// A stack of pictures, not a bare ring. `Outlined.Circle` was standing in
|
||||
// here and drew an empty circle in the tab bar, which named nothing and
|
||||
// was the one round shape left in a squared interface.
|
||||
val Stories: ImageVector = Icons.Outlined.PhotoLibrary
|
||||
val Alerts: ImageVector = Icons.Outlined.NotificationsNone
|
||||
val Account: ImageVector = Icons.Outlined.PersonOutline
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
@@ -67,6 +66,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import io.flamenet.messenger.R
|
||||
import io.flamenet.messenger.app.MessengerViewModel.Phase.Screen
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.border
|
||||
|
||||
/**
|
||||
* `FragmentActivity`, not `ComponentActivity`, and that is not cosmetic.
|
||||
@@ -231,6 +232,17 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
}
|
||||
Scaffold { padding ->
|
||||
val frame = Modifier.fillMaxSize().padding(padding).padding(20.dp)
|
||||
|
||||
// Signed in, the screen runs to its edges. The chrome, the section
|
||||
// bands and the list rows are full bleed on the portal, and a 20dp
|
||||
// margin around them turns every one of them into a card, which is
|
||||
// the single change that would undo the whole look. Prose inside a
|
||||
// screen gets its margin back from `PortalBody`.
|
||||
val flush =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
when (val current = phase) {
|
||||
// Scrollable, because a keyboard over a form needs somewhere to go.
|
||||
is MessengerViewModel.Phase.Starting,
|
||||
@@ -252,7 +264,7 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
|
||||
// NOT scrollable.
|
||||
is MessengerViewModel.Phase.SignedIn -> {
|
||||
Column(modifier = frame, verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Column(modifier = flush) {
|
||||
// Above the screen switch, not inside one branch.
|
||||
CallBanner(model, current)
|
||||
// §6.30, another of this account's devices offering the
|
||||
@@ -279,12 +291,15 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
)
|
||||
} else {
|
||||
// The content area is weighted, and the tab bar is not.
|
||||
// No arrangement spacing: a band sits hard against the
|
||||
// row above it, and a screen that wants air asks for it
|
||||
// inside `PortalBody`.
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
when (current.screen) {
|
||||
MessengerViewModel.Phase.Screen.CONTACTS -> {
|
||||
@@ -613,59 +628,63 @@ object NavDestinations {
|
||||
* The places to be, always visible.
|
||||
*/
|
||||
@Composable
|
||||
private fun BottomNav(
|
||||
internal fun BottomNav(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
val unreadNotes = phase.notifications.count { !it.read }
|
||||
// A floating pill, not a full-width strip.
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = Kit.pillHeight)
|
||||
.clip(RoundedCornerShape(Kit.pillRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
// Supervision removes the Stories surface rather than emptying it.
|
||||
val storiesHidden =
|
||||
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
|
||||
for (tab in NavDestinations.tabs(storiesHidden)) {
|
||||
val badge =
|
||||
when (tab.screen) {
|
||||
// The inbox badge lives on Messages.
|
||||
Screen.MESSAGES -> phase.unread.size
|
||||
|
||||
// Missed calls SINCE the tab was last opened, it used to count
|
||||
// every missed call in the log for ever, a badge nothing could
|
||||
// clear.
|
||||
Screen.CALLS -> model.missedCallsUnseen(phase)
|
||||
|
||||
// There is no Alerts tab, and iOS never had one; its
|
||||
// unread count badges Account on both phones.
|
||||
Screen.ACCOUNT -> unreadNotes
|
||||
|
||||
else -> 0
|
||||
}
|
||||
val (icon, active) =
|
||||
when (tab.screen) {
|
||||
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
||||
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
||||
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
||||
Screen.STORIES -> KitIcon.Stories to KitIcon.Stories
|
||||
Screen.FILES -> KitIcon.Files to KitIcon.Files
|
||||
Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet
|
||||
else -> KitIcon.Account to KitIcon.Account
|
||||
}
|
||||
NavButton(model, phase, tab.screen, icon, active, tab.label, badge, tab.tag)
|
||||
// The portal's bar: a full-width navy strip with a rule along its top,
|
||||
// not a floating pill. The tab you are on is marked in gold, which is
|
||||
// the only place besides an unread count that the accent appears at all.
|
||||
Column(modifier = Modifier.fillMaxWidth().background(Portal.chrome())) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(Kit.hairline)
|
||||
.background(Palette.NavyDark),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
// Supervision removes the Stories surface rather than emptying it.
|
||||
val storiesHidden =
|
||||
ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)
|
||||
for (tab in NavDestinations.tabs(storiesHidden)) {
|
||||
val badge =
|
||||
when (tab.screen) {
|
||||
// The inbox badge lives on Messages.
|
||||
Screen.MESSAGES -> phase.unread.size
|
||||
// Missed calls SINCE the tab was last opened, it used to
|
||||
// count every missed call in the log for ever, a badge
|
||||
// nothing could clear.
|
||||
Screen.CALLS -> model.missedCallsUnseen(phase)
|
||||
// There is no Alerts tab, and iOS never had one; its
|
||||
// unread count badges Account on both phones.
|
||||
Screen.ACCOUNT -> unreadNotes
|
||||
else -> 0
|
||||
}
|
||||
val (icon, active) =
|
||||
when (tab.screen) {
|
||||
Screen.CONTACTS -> KitIcon.Home to KitIcon.Home
|
||||
Screen.MESSAGES -> KitIcon.Chats to KitIcon.ChatsActive
|
||||
Screen.CALLS -> KitIcon.Call to KitIcon.Call
|
||||
Screen.STORIES -> KitIcon.Stories to KitIcon.Stories
|
||||
Screen.FILES -> KitIcon.Files to KitIcon.Files
|
||||
Screen.WALLET -> KitIcon.Wallet to KitIcon.Wallet
|
||||
else -> KitIcon.Account to KitIcon.Account
|
||||
}
|
||||
NavButton(model, phase, tab.screen, icon, active, tab.label, badge, tab.tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One tab: a drawn icon, and a count when there is one.
|
||||
* One tab: a drawn icon over its name, the gold mark when you are on it, and
|
||||
* a squared count when there is one.
|
||||
*/
|
||||
@Composable
|
||||
private fun RowScope.NavButton(
|
||||
@@ -679,11 +698,13 @@ private fun RowScope.NavButton(
|
||||
tag: String,
|
||||
) {
|
||||
val selected = phase.screen == screen
|
||||
val face = if (selected) Color.White else Color(0xFFB4D2EE)
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.height(Kit.pillHeight)
|
||||
.height(Kit.pillHeight + Space.sm)
|
||||
.background(if (selected) Color(0x2E000000) else Color.Transparent)
|
||||
// `navigateTo`, not `showScreen`.
|
||||
.clickable { model.navigateTo(screen) }
|
||||
.semantics {
|
||||
@@ -692,27 +713,52 @@ private fun RowScope.NavButton(
|
||||
}.testTag(tag),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
if (selected) activeIcon else icon,
|
||||
contentDescription = null,
|
||||
tint =
|
||||
if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
modifier = Modifier.size(Kit.icon),
|
||||
)
|
||||
if (selected) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(3.dp)
|
||||
.background(Palette.Gold),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Icon(
|
||||
if (selected) activeIcon else icon,
|
||||
contentDescription = null,
|
||||
tint = face,
|
||||
modifier = Modifier.size(Kit.icon),
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = face,
|
||||
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
}
|
||||
if (badge > 0) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = Space.md, end = Space.lg)
|
||||
.size(Space.sm)
|
||||
.clip(CircleShape)
|
||||
.background(Palette.Pink),
|
||||
)
|
||||
.padding(top = Space.xs, end = Space.xs)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.Gold)
|
||||
.border(Kit.hairline, Palette.GoldEdge, RoundedCornerShape(Radius.small))
|
||||
.padding(horizontal = 4.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
if (badge > 99) "99+" else badge.toString(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF3A2E00),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -748,21 +794,15 @@ internal fun ColumnScope.SignedIn(
|
||||
}
|
||||
|
||||
// Home is two things now, on purpose: your people, and their stories.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
PortalHeader(
|
||||
title = "Home",
|
||||
subtitle = phase.handle.ifBlank { null }?.let { "$it · ${phase.relay}" },
|
||||
) {
|
||||
Text(
|
||||
"Home",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Scan their Address screen, iOS's home has the same button.
|
||||
BarAction(
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Scan,
|
||||
label = "Scan an address",
|
||||
tag = "scan-address",
|
||||
contentDescription = "Scan an address",
|
||||
modifier = Modifier.testTag("scan-address"),
|
||||
onClick = {
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
@@ -776,10 +816,10 @@ internal fun ColumnScope.SignedIn(
|
||||
}
|
||||
},
|
||||
)
|
||||
BarAction(
|
||||
PortalHeaderIcon(
|
||||
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||
label = "Add contact",
|
||||
tag = "add-contact",
|
||||
contentDescription = "Add contact",
|
||||
modifier = Modifier.testTag("add-contact"),
|
||||
onClick = { addingContact = !addingContact },
|
||||
)
|
||||
}
|
||||
@@ -799,12 +839,14 @@ internal fun ColumnScope.SignedIn(
|
||||
}
|
||||
|
||||
phase.error?.let {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("error"),
|
||||
)
|
||||
PortalBody {
|
||||
Text(
|
||||
it,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.testTag("error"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// A call refused because the peer's keys changed is dealt with here, where
|
||||
@@ -818,28 +860,32 @@ internal fun ColumnScope.SignedIn(
|
||||
}
|
||||
|
||||
if (addingContact) {
|
||||
AddContactForm(model, phase, initial = scannedPrefill) {
|
||||
addingContact = false
|
||||
scannedPrefill = ""
|
||||
PortalBody {
|
||||
AddContactForm(model, phase, initial = scannedPrefill) {
|
||||
addingContact = false
|
||||
scannedPrefill = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PortalBand("Your people")
|
||||
if (phase.buddies.isEmpty()) {
|
||||
Text(
|
||||
if (phase.buddiesLoaded) {
|
||||
"Nobody here yet. Add someone by the username they signed up with."
|
||||
} else {
|
||||
"Couldn't load your contacts. Check the connection and sign in again."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
PortalBody {
|
||||
Text(
|
||||
if (phase.buddiesLoaded) {
|
||||
"Nobody here yet. Add someone by the username they signed up with."
|
||||
} else {
|
||||
"Couldn't load your contacts. Check the connection and sign in again."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
BuddyCirclesRow(model, phase) { openProfile = it }
|
||||
}
|
||||
|
||||
HorizontalDivider(color = Palette.SecondaryText.copy(alpha = 0.16f))
|
||||
|
||||
if (!ParentalControls.storiesHidden(androidx.compose.ui.platform.LocalContext.current)) {
|
||||
PortalBand("Stories")
|
||||
StoriesCards(model, phase)
|
||||
}
|
||||
}
|
||||
@@ -858,7 +904,7 @@ private fun BarAction(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.icon + Space.md)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable(onClick = onClick)
|
||||
.semantics { contentDescription = label }
|
||||
.testTag(tag),
|
||||
|
||||
@@ -13,7 +13,7 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -62,46 +62,29 @@ fun ColumnScope.MessagesScreen(
|
||||
// Starting a group sits on the Groups heading and a chat on the Chats
|
||||
// heading; the bar keeps the two ways a NEW person gets in, scan their
|
||||
// code, or add them by name, the same pair the Home bar carries.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Messages",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
androidx.compose.material3.Icon(
|
||||
KitIcon.Scan,
|
||||
PortalHeader(title = "Messages") {
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Scan,
|
||||
contentDescription = "Scan an address",
|
||||
tint = Palette.Accent,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(24.dp)
|
||||
.clickable {
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) {
|
||||
scanning = true
|
||||
} else {
|
||||
askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
}.testTag("messages-scan"),
|
||||
modifier = Modifier.testTag("messages-scan"),
|
||||
onClick = {
|
||||
val has =
|
||||
androidx.core.content.ContextCompat.checkSelfPermission(
|
||||
scanContext,
|
||||
android.Manifest.permission.CAMERA,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
if (has) {
|
||||
scanning = true
|
||||
} else {
|
||||
askCamera.launch(android.Manifest.permission.CAMERA)
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.width(Space.md))
|
||||
androidx.compose.material3.Icon(
|
||||
if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||
PortalHeaderIcon(
|
||||
icon = if (addingContact) KitIcon.Close else KitIcon.Add,
|
||||
contentDescription = "Add contact",
|
||||
tint = Palette.Accent,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(26.dp)
|
||||
.clickable { addingContact = !addingContact }
|
||||
.testTag("messages-add-contact"),
|
||||
modifier = Modifier.testTag("messages-add-contact"),
|
||||
onClick = { addingContact = !addingContact },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -126,56 +109,46 @@ fun ColumnScope.MessagesScreen(
|
||||
}
|
||||
|
||||
if (addingContact) {
|
||||
AddContactForm(model, phase, initial = scannedPrefill) {
|
||||
addingContact = false
|
||||
scannedPrefill = ""
|
||||
PortalBody {
|
||||
AddContactForm(model, phase, initial = scannedPrefill) {
|
||||
addingContact = false
|
||||
scannedPrefill = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
OutlinedTextField(
|
||||
value = query.value,
|
||||
onValueChange = { query.value = it },
|
||||
placeholder = { Text("Search conversations") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
|
||||
)
|
||||
Spacer(Modifier.height(Space.md))
|
||||
PortalBody {
|
||||
OutlinedTextField(
|
||||
value = query.value,
|
||||
onValueChange = { query.value = it },
|
||||
placeholder = { Text("Search conversations") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("messages-search"),
|
||||
)
|
||||
|
||||
// Above the conversation list.
|
||||
MessageHits(model, query.value)
|
||||
// Above the conversation list.
|
||||
MessageHits(model, query.value)
|
||||
}
|
||||
|
||||
PortalBand("Recent")
|
||||
BuddyCirclesRow(model, phase)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
Divider()
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
// The kit's listing holds every conversation, groups included.
|
||||
GroupsSection(model, phase, query.value, creatingGroup.value) { creatingGroup.value = it }
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
val showNewChat = remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"CHATS",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = Palette.SecondaryText,
|
||||
letterSpacing =
|
||||
androidx.compose.ui.unit
|
||||
.TextUnit(0.8f, androidx.compose.ui.unit.TextUnitType.Sp),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// The way in, beside the label, exactly as New group sits beside its
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
PortalBand("Chats")
|
||||
// The way in, on the band itself, exactly as New group sits beside its
|
||||
// heading in the bar above.
|
||||
androidx.compose.material3.Icon(
|
||||
KitIcon.NewChat,
|
||||
contentDescription = "New chat",
|
||||
tint = Palette.Accent,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = Space.md)
|
||||
.size(22.dp)
|
||||
.clickable { showNewChat.value = true }
|
||||
.testTag("new-chat"),
|
||||
@@ -244,15 +217,16 @@ fun ColumnScope.MessagesScreen(
|
||||
)
|
||||
|
||||
if (shown.isEmpty()) {
|
||||
Text(
|
||||
if (phase.buddies.isEmpty()) {
|
||||
"No conversations yet. Add someone on Home, everyone you add is a tap away."
|
||||
} else {
|
||||
"Nothing matches that search."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(vertical = Space.lg),
|
||||
)
|
||||
PortalBody {
|
||||
Text(
|
||||
if (phase.buddies.isEmpty()) {
|
||||
"No conversations yet. Add someone on Home, everyone you add is a tap away."
|
||||
} else {
|
||||
"Nothing matches that search."
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -263,15 +237,11 @@ fun ColumnScope.MessagesScreen(
|
||||
.ifBlank { buddy.handle }
|
||||
.ifBlank { "user ${buddy.userId}" }
|
||||
val unread = Vault.threadForUser(buddy.userId) in phase.unread
|
||||
Row(
|
||||
PortalRow(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openChat(buddy.userId, name) }
|
||||
.padding(vertical = Kit.rowGap / 2)
|
||||
.testTag("conversation-${buddy.userId}"),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
) {
|
||||
Avatar(
|
||||
name = name,
|
||||
@@ -282,41 +252,30 @@ fun ColumnScope.MessagesScreen(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
name,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Medium,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
if (unread) "New messages" else buddy.presence,
|
||||
fontSize = 12.sp,
|
||||
fontSize = 13.sp,
|
||||
color =
|
||||
if (unread) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
Palette.SecondaryText
|
||||
},
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (unread) UnreadDot()
|
||||
if (unread) PortalUnreadBadge()
|
||||
}
|
||||
Divider(modifier = Modifier.padding(start = 64.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The kit's unread marker: a small filled circle in the row's trailing slot. */
|
||||
@Composable
|
||||
private fun UnreadDot() {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* What a search found inside conversations, rather than in their names.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The portal's furniture: the chrome a screen wears, the bands between its
|
||||
* sections, and the raised face a control is drawn on.
|
||||
*
|
||||
* The site does three things that a flat app does not, and they are what make
|
||||
* this read as the portal rather than as a palette swap:
|
||||
*
|
||||
* - chrome is a 180 degree gradient with a rule under it, never a flat bar,
|
||||
* - a raised surface is an inset highlight along its top and a 1px edge,
|
||||
* never a drop shadow,
|
||||
* - a section starts with a labelled band, not with whitespace.
|
||||
*
|
||||
* The header runs to the screen edges. Anything inset by a margin stops
|
||||
* looking like chrome and starts looking like a card, which is the single
|
||||
* change that would undo the rest of this.
|
||||
*/
|
||||
|
||||
/** The inset highlight along the top of a raised face. */
|
||||
private val FaceHighlight = Color(0xD9FFFFFF)
|
||||
|
||||
private val FaceHighlightDim = Color(0x47FFFFFF)
|
||||
|
||||
/**
|
||||
* A raised control face: gradient, top highlight, 1dp edge, 2dp corners.
|
||||
*
|
||||
* Order matters. `background` paints the gradient, `drawBehind` then lays the
|
||||
* highlight over it, and `border` draws the edge last so it is not covered.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.portalFace(
|
||||
face: Brush,
|
||||
edge: Color = Portal.buttonEdge(),
|
||||
highlight: Color = FaceHighlight,
|
||||
radius: androidx.compose.ui.unit.Dp = Radius.small,
|
||||
): Modifier =
|
||||
this
|
||||
.clip(RoundedCornerShape(radius))
|
||||
.background(face)
|
||||
.drawBehind {
|
||||
val y = Kit.hairline.toPx() / 2f
|
||||
drawLine(
|
||||
color = highlight,
|
||||
start = Offset(0f, y),
|
||||
end = Offset(size.width, y),
|
||||
strokeWidth = Kit.hairline.toPx(),
|
||||
)
|
||||
}.border(Kit.hairline, edge, RoundedCornerShape(radius))
|
||||
|
||||
/** The cream face an ordinary button wears. */
|
||||
@Composable
|
||||
fun Modifier.portalButtonFace(): Modifier = portalFace(Portal.buttonFace(), Portal.buttonEdge())
|
||||
|
||||
/** The indigo face the one encouraged action on a screen wears. */
|
||||
@Composable
|
||||
fun Modifier.portalPrimaryFace(): Modifier =
|
||||
portalFace(Portal.primaryFace(), Palette.PrimaryEdge, FaceHighlightDim)
|
||||
|
||||
/**
|
||||
* The chrome at the top of a screen.
|
||||
*
|
||||
* `eyebrow` is the product's name over the screen's own, the way the site
|
||||
* carries FLAMENET over a page title. `subtitle` is for the one fact that
|
||||
* belongs beside the title, an address or a relay, and nothing else.
|
||||
*/
|
||||
@Composable
|
||||
fun PortalHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
eyebrow: String? = "FLAMENET",
|
||||
subtitle: String? = null,
|
||||
face: Brush? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val brush = face ?: Portal.chrome()
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(brush)
|
||||
.statusBarsPadding()
|
||||
.padding(start = 14.dp, end = 8.dp, top = 12.dp, bottom = 11.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
if (eyebrow != null) {
|
||||
Text(
|
||||
eyebrow,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.8.sp,
|
||||
color = Palette.ChromeEyebrow,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
title,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = (-0.5).sp,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style =
|
||||
MaterialTheme.typography.headlineMedium.copy(
|
||||
shadow = Shadow(Color(0x73000000), Offset(0f, 1f), 2f),
|
||||
),
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
subtitle,
|
||||
fontSize = 12.sp,
|
||||
color = Palette.ChromeSubtitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
actions()
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Palette.NavyDark))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The chrome a conversation wears.
|
||||
*
|
||||
* A conversation is the Messenger channel, and on the site a channel carries
|
||||
* its own colour rather than the site navy. That is the whole reason this is
|
||||
* maroon: it is not decoration, it says which part of Flamenet you are in.
|
||||
*/
|
||||
@Composable
|
||||
fun ChannelHeader(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Portal.channelChrome())
|
||||
.statusBarsPadding()
|
||||
.padding(start = 4.dp, end = 4.dp, top = 8.dp, bottom = 9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PortalHeaderIcon(KitIcon.Back, "Back", onBack, Modifier.testTag("back"))
|
||||
if (leading != null) {
|
||||
leading()
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
|
||||
Text(
|
||||
title,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = (-0.2).sp,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style =
|
||||
MaterialTheme.typography.titleMedium.copy(
|
||||
shadow = Shadow(Color(0x59000000), Offset(0f, 1f), 2f),
|
||||
),
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
subtitle,
|
||||
fontSize = 11.sp,
|
||||
color = Palette.ChannelSubtitle,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
actions()
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Palette.ChannelDark))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One icon in a header. 44dp because that is what a thumb needs, drawn in the
|
||||
* chrome's own pale blue so it reads as furniture rather than as a link.
|
||||
*/
|
||||
@Composable
|
||||
fun PortalHeaderIcon(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
androidx.compose.material3.Icon(
|
||||
icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled section band, ruled top and bottom.
|
||||
*
|
||||
* This is the portal's heading. Passing no label leaves the band as a plain
|
||||
* separator, which is what sits between two runs of rows that need parting but
|
||||
* not naming.
|
||||
*/
|
||||
@Composable
|
||||
fun PortalBand(
|
||||
label: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxWidth()) {
|
||||
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Portal.rule()))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Portal.band())
|
||||
.padding(
|
||||
horizontal = Space.md,
|
||||
vertical = if (label == null) Space.sm else 7.dp,
|
||||
),
|
||||
) {
|
||||
if (label != null) {
|
||||
Text(
|
||||
label.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(Kit.hairline).background(Portal.rule()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The margin a screen's prose gets back.
|
||||
*
|
||||
* The screen itself is flush to the edges so that chrome, bands and rows can
|
||||
* be. Everything else, a paragraph, a form, a row of buttons, sits inside one
|
||||
* of these. A screen alternates the two: band, body, band, body.
|
||||
*/
|
||||
@Composable
|
||||
fun PortalBody(
|
||||
modifier: Modifier = Modifier,
|
||||
spacing: androidx.compose.ui.unit.Dp = Space.sm,
|
||||
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Space.lg, vertical = Space.md),
|
||||
verticalArrangement = Arrangement.spacedBy(spacing),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A squared avatar tile.
|
||||
*
|
||||
* The site has no circles in it. A round avatar is the one shape that would
|
||||
* give the whole screen away, so this is the tile every list row uses.
|
||||
*/
|
||||
@Composable
|
||||
fun PortalAvatarTile(
|
||||
letter: String,
|
||||
modifier: Modifier = Modifier,
|
||||
size: androidx.compose.ui.unit.Dp = Kit.avatar,
|
||||
fill: Color? = null,
|
||||
edge: Color? = null,
|
||||
ink: Color? = null,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(fill ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||
.border(Kit.hairline, edge ?: Portal.rule(), RoundedCornerShape(Radius.small)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
letter.take(1).uppercase(),
|
||||
fontSize = (size.value * 0.4f).sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = ink ?: MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The gold count on a conversation row, squared like everything else.
|
||||
*/
|
||||
@Composable
|
||||
fun PortalUnreadBadge(
|
||||
count: Int? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.height(20.dp)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.Gold)
|
||||
.border(Kit.hairline, Palette.GoldEdge, RoundedCornerShape(Radius.small))
|
||||
.padding(horizontal = 5.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
count?.toString() ?: " ",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Palette.GoldInk,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A full width row on white, ruled underneath.
|
||||
*
|
||||
* The mockup's list is edge to edge: the rule runs the whole width and the
|
||||
* padding lives inside the row, which is what makes a list read as a table
|
||||
* rather than as a stack of cards.
|
||||
*/
|
||||
@Composable
|
||||
fun PortalRow(
|
||||
modifier: Modifier = Modifier,
|
||||
height: androidx.compose.ui.unit.Dp = Kit.rowHeight,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.height(height)
|
||||
.padding(horizontal = Space.md),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
content = content,
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(Kit.hairline)
|
||||
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
@@ -64,27 +63,15 @@ fun ColumnScope.StoriesFeed(
|
||||
StoryMedia.stage(context, uri, model)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
"Stories",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// A menu, not a picker.
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
// A menu, not a picker.
|
||||
var choosing by remember { mutableStateOf(false) }
|
||||
PortalHeader(title = "Stories") {
|
||||
Box {
|
||||
Icon(
|
||||
KitIcon.Add,
|
||||
PortalHeaderIcon(
|
||||
icon = KitIcon.Add,
|
||||
contentDescription = "New story",
|
||||
tint = Palette.Accent,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(28.dp)
|
||||
.clickable { choosing = true }
|
||||
.testTag("new-story"),
|
||||
modifier = Modifier.testTag("new-story"),
|
||||
onClick = { choosing = true },
|
||||
)
|
||||
DropdownMenu(expanded = choosing, onDismissRequest = { choosing = false }) {
|
||||
DropdownMenuItem(
|
||||
@@ -184,7 +171,7 @@ private fun StoryCard(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(Kit.presenceDot)
|
||||
.clip(CircleShape)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
@@ -241,6 +228,7 @@ private fun StoryCard(
|
||||
/**
|
||||
* React, reply, or stop seeing somebody's stories.
|
||||
*/
|
||||
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun StoryActions(
|
||||
story: Vault.Story,
|
||||
@@ -263,7 +251,7 @@ private fun StoryActions(
|
||||
modifier = Modifier.testTag("story-tally"),
|
||||
) {
|
||||
tally.entries.sortedBy { it.key }.forEach { (emoji, count) ->
|
||||
Text("$emoji $count", fontSize = 13.sp, color = Palette.Mist)
|
||||
Text("$emoji $count", fontSize = 13.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -271,9 +259,13 @@ private fun StoryActions(
|
||||
}
|
||||
|
||||
val mine = model.myStoryReaction(story.id)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
// Wrapping, because five reactions plus three words do not fit a phone:
|
||||
// the row used to push Report off the edge and break it across two lines
|
||||
// mid-word.
|
||||
androidx.compose.foundation.layout.FlowRow(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
quick.forEach { emoji ->
|
||||
Text(
|
||||
@@ -295,10 +287,11 @@ private fun StoryActions(
|
||||
.testTag("story-react-$emoji"),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Spacer(Modifier.size(12.dp))
|
||||
Text(
|
||||
"Reply",
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
color = Palette.Accent,
|
||||
modifier = Modifier.clickable { replying = true }.testTag("story-reply"),
|
||||
)
|
||||
@@ -306,7 +299,8 @@ private fun StoryActions(
|
||||
Text(
|
||||
"Mute",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Mist,
|
||||
maxLines = 1,
|
||||
color = Palette.SecondaryText,
|
||||
modifier = Modifier.clickable { muting = true }.testTag("story-mute"),
|
||||
)
|
||||
// Reporting a story, which Android could not do. iOS has had it since
|
||||
@@ -317,7 +311,8 @@ private fun StoryActions(
|
||||
Text(
|
||||
"Report",
|
||||
fontSize = 13.sp,
|
||||
color = Palette.Mist,
|
||||
maxLines = 1,
|
||||
color = Palette.SecondaryText,
|
||||
modifier =
|
||||
Modifier
|
||||
.clickable { reporting = true }
|
||||
@@ -460,7 +455,7 @@ fun StoryComposerDialog(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp)),
|
||||
.clip(RoundedCornerShape(Radius.small)),
|
||||
)
|
||||
if (draft.duration > 0) {
|
||||
Text(
|
||||
@@ -600,7 +595,10 @@ fun ColumnScope.StoriesCards(
|
||||
|
||||
if (phase.stories.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = Space.lg, vertical = Space.xxl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
@@ -623,9 +621,16 @@ fun ColumnScope.StoriesCards(
|
||||
return
|
||||
}
|
||||
|
||||
phase.stories.forEach { story ->
|
||||
StoryCard(model, phase, story)
|
||||
Spacer(Modifier.height(Space.xl))
|
||||
// The screen is flush now, so a card asks for its own margin. Without it
|
||||
// the picture bleeds off both edges and the reaction row runs past the
|
||||
// right one.
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = Space.md, vertical = Space.md),
|
||||
verticalArrangement = Arrangement.spacedBy(Space.xl),
|
||||
) {
|
||||
phase.stories.forEach { story ->
|
||||
StoryCard(model, phase, story)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,7 +732,7 @@ fun TextStoryCard(text: String) {
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.10f))
|
||||
.testTag("story-text"),
|
||||
contentAlignment = Alignment.Center,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -10,10 +12,13 @@ import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -21,71 +26,242 @@ import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The Flamenet design system.
|
||||
*
|
||||
* These are the portal's own values, taken out of `flamenet-relay`'s
|
||||
* `Public/portal.css` rather than sampled from a render:
|
||||
* ```
|
||||
* --fn-navy: #004E82 --fn-navy-dark: #00314F --fn-mid: #026AAB
|
||||
* --fn-chan: #963455 --fn-chan-dark: #6F2740 --fn-chan-tint: #F3E9ED
|
||||
* --fn-left: #C4D9EF --fn-right: #EDF5FD --fn-rule: #A9C4E0
|
||||
* --fn-link: #0266A8 --fn-visited: #635190 --fn-gold: #FFCC00
|
||||
* ```
|
||||
* The site is light only. The dark half is derived from it rather than
|
||||
* borrowed: the grounds go deep navy instead of neutral black so the chrome
|
||||
* still belongs to them, and the gold is the one value that crosses
|
||||
* unchanged. Byte-identical to `Theme.swift` on iOS.
|
||||
*
|
||||
* Three things make this read as the portal, and they matter more than the
|
||||
* hues: corners are 2dp, surfaces run as 180-degree gradients, and a raised
|
||||
* edge is one inset highlight along its top rather than a drop shadow.
|
||||
*/
|
||||
object Palette {
|
||||
// The design kit's palette, measured out of the file, not sampled from a
|
||||
// render and not approximated.
|
||||
val Accent = Color(0xFF2298FF) // buttons, bubbles, links
|
||||
val AccentBright = Color(0xFF4FACFE) // the kit's lighter primary swatch
|
||||
val AccentSoft = Color(0x1A2298FF)
|
||||
// Chrome navy: top bars, the tab bar, a message you sent.
|
||||
val Navy = Color(0xFF004E82)
|
||||
val NavyDark = Color(0xFF00314F)
|
||||
val NavyLift = Color(0xFF0A5C92)
|
||||
val Mid = Color(0xFF026AAB)
|
||||
|
||||
val Teal = Color(0xFF3BD5B2) // success and presence
|
||||
val Pink = Color(0xFFED497C) // the kit's alert accent
|
||||
// The Messenger channel's own colour.
|
||||
val Channel = Color(0xFF963455)
|
||||
val ChannelDark = Color(0xFF6F2740)
|
||||
val ChannelLift = Color(0xFFA8405F)
|
||||
val ChannelTint = Color(0xFFF3E9ED)
|
||||
|
||||
// Neutrals, all from the kit.
|
||||
val Ink = Color(0xFF090710)
|
||||
val Mist = Color(0xFFF9F9F9) // the page ground
|
||||
val SurfaceAlt = Color(0xFFF3F3F3) // received bubbles, pills, bands
|
||||
val IncomingBubble = Color(0xFFF3F3F3)
|
||||
// Used twice and no more: an unread count, and the mark under the tab
|
||||
// you are on.
|
||||
val Gold = Color(0xFFFFCC00)
|
||||
val GoldEdge = Color(0xFFC9A200)
|
||||
val GoldInk = Color(0xFF3A2E00)
|
||||
|
||||
// A blue-tinted grey rather than the flat #F3F3F3: an initials avatar needs
|
||||
// to read as a circle against the surface behind it, and two greys a shade
|
||||
// apart do not.
|
||||
// Text drawn on the chrome. Pale enough to sit under the white title
|
||||
// without competing with it.
|
||||
val ChromeEyebrow = Color(0xFFB4D2EE)
|
||||
val ChromeSubtitle = Color(0xFFC7DEF2)
|
||||
val ChannelSubtitle = Color(0xFFF0D6DE)
|
||||
|
||||
// Kept as the old names so existing call sites still read correctly.
|
||||
val Accent = Navy
|
||||
val AccentBright = Mid
|
||||
val AccentSoft = Color(0x8CC4D9EF)
|
||||
|
||||
val Teal = Color(0xFF137A4B)
|
||||
val Pink = Color(0xFF9B2233)
|
||||
|
||||
// Neutrals, light.
|
||||
val Ink = Color(0xFF12212F)
|
||||
val Mist = Color(0xFFEDF5FD) // the page ground, the site's pale column
|
||||
val SurfaceAlt = Color(0xFFC4D9EF) // section bands and the story strip
|
||||
val IncomingBubble = Color(0xFFFFFFFF)
|
||||
val AvatarFill = Color(0xFFE8F1FB)
|
||||
val SecondaryText = Color(0xFF505C69)
|
||||
val Divider = Color(0x1A505C69)
|
||||
val SecondaryText = Color(0xFF3A4A5B)
|
||||
val MetaText = Color(0xFF5B6B7D)
|
||||
|
||||
// Every 1dp border in the app. The portal draws rules, not shadows.
|
||||
val Rule = Color(0xFFA9C4E0)
|
||||
val Divider = Color(0xFFDCE7F2)
|
||||
|
||||
// Edges that go with the gradient faces below.
|
||||
val ButtonEdge = Color(0xFF87826E)
|
||||
val PrimaryEdge = Color(0xFF122678)
|
||||
val InputEdge = Color(0xFF5E7A96)
|
||||
|
||||
// Semantic.
|
||||
val Success = Color(0xFF3BD5B2)
|
||||
val Success = Color(0xFF137A4B)
|
||||
val Warning = Color(0xFFB8860B)
|
||||
val Danger = Color(0xFFED497C)
|
||||
val Danger = Color(0xFF9B2233)
|
||||
|
||||
// Dark-mode grounds, these ARE the site's, since the site is dark.
|
||||
val InkSurface = Color(0xFF121A2E) // --panel
|
||||
val NightGround = Color(0xFF0A0E1A) // --bg
|
||||
// Neutrals, dark. Derived, not borrowed.
|
||||
val NightGround = Color(0xFF0B1622)
|
||||
val InkSurface = Color(0xFF132534)
|
||||
val NightBand = Color(0xFF1B3346)
|
||||
val NightBubble = Color(0xFF182B3B)
|
||||
val NightRule = Color(0xFF2A4459)
|
||||
val NightDivider = Color(0xFF22384A)
|
||||
val NightAvatar = Color(0xFF1C3348)
|
||||
val NightInk = Color(0xFFDCE8F2)
|
||||
val NightSecondary = Color(0xFF8FA6BC)
|
||||
val NightMeta = Color(0xFF7B92A8)
|
||||
val NightLink = Color(0xFF5AB0E8)
|
||||
val NightButtonEdge = Color(0xFF3D5165)
|
||||
val NightChannelTint = Color(0xFF2E1C24)
|
||||
val NightSuccess = Color(0xFF3FB883)
|
||||
val NightWarning = Color(0xFFE0A81E)
|
||||
val NightDanger = Color(0xFFE2707F)
|
||||
}
|
||||
|
||||
/** Corner radii, matching `Theme.Radius` on iOS. */
|
||||
/** True while the portal's dark half is in force. */
|
||||
val LocalPortalDark = compositionLocalOf { false }
|
||||
|
||||
/**
|
||||
* The surfaces that run 180 degrees.
|
||||
*
|
||||
* The site gives every piece of chrome a vertical gradient, light at the top.
|
||||
* A flat fill is the single thing that makes an app read as generic, so these
|
||||
* are the fills, not the flat colours above.
|
||||
*/
|
||||
object Portal {
|
||||
/** Top bars and the tab bar. */
|
||||
@Composable @ReadOnlyComposable
|
||||
fun chrome(): Brush =
|
||||
if (LocalPortalDark.current) {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF08466B), Color(0xFF003A60), Color(0xFF002338)),
|
||||
)
|
||||
} else {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF0A5C92), Color(0xFF004E82), Color(0xFF00314F)),
|
||||
)
|
||||
}
|
||||
|
||||
/** A conversation, which wears the channel's own colour. */
|
||||
@Composable @ReadOnlyComposable
|
||||
fun channelChrome(): Brush =
|
||||
if (LocalPortalDark.current) {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF8E3350), Color(0xFF7C2A44), Color(0xFF5A1F33)),
|
||||
)
|
||||
} else {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFFA8405F), Color(0xFF963455), Color(0xFF6F2740)),
|
||||
)
|
||||
}
|
||||
|
||||
/** A section header band. */
|
||||
@Composable @ReadOnlyComposable
|
||||
fun band(): Brush =
|
||||
if (LocalPortalDark.current) {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF1F3A4F), Color(0xFF1B3346), Color(0xFF172C3C)),
|
||||
)
|
||||
} else {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFFD3E2F3), Color(0xFFC4D9EF), Color(0xFFB9D0EB)),
|
||||
)
|
||||
}
|
||||
|
||||
/** An ordinary button. Bone on light, slate on dark. */
|
||||
@Composable @ReadOnlyComposable
|
||||
fun buttonFace(): Brush =
|
||||
if (LocalPortalDark.current) {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF2A3B4C), Color(0xFF1E2C3A), Color(0xFF16212C)),
|
||||
)
|
||||
} else {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFFFCFBF6), Color(0xFFE7E3D3), Color(0xFFD4CEB9)),
|
||||
)
|
||||
}
|
||||
|
||||
/** The one button per screen that is the thing to do. */
|
||||
@Composable @ReadOnlyComposable
|
||||
fun primaryFace(): Brush =
|
||||
if (LocalPortalDark.current) {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF6B80CC), Color(0xFF2445B5), Color(0xFF18328F)),
|
||||
)
|
||||
} else {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF596EBB), Color(0xFF1835A0), Color(0xFF13297D)),
|
||||
)
|
||||
}
|
||||
|
||||
/** A message you sent. */
|
||||
@Composable @ReadOnlyComposable
|
||||
fun sentFace(): Brush =
|
||||
if (LocalPortalDark.current) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF0A5C92), Color(0xFF04406B)))
|
||||
} else {
|
||||
Brush.verticalGradient(
|
||||
listOf(Color(0xFF0A5C92), Color(0xFF004E82), Color(0xFF00314F)),
|
||||
)
|
||||
}
|
||||
|
||||
/** The band the composer sits on, one step down from the page ground. */
|
||||
@Composable @ReadOnlyComposable
|
||||
fun composerBand(): Brush =
|
||||
if (LocalPortalDark.current) {
|
||||
Brush.verticalGradient(listOf(Color(0xFF16293A), Color(0xFF101F2C)))
|
||||
} else {
|
||||
Brush.verticalGradient(listOf(Color(0xFFE6EFF9), Color(0xFFD7E6F5)))
|
||||
}
|
||||
|
||||
/** Every 1dp rule, in the appearance in force. */
|
||||
@Composable @ReadOnlyComposable
|
||||
fun rule(): Color = if (LocalPortalDark.current) Palette.NightRule else Palette.Rule
|
||||
|
||||
@Composable @ReadOnlyComposable
|
||||
fun buttonEdge(): Color =
|
||||
if (LocalPortalDark.current) Palette.NightButtonEdge else Palette.ButtonEdge
|
||||
}
|
||||
|
||||
/**
|
||||
* Corner radii, matching `Theme.Radius` on iOS. The portal is square: 2 is
|
||||
* the whole vocabulary, and nothing else moves an interface into this idiom
|
||||
* faster.
|
||||
*/
|
||||
object Radius {
|
||||
val small = 10.dp
|
||||
val medium = 16.dp
|
||||
val large = 22.dp
|
||||
val pill = 999.dp
|
||||
}
|
||||
val small = 2.dp
|
||||
val medium = 2.dp
|
||||
val large = 2.dp
|
||||
|
||||
/** The 4dp spacing scale, matching `Theme.Space` on iOS. */
|
||||
/** Kept so old call sites compile. There are no pills here. */
|
||||
val pill = 2.dp
|
||||
}
|
||||
|
||||
/**
|
||||
* Measurements taken from the design kit, in the kit's own terms.
|
||||
*/
|
||||
object Kit {
|
||||
val margin = 24.dp
|
||||
val rowHeight = 48.dp
|
||||
val rowGap = 24.dp
|
||||
val avatar = 48.dp
|
||||
val margin = 16.dp
|
||||
val rowHeight = 62.dp
|
||||
val rowGap = 0.dp
|
||||
val avatar = 40.dp
|
||||
val bubbleAvatar = 32.dp
|
||||
val gutter = 16.dp
|
||||
val gutter = 12.dp
|
||||
val bubblePadding = 12.dp
|
||||
val receivedInset = 40.dp
|
||||
val pillHeight = 50.dp
|
||||
val pillRadius = 25.dp
|
||||
val icon = 24.dp
|
||||
val presenceDot = 12.dp
|
||||
|
||||
/** Every bubble corner but the one that points at its author. */
|
||||
val bubbleRadius = 16.dp
|
||||
/** 44 is the floor a thumb needs; the portal's own 11px chrome is not it. */
|
||||
val pillHeight = 44.dp
|
||||
val pillRadius = 2.dp
|
||||
val icon = 22.dp
|
||||
val presenceDot = 10.dp
|
||||
|
||||
/** Not zero: a hard point reads as a rendering artefact at this size. */
|
||||
/** Every border the app draws. */
|
||||
val hairline = 1.dp
|
||||
|
||||
/** Bubbles are squared like everything else. */
|
||||
val bubbleRadius = 2.dp
|
||||
val bubbleTail = 2.dp
|
||||
}
|
||||
|
||||
@@ -109,30 +285,29 @@ private val FlamenetShapes =
|
||||
|
||||
/**
|
||||
* The type scale.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The design kit's type scale, applied globally.
|
||||
*
|
||||
* The site sets 11px Tahoma. On a phone that fails both legibility and the
|
||||
* system font-size setting, so chrome keeps the site's tight, bold character
|
||||
* while anything read at length is the platform face at a humane size.
|
||||
*/
|
||||
private val FlamenetTypography =
|
||||
Typography().let { base ->
|
||||
base.copy(
|
||||
headlineLarge = base.headlineLarge.copy(fontSize = 32.sp, fontWeight = FontWeight.Bold),
|
||||
headlineLarge = base.headlineLarge.copy(fontSize = 30.sp, fontWeight = FontWeight.Bold),
|
||||
headlineMedium = base.headlineMedium.copy(fontSize = 24.sp, fontWeight = FontWeight.Bold),
|
||||
headlineSmall = base.headlineSmall.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold),
|
||||
titleLarge = base.titleLarge.copy(fontSize = 20.sp, fontWeight = FontWeight.Bold),
|
||||
// Sub 3, what a section label is, not a headline.
|
||||
titleMedium = base.titleMedium.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium),
|
||||
titleSmall = base.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||
titleMedium = base.titleMedium.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold),
|
||||
titleSmall = base.titleSmall.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold),
|
||||
bodyLarge = base.bodyLarge.copy(fontSize = 16.sp),
|
||||
bodyMedium = base.bodyMedium.copy(fontSize = 14.sp),
|
||||
bodySmall = base.bodySmall.copy(fontSize = 12.sp),
|
||||
labelLarge = base.labelLarge.copy(fontSize = 14.sp, fontWeight = FontWeight.Medium),
|
||||
labelMedium = base.labelMedium.copy(fontSize = 12.sp),
|
||||
// Description 2, the timestamp above a bubble.
|
||||
// A section band's label: small, bold, barely spaced.
|
||||
labelLarge = base.labelLarge.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold),
|
||||
labelMedium = base.labelMedium.copy(fontSize = 12.sp, fontWeight = FontWeight.Bold),
|
||||
labelSmall =
|
||||
base.labelSmall.copy(
|
||||
fontSize = 10.sp,
|
||||
fontSize = 11.sp,
|
||||
letterSpacing = 0.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
),
|
||||
@@ -140,14 +315,27 @@ private val FlamenetTypography =
|
||||
}
|
||||
|
||||
/**
|
||||
* The card: raised surface, generous radius, one soft shadow.
|
||||
* The card: a squared panel with one rule around it. No shadow.
|
||||
*
|
||||
* The portal raises a surface with a rule and an inset highlight, never with
|
||||
* a drop shadow, and copying the shadow instead is what makes a revival look
|
||||
* like a theme rather than the thing itself.
|
||||
*/
|
||||
@Composable
|
||||
fun Modifier.flamenetCard(padding: PaddingValues = PaddingValues(Space.lg)): Modifier =
|
||||
this
|
||||
.shadow(3.dp, RoundedCornerShape(Radius.medium), clip = false)
|
||||
.clip(RoundedCornerShape(Radius.medium))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.border(Kit.hairline, Portal.rule(), RoundedCornerShape(Radius.medium))
|
||||
.padding(padding)
|
||||
|
||||
/** A 1dp rule around a squared surface. */
|
||||
@Composable
|
||||
fun Modifier.portalEdge(
|
||||
colour: Color = Portal.rule(),
|
||||
radius: androidx.compose.ui.unit.Dp = Radius.small,
|
||||
): Modifier = this.border(Kit.hairline, colour, RoundedCornerShape(radius))
|
||||
|
||||
@Composable
|
||||
fun MessengerTheme(content: @Composable () -> Unit) {
|
||||
// Read here rather than passed in, so every entry point picks it up, the
|
||||
@@ -162,41 +350,45 @@ fun MessengerTheme(content: @Composable () -> Unit) {
|
||||
DisplaySettings.Appearance.DARK -> true
|
||||
DisplaySettings.Appearance.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme =
|
||||
if (dark) {
|
||||
darkColorScheme(
|
||||
primary = Palette.AccentBright,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.Accent,
|
||||
background = Palette.NightGround,
|
||||
surface = Palette.InkSurface,
|
||||
surfaceVariant = Palette.InkSurface,
|
||||
onBackground = Palette.Mist,
|
||||
onSurface = Palette.Mist,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Divider,
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
// The kit's #2298FF on white is about 3.3:1, fine for a
|
||||
// bold button label, not for small text.
|
||||
primary = Palette.Accent,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.AccentBright,
|
||||
background = Palette.Mist,
|
||||
surface = Color.White,
|
||||
surfaceVariant = Palette.SurfaceAlt,
|
||||
onBackground = Palette.Ink,
|
||||
onSurface = Palette.Ink,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Divider,
|
||||
)
|
||||
},
|
||||
shapes = FlamenetShapes,
|
||||
typography = FlamenetTypography,
|
||||
content = content,
|
||||
)
|
||||
CompositionLocalProvider(LocalPortalDark provides dark) {
|
||||
MaterialTheme(
|
||||
colorScheme =
|
||||
if (dark) {
|
||||
darkColorScheme(
|
||||
primary = Palette.NightLink,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.Gold,
|
||||
background = Palette.NightGround,
|
||||
surface = Palette.InkSurface,
|
||||
surfaceVariant = Palette.NightBand,
|
||||
onBackground = Palette.NightInk,
|
||||
onSurface = Palette.NightInk,
|
||||
onSurfaceVariant = Palette.NightSecondary,
|
||||
error = Palette.NightDanger,
|
||||
outline = Palette.NightRule,
|
||||
outlineVariant = Palette.NightDivider,
|
||||
)
|
||||
} else {
|
||||
lightColorScheme(
|
||||
// The portal's link blue, which carries small text at
|
||||
// 4.5:1 on white where the chrome navy does not.
|
||||
primary = Palette.Mid,
|
||||
onPrimary = Color.White,
|
||||
secondary = Palette.Channel,
|
||||
background = Palette.Mist,
|
||||
surface = Color.White,
|
||||
surfaceVariant = Palette.SurfaceAlt,
|
||||
onBackground = Palette.Ink,
|
||||
onSurface = Palette.Ink,
|
||||
onSurfaceVariant = Palette.SecondaryText,
|
||||
error = Palette.Danger,
|
||||
outline = Palette.Rule,
|
||||
outlineVariant = Palette.Divider,
|
||||
)
|
||||
},
|
||||
shapes = FlamenetShapes,
|
||||
typography = FlamenetTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ fun ColumnScope.WalletScreen(
|
||||
val context = LocalContext.current
|
||||
val clipboard = LocalClipboardManager.current
|
||||
|
||||
PortalHeader(title = "Wallet")
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
|
||||
@@ -56,6 +56,13 @@ class ScreenshotTest {
|
||||
.Column { SignedIn(model, phase) }
|
||||
}
|
||||
|
||||
// The tab bar was rewritten for the portal chrome and no shot showed it,
|
||||
// so it was the one piece of the restyle nobody had looked at.
|
||||
@Test fun tabBar() =
|
||||
shootFullScreen("nav-bar") { model, phase ->
|
||||
BottomNav(model, phase)
|
||||
}
|
||||
|
||||
@Test fun accountScreen() =
|
||||
shootFullScreen("screen-account") { model, phase ->
|
||||
androidx.compose.foundation.layout
|
||||
@@ -384,17 +391,21 @@ class ScreenshotTest {
|
||||
.getApplicationContext<android.app.Application>()
|
||||
val model = MessengerViewModel(app)
|
||||
val phase = samplePhase()
|
||||
shoot(name) { content(model, phase) }
|
||||
// Flush, because the app is: chrome, bands and rows all run to the
|
||||
// screen edges, and padding them here would photograph a layout the
|
||||
// app does not have.
|
||||
shoot(name, pad = false) { content(model, phase) }
|
||||
}
|
||||
|
||||
private fun shoot(
|
||||
name: String,
|
||||
pad: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
compose.setContent {
|
||||
MessengerTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(Modifier.padding(20.dp)) { content() }
|
||||
Column(if (pad) Modifier.padding(20.dp) else Modifier) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user