Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4378c98
docs: add documentation for Outcome
wmontwe Nov 3, 2025
dd3e629
refactor: change feature:account:core module to kmp
wmontwe Nov 3, 2025
4c6e31f
feat(account): add getAll to AccountProfileRepository
wmontwe Nov 3, 2025
62f0a7c
feat(account): inject account colors
wmontwe Nov 3, 2025
13823c5
refactor(account-setting): use injected account colors to remove lega…
wmontwe Nov 4, 2025
a7ae92f
refactor(account-setting): rename SettingsError to AccountSettingError
wmontwe Nov 4, 2025
72c46f9
refactor(account-setting): change update use case to command pattern
wmontwe Nov 4, 2025
8e7a819
feat(account-setting): add account name and avatar validators
wmontwe Nov 4, 2025
e4169cf
feat(core-validation): add integer input field
wmontwe Nov 4, 2025
894d67d
refactor(account-setting): change the init to launchIn
wmontwe Nov 4, 2025
5ff50b1
refactor(account-setting): remove GeneralPreference and move settings…
wmontwe Nov 6, 2025
18b35e6
feat(account-settind): add account avatar monogram customization
wmontwe Nov 6, 2025
540a994
fix(core-setting): item should expand to full width
wmontwe Nov 6, 2025
03b4061
refactor(account-avatar): rename AccountAvatar to Avatar and move to …
wmontwe Nov 6, 2025
19c08ce
feat(account-avatar): add account avatar with monogram support
wmontwe Nov 7, 2025
e99ef9a
refactor(account-storage): rename AccountAvatarDataMapper to AvatarDa…
wmontwe Nov 7, 2025
2bbab70
feat(drawer): add avatar support
wmontwe Nov 7, 2025
e90a209
feat(account-settings): add transform to SettingValue.Text to allow c…
wmontwe Nov 7, 2025
cb1155f
feat(account-settig): add account name and monogram validation support
wmontwe Nov 10, 2025
5068539
refactor(account-avatar): rename profile indicator to avatar
wmontwe Nov 10, 2025
b8036f8
feat(account-setting): add description for the avatar selection and m…
wmontwe Nov 10, 2025
08e32ec
refactor(account-settings): rename GetGeneralSettings to GetAccountPr…
wmontwe Nov 19, 2025
23b25f3
feat(account-settings): add link to RFC for account name validation
wmontwe Nov 19, 2025
678d4c0
refactor(account-settings): remove redundant text transform
wmontwe Nov 19, 2025
c9cd5ee
chore(build): change :feature:account:core to new kmp plugin
wmontwe Nov 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ dependencies {

implementation(libs.androidx.work.runtime)
implementation(libs.androidx.lifecycle.process)
implementation(libs.kotlinx.collections.immutable)

testImplementation(projects.feature.account.fake)
testImplementation(projects.core.testing)
}
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
package net.thunderbird.app.common.account

import android.content.res.Resources
import app.k9mail.core.ui.legacy.theme2.common.R
import net.thunderbird.core.android.account.LegacyAccountDtoManager
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.first
import net.thunderbird.feature.account.profile.AccountProfileRepository

internal class AccountColorPicker(
private val accountManager: LegacyAccountDtoManager,
private val resources: Resources,
private val repository: AccountProfileRepository,
private val accountColors: ImmutableList<Int>,
Comment thread
rafaeltonholo marked this conversation as resolved.
) {
fun pickColor(): Int {
val accounts = accountManager.getAccounts()
val usedAccountColors = accounts.map { it.chipColor }.toSet()
val accountColors = resources.getIntArray(R.array.account_colors).toList()
suspend fun pickColor(): Int {
val profiles = repository.getAll().first()
val usedCounts = profiles.groupingBy { it.color }.eachCount()

val availableColors = accountColors - usedAccountColors
if (availableColors.isEmpty()) {
return accountColors.random()
val minCount = accountColors.minOf { usedCounts[it] ?: 0 }
val candidates = accountColors.filter {
(usedCounts[it] ?: 0) == minCount
}

val defaultAccountColors = resources.getIntArray(R.array.default_account_colors)
return availableColors.shuffled().minByOrNull { color ->
val index = defaultAccountColors.indexOf(color)
if (index != -1) index else defaultAccountColors.size
} ?: error("availableColors must not be empty")
return if (candidates.isNotEmpty()) {
candidates.shuffled().first()
} else {
accountColors.shuffled().first()
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package net.thunderbird.app.common.account

import app.k9mail.feature.account.setup.AccountSetupExternalContract
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import net.thunderbird.app.common.account.data.DefaultAccountProfileLocalDataSource
import net.thunderbird.app.common.account.data.DefaultLegacyAccountManager
import net.thunderbird.core.android.account.AccountDefaultsProvider
Expand All @@ -13,8 +15,11 @@ import net.thunderbird.feature.account.core.featureAccountCoreModule
import net.thunderbird.feature.account.storage.legacy.featureAccountStorageLegacyModule
import net.thunderbird.feature.mail.account.api.AccountManager
import org.koin.android.ext.koin.androidApplication
import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
import org.koin.dsl.binds
import org.koin.dsl.module
import app.k9mail.core.ui.legacy.theme2.common.R as ThemeCommonR

internal val appCommonAccountModule = module {
includes(
Expand Down Expand Up @@ -43,10 +48,16 @@ internal val appCommonAccountModule = module {
)
}

factory<ImmutableList<Int>>(named("AccountColors")) {
androidContext().resources.getIntArray(
ThemeCommonR.array.account_colors,
).toList().toImmutableList()
}

factory {
AccountColorPicker(
accountManager = get(),
resources = get(),
repository = get(),
accountColors = get(named("AccountColors")),
Comment thread
rafaeltonholo marked this conversation as resolved.
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ internal class DefaultAccountProfileLocalDataSource(
private val dataMapper: AccountProfileDataMapper,
) : AccountProfileLocalDataSource {

override fun getAll(): Flow<List<AccountProfile>> {
return accountManager.getAll()
.map { accounts ->
accounts.map { dto ->
dataMapper.toDomain(dto.profile)
}
}
}

override fun getById(accountId: AccountId): Flow<AccountProfile?> {
return accountManager.getById(accountId)
.map { account ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package net.thunderbird.app.common.account

import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isOneOf
import kotlin.test.Test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import net.thunderbird.app.common.account.data.FakeAccountProfileRepository
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.avatar.Avatar
import net.thunderbird.feature.account.profile.AccountProfile

class AccountColorPickerTest {

@Test
fun `should pick random color when none used`() = runTest {
// Arrange
val profiles: MutableStateFlow<List<AccountProfile>> = MutableStateFlow(emptyList())
val testSubject = AccountColorPicker(
repository = FakeAccountProfileRepository(profiles),
accountColors = ACCOUNT_COLORS,
)

// Act
val result = testSubject.pickColor()

// Assert
assertThat(result).isOneOf(COLOR_RED, COLOR_GREEN, COLOR_BLUE)
}

@Test
fun `should pick one of the available colors when some are used`() = runTest {
// Arrange
val profiles: MutableStateFlow<List<AccountProfile>> = MutableStateFlow(
listOf(
ACCOUNT_PROFILE_GREEN_1,
),
)
val testSubject = AccountColorPicker(
repository = FakeAccountProfileRepository(profiles),
accountColors = ACCOUNT_COLORS,
)

// Act
val result = testSubject.pickColor()

// Assert
assertThat(result).isOneOf(COLOR_RED, COLOR_BLUE)
}

@Test
fun `should pick last available color when others are used`() = runTest {
// Arrange
val profiles: MutableStateFlow<List<AccountProfile>> = MutableStateFlow(
listOf(
ACCOUNT_PROFILE_RED_1,
ACCOUNT_PROFILE_GREEN_1,
),
)
val testSubject = AccountColorPicker(
repository = FakeAccountProfileRepository(profiles),
accountColors = ACCOUNT_COLORS,
)

// Act
val result = testSubject.pickColor()

// Assert
assertThat(result).isEqualTo(COLOR_BLUE)
}

@Test
fun `should pick random color when all colors are used equally`() = runTest {
// Arrange
val profiles: MutableStateFlow<List<AccountProfile>> = MutableStateFlow(
listOf(
ACCOUNT_PROFILE_RED_1,
ACCOUNT_PROFILE_GREEN_1,
ACCOUNT_PROFILE_BLUE_1,
),
)
val testSubject = AccountColorPicker(
repository = FakeAccountProfileRepository(profiles),
accountColors = ACCOUNT_COLORS,
)

// Act
val result = testSubject.pickColor()

// Assert
assertThat(result).isOneOf(COLOR_RED, COLOR_GREEN, COLOR_BLUE)
}

@Test
fun `should pick from least used colors when colors are used multiple times`() = runTest {
// Arrange
val profiles: MutableStateFlow<List<AccountProfile>> = MutableStateFlow(
listOf(
ACCOUNT_PROFILE_RED_1,
ACCOUNT_PROFILE_RED_2,
ACCOUNT_PROFILE_GREEN_1,
ACCOUNT_PROFILE_GREEN_2,
ACCOUNT_PROFILE_BLUE_1,
),
)
val testSubject = AccountColorPicker(
repository = FakeAccountProfileRepository(profiles),
accountColors = ACCOUNT_COLORS,
)

// Act
val result = testSubject.pickColor()

// Assert
assertThat(result).isEqualTo(COLOR_BLUE)
}

private companion object {
const val COLOR_RED = 0xFF0000
const val COLOR_GREEN = 0x00FF00
const val COLOR_BLUE = 0x0000FF

val ACCOUNT_COLORS = persistentListOf(
COLOR_RED,
COLOR_GREEN,
COLOR_BLUE,
)

val ACCOUNT_PROFILE_RED_1 = AccountProfile(
id = AccountIdFactory.create(),
name = "Account Red 1",
color = COLOR_RED,
avatar = Avatar.Icon(name = "icon1"),
)
val ACCOUNT_PROFILE_RED_2 = AccountProfile(
id = AccountIdFactory.create(),
name = "Account Red 2",
color = COLOR_RED,
avatar = Avatar.Icon(name = "icon4"),
)

val ACCOUNT_PROFILE_GREEN_1 = AccountProfile(
id = AccountIdFactory.create(),
name = "Account Green 1",
color = COLOR_GREEN,
avatar = Avatar.Icon(name = "icon2"),
)

val ACCOUNT_PROFILE_GREEN_2 = AccountProfile(
id = AccountIdFactory.create(),
name = "Account Green 2",
color = COLOR_GREEN,
avatar = Avatar.Icon(name = "icon5"),
)

val ACCOUNT_PROFILE_BLUE_1 = AccountProfile(
id = AccountIdFactory.create(),
name = "Account Blue 1",
color = COLOR_BLUE,
avatar = Avatar.Icon(name = "icon3"),
)
}
}
Loading
Loading