diff --git a/apps/flipcash/shared/common-ui/build.gradle.kts b/apps/flipcash/shared/common-ui/build.gradle.kts index 87527e41bd..4dc9ca500d 100644 --- a/apps/flipcash/shared/common-ui/build.gradle.kts +++ b/apps/flipcash/shared/common-ui/build.gradle.kts @@ -17,5 +17,6 @@ dependencies { implementation(project(":apps:flipcash:shared:theme")) api(libs.compose.material.icons.extended) + testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) } diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index 3251205439..c383aebee9 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -38,6 +38,8 @@ import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.services.models.HandlePrefix import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.MediaItem +import com.flipcash.services.models.handle +import com.flipcash.services.models.nameOrHandle import com.getcode.theme.CodeTheme import com.getcode.ui.core.addIf @@ -103,15 +105,26 @@ fun ContactAvatar( } } +/** + * A person's avatar taken entirely from their server [UserProfile] — the chat header and info card, + * the activity feed. Falls back to the initials of their name-or-handle, matching the surfaces that + * pass the two separately ([ContactAvatar] with [MediaItem] + display name). + * + * A profile with neither a name nor a handle is not a person we can name, so it keeps the silhouette. + * The activity feed relies on that for its non-person rows, which pass [UserProfile.Empty]. + */ @Composable fun ContactAvatar( userProfile: UserProfile, modifier: Modifier = Modifier, ) { + val name = remember(userProfile) { nameOrHandle(userProfile.displayName, userProfile.handle) } ProfileAvatar( image = userProfile.profilePicture, modifier = modifier, - fallback = { UnknownContactAvatar(includeBorder = true) }, + fallback = { + if (name != null) InitialsText(name) else UnknownContactAvatar(includeBorder = true) + }, ) } diff --git a/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/ContactAvatarFallbackScreenshotTest.kt b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/ContactAvatarFallbackScreenshotTest.kt new file mode 100644 index 0000000000..ade31e1e2d --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/test/kotlin/com/flipcash/shared/common/ui/ContactAvatarFallbackScreenshotTest.kt @@ -0,0 +1,105 @@ +package com.flipcash.shared.common.ui + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.services.models.UserProfile +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import java.io.File + +/** + * What a [UserProfile]-backed avatar falls back to when the person has no picture: initials for + * anyone we can name, the silhouette only for a profile that isn't a person. + * + * Not an assertion test — it writes to `build/screenshots/` so the three states can be compared by + * eye. Same mechanics as `ChatIdentityScreenshotTest`. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [34], qualifiers = "w400dp-h400dp-xhdpi") +class ContactAvatarFallbackScreenshotTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun rendersProfileAvatarFallbacks() { + val named = UserProfile.Empty.copy(displayName = "Grace Hopper", username = "grace_hopper") + // No display name — named by handle, so the initial comes from the handle with its `@` + // stripped. This is the case that used to render a silhouette. + val handleOnly = UserProfile.Empty.copy(username = "sally_streamer") + // Not a person: the activity feed passes this for its generic rows. + val anonymous = UserProfile.Empty + + composeRule.mainClock.autoAdvance = false + composeRule.setContent { + FlipcashPreview(showBackground = true) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + listOf(named, handleOnly, anonymous).forEach { profile -> + ContactAvatar( + userProfile = profile, + modifier = Modifier.requiredSize(64.dp).clip(CircleShape), + ) + } + } + } + } + repeat(10) { composeRule.mainClock.advanceTimeByFrame() } + + capture("contact_avatar_fallbacks.png") + } + + private fun capture(name: String) { + val root: View = composeRule.activity.findViewById(android.R.id.content) + val width = root.width.takeIf { it > 0 } ?: 1080 + val height = root.height.takeIf { it > 0 } ?: 1920 + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + root.draw(Canvas(bitmap)) + val cropped = bitmap.trimmedToDrawnArea() + + val outDir = File("build/screenshots").apply { mkdirs() } + val file = File(outDir, name) + file.outputStream().use { cropped.compress(Bitmap.CompressFormat.PNG, 100, it) } + println("SCREENSHOT_WRITTEN: ${file.absolutePath} (${cropped.width}x${cropped.height})") + } + + /** Crop away the untouched (fully transparent) margin so the PNG is just what was composed. */ + private fun Bitmap.trimmedToDrawnArea(): Bitmap { + val pixels = IntArray(width * height) + getPixels(pixels, 0, width, 0, 0, width, height) + var left = width + var top = height + var right = -1 + var bottom = -1 + for (y in 0 until height) { + for (x in 0 until width) { + if (pixels[y * width + x] ushr 24 == 0) continue + if (x < left) left = x + if (x > right) right = x + if (y < top) top = y + if (y > bottom) bottom = y + } + } + if (right < left || bottom < top) return this + return Bitmap.createBitmap(this, left, top, right - left + 1, bottom - top + 1) + } +}