diff --git a/features/location/impl/build.gradle.kts b/features/location/impl/build.gradle.kts index 558cd86b25..94f1ea07dd 100644 --- a/features/location/impl/build.gradle.kts +++ b/features/location/impl/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { api(projects.features.location.api) implementation(projects.features.messages.api) implementation(libs.maplibre.compose) + implementation(libs.coil) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.libraries.di) diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt index 6fed056a28..7fce6ab281 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/LocationPinMarkers.kt @@ -9,27 +9,20 @@ package io.element.android.features.location.impl.common.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import io.element.android.compound.theme.ElementTheme import io.element.android.features.location.api.Location -import io.element.android.libraries.designsystem.components.LocationPin import io.element.android.libraries.designsystem.components.PinVariant +import io.element.android.libraries.designsystem.components.rememberLocationPinBitmap import kotlinx.serialization.json.JsonPrimitive import org.maplibre.compose.expressions.dsl.and -import org.maplibre.compose.expressions.dsl.asNumber import org.maplibre.compose.expressions.dsl.asString import org.maplibre.compose.expressions.dsl.const import org.maplibre.compose.expressions.dsl.eq import org.maplibre.compose.expressions.dsl.feature import org.maplibre.compose.expressions.dsl.image import org.maplibre.compose.expressions.dsl.not -import org.maplibre.compose.expressions.dsl.step import org.maplibre.compose.expressions.value.SymbolAnchor -import org.maplibre.compose.expressions.value.SymbolPlacement import org.maplibre.compose.layers.CircleLayer import org.maplibre.compose.layers.SymbolLayer import org.maplibre.compose.sources.GeoJsonData @@ -60,14 +53,12 @@ data class LocationMarkerData( * A composable that renders location markers on a MapLibre map with clustering support. * * Uses GeoJSON source with clustering enabled to group nearby markers. - * Individual markers are rendered using [LocationPin] composable converted to bitmaps. + * Individual markers are rendered using Canvas-based pin rendering with Coil for avatar loading. * Clusters are rendered as circles with point counts. * * Must be used within a MaplibreMap content block. * * @param markers List of markers to display on the map - * @param clusterRadius Radius of each cluster when clustering points (default 50) - * @param clusterMaxZoom Maximum zoom level at which to cluster points (default 14) * @param onMarkerClick Callback when a marker is clicked * @param onClusterClick Callback when a cluster is clicked, provides cluster center position */ @@ -157,36 +148,23 @@ private fun LocationPinMarkerLayer( source: GeoJsonSource, onMarkerClick: ((LocationMarkerData) -> Unit)?, ) { - val imageBitmap = rememberLocationPinImage(marker.variant) - SymbolLayer( - id = "pin-marker-${marker.id}", - source = source, - filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)), - iconImage = image(imageBitmap), - iconAnchor = const(SymbolAnchor.Bottom), - iconAllowOverlap = const(true), - onClick = { features -> - if (features.isNotEmpty() && onMarkerClick != null) { - onMarkerClick(marker) - ClickResult.Consume - } else { - ClickResult.Pass - } - }, - ) -} - -/** - * Renders a LocationPin composable to an ImageBitmap for use in SymbolLayer. - */ -@Composable -private fun rememberLocationPinImage(variant: PinVariant): ImageBitmap { - val bitmap = rememberMarkerBitmap(variant) { - LocationPin( - variant = variant, - // Disable as it doesn't work with the rememberMarkerBitmap method - allowHardwareBitmapRendering = false + val imageBitmap = rememberLocationPinBitmap(marker.variant) + if (imageBitmap != null) { + SymbolLayer( + id = "pin-marker-${marker.id}", + source = source, + filter = !feature.has("point_count") and (feature["id"].asString() eq const(marker.id)), + iconImage = image(imageBitmap), + iconAnchor = const(SymbolAnchor.Bottom), + iconAllowOverlap = const(true), + onClick = { features -> + if (features.isNotEmpty() && onMarkerClick != null) { + onMarkerClick(marker) + ClickResult.Consume + } else { + ClickResult.Pass + } + }, ) } - return bitmap.asImageBitmap() } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt deleted file mode 100644 index 6c083e2a45..0000000000 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/ui/RememberMarkerBitmap.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.features.location.impl.common.ui - -import android.graphics.Bitmap -import android.graphics.Canvas -import android.os.Build -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionContext -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCompositionContext -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalView -import androidx.core.graphics.createBitmap - -/** - * Renders a composable [content] to an Android [Bitmap]. - * Useful for MapLibre SymbolLayer rendering. - * - * Uses a temporary ComposeView to render off-screen without - * adding to the visible composition tree. - * - * Note: This function provides a software-only ImageLoader to avoid - * "Software rendering doesn't support hardware bitmaps" errors when - * rendering Coil images to a Canvas. - * - * @param keys to trigger recomposition. - * @return The rendered Android [Bitmap]. - */ -@Composable -fun rememberMarkerBitmap( - vararg keys: Any, - content: @Composable () -> Unit, -): Bitmap { - val parent = LocalView.current as ViewGroup - val compositionContext = rememberCompositionContext() - return remember(parent, compositionContext, *keys) { - renderComposableToBitmap(parent, compositionContext, content) - } -} - -private fun renderComposableToBitmap( - parent: ViewGroup, - compositionContext: CompositionContext, - content: @Composable () -> Unit, -): Bitmap { - val composeView = ComposeView(parent.context).apply { - setParentCompositionContext(compositionContext) - setContent(content) - } - // Temporarily add to parent for measurement - parent.addView( - composeView, - ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ) - ) - // Measure - composeView.measure( - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) - ) - - val width = composeView.measuredWidth - val height = composeView.measuredHeight - - // Layout - composeView.layout(0, 0, width, height) - - // Draw to bitmap - val bitmap = createBitmap(width, height) - val canvas = Canvas(bitmap) - composeView.draw(canvas) - - // Cleanup - parent.removeView(composeView) - - return bitmap -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt index 60f83ffea1..75129f6795 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/LocationPin.kt @@ -7,31 +7,42 @@ package io.element.android.libraries.designsystem.components +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.Rect +import android.graphics.RectF import androidx.compose.foundation.Canvas -import androidx.compose.foundation.border 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.offset import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Matrix -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Fill -import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap +import androidx.core.graphics.withSave +import coil3.Image +import coil3.SingletonImageLoader +import coil3.request.ImageRequest import coil3.request.allowHardware +import coil3.toBitmap import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize -import io.element.android.libraries.designsystem.components.avatar.AvatarType -import io.element.android.libraries.designsystem.components.avatar.avatarShape -import io.element.android.libraries.designsystem.components.avatar.internal.ImageAvatar import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -48,11 +59,6 @@ sealed interface PinVariant { data object StaleLocation : PinVariant } -private val PIN_MARKER_WIDTH = 42.dp -private val PIN_MARKER_HEIGHT = (PIN_MARKER_WIDTH * 1.2f) -private val DOT_RADIUS = 6.dp -private val CONTENT_OFFSET = 5.dp - /** * A location pin composable that supports multiple variants. * @@ -62,141 +68,297 @@ private val CONTENT_OFFSET = 5.dp fun LocationPin( variant: PinVariant, modifier: Modifier = Modifier, - allowHardwareBitmapRendering: Boolean = true, ) { - val colors = LocationPinColors.fromVariant(variant) - Box( - modifier = modifier.size(width = PIN_MARKER_WIDTH, height = PIN_MARKER_HEIGHT), - ) { - Canvas(modifier = Modifier.matchParentSize()) { - drawPinShape( - fillColor = colors.fill, - strokeColor = colors.stroke, - strokeWidth = 1.dp.toPx(), - ) - } - val avatarSize = PIN_MARKER_WIDTH - CONTENT_OFFSET * 2 - val contentModifier = Modifier - .align(Alignment.TopCenter) - .offset(y = CONTENT_OFFSET) - - when (variant) { - is PinVariant.UserLocation -> { - val avatarShape = AvatarType.User.avatarShape() - ImageAvatar( - avatarData = variant.avatarData, - forcedAvatarSize = avatarSize, - avatarShape = avatarShape, - modifier = contentModifier - .border(width = 1.dp, color = colors.avatarStoke, shape = avatarShape), - configureRequest = { builder -> - builder.allowHardware(allowHardwareBitmapRendering) - } - ) - } - PinVariant.PinnedLocation, PinVariant.StaleLocation -> { - Canvas( - modifier = contentModifier.size(avatarSize) - ) { - drawCircle( - color = colors.dotColor, - radius = DOT_RADIUS.toPx(), - center = center, - ) - } - } - } - } -} - -private data class LocationPinColors( - val fill: Color, - val stroke: Color, - val dotColor: Color, - val avatarStoke: Color, -) { - companion object { - @Composable - fun fromVariant(variant: PinVariant): LocationPinColors { - return when (variant) { - is PinVariant.UserLocation -> - if (variant.isLive) { - LocationPinColors( - fill = ElementTheme.colors.iconAccentPrimary, - stroke = ElementTheme.colors.bgCanvasDefault, - dotColor = Color.Transparent, - avatarStoke = ElementTheme.colors.bgCanvasDefault, - ) - } else { - LocationPinColors( - fill = ElementTheme.colors.bgCanvasDefault, - stroke = ElementTheme.colors.iconQuaternaryAlpha, - dotColor = Color.Transparent, - avatarStoke = ElementTheme.colors.iconQuaternaryAlpha, - ) - } - PinVariant.PinnedLocation -> LocationPinColors( - fill = ElementTheme.colors.bgCanvasDefault, - stroke = ElementTheme.colors.iconSecondaryAlpha, - dotColor = ElementTheme.colors.iconPrimary, - avatarStoke = Color.Transparent, - ) - PinVariant.StaleLocation -> LocationPinColors( - fill = ElementTheme.colors.bgSubtleSecondary, - stroke = ElementTheme.colors.iconDisabled, - dotColor = ElementTheme.colors.iconDisabled, - avatarStoke = Color.Transparent, - ) - } + val image = rememberLocationPinBitmap(variant) + Canvas(modifier = modifier.size(PIN_WIDTH, PIN_HEIGHT)) { + if (image != null) { + drawImage(image) } } } /** - * Draws a teardrop-shaped pin with smooth curves. - * - * Based on SVG path with dimensions 40x48 (ratio 1:1.2). - * Scales automatically to fit the canvas size. + * Renders a location pin to an [ImageBitmap] using Canvas operations. + * @param variant The pin variant to render + * @return The rendered [ImageBitmap], or null if still loading */ -private fun DrawScope.drawPinShape( - fillColor: Color, - strokeColor: Color, - strokeWidth: Float, +@Composable +fun rememberLocationPinBitmap(variant: PinVariant): ImageBitmap? { + val context = LocalContext.current + val density = LocalDensity.current + val colors = pinColors(variant) + return produceState(initialValue = null, variant, colors) { + val renderer = LocationPinRenderer(context, density) + val bitmap = renderer.renderPin(variant, colors) + value = bitmap.asImageBitmap() + }.value +} + +private val PIN_WIDTH = 42.dp +private val PIN_HEIGHT = PIN_WIDTH * 1.2f +private val AVATAR_SIZE = PIN_WIDTH - 10.dp +private val CONTENT_OFFSET = 5.dp +private val DOT_RADIUS = 6.dp +private val STROKE_WIDTH = 1.dp + +@Composable +private fun pinColors(variant: PinVariant): PinColors { + return when (variant) { + is PinVariant.UserLocation -> { + val avatarColors = AvatarColorsProvider.provide(variant.avatarData.id) + if (variant.isLive) { + PinColors( + fill = ElementTheme.colors.iconAccentPrimary, + stroke = Color.Transparent, + dot = Color.Transparent, + avatarStroke = ElementTheme.colors.bgCanvasDefault, + avatarBackground = avatarColors.background, + avatarForeground = avatarColors.foreground, + ) + } else { + PinColors( + fill = ElementTheme.colors.bgCanvasDefault, + stroke = ElementTheme.colors.iconQuaternaryAlpha, + dot = Color.Transparent, + avatarStroke = ElementTheme.colors.iconQuaternaryAlpha, + avatarBackground = avatarColors.background, + avatarForeground = avatarColors.foreground, + ) + } + } + PinVariant.PinnedLocation -> PinColors( + fill = ElementTheme.colors.bgCanvasDefault, + stroke = ElementTheme.colors.iconSecondaryAlpha, + avatarStroke = Color.Transparent, + avatarBackground = Color.Transparent, + avatarForeground = Color.Transparent, + dot = ElementTheme.colors.iconPrimary, + ) + PinVariant.StaleLocation -> PinColors( + fill = ElementTheme.colors.bgSubtleSecondary, + stroke = ElementTheme.colors.iconDisabled, + avatarStroke = Color.Transparent, + avatarBackground = Color.Transparent, + avatarForeground = Color.Transparent, + dot = ElementTheme.colors.iconDisabled, + ) + } +} + +/** + * Color configuration for rendering a location pin. + */ +data class PinColors( + val fill: Color, + val stroke: Color, + val dot: Color, + val avatarStroke: Color, + val avatarBackground: Color, + val avatarForeground: Color, +) + +/** + * Renders location pins to bitmaps using Canvas operations. + * Uses Coil for avatar loading with proper memory management. + */ +class LocationPinRenderer( + private val context: Context, + private val density: Density, ) { - val svgWidth = 40f - val svgHeight = 48f - val inset = strokeWidth / 2 - val scaleX = (size.width - strokeWidth) / svgWidth - val scaleY = (size.height - strokeWidth) / svgHeight + // Dimensions in pixels + private val pinWidthPx = with(density) { PIN_WIDTH.toPx() } + private val pinHeightPx = with(density) { PIN_HEIGHT.toPx() } + private val avatarSizePx = with(density) { AVATAR_SIZE.toPx() } + private val avatarOffsetPx = with(density) { CONTENT_OFFSET.toPx() } + private val dotRadiusPx = with(density) { DOT_RADIUS.toPx() } + private val strokeWidthPx = with(density) { STROKE_WIDTH.toPx() } - val path = Path().apply { - moveTo(20f, 48f) - cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f) - cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f) - cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f) - cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f) - cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f) - cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f) - cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f) - cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f) - cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f) - cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f) - cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f) - cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f) - cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f) - cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f) - cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f) - cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f) - close() + /** + * Renders a pin variant to bitmap. Suspending for async avatar loading. + */ + suspend fun renderPin( + variant: PinVariant, + colors: PinColors, + ): Bitmap { + val bitmap = createBitmap(pinWidthPx.toInt(), pinHeightPx.toInt()) + val canvas = Canvas(bitmap) + // Draw pin shape (fill + stroke) + canvas.drawPinShape(colors.fill, colors.stroke) + when (variant) { + is PinVariant.UserLocation -> { + val avatarImage = loadAvatarImage(variant.avatarData) + canvas.drawAvatar( + avatarImage = avatarImage, + avatarData = variant.avatarData, + borderColor = colors.avatarStroke, + backgroundColor = colors.avatarBackground, + foregroundColor = colors.avatarForeground + ) + } + PinVariant.PinnedLocation, + PinVariant.StaleLocation -> canvas.drawDot(colors.dot) + } + return bitmap + } - transform(Matrix().apply { - scale(scaleX, scaleY) - translate(inset / scaleX, inset / scaleY) + private fun Canvas.drawPinShape(fillColor: Color, strokeColor: Color) { + val path = createPinPath() + // Fill + drawPath(path, Paint().apply { + color = fillColor.toArgb() + style = Paint.Style.FILL + isAntiAlias = true + }) + // Stroke + drawPath(path, Paint().apply { + color = strokeColor.toArgb() + style = Paint.Style.STROKE + strokeWidth = strokeWidthPx + isAntiAlias = true }) } - drawPath(path = path, color = fillColor, style = Fill) - drawPath(path = path, color = strokeColor, style = Stroke(width = strokeWidth)) + /** + * Creates the teardrop-shaped pin path. + * Based on SVG path with dimensions 40x48 (ratio 1:1.2). + * Scales automatically to fit the actual size. + */ + private fun createPinPath(): Path { + val svgWidth = 40f + val svgHeight = 48f + val inset = strokeWidthPx / 2 + val scaleX = (pinWidthPx - strokeWidthPx) / svgWidth + val scaleY = (pinHeightPx - strokeWidthPx) / svgHeight + + val path = Path().apply { + moveTo(20f, 48f) + cubicTo(19.4167f, 48f, 18.8333f, 47.8965f, 18.25f, 47.6895f) + cubicTo(17.6667f, 47.4825f, 17.1458f, 47.1721f, 16.6875f, 46.7581f) + cubicTo(13.9792f, 44.2743f, 11.5833f, 41.8525f, 9.5f, 39.4929f) + cubicTo(7.41667f, 37.1332f, 5.67708f, 34.8461f, 4.28125f, 32.6313f) + cubicTo(2.88542f, 30.4166f, 1.82292f, 28.2846f, 1.09375f, 26.2354f) + cubicTo(0.364583f, 24.1863f, 0f, 22.2303f, 0f, 20.3674f) + cubicTo(0f, 14.1578f, 2.01042f, 9.21087f, 6.03125f, 5.52652f) + cubicTo(10.0521f, 1.84217f, 14.7083f, 0f, 20f, 0f) + cubicTo(25.2917f, 0f, 29.9479f, 1.84217f, 33.9688f, 5.52652f) + cubicTo(37.9896f, 9.21087f, 40f, 14.1578f, 40f, 20.3674f) + cubicTo(40f, 22.2303f, 39.6354f, 24.1863f, 38.9062f, 26.2354f) + cubicTo(38.1771f, 28.2846f, 37.1146f, 30.4166f, 35.7188f, 32.6313f) + cubicTo(34.3229f, 34.8461f, 32.5833f, 37.1332f, 30.5f, 39.4929f) + cubicTo(28.4167f, 41.8525f, 26.0208f, 44.2743f, 23.3125f, 46.7581f) + cubicTo(22.8542f, 47.1721f, 22.3333f, 47.4825f, 21.75f, 47.6895f) + cubicTo(21.1667f, 47.8965f, 20.5833f, 48f, 20f, 48f) + close() + } + // Scale and translate the path + val matrix = Matrix().apply { + setScale(scaleX, scaleY) + postTranslate(inset, inset) + } + path.transform(matrix) + return path + } + + private suspend fun loadAvatarImage(avatarData: AvatarData): Image? { + val imageLoader = SingletonImageLoader.get(context) + val request = ImageRequest.Builder(context) + .data(avatarData) + .size(avatarSizePx.toInt()) + // Disable hardware rendering for Canvas + .allowHardware(false) + .build() + + return imageLoader.execute(request).image + } + + private fun Canvas.drawAvatar( + avatarImage: Image?, + avatarData: AvatarData, + borderColor: Color, + backgroundColor: Color, + foregroundColor: Color, + ) { + val centerX = pinWidthPx / 2 + val avatarY = avatarOffsetPx + val avatarRadius = avatarSizePx / 2 + + withSave { + val clipPath = Path().apply { + addCircle(centerX, avatarY + avatarRadius, avatarRadius, Path.Direction.CW) + } + clipPath(clipPath) + if (avatarImage != null) { + // Draw the loaded avatar image + val destRect = RectF( + centerX - avatarRadius, + avatarY, + centerX + avatarRadius, + avatarY + avatarSizePx + ) + drawBitmap(avatarImage.toBitmap(), null, destRect, null) + } else { + // Fallback: draw initial letter circle + drawInitialLetterAvatar( + avatarData = avatarData, + centerX = centerX, + centerY = avatarY + avatarRadius, + radius = avatarRadius, + foreground = foregroundColor.toArgb(), + background = backgroundColor.toArgb() + ) + } + } + val paintBorder = Paint().apply { + color = borderColor.toArgb() + style = Paint.Style.STROKE + strokeWidth = strokeWidthPx + isAntiAlias = true + } + drawCircle(centerX, avatarY + avatarRadius, avatarRadius, paintBorder) + } + + private fun Canvas.drawInitialLetterAvatar( + avatarData: AvatarData, + centerX: Float, + centerY: Float, + radius: Float, + foreground: Int, + background: Int, + ) { + // Draw background circle + drawCircle(centerX, centerY, radius, Paint().apply { + color = background + style = Paint.Style.FILL + isAntiAlias = true + }) + // Draw initial letter + val textPaint = Paint().apply { + color = foreground + textSize = radius * 1.2f + textAlign = Paint.Align.CENTER + isAntiAlias = true + isFakeBoldText = true + } + // Center text vertically + val textBounds = Rect() + textPaint.getTextBounds(avatarData.initialLetter, 0, 1, textBounds) + val textY = centerY + textBounds.height() / 2f + drawText(avatarData.initialLetter, centerX, textY, textPaint) + } + + private fun Canvas.drawDot(dotColor: Color) { + if (dotColor == Color.Transparent) return + + val centerX = pinWidthPx / 2 + // Position dot in the center of the circular part of the pin + val centerY = avatarOffsetPx + avatarSizePx / 2 + + drawCircle(centerX, centerY, dotRadiusPx, Paint().apply { + color = dotColor.toArgb() + style = Paint.Style.FILL + isAntiAlias = true + }) + } } @PreviewsDayNight