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,12 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
}
android {
namespace = "net.thunderbird.feature.account.settings.api"
resourcePrefix = "account_settings_api_"
}
dependencies {
implementation(projects.core.ui.compose.navigation)
}

View file

@ -0,0 +1,5 @@
package net.thunderbird.feature.account.settings.api
import app.k9mail.core.ui.compose.navigation.Navigation
interface AccountSettingsNavigation : Navigation<AccountSettingsRoute>

View file

@ -0,0 +1,22 @@
package net.thunderbird.feature.account.settings.api
import app.k9mail.core.ui.compose.navigation.Route
import kotlinx.serialization.Serializable
sealed interface AccountSettingsRoute : Route {
@Serializable
data class GeneralSettings(val accountId: String) : AccountSettingsRoute {
override val basePath: String = BASE_PATH
override fun route(): String = "$basePath/$accountId"
companion object {
const val BASE_PATH = "$ACCOUNT_SETTINGS_BASE_PATH/general"
}
}
companion object {
const val ACCOUNT_SETTINGS_BASE_PATH = "app://account/settings"
}
}

View file

@ -0,0 +1,25 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
}
android {
namespace = "net.thunderbird.feature.account.settings"
resourcePrefix = "account_settings_"
}
dependencies {
api(projects.feature.account.settings.api)
implementation(projects.feature.account.core)
implementation(projects.feature.account.avatar.impl)
implementation(projects.core.outcome)
implementation(projects.core.logging.implLegacy)
implementation(projects.core.ui.compose.designsystem)
implementation(projects.core.ui.compose.navigation)
implementation(projects.core.ui.compose.preference)
implementation(projects.core.ui.legacy.theme2.common)
testImplementation(projects.core.logging.testing)
testImplementation(projects.core.ui.compose.testing)
}

View file

@ -0,0 +1,73 @@
package net.thunderbird.feature.account.settings.impl.ui.fake
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import app.k9mail.core.ui.compose.designsystem.atom.card.CardElevated
import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
import app.k9mail.core.ui.compose.theme2.MainTheme
import kotlinx.collections.immutable.persistentListOf
import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
object FakePreferenceData {
val textPreference = PreferenceSetting.Text(
id = "text",
icon = { Icons.Outlined.Info },
title = { "Title" },
description = { "Description" },
value = "Value",
)
val colorPreference = PreferenceSetting.Color(
id = "color",
icon = { Icons.Outlined.Info },
title = { "Title" },
description = { "Description" },
value = 0xFFFF0000.toInt(),
colors = persistentListOf(
0xFFFF0000.toInt(),
0xFF00FF00.toInt(),
0xFF0000FF.toInt(),
),
)
val customPreference = PreferenceDisplay.Custom(
id = "custom",
customUi = { modifier ->
CardElevated(
modifier = modifier
.fillMaxWidth()
.padding(MainTheme.spacings.double),
) {
TextBodyLarge(
text = "Custom UI",
modifier = Modifier
.padding(MainTheme.spacings.default)
.fillMaxWidth(),
)
}
},
)
val sectionDivider = PreferenceDisplay.SectionDivider(
id = "section_divider",
)
val sectionHeader = PreferenceDisplay.SectionHeader(
id = "section_header",
title = { "Section Title" },
color = { Color.Black },
)
val preferences = persistentListOf(
textPreference,
colorPreference,
customPreference,
sectionHeader,
sectionDivider,
)
}

View file

@ -0,0 +1,20 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
import net.thunderbird.feature.account.settings.impl.ui.fake.FakePreferenceData
@Composable
@Preview(showBackground = true)
internal fun GeneralSettingsContentPreview() {
PreviewWithTheme {
GeneralSettingsContent(
state = GeneralSettingsContract.State(
subtitle = "Subtitle",
preferences = FakePreferenceData.preferences,
),
onEvent = {},
)
}
}

View file

@ -0,0 +1,32 @@
package net.thunderbird.feature.account.settings.impl.ui.general.components
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 GeneralSettingsProfileViewPreview() {
PreviewWithThemes {
GeneralSettingsProfileView(
name = "Name",
email = "demo@example.com",
color = Color.Green,
)
}
}
@Composable
@Preview(showBackground = true)
internal fun GeneralSettingsProfileViewWithLongTextPreview() {
PreviewWithThemes {
GeneralSettingsProfileView(
name = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut " +
"labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris " +
"nisi ut aliquip ex ea commodo consequat.",
email = "verylongemailaddress@exampledomainwithaverylongname.com",
color = Color.Green,
)
}
}

View file

@ -0,0 +1,52 @@
package net.thunderbird.feature.account.settings
import net.thunderbird.feature.account.settings.api.AccountSettingsNavigation
import net.thunderbird.feature.account.settings.impl.DefaultAccountSettingsNavigation
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase
import net.thunderbird.feature.account.settings.impl.domain.usecase.GetAccountName
import net.thunderbird.feature.account.settings.impl.domain.usecase.GetGeneralPreferences
import net.thunderbird.feature.account.settings.impl.domain.usecase.UpdateGeneralPreferences
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralResourceProvider
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.viewModel
import org.koin.dsl.module
val featureAccountSettingsModule = module {
single<AccountSettingsNavigation> { DefaultAccountSettingsNavigation() }
factory<ResourceProvider.GeneralResourceProvider> {
GeneralResourceProvider(
context = androidContext(),
)
}
factory<UseCase.GetAccountName> {
GetAccountName(
repository = get(),
)
}
factory<UseCase.GetGeneralPreferences> {
GetGeneralPreferences(
repository = get(),
resourceProvider = get(),
)
}
factory<UseCase.UpdateGeneralPreferences> {
UpdateGeneralPreferences(
repository = get(),
)
}
viewModel { params ->
GeneralSettingsViewModel(
accountId = params.get(),
getAccountName = get(),
getGeneralPreferences = get(),
updateGeneralPreferences = get(),
)
}
}

View file

@ -0,0 +1,32 @@
package net.thunderbird.feature.account.settings.impl
import androidx.navigation.NavGraphBuilder
import androidx.navigation.toRoute
import app.k9mail.core.ui.compose.navigation.deepLinkComposable
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.settings.api.AccountSettingsNavigation
import net.thunderbird.feature.account.settings.api.AccountSettingsRoute
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsScreen
internal class DefaultAccountSettingsNavigation : AccountSettingsNavigation {
override fun registerRoutes(
navGraphBuilder: NavGraphBuilder,
onBack: () -> Unit,
onFinish: (AccountSettingsRoute) -> Unit,
) {
with(navGraphBuilder) {
deepLinkComposable<AccountSettingsRoute.GeneralSettings>(
basePath = AccountSettingsRoute.GeneralSettings.Companion.BASE_PATH,
) { backStackEntry ->
val generalSettingsRoute = backStackEntry.toRoute<AccountSettingsRoute.GeneralSettings>()
val accountId = AccountIdFactory.of(generalSettingsRoute.accountId)
GeneralSettingsScreen(
accountId = accountId,
onBack = onBack,
)
}
}
}
}

View file

@ -0,0 +1,60 @@
package net.thunderbird.feature.account.settings.impl.domain
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.Flow
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.core.ui.compose.preference.api.Preference
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError
internal typealias AccountNameOutcome = Outcome<String, SettingsError>
internal typealias AccountSettingsOutcome = Outcome<ImmutableList<Preference>, SettingsError>
internal interface AccountSettingsDomainContract {
interface UseCase {
fun interface GetAccountName {
operator fun invoke(accountId: AccountId): Flow<AccountNameOutcome>
}
fun interface GetGeneralPreferences {
operator fun invoke(accountId: AccountId): Flow<AccountSettingsOutcome>
}
fun interface UpdateGeneralPreferences {
suspend operator fun invoke(
accountId: AccountId,
preference: PreferenceSetting<*>,
): Outcome<Unit, SettingsError>
}
}
interface ResourceProvider {
interface GeneralResourceProvider {
fun profileUi(
name: String,
color: Int,
): @Composable (Modifier) -> Unit
val nameTitle: () -> String
val nameDescription: () -> String?
val nameIcon: () -> ImageVector?
val colorTitle: () -> String
val colorDescription: () -> String?
val colorIcon: () -> ImageVector?
val colors: ImmutableList<Int>
}
}
sealed interface SettingsError {
data class NotFound(
val message: String,
) : SettingsError
}
}

View file

@ -0,0 +1,13 @@
package net.thunderbird.feature.account.settings.impl.domain.entity
import net.thunderbird.feature.account.AccountId
internal enum class GeneralPreference {
PROFILE,
NAME,
COLOR,
}
internal fun GeneralPreference.generateId(accountId: AccountId): String {
return "${accountId.asRaw()}-general-${this.name.lowercase()}"
}

View file

@ -0,0 +1,29 @@
package net.thunderbird.feature.account.settings.impl.domain.usecase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.profile.AccountProfileRepository
import net.thunderbird.feature.account.settings.impl.domain.AccountNameOutcome
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase
internal class GetAccountName(
private val repository: AccountProfileRepository,
) : UseCase.GetAccountName {
override fun invoke(accountId: AccountId): Flow<AccountNameOutcome> {
return repository.getById(accountId).map { profile ->
if (profile != null) {
Outcome.success(profile.name)
} else {
Outcome.failure(
AccountSettingsDomainContract.SettingsError.NotFound(
message = "Account profile not found for accountId: ${accountId.asRaw()}",
),
)
}
}
}
}

View file

@ -0,0 +1,65 @@
package net.thunderbird.feature.account.settings.impl.domain.usecase
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.core.ui.compose.preference.api.Preference
import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.profile.AccountProfile
import net.thunderbird.feature.account.profile.AccountProfileRepository
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsOutcome
import net.thunderbird.feature.account.settings.impl.domain.entity.GeneralPreference
import net.thunderbird.feature.account.settings.impl.domain.entity.generateId
internal class GetGeneralPreferences(
private val repository: AccountProfileRepository,
private val resourceProvider: ResourceProvider.GeneralResourceProvider,
) : UseCase.GetGeneralPreferences {
override fun invoke(accountId: AccountId): Flow<AccountSettingsOutcome> {
return repository.getById(accountId).map { profile ->
if (profile != null) {
Outcome.success(generatePreferences(accountId, profile))
} else {
Outcome.failure(
SettingsError.NotFound(
message = "Account profile not found for accountId: ${accountId.asRaw()}",
),
)
}
}
}
private fun generatePreferences(accountId: AccountId, profile: AccountProfile): ImmutableList<Preference> {
return persistentListOf(
PreferenceDisplay.Custom(
id = GeneralPreference.PROFILE.generateId(accountId),
customUi = resourceProvider.profileUi(
name = profile.name,
color = profile.color,
),
),
PreferenceSetting.Text(
id = GeneralPreference.NAME.generateId(accountId),
title = resourceProvider.nameTitle,
description = resourceProvider.nameDescription,
icon = resourceProvider.nameIcon,
value = profile.name,
),
PreferenceSetting.Color(
id = GeneralPreference.COLOR.generateId(accountId),
title = resourceProvider.colorTitle,
description = resourceProvider.colorDescription,
icon = resourceProvider.colorIcon,
value = profile.color,
colors = resourceProvider.colors,
),
)
}
}

View file

@ -0,0 +1,58 @@
package net.thunderbird.feature.account.settings.impl.domain.usecase
import kotlinx.coroutines.flow.firstOrNull
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.profile.AccountProfile
import net.thunderbird.feature.account.profile.AccountProfileRepository
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase
import net.thunderbird.feature.account.settings.impl.domain.entity.GeneralPreference
import net.thunderbird.feature.account.settings.impl.domain.entity.generateId
internal class UpdateGeneralPreferences(
private val repository: AccountProfileRepository,
) : UseCase.UpdateGeneralPreferences {
override suspend fun invoke(
accountId: AccountId,
preference: PreferenceSetting<*>,
): Outcome<Unit, SettingsError> {
return when (preference.id) {
GeneralPreference.NAME.generateId(accountId) -> {
updateAccountProfile(accountId) {
copy(name = preference.value as String)
}
}
GeneralPreference.COLOR.generateId(accountId) -> {
updateAccountProfile(accountId) {
copy(color = preference.value as Int)
}
}
else -> Outcome.failure(
SettingsError.NotFound(
message = "Unknown preference id: ${preference.id}",
),
)
}
}
private suspend fun updateAccountProfile(
accountId: AccountId,
update: AccountProfile.() -> AccountProfile,
): Outcome<Unit, SettingsError> {
val accountProfile = repository.getById(accountId).firstOrNull()
?: return Outcome.failure(
SettingsError.NotFound(
message = "Account profile not found for accountId: $accountId",
),
)
val updatedAccountProfile = update(accountProfile)
repository.update(updatedAccountProfile)
return Outcome.success(Unit)
}
}

View file

@ -0,0 +1,48 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import net.thunderbird.feature.account.settings.R
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider
import net.thunderbird.feature.account.settings.impl.ui.general.components.GeneralSettingsProfileView
import app.k9mail.core.ui.legacy.theme2.common.R as ThunderbirdCommonR
internal class GeneralResourceProvider(
private val context: Context,
) : ResourceProvider.GeneralResourceProvider {
override fun profileUi(
name: String,
color: Int,
): @Composable ((Modifier) -> Unit) = { modifier ->
GeneralSettingsProfileView(
name = name,
email = null,
color = Color(color),
modifier = modifier,
)
}
override val nameTitle: () -> String = {
context.getString(R.string.account_settings_general_name_title)
}
override val nameDescription: () -> String? = {
context.getString(R.string.account_settings_general_name_description)
}
override val nameIcon: () -> ImageVector? = { null }
override val colorTitle: () -> String = {
context.getString(R.string.account_settings_general_color_title)
}
override val colorDescription: () -> String? = {
context.getString(R.string.account_settings_general_color_description)
}
override val colorIcon: () -> ImageVector? = { null }
override val colors: ImmutableList<Int> = context.resources.getIntArray(ThunderbirdCommonR.array.account_colors)
.toList().toImmutableList()
}

View file

@ -0,0 +1,27 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import net.thunderbird.core.ui.compose.preference.ui.PreferenceView
import net.thunderbird.feature.account.settings.R
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Event
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State
@Composable
internal fun GeneralSettingsContent(
state: State,
onEvent: (Event) -> Unit,
modifier: Modifier = Modifier,
) {
PreferenceView(
title = stringResource(R.string.account_settings_general_title),
subtitle = state.subtitle,
preferences = state.preferences,
onPreferenceChange = { preference ->
onEvent(Event.OnPreferenceSettingChange(preference))
},
onBack = { onEvent(Event.OnBackPressed) },
modifier = modifier,
)
}

View file

@ -0,0 +1,31 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import androidx.compose.runtime.Stable
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import net.thunderbird.core.ui.compose.preference.api.Preference
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
internal interface GeneralSettingsContract {
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
@Stable
data class State(
val subtitle: String? = null,
val preferences: ImmutableList<Preference> = persistentListOf<Preference>(),
)
sealed interface Event {
data class OnPreferenceSettingChange(
val preference: PreferenceSetting<*>,
) : Event
data object OnBackPressed : Event
}
sealed interface Effect {
object NavigateBack : Effect
}
}

View file

@ -0,0 +1,31 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import app.k9mail.core.ui.compose.common.mvi.observe
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
internal fun GeneralSettingsScreen(
accountId: AccountId,
onBack: () -> Unit,
viewModel: GeneralSettingsContract.ViewModel = koinViewModel<GeneralSettingsViewModel> {
parametersOf(accountId)
},
) {
val (state, dispatch) = viewModel.observe { effect ->
when (effect) {
is Effect.NavigateBack -> onBack()
}
}
BackHandler(onBack = onBack)
GeneralSettingsContent(
state = state.value,
onEvent = { dispatch(it) },
)
}

View file

@ -0,0 +1,74 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
import kotlinx.coroutines.launch
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.outcome.handle
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Event
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State
internal class GeneralSettingsViewModel(
private val accountId: AccountId,
private val getAccountName: UseCase.GetAccountName,
private val getGeneralPreferences: UseCase.GetGeneralPreferences,
private val updateGeneralPreferences: UseCase.UpdateGeneralPreferences,
initialState: State = State(),
) : BaseViewModel<State, Event, Effect>(initialState), GeneralSettingsContract.ViewModel {
init {
viewModelScope.launch {
getAccountName(accountId).collect { outcome ->
outcome.handle(
onSuccess = { accountName ->
updateState { state ->
state.copy(
subtitle = accountName,
)
}
},
onFailure = { handleError(it) },
)
}
}
viewModelScope.launch {
getGeneralPreferences(accountId).collect { outcome ->
outcome.handle(
onSuccess = { preferences ->
updateState { state ->
state.copy(
preferences = preferences,
)
}
},
onFailure = { handleError(it) },
)
}
}
}
override fun event(event: Event) {
when (event) {
is Event.OnPreferenceSettingChange -> updatePreference(event.preference)
is Event.OnBackPressed -> emitEffect(Effect.NavigateBack)
}
}
private fun updatePreference(preference: PreferenceSetting<*>) {
viewModelScope.launch {
updateGeneralPreferences(accountId, preference)
}
}
private fun handleError(error: SettingsError) {
when (error) {
is SettingsError.NotFound -> Log.w(error.message)
}
}
}

View file

@ -0,0 +1,87 @@
package net.thunderbird.feature.account.settings.impl.ui.general.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import app.k9mail.core.ui.compose.designsystem.atom.card.CardElevated
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
import app.k9mail.core.ui.compose.designsystem.atom.text.TextHeadlineSmall
import app.k9mail.core.ui.compose.theme2.MainTheme
import net.thunderbird.feature.account.avatar.ui.AvatarOutlined
import net.thunderbird.feature.account.avatar.ui.AvatarSize
@Composable
internal fun GeneralSettingsProfileView(
name: String,
email: String?,
color: Color,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.padding(MainTheme.spacings.double),
contentAlignment = Alignment.TopCenter,
) {
ProfileCard(
name = name,
email = email,
modifier = Modifier
.padding(top = MainTheme.spacings.quadruple)
.fillMaxWidth(),
)
AvatarOutlined(
color = color,
name = name,
size = AvatarSize.LARGE,
)
}
}
@Composable
private fun ProfileCard(
name: String,
email: String?,
modifier: Modifier = Modifier,
) {
CardElevated(
modifier = modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = MainTheme.spacings.oneHalf,
vertical = MainTheme.spacings.triple,
),
) {
Spacer(modifier = Modifier.height(MainTheme.spacings.triple))
TextHeadlineSmall(
text = name,
color = MainTheme.colors.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
email?.let {
Spacer(modifier = Modifier.height(MainTheme.spacings.default))
TextBodyLarge(
text = it,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
}
}
}
}

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="account_settings_general_title">General Settings</string>
<string name="account_settings_general_name_title">Account name</string>
<string name="account_settings_general_name_description">The name associated with your account.</string>
<string name="account_settings_general_color_title">Account color</string>
<string name="account_settings_general_color_description">The accent color of this account used in folders and account lists.</string>
</resources>

View file

@ -0,0 +1,22 @@
package net.thunderbird.feature.account.settings.impl
import kotlin.test.Test
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.settings.featureAccountSettingsModule
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.test.verify.verify
internal class AccountSettingsModuleKtTest {
@OptIn(KoinExperimentalAPI::class)
@Test
fun `should hava a valid di module`() {
featureAccountSettingsModule.verify(
extraTypes = listOf(
AccountId::class,
GeneralSettingsContract.State::class,
),
)
}
}

View file

@ -0,0 +1,27 @@
package net.thunderbird.feature.account.settings.impl.domain.usecase
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.profile.AccountProfile
import net.thunderbird.feature.account.profile.AccountProfileRepository
internal class FakeAccountProfileRepository(
initialAccountProfile: AccountProfile? = null,
) : AccountProfileRepository {
private val accountProfileState = MutableStateFlow<AccountProfile?>(initialAccountProfile)
private val accountProfile: StateFlow<AccountProfile?> = accountProfileState
override fun getById(accountId: AccountId): Flow<AccountProfile?> {
return accountProfile
}
override suspend fun update(accountProfile: AccountProfile) {
accountProfileState.update {
accountProfile
}
}
}

View file

@ -0,0 +1,24 @@
package net.thunderbird.feature.account.settings.impl.domain.usecase
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider
internal class FakeGeneralResourceProvider : ResourceProvider.GeneralResourceProvider {
override fun profileUi(
name: String,
color: Int,
): @Composable ((Modifier) -> Unit) = { }
override val nameTitle: () -> String = { "Name" }
override val nameDescription: () -> String? = { null }
override val nameIcon: () -> ImageVector? = { null }
override val colorTitle: () -> String = { "Color" }
override val colorDescription: () -> String? = { null }
override val colorIcon: () -> ImageVector? = { null }
override val colors: ImmutableList<Int> = persistentListOf(0xFF0000, 0x00FF00, 0x0000FF)
}

View file

@ -0,0 +1,63 @@
package net.thunderbird.feature.account.settings.impl.domain.usecase
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import kotlin.test.Test
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.profile.AccountAvatar
import net.thunderbird.feature.account.profile.AccountProfile
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase
class GetAccountNameTest {
@Test
fun `should emit account name when account profile present`() = runTest {
// Arrange
val accountId = AccountIdFactory.create()
val accountProfile = AccountProfile(
id = accountId,
name = "Test Account",
color = 0xFF0000,
avatar = AccountAvatar.Icon(name = "star"),
)
val testSubject = createTestSubject(accountProfile)
// Act & Assert
testSubject(accountId).test {
val outcome = awaitItem()
assertThat(outcome).isInstanceOf(Outcome.Success::class)
val success = outcome as Outcome.Success
assertThat(success.data).isEqualTo(accountProfile.name)
}
}
@Test
fun `should emit NotFound when account profile not present`() = runTest {
// Arrange
val accountId = AccountIdFactory.create()
val testSubject = createTestSubject()
// Act & Assert
testSubject(accountId).test {
val outcome = awaitItem()
assertThat(outcome).isInstanceOf(Outcome.Failure::class)
val failure = outcome as Outcome.Failure
assertThat(failure.error).isInstanceOf(SettingsError.NotFound::class)
}
}
private fun createTestSubject(
accountProfile: AccountProfile? = null,
): UseCase.GetAccountName {
return GetAccountName(
repository = FakeAccountProfileRepository(accountProfile),
)
}
}

View file

@ -0,0 +1,97 @@
package net.thunderbird.feature.account.settings.impl.domain.usecase
import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import kotlin.test.Test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.core.ui.compose.preference.api.PreferenceDisplay
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.profile.AccountAvatar
import net.thunderbird.feature.account.profile.AccountProfile
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.ResourceProvider
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.UseCase
internal class GetGeneralPreferencesTest {
@Test
fun `should emit preferences when account profile present`() = runTest {
// Arrange
val accountId = AccountIdFactory.create()
val accountProfile = AccountProfile(
id = accountId,
name = "Test Account",
color = 0xFF0000,
avatar = AccountAvatar.Icon(name = "star"),
)
val resourceProvider = FakeGeneralResourceProvider()
val testSubject = createTestSubject(accountProfile)
// Act & Assert
testSubject(accountId).test {
val outcome = awaitItem()
assertThat(outcome).isInstanceOf(Outcome.Success::class)
val success = outcome as Outcome.Success
assertThat(success.data).isEqualTo(
persistentListOf(
PreferenceDisplay.Custom(
id = "${accountId.asRaw()}-general-profile",
customUi = resourceProvider.profileUi(
name = accountProfile.name,
color = accountProfile.color,
),
),
PreferenceSetting.Text(
id = "${accountId.asRaw()}-general-name",
title = resourceProvider.nameTitle,
description = resourceProvider.nameDescription,
icon = resourceProvider.nameIcon,
value = accountProfile.name,
),
PreferenceSetting.Color(
id = "${accountId.asRaw()}-general-color",
title = resourceProvider.colorTitle,
description = resourceProvider.colorDescription,
icon = resourceProvider.colorIcon,
value = accountProfile.color,
colors = resourceProvider.colors,
),
),
)
}
}
@Test
fun `should emit NotFound when account profile not found`() = runTest {
// Arrange
val accountId = AccountIdFactory.create()
val testSubject = createTestSubject()
// Act & Assert
testSubject(accountId).test {
assertThat(awaitItem()).isEqualTo(
Outcome.failure(
SettingsError.NotFound(
message = "Account profile not found for accountId: ${accountId.asRaw()}",
),
),
)
}
}
private fun createTestSubject(
accountProfile: AccountProfile? = null,
resourceProvider: ResourceProvider.GeneralResourceProvider = FakeGeneralResourceProvider(),
): UseCase.GetGeneralPreferences {
return GetGeneralPreferences(
repository = FakeAccountProfileRepository(accountProfile),
resourceProvider = resourceProvider,
)
}
}

View file

@ -0,0 +1,123 @@
package net.thunderbird.feature.account.settings.impl.domain.usecase
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import kotlin.test.Test
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.profile.AccountAvatar
import net.thunderbird.feature.account.profile.AccountProfile
import net.thunderbird.feature.account.settings.impl.domain.AccountSettingsDomainContract.SettingsError
import net.thunderbird.feature.account.settings.impl.domain.entity.GeneralPreference
import net.thunderbird.feature.account.settings.impl.domain.entity.generateId
class UpdateGeneralPreferencesTest {
@Test
fun `should update account profile`() = runTest {
// Arrange
val accountId = AccountIdFactory.create()
val accountProfile = AccountProfile(
id = accountId,
name = "Test Account",
color = 0xFF0000,
avatar = AccountAvatar.Icon(name = "star"),
)
val newName = "Updated Account Name"
val preference = PreferenceSetting.Text(
id = GeneralPreference.NAME.generateId(accountId),
title = { "Name" },
description = { "Account name" },
icon = { null },
value = newName,
)
val repository = FakeAccountProfileRepository(
initialAccountProfile = accountProfile,
)
val testSubject = UpdateGeneralPreferences(repository)
// Act
val result = testSubject(accountId, preference)
// Assert
assertThat(result).isInstanceOf(Outcome.Success::class)
assertThat(repository.getById(accountId).firstOrNull()).isEqualTo(
accountProfile.copy(name = newName),
)
}
@Test
fun `should update account profile for all general settings`() = runTest {
// Arrange
val accountId = AccountIdFactory.create()
val accountProfile = AccountProfile(
id = accountId,
name = "Test Account",
color = 0xFF0000,
avatar = AccountAvatar.Icon(name = "star"),
)
val newName = "Updated Account Name"
val newColor = 0x00FF00
val preferences = listOf(
PreferenceSetting.Text(
id = GeneralPreference.NAME.generateId(accountId),
title = { "Name" },
description = { "Account name" },
icon = { null },
value = newName,
),
PreferenceSetting.Color(
id = GeneralPreference.COLOR.generateId(accountId),
title = { "Color" },
description = { "Account color" },
icon = { null },
value = newColor,
colors = persistentListOf(0xFF0000, 0x00FF00, 0x0000FF),
),
)
val repository = FakeAccountProfileRepository(
initialAccountProfile = accountProfile,
)
val testSubject = UpdateGeneralPreferences(repository)
// Act
preferences.forEach { preference ->
testSubject(accountId, preference)
}
// Assert
assertThat(repository.getById(accountId).firstOrNull()).isEqualTo(
accountProfile.copy(
name = newName,
color = newColor,
),
)
}
@Test
fun `should emit NotFound when account profile not found`() = runTest {
// Arrange
val accountId = AccountIdFactory.create()
val preference = PreferenceSetting.Text(
id = GeneralPreference.NAME.generateId(accountId),
title = { "Name" },
description = { "Account name" },
icon = { null },
value = "Updated Account Name",
)
val repository = FakeAccountProfileRepository()
val testSubject = UpdateGeneralPreferences(repository)
// Act
val result = testSubject(accountId, preference)
// Assert
assertThat(result).isInstanceOf(Outcome.Failure::class)
assertThat((result as Outcome.Failure).error).isInstanceOf(SettingsError.NotFound::class)
}
}

View file

@ -0,0 +1,19 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import net.thunderbird.core.ui.compose.preference.api.Preference
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
internal object FakeData {
val preferences: ImmutableList<Preference> = persistentListOf(
PreferenceSetting.Text(
id = "test_id",
title = { "Title" },
description = { "Description" },
icon = { null },
value = "Test",
),
)
}

View file

@ -0,0 +1,11 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import app.k9mail.core.ui.compose.testing.BaseFakeViewModel
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Event
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.ViewModel
internal class FakeGeneralSettingsViewModel(
initialState: State = State(),
) : BaseFakeViewModel<State, Event, Effect>(initialState), ViewModel

View file

@ -0,0 +1,58 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import app.k9mail.core.ui.compose.testing.ComposeTest
import app.k9mail.core.ui.compose.testing.pressBack
import app.k9mail.core.ui.compose.testing.setContentWithTheme
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State
internal class GeneralSettingsScreenKtTest : ComposeTest() {
@Test
fun `should call onBack when back button is pressed`() {
val initialState = State()
val accountId = AccountIdFactory.create()
val viewModel = FakeGeneralSettingsViewModel(initialState)
var onBackCounter = 0
setContentWithTheme {
GeneralSettingsScreen(
accountId = accountId,
onBack = { onBackCounter++ },
viewModel = viewModel,
)
}
assertThat(onBackCounter).isEqualTo(0)
pressBack()
assertThat(onBackCounter).isEqualTo(1)
}
@Test
fun `should call onBack when navigate back effect received`() {
val initialState = State()
val accountId = AccountIdFactory.create()
val viewModel = FakeGeneralSettingsViewModel(initialState)
var onBackCounter = 0
setContentWithTheme {
GeneralSettingsScreen(
accountId = accountId,
onBack = { onBackCounter++ },
viewModel = viewModel,
)
}
assertThat(onBackCounter).isEqualTo(0)
viewModel.effect(Effect.NavigateBack)
assertThat(onBackCounter).isEqualTo(1)
}
}

View file

@ -0,0 +1,24 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.collections.immutable.persistentListOf
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State
internal class GeneralSettingsStateTest {
@Test
fun `should set default values`() {
// Arrange
val state = State()
// Assert
assertThat(state).isEqualTo(
State(
subtitle = null,
preferences = persistentListOf(),
),
)
}
}

View file

@ -0,0 +1,199 @@
package net.thunderbird.feature.account.settings.impl.ui.general
import app.k9mail.core.ui.compose.testing.mvi.MviContext
import app.k9mail.core.ui.compose.testing.mvi.MviTurbines
import app.k9mail.core.ui.compose.testing.mvi.runMviTest
import app.k9mail.core.ui.compose.testing.mvi.turbinesWithInitialStateCheck
import assertk.assertThat
import assertk.assertions.isEqualTo
import kotlin.test.Test
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.StandardTestDispatcher
import net.thunderbird.core.logging.legacy.Log
import net.thunderbird.core.logging.testing.TestLogger
import net.thunderbird.core.outcome.Outcome
import net.thunderbird.core.testing.coroutines.MainDispatcherRule
import net.thunderbird.core.ui.compose.preference.api.Preference
import net.thunderbird.core.ui.compose.preference.api.PreferenceSetting
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.account.AccountIdFactory
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.Effect
import net.thunderbird.feature.account.settings.impl.ui.general.GeneralSettingsContract.State
import org.junit.Before
import org.junit.Rule
class GeneralSettingsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher())
@Before
fun setUp() {
Log.logger = TestLogger()
}
@Test
fun `should load account name`() = runMviTest {
val accountId = AccountIdFactory.create()
val initialState = State(
subtitle = null,
preferences = persistentListOf(),
)
generalSettingsRobot(accountId, initialState, persistentListOf()) {
verifyAccountNameLoaded()
}
}
@Test
fun `should load general settings`() = runMviTest {
val accountId = AccountIdFactory.create()
val initialState = State(
subtitle = "Subtitle",
preferences = persistentListOf(),
)
val preferences = FakeData.preferences
generalSettingsRobot(accountId, initialState, preferences) {
verifyGeneralSettingsLoaded(preferences)
}
}
@Test
fun `should navigate back when back is pressed`() = runMviTest {
val accountId = AccountIdFactory.create()
val initialState = State(
subtitle = "Subtitle",
preferences = persistentListOf(),
)
val preferences = FakeData.preferences
generalSettingsRobot(accountId, initialState, preferences) {
verifyGeneralSettingsLoaded(preferences)
pressBack()
verifyBackNavigation()
}
}
@Test
fun `should update preference when changed`() = runMviTest {
val accountId = AccountIdFactory.create()
val initialState = State(
subtitle = "Subtitle",
preferences = persistentListOf(),
)
val preferences = FakeData.preferences
generalSettingsRobot(accountId, initialState, preferences) {
verifyGeneralSettingsLoaded(preferences)
val updatedPreference = (preferences.first() as PreferenceSetting.Text).copy(
title = { "Updated Title" },
description = { "Updated Description" },
)
updatePreference(updatedPreference)
verifyPreferenceUpdated(updatedPreference)
}
}
}
private suspend fun MviContext.generalSettingsRobot(
accountId: AccountId,
initialState: State,
preferences: ImmutableList<Preference>,
interaction: suspend GeneralSettingsRobot.() -> Unit,
) = GeneralSettingsRobot(this, accountId, initialState, preferences).apply {
initialize()
interaction()
}
private class GeneralSettingsRobot(
private val mviContext: MviContext,
private val accountId: AccountId,
private val initialState: State = State(),
private val preferences: ImmutableList<Preference>,
) {
private lateinit var preferencesState: MutableStateFlow<ImmutableList<Preference>>
private lateinit var turbines: MviTurbines<State, Effect>
private val viewModel: GeneralSettingsContract.ViewModel by lazy {
GeneralSettingsViewModel(
accountId = accountId,
getAccountName = {
flowOf(Outcome.success("Subtitle"))
},
getGeneralPreferences = {
preferencesState.map {
println("Loading preferences: $it")
Outcome.success(it)
}
},
updateGeneralPreferences = { _, preference ->
preferencesState.value = preferencesState.value.map { existingPreference ->
if (existingPreference is PreferenceSetting<*> && existingPreference.id == preference.id) {
println("Updating preference: ${preference.id}")
println("Old preference: $existingPreference")
println("New preference: $preference")
preference
} else {
existingPreference
}
}.toImmutableList()
Outcome.success(Unit)
},
initialState = initialState,
)
}
suspend fun initialize() {
preferencesState = MutableStateFlow(preferences)
turbines = mviContext.turbinesWithInitialStateCheck(
initialState = initialState,
viewModel = viewModel,
)
}
suspend fun verifyAccountNameLoaded() {
assertThat(turbines.awaitStateItem()).isEqualTo(
initialState.copy(
subtitle = "Subtitle",
),
)
}
suspend fun verifyGeneralSettingsLoaded(preferences: ImmutableList<Preference>) {
assertThat(turbines.awaitStateItem()).isEqualTo(
initialState.copy(
preferences = preferences,
),
)
}
fun pressBack() {
viewModel.event(GeneralSettingsContract.Event.OnBackPressed)
}
suspend fun verifyBackNavigation() {
assertThat(turbines.awaitEffectItem()).isEqualTo(
Effect.NavigateBack,
)
}
fun updatePreference(preference: PreferenceSetting<*>) {
viewModel.event(GeneralSettingsContract.Event.OnPreferenceSettingChange(preference))
}
suspend fun verifyPreferenceUpdated(preference: PreferenceSetting<*>) {
val updatedPreference = turbines.awaitStateItem().preferences
.filterIsInstance<PreferenceSetting<*>>()
.find { it.id == preference.id }
assertThat(updatedPreference).isEqualTo(preference)
}
}