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:
parent
76d8746a9b
commit
7b8fa652f6
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 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)
|
||||
}
|
||||
|
|
@ -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<Uri?>(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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in New Issue