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,15 @@
plugins {
id(ThunderbirdPlugins.Library.kmp)
}
android {
namespace = "net.thunderbird.feature.account"
}
kotlin {
sourceSets {
commonMain.dependencies {
api(projects.core.architecture.api)
}
}
}

View file

@ -0,0 +1,8 @@
package net.thunderbird.feature.account
import net.thunderbird.core.architecture.model.Identifiable
/**
* Interface representing an account by its unique identifier [AccountId].
*/
interface Account : Identifiable<Account>

View file

@ -0,0 +1,8 @@
package net.thunderbird.feature.account
import net.thunderbird.core.architecture.model.Id
/**
* Represents a unique identifier for an [Account].
*/
typealias AccountId = Id<Account>

View file

@ -0,0 +1,8 @@
package net.thunderbird.feature.account
import net.thunderbird.core.architecture.model.BaseIdFactory
/**
* Factory object for creating unique identifiers for [Account] instances.
*/
object AccountIdFactory : BaseIdFactory<Account>()

View file

@ -0,0 +1,18 @@
package net.thunderbird.feature.account.profile
/**
* Sealed interface representing the avatar of an account.
*/
sealed interface AccountAvatar {
data class Monogram(
val value: String,
) : AccountAvatar
data class Image(
val uri: String,
) : AccountAvatar
data class Icon(
val name: String,
) : AccountAvatar
}

View file

@ -0,0 +1,19 @@
package net.thunderbird.feature.account.profile
import net.thunderbird.feature.account.Account
import net.thunderbird.feature.account.AccountId
/**
* Data class representing an account profile.
*
* @property id The unique identifier of the account profile.
* @property name The name of the account.
* @property color The color associated with the account.
* @property avatar The [AccountAvatar] representing the avatar of the account.
*/
data class AccountProfile(
override val id: AccountId,
val name: String,
val color: Int,
val avatar: AccountAvatar,
) : Account

View file

@ -0,0 +1,11 @@
package net.thunderbird.feature.account.profile
import kotlinx.coroutines.flow.Flow
import net.thunderbird.feature.account.AccountId
interface AccountProfileRepository {
fun getById(accountId: AccountId): Flow<AccountProfile?>
suspend fun update(accountProfile: AccountProfile)
}

View file

@ -0,0 +1,62 @@
package net.thunderbird.feature.account
import assertk.Assert
import assertk.assertFailure
import assertk.assertThat
import assertk.assertions.hasMessage
import assertk.assertions.isEqualTo
import assertk.assertions.isInstanceOf
import assertk.assertions.isNotEqualTo
import kotlin.test.Test
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
class AccountIdFactoryTest {
@Test
fun `create should return AccountId with the same id`() {
val id = "123e4567-e89b-12d3-a456-426614174000"
val result = AccountIdFactory.of(id)
assertThat(result.asRaw()).isEqualTo(id)
}
@Test
fun `create should throw IllegalArgumentException when id is invalid`() {
val id = "invalid"
val result = assertFailure {
AccountIdFactory.of(id)
}
result.hasMessage(
"Expected either a 36-char string in the standard hex-and-dash UUID format or a 32-char " +
"hexadecimal string, but was \"invalid\" of length 7",
)
result.isInstanceOf<IllegalArgumentException>()
}
@Test
fun `new should return AccountId with a uuid`() {
val result = AccountIdFactory.create()
assertThat(result.asRaw()).isUuid()
}
@Test
fun `create should return AccountId with unique ids`() {
val ids = List(10) { AccountIdFactory.create().asRaw() }
ids.forEachIndexed { index, id ->
ids.drop(index + 1).forEach { otherId ->
assertThat(id).isNotEqualTo(otherId)
}
}
}
@OptIn(ExperimentalUuidApi::class)
private fun Assert<String>.isUuid() = given { actual ->
Uuid.parse(actual)
}
}

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)
}
}

View file

@ -0,0 +1,17 @@
plugins {
id(ThunderbirdPlugins.Library.androidCompose)
}
android {
namespace = "app.k9mail.feature.account.common"
resourcePrefix = "account_common_"
}
dependencies {
implementation(projects.core.ui.compose.designsystem)
implementation(projects.core.common)
implementation(projects.mail.common)
testImplementation(projects.core.ui.compose.testing)
}

View file

@ -0,0 +1,15 @@
package app.k9mail.feature.account.common.ui
import androidx.compose.runtime.Composable
import app.k9mail.core.ui.compose.common.annotation.PreviewDevices
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
@Composable
@PreviewDevices
internal fun AccountTopAppBarPreview() {
PreviewWithThemes {
AccountTopAppBar(
title = "Title",
)
}
}

View file

@ -0,0 +1,13 @@
package app.k9mail.feature.account.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
@Composable
@Preview(showBackground = true)
internal fun AppTitleTopHeaderPreview() {
PreviewWithThemes {
AppTitleTopHeader(title = "Title")
}
}

View file

@ -0,0 +1,24 @@
package app.k9mail.feature.account.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
import app.k9mail.core.ui.compose.designsystem.atom.text.TextTitleMedium
@Composable
@Preview(showBackground = true)
internal fun ContentListViewPreview() {
PreviewWithThemes {
ContentListView {
item {
TextTitleMedium("Item 1")
}
item {
TextTitleMedium("Item 2")
}
item {
TextTitleMedium("Item 3")
}
}
}
}

View file

@ -0,0 +1,59 @@
package app.k9mail.feature.account.common.ui
import androidx.compose.runtime.Composable
import app.k9mail.core.ui.compose.common.annotation.PreviewDevices
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
@Composable
@PreviewDevices
internal fun WizardNavigationBarPreview() {
PreviewWithThemes {
WizardNavigationBar(
onNextClick = {},
onBackClick = {},
)
}
}
@Composable
@PreviewDevices
internal fun WizardNavigationBarDisabledPreview() {
PreviewWithThemes {
WizardNavigationBar(
onNextClick = {},
onBackClick = {},
state = WizardNavigationBarState(
isNextEnabled = false,
isBackEnabled = false,
),
)
}
}
@Composable
@PreviewDevices
internal fun WizardNavigationBarHideNextPreview() {
PreviewWithThemes {
WizardNavigationBar(
onNextClick = {},
onBackClick = {},
state = WizardNavigationBarState(
showNext = false,
),
)
}
}
@Composable
@PreviewDevices
internal fun WizardNavigationBarHideBackPreview() {
PreviewWithThemes {
WizardNavigationBar(
onNextClick = {},
onBackClick = {},
state = WizardNavigationBarState(
showBack = false,
),
)
}
}

View file

@ -0,0 +1,57 @@
package app.k9mail.feature.account.common.ui.fake
import app.k9mail.feature.account.common.domain.AccountDomainContract
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
import app.k9mail.feature.account.common.domain.entity.MailConnectionSecurity
import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings
import com.fsck.k9.mail.AuthType
import com.fsck.k9.mail.ServerSettings
@Suppress("TooManyFunctions")
class FakeAccountStateRepository : AccountDomainContract.AccountStateRepository {
override fun getState(): AccountState = AccountState(
emailAddress = "test@example.com",
incomingServerSettings = ServerSettings(
type = "imap",
host = "imap.example.com",
port = 993,
connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = "test",
password = "password",
clientCertificateAlias = null,
),
outgoingServerSettings = ServerSettings(
type = "smtp",
host = "smtp.example.com",
port = 465,
connectionSecurity = MailConnectionSecurity.SSL_TLS_REQUIRED,
authenticationType = AuthType.PLAIN,
username = "test",
password = "password",
clientCertificateAlias = null,
),
)
override fun setState(accountState: AccountState) = Unit
override fun setEmailAddress(emailAddress: String) = Unit
override fun setIncomingServerSettings(serverSettings: ServerSettings) = Unit
override fun setOutgoingServerSettings(serverSettings: ServerSettings) = Unit
override fun setAuthorizationState(authorizationState: AuthorizationState) = Unit
override fun setSpecialFolderSettings(specialFolderSettings: SpecialFolderSettings) = Unit
override fun setDisplayOptions(displayOptions: AccountDisplayOptions) = Unit
override fun setSyncOptions(syncOptions: AccountSyncOptions) = Unit
override fun clear() = Unit
}

View file

@ -0,0 +1,10 @@
package app.k9mail.feature.account.common
import app.k9mail.feature.account.common.domain.entity.AccountState
interface AccountCommonExternalContract {
fun interface AccountStateLoader {
suspend fun loadAccountState(accountUuid: String): AccountState?
}
}

View file

@ -0,0 +1,17 @@
package app.k9mail.feature.account.common
import app.k9mail.feature.account.common.data.InMemoryAccountStateRepository
import app.k9mail.feature.account.common.domain.AccountDomainContract
import com.fsck.k9.mail.oauth.AuthStateStorage
import net.thunderbird.core.common.coreCommonModule
import org.koin.core.module.Module
import org.koin.dsl.binds
import org.koin.dsl.module
val featureAccountCommonModule: Module = module {
includes(coreCommonModule)
single {
InMemoryAccountStateRepository()
}.binds(arrayOf(AccountDomainContract.AccountStateRepository::class, AuthStateStorage::class))
}

View file

@ -0,0 +1,64 @@
package app.k9mail.feature.account.common.data
import app.k9mail.feature.account.common.domain.AccountDomainContract
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings
import com.fsck.k9.mail.ServerSettings
import com.fsck.k9.mail.oauth.AuthStateStorage
@Suppress("TooManyFunctions")
class InMemoryAccountStateRepository(
private var state: AccountState = AccountState(),
) : AccountDomainContract.AccountStateRepository, AuthStateStorage {
override fun getState(): AccountState {
return state
}
override fun setState(accountState: AccountState) {
state = accountState
}
override fun setEmailAddress(emailAddress: String) {
state = state.copy(emailAddress = emailAddress)
}
override fun setIncomingServerSettings(serverSettings: ServerSettings) {
state = state.copy(incomingServerSettings = serverSettings)
}
override fun setOutgoingServerSettings(serverSettings: ServerSettings) {
state = state.copy(outgoingServerSettings = serverSettings)
}
override fun setAuthorizationState(authorizationState: AuthorizationState) {
state = state.copy(authorizationState = authorizationState)
}
override fun setSpecialFolderSettings(specialFolderSettings: SpecialFolderSettings) {
state = state.copy(specialFolderSettings = specialFolderSettings)
}
override fun setDisplayOptions(displayOptions: AccountDisplayOptions) {
state = state.copy(displayOptions = displayOptions)
}
override fun setSyncOptions(syncOptions: AccountSyncOptions) {
state = state.copy(syncOptions = syncOptions)
}
override fun clear() {
state = AccountState()
}
override fun getAuthorizationState(): String? {
return state.authorizationState?.value
}
override fun updateAuthorizationState(authorizationState: String?) {
state = state.copy(authorizationState = AuthorizationState(authorizationState))
}
}

View file

@ -0,0 +1,34 @@
package app.k9mail.feature.account.common.domain
import app.k9mail.feature.account.common.domain.entity.AccountDisplayOptions
import app.k9mail.feature.account.common.domain.entity.AccountState
import app.k9mail.feature.account.common.domain.entity.AccountSyncOptions
import app.k9mail.feature.account.common.domain.entity.AuthorizationState
import app.k9mail.feature.account.common.domain.entity.SpecialFolderSettings
import com.fsck.k9.mail.ServerSettings
interface AccountDomainContract {
@Suppress("TooManyFunctions")
interface AccountStateRepository {
fun getState(): AccountState
fun setState(accountState: AccountState)
fun setEmailAddress(emailAddress: String)
fun setIncomingServerSettings(serverSettings: ServerSettings)
fun setOutgoingServerSettings(serverSettings: ServerSettings)
fun setAuthorizationState(authorizationState: AuthorizationState)
fun setSpecialFolderSettings(specialFolderSettings: SpecialFolderSettings)
fun setDisplayOptions(displayOptions: AccountDisplayOptions)
fun setSyncOptions(syncOptions: AccountSyncOptions)
fun clear()
}
}

View file

@ -0,0 +1,13 @@
package app.k9mail.feature.account.common.domain.entity
import com.fsck.k9.mail.ServerSettings
data class Account(
val uuid: String,
val emailAddress: String,
val incomingServerSettings: ServerSettings,
val outgoingServerSettings: ServerSettings,
val authorizationState: String?,
val specialFolderSettings: SpecialFolderSettings?,
val options: AccountOptions,
)

View file

@ -0,0 +1,7 @@
package app.k9mail.feature.account.common.domain.entity
data class AccountDisplayOptions(
val accountName: String,
val displayName: String,
val emailSignature: String?,
)

View file

@ -0,0 +1,10 @@
package app.k9mail.feature.account.common.domain.entity
data class AccountOptions(
val accountName: String,
val displayName: String,
val emailSignature: String?,
val checkFrequencyInMinutes: Int,
val messageDisplayCount: Int,
val showNotification: Boolean,
)

View file

@ -0,0 +1,14 @@
package app.k9mail.feature.account.common.domain.entity
import com.fsck.k9.mail.ServerSettings
data class AccountState(
val uuid: String? = null,
val emailAddress: String? = null,
val incomingServerSettings: ServerSettings? = null,
val outgoingServerSettings: ServerSettings? = null,
val authorizationState: AuthorizationState? = null,
val specialFolderSettings: SpecialFolderSettings? = null,
val displayOptions: AccountDisplayOptions? = null,
val syncOptions: AccountSyncOptions? = null,
)

View file

@ -0,0 +1,7 @@
package app.k9mail.feature.account.common.domain.entity
data class AccountSyncOptions(
val checkFrequencyInMinutes: Int,
val messageDisplayCount: Int,
val showNotification: Boolean,
)

View file

@ -0,0 +1,58 @@
package app.k9mail.feature.account.common.domain.entity
import com.fsck.k9.mail.AuthType
import kotlinx.collections.immutable.toImmutableList
enum class AuthenticationType(
val isUsernameRequired: Boolean,
val isPasswordRequired: Boolean,
) {
None(
isUsernameRequired = false,
isPasswordRequired = false,
),
PasswordCleartext(
isUsernameRequired = true,
isPasswordRequired = true,
),
PasswordEncrypted(
isUsernameRequired = true,
isPasswordRequired = true,
),
ClientCertificate(
isUsernameRequired = true,
isPasswordRequired = false,
),
OAuth2(
isUsernameRequired = true,
isPasswordRequired = false,
),
;
companion object {
val DEFAULT = PasswordCleartext
fun all() = entries.toImmutableList()
fun outgoing() = all()
}
}
fun AuthenticationType.toAuthType(): AuthType {
return when (this) {
AuthenticationType.None -> AuthType.NONE
AuthenticationType.PasswordCleartext -> AuthType.PLAIN
AuthenticationType.PasswordEncrypted -> AuthType.CRAM_MD5
AuthenticationType.ClientCertificate -> AuthType.EXTERNAL
AuthenticationType.OAuth2 -> AuthType.XOAUTH2
}
}
fun AuthType.toAuthenticationType(): AuthenticationType {
return when (this) {
AuthType.PLAIN -> AuthenticationType.PasswordCleartext
AuthType.CRAM_MD5 -> AuthenticationType.PasswordEncrypted
AuthType.EXTERNAL -> AuthenticationType.ClientCertificate
AuthType.XOAUTH2 -> AuthenticationType.OAuth2
AuthType.NONE -> AuthenticationType.None
}
}

View file

@ -0,0 +1,5 @@
package app.k9mail.feature.account.common.domain.entity
data class AuthorizationState(
val value: String? = null,
)

View file

@ -0,0 +1,61 @@
package app.k9mail.feature.account.common.domain.entity
import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity.None
import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity.StartTLS
import app.k9mail.feature.account.common.domain.entity.ConnectionSecurity.TLS
import kotlinx.collections.immutable.toImmutableList
enum class ConnectionSecurity {
None,
StartTLS,
TLS,
;
companion object {
val DEFAULT = TLS
fun all() = entries.toImmutableList()
}
}
fun ConnectionSecurity.toMailConnectionSecurity(): MailConnectionSecurity {
return when (this) {
None -> MailConnectionSecurity.NONE
StartTLS -> MailConnectionSecurity.STARTTLS_REQUIRED
TLS -> MailConnectionSecurity.SSL_TLS_REQUIRED
}
}
fun MailConnectionSecurity.toConnectionSecurity(): ConnectionSecurity {
return when (this) {
MailConnectionSecurity.NONE -> None
MailConnectionSecurity.STARTTLS_REQUIRED -> StartTLS
MailConnectionSecurity.SSL_TLS_REQUIRED -> TLS
}
}
@Suppress("MagicNumber")
fun ConnectionSecurity.toSmtpDefaultPort(): Long {
return when (this) {
None -> 587
StartTLS -> 587
TLS -> 465
}
}
@Suppress("MagicNumber")
fun ConnectionSecurity.toImapDefaultPort(): Long {
return when (this) {
None -> 143
StartTLS -> 143
TLS -> 993
}
}
@Suppress("MagicNumber")
fun ConnectionSecurity.toPop3DefaultPort(): Long {
return when (this) {
None -> 110
StartTLS -> 110
TLS -> 995
}
}

View file

@ -0,0 +1,29 @@
package app.k9mail.feature.account.common.domain.entity
import kotlinx.collections.immutable.toImmutableList
enum class IncomingProtocolType(
val defaultName: String,
val defaultConnectionSecurity: ConnectionSecurity,
) {
IMAP("imap", ConnectionSecurity.TLS),
POP3("pop3", ConnectionSecurity.TLS),
;
companion object {
val DEFAULT = IMAP
fun all() = entries.toImmutableList()
fun fromName(name: String): IncomingProtocolType {
return entries.find { it.defaultName == name } ?: throw IllegalArgumentException("Unknown protocol: $name")
}
}
}
fun IncomingProtocolType.toDefaultPort(connectionSecurity: ConnectionSecurity): Long {
return when (this) {
IncomingProtocolType.IMAP -> connectionSecurity.toImapDefaultPort()
IncomingProtocolType.POP3 -> connectionSecurity.toPop3DefaultPort()
}
}

View file

@ -0,0 +1,9 @@
package app.k9mail.feature.account.common.domain.entity
/**
* Enum representing the mode a user is interacting with an account or setting.
*/
enum class InteractionMode {
Create,
Edit,
}

View file

@ -0,0 +1,3 @@
package app.k9mail.feature.account.common.domain.entity
typealias MailConnectionSecurity = com.fsck.k9.mail.ConnectionSecurity

View file

@ -0,0 +1,23 @@
package app.k9mail.feature.account.common.domain.entity
import kotlinx.collections.immutable.toImmutableList
enum class OutgoingProtocolType(
val defaultName: String,
val defaultConnectionSecurity: ConnectionSecurity,
) {
SMTP("smtp", ConnectionSecurity.TLS),
;
companion object {
val DEFAULT = SMTP
fun all() = entries.toImmutableList()
}
}
fun OutgoingProtocolType.toDefaultPort(connectionSecurity: ConnectionSecurity): Long {
return when (this) {
OutgoingProtocolType.SMTP -> connectionSecurity.toSmtpDefaultPort()
}
}

View file

@ -0,0 +1,18 @@
package app.k9mail.feature.account.common.domain.entity
import com.fsck.k9.mail.folders.RemoteFolder
sealed interface SpecialFolderOption {
data class None(
val isAutomatic: Boolean = false,
) : SpecialFolderOption
data class Regular(
val remoteFolder: RemoteFolder,
) : SpecialFolderOption
data class Special(
val isAutomatic: Boolean = false,
val remoteFolder: RemoteFolder,
) : SpecialFolderOption
}

View file

@ -0,0 +1,9 @@
package app.k9mail.feature.account.common.domain.entity
data class SpecialFolderOptions(
val archiveSpecialFolderOptions: List<SpecialFolderOption>,
val draftsSpecialFolderOptions: List<SpecialFolderOption>,
val sentSpecialFolderOptions: List<SpecialFolderOption>,
val spamSpecialFolderOptions: List<SpecialFolderOption>,
val trashSpecialFolderOptions: List<SpecialFolderOption>,
)

View file

@ -0,0 +1,9 @@
package app.k9mail.feature.account.common.domain.entity
data class SpecialFolderSettings(
val archiveSpecialFolderOption: SpecialFolderOption,
val draftsSpecialFolderOption: SpecialFolderOption,
val sentSpecialFolderOption: SpecialFolderOption,
val spamSpecialFolderOption: SpecialFolderOption,
val trashSpecialFolderOption: SpecialFolderOption,
)

View file

@ -0,0 +1,74 @@
package app.k9mail.feature.account.common.domain.input
import net.thunderbird.core.common.domain.usecase.validation.ValidationError
import net.thunderbird.core.common.domain.usecase.validation.ValidationResult
class BooleanInputField(
override val value: Boolean? = null,
override val error: ValidationError? = null,
override val isValid: Boolean = false,
) : InputField<Boolean?> {
override fun updateValue(value: Boolean?): BooleanInputField {
return BooleanInputField(
value = value,
error = null,
isValid = false,
)
}
override fun updateError(error: ValidationError?): BooleanInputField {
return BooleanInputField(
value = value,
error = error,
isValid = false,
)
}
override fun updateValidity(isValid: Boolean): BooleanInputField {
if (isValid == this.isValid) return this
return BooleanInputField(
value = value,
error = null,
isValid = isValid,
)
}
override fun updateFromValidationResult(result: ValidationResult): BooleanInputField {
return when (result) {
is ValidationResult.Success -> BooleanInputField(
value = value,
error = null,
isValid = true,
)
is ValidationResult.Failure -> BooleanInputField(
value = value,
error = result.error,
isValid = false,
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as BooleanInputField
if (value != other.value) return false
if (error != other.error) return false
return isValid == other.isValid
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + isValid.hashCode()
return result
}
override fun toString(): String {
return "BooleanInputField(value=$value, error=$error, isValid=$isValid)"
}
}

View file

@ -0,0 +1,48 @@
package app.k9mail.feature.account.common.domain.input
import net.thunderbird.core.common.domain.usecase.validation.ValidationError
import net.thunderbird.core.common.domain.usecase.validation.ValidationResult
/**
* InputField is an interface defining the state of an input field.
*
* @param T The type of the value the input field holds.
*/
interface InputField<T> {
val value: T
val error: ValidationError?
val isValid: Boolean
/**
* Updates the current value of the input field.
*
* @param value The new value to be set for the input field.
* @return a new InputField instance with the updated value.
*/
fun updateValue(value: T): InputField<T>
/**
* Updates the current error of the input field.
*
* @param error The new error to be set for the input field.
*/
fun updateError(error: ValidationError?): InputField<T>
/**
* Updates the current validity of the input field.
*
* @param isValid The new validity to be set for the input field.
*/
fun updateValidity(isValid: Boolean): InputField<T>
/**
* Checks if the input field currently has an error.
*
* @return a Boolean indicating whether the input field has an error.
*/
fun hasError(): Boolean {
return error != null
}
fun updateFromValidationResult(result: ValidationResult): InputField<T>
}

View file

@ -0,0 +1,75 @@
package app.k9mail.feature.account.common.domain.input
import net.thunderbird.core.common.domain.usecase.validation.ValidationError
import net.thunderbird.core.common.domain.usecase.validation.ValidationResult
class NumberInputField(
override val value: Long? = null,
override val error: ValidationError? = null,
override val isValid: Boolean = false,
) : InputField<Long?> {
override fun updateValue(value: Long?): NumberInputField {
return NumberInputField(
value = value,
error = null,
isValid = false,
)
}
override fun updateError(error: ValidationError?): NumberInputField {
return NumberInputField(
value = value,
error = error,
isValid = false,
)
}
override fun updateValidity(isValid: Boolean): NumberInputField {
if (isValid == this.isValid) return this
return NumberInputField(
value = value,
error = null,
isValid = isValid,
)
}
override fun updateFromValidationResult(result: ValidationResult): NumberInputField {
return when (result) {
is ValidationResult.Success -> NumberInputField(
value = value,
error = null,
isValid = true,
)
is ValidationResult.Failure -> NumberInputField(
value = value,
error = result.error,
isValid = false,
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as NumberInputField
if (value != other.value) return false
if (error != other.error) return false
return isValid == other.isValid
}
override fun hashCode(): Int {
var result = value?.hashCode() ?: 0
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + isValid.hashCode()
return result
}
override fun toString(): String {
return "NumberInputField(value=$value, error=$error, isValid=$isValid)"
}
}

View file

@ -0,0 +1,75 @@
package app.k9mail.feature.account.common.domain.input
import net.thunderbird.core.common.domain.usecase.validation.ValidationError
import net.thunderbird.core.common.domain.usecase.validation.ValidationResult
class StringInputField(
override val value: String = "",
override val error: ValidationError? = null,
override val isValid: Boolean = false,
) : InputField<String> {
override fun updateValue(value: String): StringInputField {
return StringInputField(
value = value,
error = null,
isValid = false,
)
}
override fun updateError(error: ValidationError?): StringInputField {
return StringInputField(
value = value,
error = error,
isValid = false,
)
}
override fun updateValidity(isValid: Boolean): StringInputField {
if (isValid == this.isValid) return this
return StringInputField(
value = value,
error = null,
isValid = isValid,
)
}
override fun updateFromValidationResult(result: ValidationResult): StringInputField {
return when (result) {
is ValidationResult.Success -> StringInputField(
value = value,
error = null,
isValid = true,
)
is ValidationResult.Failure -> StringInputField(
value = value,
error = result.error,
isValid = false,
)
}
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as StringInputField
if (value != other.value) return false
if (error != other.error) return false
return isValid == other.isValid
}
override fun hashCode(): Int {
var result = value.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + isValid.hashCode()
return result
}
override fun toString(): String {
return "StringInputField(value='$value', error=$error, isValid=$isValid)"
}
}

View file

@ -0,0 +1,19 @@
package app.k9mail.feature.account.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.k9mail.core.ui.compose.designsystem.organism.TopAppBar
/**
* Top app bar for the account screens.
*/
@Composable
fun AccountTopAppBar(
title: String,
modifier: Modifier = Modifier,
) {
TopAppBar(
title = title,
modifier = modifier,
)
}

View file

@ -0,0 +1,58 @@
package app.k9mail.feature.account.common.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import app.k9mail.core.ui.compose.designsystem.atom.text.TextDisplayMediumAutoResize
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
import app.k9mail.core.ui.compose.theme2.MainTheme
private const val TITLE_ICON_SIZE_DP = 56
@Composable
fun AppTitleTopHeader(
title: String,
modifier: Modifier = Modifier,
) {
ResponsiveWidthContainer(
modifier = Modifier
.fillMaxWidth()
.padding(
top = MainTheme.spacings.quadruple,
bottom = MainTheme.spacings.default,
)
.then(modifier),
) { contentPadding ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(
start = MainTheme.spacings.half,
end = MainTheme.spacings.quadruple,
)
.padding(contentPadding)
.then(modifier),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = MainTheme.images.logo),
modifier = Modifier
.padding(all = MainTheme.spacings.default)
.padding(end = MainTheme.spacings.default)
.size(TITLE_ICON_SIZE_DP.dp),
contentDescription = null,
)
TextDisplayMediumAutoResize(text = title)
}
}
}

View file

@ -0,0 +1,42 @@
package app.k9mail.feature.account.common.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
import app.k9mail.core.ui.compose.theme2.MainTheme
@Composable
fun ContentListView(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(),
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(MainTheme.spacings.default),
items: LazyListScope.() -> Unit,
) {
ResponsiveWidthContainer(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth()
.then(modifier),
) { contentPadding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.imePadding(),
contentPadding = contentPadding,
horizontalAlignment = horizontalAlignment,
verticalArrangement = verticalArrangement,
) {
items()
}
}
}

View file

@ -0,0 +1,10 @@
package app.k9mail.feature.account.common.ui
import app.k9mail.feature.account.common.domain.entity.InteractionMode
/**
* Interface for screens that can be used in different interaction modes.
*/
interface WithInteractionMode {
val mode: InteractionMode
}

View file

@ -0,0 +1,5 @@
package app.k9mail.feature.account.common.ui
object WizardConstants {
const val CONTINUE_NEXT_DELAY = 500L
}

View file

@ -0,0 +1,71 @@
package app.k9mail.feature.account.common.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonFilled
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonOutlined
import app.k9mail.core.ui.compose.designsystem.template.ResponsiveWidthContainer
import app.k9mail.core.ui.compose.theme2.MainTheme
import app.k9mail.feature.account.common.R
import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
@Composable
fun WizardNavigationBar(
onNextClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
nextButtonText: String = stringResource(id = R.string.account_common_button_next),
backButtonText: String = stringResource(id = R.string.account_common_button_back),
state: WizardNavigationBarState = WizardNavigationBarState(),
) {
ResponsiveWidthContainer(
modifier = Modifier
.fillMaxWidth()
.then(modifier),
) { contentPadding ->
Row(
modifier = Modifier
.padding(
start = MainTheme.spacings.quadruple,
top = MainTheme.spacings.default,
end = MainTheme.spacings.quadruple,
bottom = MainTheme.spacings.double,
)
.padding(contentPadding)
.fillMaxWidth(),
horizontalArrangement = getHorizontalArrangement(state),
) {
if (state.showBack) {
ButtonOutlined(
text = backButtonText,
onClick = onBackClick,
enabled = state.isBackEnabled,
modifier = Modifier.testTagAsResourceId("account_setup_back_button"),
)
}
if (state.showNext) {
ButtonFilled(
text = nextButtonText,
onClick = onNextClick,
enabled = state.isNextEnabled,
modifier = Modifier.testTagAsResourceId("account_setup_next_button"),
)
}
}
}
}
private fun getHorizontalArrangement(state: WizardNavigationBarState): Arrangement.Horizontal {
return if (state.showNext && state.showBack) {
Arrangement.SpaceBetween
} else if (state.showNext) {
Arrangement.End
} else {
Arrangement.Start
}
}

View file

@ -0,0 +1,8 @@
package app.k9mail.feature.account.common.ui
data class WizardNavigationBarState(
val isNextEnabled: Boolean = true,
val showNext: Boolean = true,
val isBackEnabled: Boolean = true,
val showBack: Boolean = true,
)

View file

@ -0,0 +1,24 @@
package app.k9mail.feature.account.common.ui.item
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.k9mail.core.ui.compose.designsystem.molecule.ErrorView
@Composable
fun LazyItemScope.ErrorItem(
title: String,
modifier: Modifier = Modifier,
message: String? = null,
onRetry: () -> Unit = { },
) {
ListItem(
modifier = modifier,
) {
ErrorView(
title = title,
message = message,
onRetry = onRetry,
)
}
}

View file

@ -0,0 +1,19 @@
package app.k9mail.feature.account.common.ui.item
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import app.k9mail.core.ui.compose.theme2.MainTheme
@Composable
fun defaultHeadlineItemPadding() = PaddingValues(
start = MainTheme.spacings.quadruple,
top = MainTheme.spacings.triple,
end = MainTheme.spacings.quadruple,
bottom = MainTheme.spacings.default,
)
@Composable
fun defaultItemPadding() = PaddingValues(
horizontal = MainTheme.spacings.quadruple,
vertical = MainTheme.spacings.zero,
)

View file

@ -0,0 +1,26 @@
package app.k9mail.feature.account.common.ui.item
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun LazyItemScope.ListItem(
modifier: Modifier = Modifier,
contentPaddingValues: PaddingValues = defaultItemPadding(),
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier
.padding(contentPaddingValues)
.animateItem()
.fillMaxWidth()
.then(modifier),
) {
content()
}
}

View file

@ -0,0 +1,20 @@
package app.k9mail.feature.account.common.ui.item
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import app.k9mail.core.ui.compose.designsystem.molecule.LoadingView
@Composable
fun LazyItemScope.LoadingItem(
modifier: Modifier = Modifier,
message: String? = null,
) {
ListItem(
modifier = modifier,
) {
LoadingView(
message = message,
)
}
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_error_server_message">قام الخادم بإرجاع الرسالة التالية:\n%s</string>
<string name="account_common_button_next">التالي</string>
<string name="account_common_button_back">السابق</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Növbəti</string>
<string name="account_common_button_back">Geri</string>
<string name="account_common_error_server_message">Server aşağıdakı məlumatı verdi: \n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Далей</string>
<string name="account_common_button_back">Назад</string>
<string name="account_common_error_server_message">Сервер вярнуў наступнае паведамленне:\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Напред</string>
<string name="account_common_button_back">Назад</string>
<string name="account_common_error_server_message">Сървърът връща следното съобщение\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_back">পিছনে</string>
<string name="account_common_error_server_message">সার্ভার এই বার্তাটি পাঠিয়েছে:\n%s</string>
<string name="account_common_button_next">পরবর্তী</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Da heul</string>
<string name="account_common_button_back">Kent</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Sljedeći</string>
<string name="account_common_button_back">Nazad</string>
<string name="account_common_error_server_message">Server je odgovorio ovom porukom:
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Següent</string>
<string name="account_common_button_back">Enrere</string>
<string name="account_common_error_server_message">El servidor ha retornat en següent missatge:
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Seguente</string>
<string name="account_common_button_back">Ritornu</string>
<string name="account_common_error_server_message">U servitore hà mandatu quessu messaghju :
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Další</string>
<string name="account_common_button_back">Zpět</string>
<string name="account_common_error_server_message">Server vrátil následující zprávu:
\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Nesaf</string>
<string name="account_common_button_back">Nôl</string>
<string name="account_common_error_server_message">Dychwelodd y gweinydd y negae ganlynol:\n%s</string>
</resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Næste</string>
<string name="account_common_button_back">Tilbage</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Weiter</string>
<string name="account_common_button_back">Zurück</string>
<string name="account_common_error_server_message">Der Server hat die folgende Nachricht zurückgegeben:\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Επόμενο</string>
<string name="account_common_button_back">Πίσω</string>
<string name="account_common_error_server_message">Ο διακομιστής επέστρεψε το ακόλουθο μήνυμα:
\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Next</string>
<string name="account_common_button_back">Back</string>
<string name="account_common_error_server_message">The server returned the following message:\n%s</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_error_server_message">La servilo sendis la jenan mesaĝon:
\n%s</string>
<string name="account_common_button_next">Sekven</string>
<string name="account_common_button_back">Reen</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Siguiente</string>
<string name="account_common_button_back">Atrás</string>
<string name="account_common_error_server_message">El servidor devolvió el siguiente mensaje:
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Edasi</string>
<string name="account_common_button_back">Tagasi</string>
<string name="account_common_error_server_message">Serveri vastuses sisaldus selline sõnum:
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Hurrengoa</string>
<string name="account_common_button_back">Aurrekoa</string>
<string name="account_common_error_server_message">Zerbitzariak mezu hau itzuli du:
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">بعدی</string>
<string name="account_common_button_back">بازگشت</string>
<string name="account_common_error_server_message">کارساز، پیام زیر را برگردانده است:
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Seuraava</string>
<string name="account_common_button_back">Takaisin</string>
<string name="account_common_error_server_message">Palvelin palautti seuraavan viestin:
\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Suivant</string>
<string name="account_common_button_back">Précédent</string>
<string name="account_common_error_server_message">Le message suivant a été retourné par le serveur :\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Folgjende</string>
<string name="account_common_button_back">Tebek</string>
<string name="account_common_error_server_message">De server joech it folgjende berjocht werom:\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Ar aghaidh</string>
<string name="account_common_button_back">Ar ais</string>
<string name="account_common_error_server_message">Chuir an freastalaí an teachtaireacht seo a leanas ar ais:\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_error_server_message">Thill am frithealaiche an teachdaireachd a leanas:\n%s</string>
<string name="account_common_button_next">Air adhart</string>
<string name="account_common_button_back">Air ais</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">seguinte</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">अगला</string>
<string name="account_common_button_back">पिछला</string>
<string name="account_common_error_server_message">सेवक ने निम्न संदेश लौटायाः\n%s</string>
</resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Sljedeće</string>
<string name="account_common_button_back">Natrag</string>
<string name="account_common_error_server_message">Poslužitelj je vratio sljedeću poruku:\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Következő</string>
<string name="account_common_button_back">Vissza</string>
<string name="account_common_error_server_message">A kiszolgáló a következő üzenetet küldte vissza:
\n%s</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_error_server_message">Peladen mengembalikan pesan berikut ini:\n%s</string>
<string name="account_common_button_next">Berikutnya</string>
<string name="account_common_button_back">Kembali</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Næsta</string>
<string name="account_common_button_back">Til baka</string>
<string name="account_common_error_server_message">Póstþjónninn svaraði með eftirfarandi skilaboðum:
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Successivo</string>
<string name="account_common_button_back">Precedente</string>
<string name="account_common_error_server_message">Il server ha restituito il seguente messaggio:
\n%s</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_error_server_message">השרת החזיר את השגיאה הבאה:
\n%s</string>
<string name="account_common_button_next">הבא</string>
<string name="account_common_button_back">הקודם</string>
</resources>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">次へ</string>
<string name="account_common_button_back">戻る</string>
<string name="account_common_error_server_message">サーバーから以下のメッセージが返答されました:
\n%s</string>
</resources>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="account_common_button_next">Uḍfir</string>
<string name="account_common_button_back">Uɣal</string>
</resources>

Some files were not shown because too many files have changed in this diff Show more