Stories as a feed you scroll, which is the kit's Timeline
A screen, not a strip. Stories were a horizontal row of circles on the Messages screen -- enough to tell you something exists and nothing more. The kit's Timeline is a column of full-width cards: author over a description line, the image, the caption. A card feed cannot sit above a contact list without pushing it off the phone, so it gets a tab; the strip stays on Messages as the entry point. ⚠️ No like-or-comment bar, and the kit's post card has one -- two emoji reactions and a share icon. A story here has none of those: no comment thread, no reaction, nothing to share it to. Drawing the bar and wiring it to nothing would be a lie told in pixels. What a story DOES have that the kit's post does not is a deadline, so that is what sits in the slot the kit gives a description -- the one fact about a story that changes while you are looking at it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
45aa5b470c
commit
1f2a702100
@@ -300,7 +300,8 @@ fun ColumnScope.BuddyList(
|
||||
* width and height for exactly this reason.
|
||||
*/
|
||||
@Composable
|
||||
private fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
|
||||
/** ⚠️ Internal, not private: the stories feed draws the same blob. */
|
||||
internal fun AttachedImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
|
||||
var bitmap by remember(ref.id) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
var failed by remember(ref.id) { mutableStateOf(false) }
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ private fun Root(model: MessengerViewModel = viewModel()) {
|
||||
) {
|
||||
when (current.screen) {
|
||||
MessengerViewModel.Phase.Screen.CONTACTS -> SignedIn(model, current)
|
||||
MessengerViewModel.Phase.Screen.STORIES ->
|
||||
StoriesFeed(model, current)
|
||||
MessengerViewModel.Phase.Screen.NOTIFICATIONS ->
|
||||
NotificationsScreen(model, current)
|
||||
MessengerViewModel.Phase.Screen.ACCOUNT ->
|
||||
@@ -432,6 +434,7 @@ private fun BottomNav(
|
||||
) {
|
||||
NavButton(model, phase, MessengerViewModel.Phase.Screen.CONTACTS,
|
||||
"Contacts" + (phase.unread.size.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-contacts")
|
||||
NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES, "Stories", "nav-stories")
|
||||
NavButton(model, phase, MessengerViewModel.Phase.Screen.NOTIFICATIONS,
|
||||
"Alerts" + (unreadNotes.takeIf { it > 0 }?.let { " ($it)" } ?: ""), "nav-alerts")
|
||||
NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT, "Account", "nav-account")
|
||||
|
||||
@@ -119,7 +119,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val forkDetected: Boolean = false,
|
||||
) : Phase
|
||||
|
||||
enum class Screen { CONTACTS, NOTIFICATIONS, ACCOUNT }
|
||||
/**
|
||||
* ⚠️ STORIES is new, and it is a tab rather than a strip because a
|
||||
* feed needs a screen. The horizontal row on Messages stays as the
|
||||
* entry point — that is where you see there IS something — but a
|
||||
* card feed cannot live above a contact list without pushing it off.
|
||||
*/
|
||||
enum class Screen { CONTACTS, STORIES, NOTIFICATIONS, ACCOUNT }
|
||||
|
||||
/** What the call UI needs to draw itself. */
|
||||
data class CallState(
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package io.flamenet.messenger.app
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import io.flamenet.e2e.Vault
|
||||
|
||||
/**
|
||||
* Stories as a feed you scroll, which is the kit's Timeline.
|
||||
*
|
||||
* 🔴 **A screen, not a strip.** Stories were a horizontal row of circles on the
|
||||
* Messages screen — enough to tell you something exists and nothing more. The
|
||||
* kit's Timeline is a column of full-width cards: author, image, caption. A
|
||||
* card feed cannot sit above a contact list without pushing it off the phone,
|
||||
* so it gets a tab. The strip stays on Messages as the entry point.
|
||||
*
|
||||
* ⚠️ **No like or comment bar, and the kit has one.** Its post card carries two
|
||||
* emoji reactions and a share icon; a story here has none of those — there is
|
||||
* no comment thread, no reaction on a story, and nothing to share it to. Drawing
|
||||
* the bar and wiring it to nothing would be a lie told in pixels. What a story
|
||||
* DOES have that the kit's post does not is a deadline, so that is what the
|
||||
* card says.
|
||||
*/
|
||||
@Composable
|
||||
fun ColumnScope.StoriesFeed(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
) {
|
||||
LaunchedEffect(Unit) { model.refreshStories() }
|
||||
|
||||
Text("Stories", style = MaterialTheme.typography.headlineMedium)
|
||||
Spacer(Modifier.height(Space.sm))
|
||||
|
||||
if (phase.stories.isEmpty()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xxl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(Space.sm),
|
||||
) {
|
||||
Text("◎", fontSize = 32.sp, color = Palette.SecondaryText)
|
||||
Text("Nothing here yet", fontSize = 16.sp, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
"A story goes to your contacts and disappears after a day — on "
|
||||
+ "your device and theirs, because their app deletes it too. "
|
||||
+ "It is not a secret you can take back.",
|
||||
fontSize = 14.sp,
|
||||
color = Palette.SecondaryText,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
phase.stories.forEach { story ->
|
||||
StoryCard(model, phase, story)
|
||||
Spacer(Modifier.height(Space.xl))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One card, laid out like the kit's `Post/full width`: a 40dp avatar header
|
||||
* with a name over a description line, then the image, then the caption.
|
||||
*/
|
||||
@Composable
|
||||
private fun StoryCard(
|
||||
model: MessengerViewModel,
|
||||
phase: MessengerViewModel.Phase.SignedIn,
|
||||
story: Vault.Story,
|
||||
) {
|
||||
val author = remember(story.authorId, phase.buddies) {
|
||||
phase.buddies.firstOrNull { it.userId == story.authorId }
|
||||
?.let { it.displayName.ifBlank { it.handle } }
|
||||
?: if (story.mine) "You" else "user ${story.authorId}"
|
||||
}
|
||||
val remaining = remember(story.id) {
|
||||
val seconds = story.ref.expires - System.currentTimeMillis() / 1000.0
|
||||
when {
|
||||
story.ref.expires <= 0L -> "no expiry"
|
||||
seconds <= 0 -> "expired"
|
||||
seconds < 3600 -> "${(seconds / 60).toInt()} min left"
|
||||
else -> "${(seconds / 3600).toInt()} h left"
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { model.openStory(story) }
|
||||
.testTag("story-card-${story.id}"),
|
||||
) {
|
||||
// Header: avatar 40, name 14 medium over a 12 regular description.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(Kit.gutter),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape).background(Palette.AvatarFill),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
author.take(1).uppercase(),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(author, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
// ⚠️ The deadline, in the slot the kit gives a description.
|
||||
// It is the one fact about a story that changes while you are
|
||||
// looking at it, and the whole point of the feature.
|
||||
Text(remaining, fontSize = 12.sp, color = Palette.SecondaryText)
|
||||
}
|
||||
// Unseen is marked here rather than by styling the whole card: a
|
||||
// feed where read and unread look alike is a feed nobody returns to.
|
||||
if (!story.seen) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(Kit.presenceDot)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(Space.lg))
|
||||
|
||||
// The image. 375x320 in the kit; an aspect ratio here, because the kit
|
||||
// is drawn on one canvas and this has to hold on every phone.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(375f / 320f)
|
||||
.clip(RoundedCornerShape(Radius.small))
|
||||
.background(Palette.SurfaceAlt),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AttachedImage(story.ref, model)
|
||||
}
|
||||
|
||||
if (story.caption.isNotBlank()) {
|
||||
Spacer(Modifier.height(Space.md))
|
||||
Text(story.caption, fontSize = 14.sp, maxLines = 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,19 @@ class ScreenshotTest {
|
||||
androidx.compose.foundation.layout.Column { AccountScreen(model, phase) }
|
||||
}
|
||||
|
||||
/** The feed, with a couple of stories on it. */
|
||||
@Test fun storiesFeed() = shootFullScreen("screen-stories") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { StoriesFeed(model, phase) }
|
||||
}
|
||||
|
||||
@Test fun storiesFeedEmpty() = shoot("screen-stories-empty") {
|
||||
val app = androidx.test.core.app.ApplicationProvider
|
||||
.getApplicationContext<android.app.Application>()
|
||||
androidx.compose.foundation.layout.Column {
|
||||
StoriesFeed(MessengerViewModel(app), samplePhase().copy(stories = emptyList()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun alertsScreen() = shootFullScreen("screen-alerts") { model, phase ->
|
||||
androidx.compose.foundation.layout.Column { NotificationsScreen(model, phase) }
|
||||
}
|
||||
@@ -124,6 +137,25 @@ class ScreenshotTest {
|
||||
),
|
||||
buddiesLoaded = true,
|
||||
groupsLoaded = true,
|
||||
stories = listOf(
|
||||
io.flamenet.e2e.Vault.Story(
|
||||
id = "s1", authorId = 33, mine = false,
|
||||
ref = io.flamenet.e2e.Attachment.Ref(
|
||||
kind = io.flamenet.e2e.Attachment.STORY,
|
||||
id = "blob1", key = ByteArray(32), digest = ByteArray(32),
|
||||
bytes = 1024, mime = "image/jpeg", width = 1200, height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 7 * 3600),
|
||||
caption = "The view from the workshop this morning.",
|
||||
postedAt = System.currentTimeMillis(), seen = false),
|
||||
io.flamenet.e2e.Vault.Story(
|
||||
id = "s2", authorId = 32, mine = false,
|
||||
ref = io.flamenet.e2e.Attachment.Ref(
|
||||
kind = io.flamenet.e2e.Attachment.STORY,
|
||||
id = "blob2", key = ByteArray(32), digest = ByteArray(32),
|
||||
bytes = 1024, mime = "image/jpeg", width = 1200, height = 900,
|
||||
expires = System.currentTimeMillis() / 1000.0 + 1800),
|
||||
caption = "", postedAt = System.currentTimeMillis(), seen = true),
|
||||
),
|
||||
notifications = listOf(
|
||||
MessengerApi.Notification(1, "contact request",
|
||||
"Hoang Nguyen wants to add you", read = false),
|
||||
|
||||
Reference in New Issue
Block a user