Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,7 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.feature.account.avatar"
}

View file

@ -0,0 +1,18 @@
package net.thunderbird.feature.account.avatar
/**
* Interface for creating a monogram based on a name or email address.
*
* This interface is used to generate a monogram, which is typically the initials of a person's name,
* or a representation based on an email address. Implementations should handle null or empty inputs gracefully.
*/
fun interface AvatarMonogramCreator {
/**
* Creates a monogram for the given name or email.
*
* @param name The name to generate a monogram for.
* @param email The email address to generate a monogram for.
* @return A string representing the monogram, or an empty string if the name or email is null or empty.
*/
fun create(name: String?, email: String?): String
}

View file

@ -0,0 +1,17 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
}
android {
namespace = "net.thunderbird.feature.account.avatar.impl"
resourcePrefix = "account_avatar_"
}
dependencies {
implementation(projects.core.ui.compose.designsystem)
implementation(projects.core.common)
implementation(projects.feature.account.avatar.api)
testImplementation(projects.core.ui.compose.testing)
}

View file

@ -0,0 +1,29 @@
package net.thunderbird.feature.account.avatar.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
@Composable
@Preview(showBackground = true)
internal fun AvatarOutlinedPreview() {
PreviewWithThemes {
AvatarOutlined(
color = Color(0xFFe57373),
name = "example",
)
}
}
@Composable
@Preview(showBackground = true)
internal fun AvatarOutlinedLargePreview() {
PreviewWithThemes {
AvatarOutlined(
color = Color(0xFFe57373),
name = "example",
size = AvatarSize.LARGE,
)
}
}

View file

@ -0,0 +1,30 @@
package net.thunderbird.feature.account.avatar.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
@Composable
@Preview(showBackground = true)
internal fun AvatarPreview() {
PreviewWithThemes {
Avatar(
color = Color(0xFFe57373),
name = "example",
selected = false,
)
}
}
@Composable
@Preview(showBackground = true)
internal fun AvatarSelectedPreview() {
PreviewWithThemes {
Avatar(
color = Color(0xFFe57373),
name = "example",
selected = true,
)
}
}

View file

@ -0,0 +1,27 @@
package net.thunderbird.feature.account.avatar
/**
* Creates a monogram based on a name or email address.
*
* This implementation generates a monogram by taking the first two characters of the name or email,
* removing spaces, and converting them to uppercase.
*/
class DefaultAvatarMonogramCreator : AvatarMonogramCreator {
override fun create(name: String?, email: String?): String {
return if (name != null && name.isNotEmpty()) {
composeAvatarMonogram(name)
} else if (email != null && email.isNotEmpty()) {
composeAvatarMonogram(email)
} else {
AVATAR_MONOGRAM_DEFAULT
}
}
private fun composeAvatarMonogram(name: String): String {
return name.replace(" ", "").take(2).uppercase()
}
private companion object {
private const val AVATAR_MONOGRAM_DEFAULT = "XX"
}
}

View file

@ -0,0 +1,92 @@
package net.thunderbird.feature.account.avatar.ui
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium
import app.k9mail.core.ui.compose.theme2.MainTheme
val selectedAvatarSize = 40.dp
@Composable
fun Avatar(
color: Color,
name: String,
selected: Boolean,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
) {
val avatarSize by animateDpAsState(
targetValue = if (selected) selectedAvatarSize else MainTheme.sizes.iconAvatar,
label = "Avatar size",
)
Box(
modifier = modifier
.clip(CircleShape)
.clickable(enabled = onClick != null && !selected, onClick = { onClick?.invoke() }),
contentAlignment = Alignment.Center,
) {
AvatarOutline(
color = color,
modifier = Modifier.size(avatarSize),
) {
AvatarPlaceholder(
displayName = name,
)
// TODO: Add image loading
}
}
}
@Composable
private fun AvatarOutline(
color: Color,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier
.clip(CircleShape)
.border(2.dp, color, CircleShape)
.padding(2.dp),
color = color.copy(alpha = 0.3f),
) {
Box(
modifier = Modifier
.fillMaxSize()
.border(2.dp, MainTheme.colors.surfaceContainerLowest, CircleShape),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
@Composable
private fun AvatarPlaceholder(
displayName: String,
modifier: Modifier = Modifier,
) {
TextTitleMedium(
text = extractNameInitials(displayName).uppercase(),
modifier = modifier,
)
}
private fun extractNameInitials(displayName: String): String {
return displayName.take(2)
}

View file

@ -0,0 +1,117 @@
package net.thunderbird.feature.account.avatar.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.designsystem.atom.Surface
import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleLarge
import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium
import app.k9mail.core.ui.compose.theme2.MainTheme
import app.k9mail.core.ui.compose.theme2.toSurfaceContainer
private const val AVATAR_ALPHA = 0.2f
@Composable
fun AvatarOutlined(
color: Color,
name: String,
modifier: Modifier = Modifier,
size: AvatarSize = AvatarSize.MEDIUM,
onClick: (() -> Unit)? = null,
) {
val avatarColor = calculateAvatarColor(color)
val containerColor = avatarColor.toSurfaceContainer(alpha = AVATAR_ALPHA)
AvatarLayout(
color = containerColor,
borderColor = avatarColor,
onClick = onClick,
modifier = modifier.size(getAvatarSize(size)),
) {
AvatarPlaceholder(
color = avatarColor,
displayName = name,
size = size,
)
// TODO: Add image loading
}
}
@Composable
private fun AvatarLayout(
color: Color,
borderColor: Color,
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
Surface(
color = color,
shape = CircleShape,
modifier = modifier
.border(
width = 2.dp,
shape = CircleShape,
color = borderColor,
)
.clickable(
enabled = onClick != null,
onClick = { onClick?.invoke() },
),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
@Composable
private fun AvatarPlaceholder(
color: Color,
displayName: String,
size: AvatarSize,
modifier: Modifier = Modifier,
) {
when (size) {
AvatarSize.MEDIUM -> {
TextTitleMedium(
text = extractNameInitials(displayName).uppercase(),
color = color,
modifier = modifier,
)
}
AvatarSize.LARGE -> {
TextTitleLarge(
text = extractNameInitials(displayName).uppercase(),
color = color,
modifier = modifier,
)
}
}
}
@Composable
private fun getAvatarSize(size: AvatarSize): Dp {
return when (size) {
AvatarSize.MEDIUM -> MainTheme.sizes.iconAvatar
AvatarSize.LARGE -> MainTheme.sizes.large
}
}
private fun extractNameInitials(displayName: String): String {
return displayName.take(2)
}

View file

@ -0,0 +1,6 @@
package net.thunderbird.feature.account.avatar.ui
enum class AvatarSize {
MEDIUM,
LARGE,
}

View file

@ -0,0 +1,15 @@
package net.thunderbird.feature.account.avatar.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import app.k9mail.core.ui.compose.theme2.MainTheme
import app.k9mail.core.ui.compose.theme2.toHarmonizedColor
@Composable
internal fun calculateAvatarColor(accountColor: Color): Color {
return if (accountColor == Color.Unspecified) {
MainTheme.colors.tertiary
} else {
accountColor.toHarmonizedColor(MainTheme.colors.surface)
}
}

View file

@ -0,0 +1,41 @@
package net.thunderbird.feature.account.avatar
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
class DefaultAvatarMonogramCreatorTest {
private val testSubject = DefaultAvatarMonogramCreator()
@Test
fun `create returns correct monogram for name`() {
val name = "John Doe"
val expectedMonogram = "JO"
val result = testSubject.create(name, null)
assertThat(result).isEqualTo(expectedMonogram)
}
@Test
fun `create returns correct monogram for email`() {
val email = "test@example.com"
val expectedMonogram = "TE"
val result = testSubject.create(null, email)
assertThat(result).isEqualTo(expectedMonogram)
}
@Test
fun `create returns default monogram for null or empty inputs`() {
val expectedMonogram = "XX"
val resultWithNulls = testSubject.create(null, null)
assertThat(resultWithNulls).isEqualTo(expectedMonogram)
val resultWithEmptyStrings = testSubject.create("", "")
assertThat(resultWithEmptyStrings).isEqualTo(expectedMonogram)
}
}