diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42cbf5cc..5d34f9db 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,6 +153,7 @@ dependencies { implementation(composeBom) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.exifinterface) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/java/app/closer/ui/components/Initials.kt b/app/src/main/java/app/closer/ui/components/Initials.kt new file mode 100644 index 00000000..795e1bb1 --- /dev/null +++ b/app/src/main/java/app/closer/ui/components/Initials.kt @@ -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() +} diff --git a/app/src/main/java/app/closer/ui/home/components/HomeHeaderSection.kt b/app/src/main/java/app/closer/ui/home/components/HomeHeaderSection.kt index 19e61401..030546b3 100644 --- a/app/src/main/java/app/closer/ui/home/components/HomeHeaderSection.kt +++ b/app/src/main/java/app/closer/ui/home/components/HomeHeaderSection.kt @@ -39,6 +39,7 @@ import app.closer.core.navigation.AppRoute import app.closer.crypto.FieldEncryptor import app.closer.ui.home.DailyQuestionState import app.closer.ui.home.HomeUiState +import app.closer.ui.components.personInitials import app.closer.ui.theme.CloserPalette import coil.compose.SubcomposeAsyncImage import coil.request.ImageRequest @@ -185,25 +186,13 @@ private fun PartnerHeaderBubble( @Composable private fun PartnerInitials(partnerName: String?) { Text( - text = partnerInitials(partnerName), + text = personInitials(partnerName), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = CloserPalette.PurpleRich, 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 * actions. Replaces the old dead-end into the read-only "Together" feed. Only shown when paired. diff --git a/app/src/main/java/app/closer/ui/settings/AvatarCropSheet.kt b/app/src/main/java/app/closer/ui/settings/AvatarCropSheet.kt new file mode 100644 index 00000000..47a253ef --- /dev/null +++ b/app/src/main/java/app/closer/ui/settings/AvatarCropSheet.kt @@ -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(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 viewport→output 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) +} diff --git a/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt b/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt index aae4904c..d58220bf 100644 --- a/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/EditProfileScreen.kt @@ -46,7 +46,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.runtime.getValue +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 @@ -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(null) } + val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri: Uri? -> - uri?.let { viewModel.setPhotoUri(it.toString()) } + uri?.let { cropSource = it } } val cameraFile = remember(context) { @@ -117,7 +123,7 @@ fun EditProfileContent( val cameraLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.TakePicture() ) { success -> - if (success) viewModel.setPhotoUri(cameraUri.toString()) + if (success) cropSource = cameraUri } val cameraPermissionLauncher = rememberLauncherForActivityResult( @@ -126,6 +132,17 @@ fun EditProfileContent( 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 // already wraps it in a verticalScroll Column (with extra cards below), and nesting two vertical // scrollers crashes with "infinity maximum height". (C-ACCOUNT-001) diff --git a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt index 095b8b4e..aeba32f2 100644 --- a/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/SettingsScreen.kt @@ -69,6 +69,7 @@ import app.closer.ui.settings.SettingsPrimaryDeep import app.closer.ui.settings.SettingsSoft import coil.compose.AsyncImage import app.closer.ui.components.CloserGlyphs +import app.closer.ui.components.personInitials // ==================== // Settings Subpages @@ -393,18 +394,37 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp) ) { - // A real partner photo shows as the avatar; otherwise fall back to the same - // 48dp tile + 24dp glyph the unpaired state and every settings row use. - // ProfileAvatar's own fallback is a bare 40dp icon with no container, which - // left the paired state showing an outsized naked heart unlike anything - // else on the page. + // Paired: the partner's photo, else their initials — the same answer Home's + // partner bubble gives for the same person (they disagreed before: Home + // showed initials, this row showed a heart). The initials sit in a 48dp + // circle so the photo and no-photo states share one silhouette. + // 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()) { ProfileAvatar( imageUrl = state.partnerPhotoUrl, fallbackIcon = CloserGlyphs.Heart, 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 { + // Unpaired: there is no one to take initials from yet. Box( modifier = Modifier .size(48.dp) @@ -413,7 +433,7 @@ fun SettingsScreen( contentAlignment = Alignment.Center ) { Icon( - if (state.isPaired) CloserGlyphs.Heart else CloserGlyphs.HeartOutline, + CloserGlyphs.HeartOutline, contentDescription = null, tint = partnerAccent, modifier = Modifier.size(24.dp) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6169b224..64a209da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ googleid = "1.1.1" # Third-party revenuecat = "10.13.0" coil = "2.7.0" +exifinterface = "1.3.7" coroutines = "1.9.0" tink = "1.13.0" bouncycastle = "1.78.1" @@ -45,6 +46,7 @@ androidxTestRunner = "1.5.2" [libraries] # AndroidX core 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-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } androidx-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" }