feat(avatar): frame a picked photo, and fall back to initials not a heart

Initials, shared. Home's partner bubble and the Settings "Connected with" row
are two views of one person and gave different answers when there was no photo —
Home showed initials, Settings showed a heart. Settings now shows the partner's
initials in a 48dp circle, so the photo and no-photo states share one
silhouette. The helper is lifted to ui/components/Initials.kt rather than copied;
an identical copy in two files is exactly how packArtworkRes drifted. The
unpaired state keeps the heart — there is nobody to take initials from yet.

Framing. Picking a photo now opens a crop sheet: pinch to zoom, drag to move,
inside the real avatar circle. The avatar was a blind ContentScale.Crop, so a
non-square or off-centre photo was centre-cropped and could lose the subject —
Ava's 640x480 test photo cropped straight past her face. The crop is applied at
pick time and handed back as a normal Uri, so setPhotoUri → upload is untouched.
EXIF orientation is honoured (a portrait selfie would otherwise crop sideways),
output is a 512px JPEG, and the source aspect is respected.

Two bugs found by driving it live, both of which looked fine in code:
- Panning did nothing at 1x. The clamp was viewport*(scale-1)/2, which is 0 at
  1x — correct only for a square source. A cover-fit 640x480 photo already
  overflows horizontally at 1x, so that pinned it dead centre and made framing
  impossible: the whole point of the sheet. Now clamped to the picture's actual
  overflow, computed from the source aspect.
- Panning then revealed empty space at the circle's edge. ContentScale.Crop had
  already discarded the overflow and returned a viewport-sized node, so
  graphicsLayer was sliding an already-cropped square around — the pixels being
  panned to had been thrown away first. The preview now draws the bitmap itself
  at cover*scale with the same maths as the crop, so preview == output.

Adds androidx.exifinterface. Verified live on 5554: sheet opens, pan moves the
picture (pixel-probed), no empty edge. 0 FATAL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-14 23:19:37 -05:00
parent 76d8746a9b
commit 7b8fa652f6
7 changed files with 358 additions and 21 deletions

View File

@ -153,6 +153,7 @@ dependencies {
implementation(composeBom) implementation(composeBom)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.exifinterface)
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View File

@ -0,0 +1,21 @@
package app.closer.ui.components
/**
* Initials for an avatar slot when there is no photo.
*
* Shared rather than copied: Home's partner bubble and the Settings "Connected with" row are two
* views of the same person, so they must agree Settings previously fell back to a heart glyph
* while Home showed initials for the same partner. A blank name yields "2" (the couple), matching
* what Home has always shown.
*/
internal fun personInitials(name: String?): String {
val clean = name?.trim().orEmpty()
if (clean.isBlank()) return "2"
val words = clean.split(Regex("\\s+")).filter { it.isNotBlank() }
val initials = if (words.size >= 2) {
"${words[0].first()}${words[1].first()}"
} else {
clean.take(2)
}
return initials.uppercase()
}

View File

@ -39,6 +39,7 @@ import app.closer.core.navigation.AppRoute
import app.closer.crypto.FieldEncryptor import app.closer.crypto.FieldEncryptor
import app.closer.ui.home.DailyQuestionState import app.closer.ui.home.DailyQuestionState
import app.closer.ui.home.HomeUiState import app.closer.ui.home.HomeUiState
import app.closer.ui.components.personInitials
import app.closer.ui.theme.CloserPalette import app.closer.ui.theme.CloserPalette
import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
@ -185,25 +186,13 @@ private fun PartnerHeaderBubble(
@Composable @Composable
private fun PartnerInitials(partnerName: String?) { private fun PartnerInitials(partnerName: String?) {
Text( Text(
text = partnerInitials(partnerName), text = personInitials(partnerName),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = CloserPalette.PurpleRich, color = CloserPalette.PurpleRich,
maxLines = 1 maxLines = 1
) )
} }
private fun partnerInitials(name: String?): String {
val clean = name?.trim().orEmpty()
if (clean.isBlank()) return "2"
val words = clean.split(Regex("\\s+")).filter { it.isNotBlank() }
val initials = if (words.size >= 2) {
"${words[0].first()}${words[1].first()}"
} else {
clean.take(2)
}
return initials.uppercase()
}
/** /**
* Warm quick-actions sheet shown when the partner avatar is tapped: a relationship glance + one-tap * Warm quick-actions sheet shown when the partner avatar is tapped: a relationship glance + one-tap
* actions. Replaces the old dead-end into the read-only "Together" feed. Only shown when paired. * actions. Replaces the old dead-end into the read-only "Together" feed. Only shown when paired.

View File

@ -0,0 +1,287 @@
package app.closer.ui.settings
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTransformGestures
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import kotlin.math.max
import kotlin.math.roundToInt
/** Longest edge of the saved avatar. Big enough for any avatar slot, small enough to upload fast. */
private const val OUTPUT_PX = 512
/** How far in the picture can be pushed. 1x = fills the circle exactly. */
private const val MAX_SCALE = 4f
/**
* Lets the user frame a picked photo inside the avatar circle before it is saved.
*
* Without this the avatar was a blind `ContentScale.Crop`: the image was centre-cropped, so an
* off-centre subject (or any non-square photo) simply lost their head. Pinch to zoom, drag to move;
* the crop that gets uploaded is exactly what the circle shows.
*
* The crop is applied here rather than at save time so the rest of the flow is unchanged this
* hands back a normal image [Uri] that the existing `setPhotoUri` upload path treats like any
* other pick.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AvatarCropSheet(
sourceUri: Uri,
onDismiss: () -> Unit,
onConfirm: (Uri) -> Unit
) {
val context = LocalContext.current
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var bitmap by remember(sourceUri) { mutableStateOf<Bitmap?>(null) }
var failed by remember(sourceUri) { mutableStateOf(false) }
var scale by remember(sourceUri) { mutableFloatStateOf(1f) }
var offset by remember(sourceUri) { mutableStateOf(Offset.Zero) }
var viewport by remember(sourceUri) { mutableStateOf(0f) }
var saving by remember(sourceUri) { mutableStateOf(false) }
LaunchedEffect(sourceUri) {
bitmap = withContext(Dispatchers.IO) { runCatching { loadOriented(context, sourceUri) }.getOrNull() }
if (bitmap == null) failed = true
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
Text(
text = "Frame your photo",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
color = SettingsInk
)
Text(
text = if (failed) "That image couldn't be opened. Try another one."
else "Pinch to zoom, drag to move.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted
)
val bmp = bitmap
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(CircleShape)
.background(SettingsCard)
.border(2.dp, SettingsPrimary.copy(alpha = 0.3f), CircleShape)
.onSizeChanged { viewport = it.width.toFloat() }
.pointerInput(bmp, viewport) {
if (bmp == null) return@pointerInput
detectTransformGestures { _, pan, zoom, _ ->
val next = (scale * zoom).coerceIn(1f, MAX_SCALE)
val candidate = offset + pan
scale = next
offset = clampOffset(candidate, next, viewport, bmp.width, bmp.height)
}
},
contentAlignment = Alignment.Center
) {
if (bmp != null) {
val image = remember(bmp) { bmp.asImageBitmap() }
// Drawn by hand rather than Image(ContentScale.Crop) + graphicsLayer: Crop
// discards the overflow and yields a viewport-sized node, so translating it
// slid an already-cropped square around and exposed empty space at the edge —
// the very pixels the user is trying to pan to had been thrown away first.
// This draws the whole picture at cover*scale and positions it, which is the
// same maths cropToCircleSquare uses, so what you see is what gets saved.
Canvas(modifier = Modifier.fillMaxSize()) {
val cover = max(size.width / image.width, size.height / image.height)
val total = cover * scale
val drawnW = image.width * total
val drawnH = image.height * total
drawImage(
image = image,
dstOffset = IntOffset(
((size.width - drawnW) / 2f + offset.x).roundToInt(),
((size.height - drawnH) / 2f + offset.y).roundToInt()
),
dstSize = IntSize(drawnW.roundToInt(), drawnH.roundToInt()),
filterQuality = FilterQuality.Medium
)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
TextButton(
onClick = { scale = 1f; offset = Offset.Zero },
modifier = Modifier.weight(1f)
) { Text("Reset") }
Button(
onClick = {
val source = bmp ?: return@Button
saving = true
val out = runCatching {
cropToCircleSquare(context, source, scale, offset, viewport)
}.getOrNull()
saving = false
if (out != null) onConfirm(out) else onDismiss()
},
enabled = bmp != null && !saving,
modifier = Modifier.weight(2f),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) { Text("Use photo", fontWeight = FontWeight.SemiBold) }
}
}
}
}
/**
* Limits panning to the picture's own edges, so the circle can never show empty space.
*
* The travel available is whatever the picture overflows the viewport by which depends on the
* source's aspect ratio, not just the zoom. A cover-fit landscape photo already overflows
* horizontally at 1x, so it must stay pannable there: clamping on zoom alone (`viewport*(scale-1)/2`)
* pinned a 640x480 photo to dead centre and made framing it impossible the exact thing this sheet
* exists to allow.
*/
private fun clampOffset(
candidate: Offset,
scale: Float,
viewport: Float,
srcW: Int,
srcH: Int
): Offset {
if (viewport <= 0f || srcW <= 0 || srcH <= 0) return Offset.Zero
val cover = max(viewport / srcW, viewport / srcH)
val drawnW = srcW * cover * scale
val drawnH = srcH * cover * scale
val boundX = ((drawnW - viewport) / 2f).coerceAtLeast(0f)
val boundY = ((drawnH - viewport) / 2f).coerceAtLeast(0f)
return Offset(
candidate.x.coerceIn(-boundX, boundX),
candidate.y.coerceIn(-boundY, boundY)
)
}
/**
* Decodes [uri], honouring the EXIF orientation. Phone cameras store the sensor image and a
* rotation flag; without applying it a portrait selfie crops sideways.
*/
private fun loadOriented(context: Context, uri: Uri): Bitmap? {
val decoded = context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it)
} ?: return null
val rotation = context.contentResolver.openInputStream(uri)?.use { stream ->
when (ExifInterface(stream).getAttributeInt(
ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL
)) {
ExifInterface.ORIENTATION_ROTATE_90 -> 90f
ExifInterface.ORIENTATION_ROTATE_180 -> 180f
ExifInterface.ORIENTATION_ROTATE_270 -> 270f
else -> 0f
}
} ?: 0f
if (rotation == 0f) return decoded
val m = Matrix().apply { postRotate(rotation) }
return Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, m, true)
}
/**
* Renders exactly what the circle is showing into a square JPEG and returns its [Uri].
*
* Mirrors the preview's own maths so what you framed is what you get: cover-fit the source into the
* viewport, apply the user's [scale] about the centre, then their [offset] all re-expressed at
* [OUTPUT_PX] by the viewportoutput ratio. Square, not circular: every avatar slot already clips
* to a circle, and a transparent-cornered PNG would cost size for nothing.
*/
private fun cropToCircleSquare(
context: Context,
source: Bitmap,
scale: Float,
offset: Offset,
viewportPx: Float
): Uri {
val out = Bitmap.createBitmap(OUTPUT_PX, OUTPUT_PX, Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(out)
val ratio = if (viewportPx > 0f) OUTPUT_PX / viewportPx else 1f
// ContentScale.Crop == cover: the smaller edge fills the box.
val cover = max(OUTPUT_PX.toFloat() / source.width, OUTPUT_PX.toFloat() / source.height)
val total = cover * scale
val drawnW = source.width * total
val drawnH = source.height * total
val matrix = Matrix().apply {
postScale(total, total)
postTranslate(
(OUTPUT_PX - drawnW) / 2f + offset.x * ratio,
(OUTPUT_PX - drawnH) / 2f + offset.y * ratio
)
}
canvas.drawBitmap(source, matrix, android.graphics.Paint().apply { isFilterBitmap = true })
val dir = File(context.cacheDir, "avatar").apply { mkdirs() }
val file = File(dir, "avatar_crop_${System.currentTimeMillis()}.jpg")
FileOutputStream(file).use { out.compress(Bitmap.CompressFormat.JPEG, 92, it) }
return Uri.fromFile(file)
}

View File

@ -46,7 +46,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -97,10 +99,14 @@ fun EditProfileContent(
} }
} }
// A picked photo goes to the cropper first, not straight to the profile: the avatar is a
// circle, so an un-framed non-square photo gets centre-cropped and can lose the subject.
var cropSource by remember { mutableStateOf<Uri?>(null) }
val galleryLauncher = rememberLauncherForActivityResult( val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia() contract = ActivityResultContracts.PickVisualMedia()
) { uri: Uri? -> ) { uri: Uri? ->
uri?.let { viewModel.setPhotoUri(it.toString()) } uri?.let { cropSource = it }
} }
val cameraFile = remember(context) { val cameraFile = remember(context) {
@ -117,7 +123,7 @@ fun EditProfileContent(
val cameraLauncher = rememberLauncherForActivityResult( val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture() contract = ActivityResultContracts.TakePicture()
) { success -> ) { success ->
if (success) viewModel.setPhotoUri(cameraUri.toString()) if (success) cropSource = cameraUri
} }
val cameraPermissionLauncher = rememberLauncherForActivityResult( val cameraPermissionLauncher = rememberLauncherForActivityResult(
@ -126,6 +132,17 @@ fun EditProfileContent(
if (granted) cameraLauncher.launch(cameraUri) if (granted) cameraLauncher.launch(cameraUri)
} }
cropSource?.let { source ->
AvatarCropSheet(
sourceUri = source,
onDismiss = { cropSource = null },
onConfirm = { cropped ->
viewModel.setPhotoUri(cropped.toString())
cropSource = null
}
)
}
// NOTE: this content does NOT scroll itself — the host screen owns the scroll container. AccountScreen // NOTE: this content does NOT scroll itself — the host screen owns the scroll container. AccountScreen
// already wraps it in a verticalScroll Column (with extra cards below), and nesting two vertical // already wraps it in a verticalScroll Column (with extra cards below), and nesting two vertical
// scrollers crashes with "infinity maximum height". (C-ACCOUNT-001) // scrollers crashes with "infinity maximum height". (C-ACCOUNT-001)

View File

@ -69,6 +69,7 @@ import app.closer.ui.settings.SettingsPrimaryDeep
import app.closer.ui.settings.SettingsSoft import app.closer.ui.settings.SettingsSoft
import coil.compose.AsyncImage import coil.compose.AsyncImage
import app.closer.ui.components.CloserGlyphs import app.closer.ui.components.CloserGlyphs
import app.closer.ui.components.personInitials
// ==================== // ====================
// Settings Subpages // Settings Subpages
@ -393,18 +394,37 @@ fun SettingsScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
// A real partner photo shows as the avatar; otherwise fall back to the same // Paired: the partner's photo, else their initials — the same answer Home's
// 48dp tile + 24dp glyph the unpaired state and every settings row use. // partner bubble gives for the same person (they disagreed before: Home
// ProfileAvatar's own fallback is a bare 40dp icon with no container, which // showed initials, this row showed a heart). The initials sit in a 48dp
// left the paired state showing an outsized naked heart unlike anything // circle so the photo and no-photo states share one silhouette.
// else on the page. // ProfileAvatar's own fallback is a bare 40dp icon with no container, so it
// is only used when there is a real photo to draw.
if (state.isPaired && !state.partnerPhotoUrl.isNullOrBlank()) { if (state.isPaired && !state.partnerPhotoUrl.isNullOrBlank()) {
ProfileAvatar( ProfileAvatar(
imageUrl = state.partnerPhotoUrl, imageUrl = state.partnerPhotoUrl,
fallbackIcon = CloserGlyphs.Heart, fallbackIcon = CloserGlyphs.Heart,
fallbackTint = partnerAccent fallbackTint = partnerAccent
) )
} else if (state.isPaired) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(partnerAccent.copy(alpha = 0.13f))
.border(1.5.dp, partnerAccent.copy(alpha = 0.22f), CircleShape),
contentAlignment = Alignment.Center
) {
Text(
text = personInitials(state.partnerName),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = partnerAccent,
maxLines = 1
)
}
} else { } else {
// Unpaired: there is no one to take initials from yet.
Box( Box(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
@ -413,7 +433,7 @@ fun SettingsScreen(
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
if (state.isPaired) CloserGlyphs.Heart else CloserGlyphs.HeartOutline, CloserGlyphs.HeartOutline,
contentDescription = null, contentDescription = null,
tint = partnerAccent, tint = partnerAccent,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)

View File

@ -31,6 +31,7 @@ googleid = "1.1.1"
# Third-party # Third-party
revenuecat = "10.13.0" revenuecat = "10.13.0"
coil = "2.7.0" coil = "2.7.0"
exifinterface = "1.3.7"
coroutines = "1.9.0" coroutines = "1.9.0"
tink = "1.13.0" tink = "1.13.0"
bouncycastle = "1.78.1" bouncycastle = "1.78.1"
@ -45,6 +46,7 @@ androidxTestRunner = "1.5.2"
[libraries] [libraries]
# AndroidX core # AndroidX core
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = "exifinterface" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" } androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "splashscreen" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }