mirror of
https://github.com/thunderbird/thunderbird-android.git
synced 2024-09-19 19:52:14 +02:00
Merge pull request #8136 from wmontwe/add-drawer-account-top-view
Add drawer account view
This commit is contained in:
commit
c05533a26f
@ -113,4 +113,4 @@ private fun ColorScheme.toDynamicThemeColorScheme(
|
||||
)
|
||||
}
|
||||
|
||||
private fun Color.toHarmonizedColor(target: Color) = Color(MaterialColors.harmonize(toArgb(), target.toArgb()))
|
||||
fun Color.toHarmonizedColor(target: Color) = Color(MaterialColors.harmonize(toArgb(), target.toArgb()))
|
||||
|
@ -3,11 +3,30 @@ package app.k9mail.feature.navigation.drawer.ui
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.designsystem.PreviewWithTheme
|
||||
import app.k9mail.feature.navigation.drawer.ui.account.FakeData.DISPLAY_ACCOUNT
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun DrawerContentPreview() {
|
||||
PreviewWithTheme {
|
||||
DrawerContent()
|
||||
DrawerContent(
|
||||
state = DrawerContract.State(
|
||||
accounts = emptyList(),
|
||||
currentAccount = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
fun DrawerContentWithAccountPreview() {
|
||||
PreviewWithTheme {
|
||||
DrawerContent(
|
||||
state = DrawerContract.State(
|
||||
accounts = listOf(DISPLAY_ACCOUNT),
|
||||
currentAccount = DISPLAY_ACCOUNT,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,43 @@
|
||||
package app.k9mail.feature.navigation.drawer.ui.account
|
||||
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
|
||||
import app.k9mail.core.ui.compose.theme2.MainTheme
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun AccountIndicatorPreview() {
|
||||
PreviewWithThemes {
|
||||
AccountIndicator(
|
||||
accountColor = 0,
|
||||
modifier = Modifier.height(MainTheme.spacings.double),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun AccountIndicatorPreviewWithYellowAccountColor() {
|
||||
PreviewWithThemes {
|
||||
AccountIndicator(
|
||||
accountColor = Color.Yellow.toArgb(),
|
||||
modifier = Modifier.height(MainTheme.spacings.double),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun AccountIndicatorPreviewWithGrayAccountColor() {
|
||||
PreviewWithThemes {
|
||||
AccountIndicator(
|
||||
accountColor = Color.Gray.toArgb(),
|
||||
modifier = Modifier.height(MainTheme.spacings.double),
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
package app.k9mail.feature.navigation.drawer.ui.account
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import app.k9mail.core.ui.compose.designsystem.PreviewWithThemes
|
||||
import app.k9mail.feature.navigation.drawer.ui.account.FakeData.DISPLAY_NAME
|
||||
import app.k9mail.feature.navigation.drawer.ui.account.FakeData.EMAIL_ADDRESS
|
||||
import app.k9mail.feature.navigation.drawer.ui.account.FakeData.LONG_TEXT
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun AccountViewPreview() {
|
||||
PreviewWithThemes {
|
||||
AccountView(
|
||||
displayName = DISPLAY_NAME,
|
||||
emailAddress = EMAIL_ADDRESS,
|
||||
accountColor = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun AccountViewWithColorPreview() {
|
||||
PreviewWithThemes {
|
||||
AccountView(
|
||||
displayName = DISPLAY_NAME,
|
||||
emailAddress = EMAIL_ADDRESS,
|
||||
accountColor = 0xFF0000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun AccountViewWithLongDisplayName() {
|
||||
PreviewWithThemes {
|
||||
AccountView(
|
||||
displayName = "$LONG_TEXT $DISPLAY_NAME",
|
||||
emailAddress = EMAIL_ADDRESS,
|
||||
accountColor = 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview(showBackground = true)
|
||||
internal fun AccountViewWithLongEmailPreview() {
|
||||
PreviewWithThemes {
|
||||
AccountView(
|
||||
displayName = DISPLAY_NAME,
|
||||
emailAddress = "$LONG_TEXT@example.com",
|
||||
accountColor = 0,
|
||||
)
|
||||
}
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package app.k9mail.feature.navigation.drawer.ui.account
|
||||
|
||||
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
|
||||
import app.k9mail.legacy.account.Account
|
||||
import app.k9mail.legacy.account.Identity
|
||||
|
||||
internal object FakeData {
|
||||
|
||||
const val ACCOUNT_UUID = "uuid"
|
||||
const val DISPLAY_NAME = "Account Name"
|
||||
const val EMAIL_ADDRESS = "test@example.com"
|
||||
|
||||
const val LONG_TEXT = "loremipsumdolorsitametconsetetursadipscingelitr" +
|
||||
"seddiamnonumyeirmodtemporinviduntutlaboreetdoloremagnaaliquyameratseddiamvoluptua"
|
||||
|
||||
val ACCOUNT = Account(
|
||||
uuid = ACCOUNT_UUID,
|
||||
).apply {
|
||||
identities = ArrayList()
|
||||
|
||||
val identity = Identity(
|
||||
signatureUse = false,
|
||||
signature = "",
|
||||
description = "",
|
||||
)
|
||||
identities.add(identity)
|
||||
|
||||
name = DISPLAY_NAME
|
||||
email = EMAIL_ADDRESS
|
||||
}
|
||||
|
||||
val DISPLAY_ACCOUNT = DisplayAccount(
|
||||
account = ACCOUNT,
|
||||
unreadMessageCount = 0,
|
||||
starredMessageCount = 0,
|
||||
)
|
||||
}
|
@ -39,5 +39,9 @@ val navigationDrawerModule: Module = module {
|
||||
)
|
||||
}
|
||||
|
||||
viewModel { DrawerViewModel() }
|
||||
viewModel {
|
||||
DrawerViewModel(
|
||||
getDisplayAccounts = get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ interface DomainContract {
|
||||
|
||||
interface UseCase {
|
||||
fun interface GetDisplayAccounts {
|
||||
fun execute(): Flow<List<DisplayAccount>>
|
||||
operator fun invoke(): Flow<List<DisplayAccount>>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -27,7 +27,7 @@ class GetDisplayAccounts(
|
||||
) : UseCase.GetDisplayAccounts {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun execute(): Flow<List<DisplayAccount>> {
|
||||
override fun invoke(): Flow<List<DisplayAccount>> {
|
||||
return accountManager.getAccountsFlow()
|
||||
.flatMapLatest { accounts ->
|
||||
val messageCountsFlows: List<Flow<MessageCounts>> = accounts.map { account ->
|
||||
|
@ -9,5 +9,5 @@ import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
|
||||
class AccountsViewModel(
|
||||
getDisplayAccounts: UseCase.GetDisplayAccounts,
|
||||
) : ViewModel() {
|
||||
val displayAccountsLiveData: LiveData<List<DisplayAccount>> = getDisplayAccounts.execute().asLiveData()
|
||||
val displayAccountsLiveData: LiveData<List<DisplayAccount>> = getDisplayAccounts().asLiveData()
|
||||
}
|
||||
|
@ -1,17 +1,23 @@
|
||||
package app.k9mail.feature.navigation.drawer.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.DividerHorizontal
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.Surface
|
||||
import app.k9mail.core.ui.compose.designsystem.organism.drawer.NavigationDrawerItem
|
||||
import app.k9mail.core.ui.compose.theme2.MainTheme
|
||||
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
|
||||
import app.k9mail.feature.navigation.drawer.ui.account.AccountView
|
||||
|
||||
@Composable
|
||||
fun DrawerContent(
|
||||
state: State,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
@ -19,12 +25,26 @@ fun DrawerContent(
|
||||
.fillMaxSize()
|
||||
.testTag("DrawerContent"),
|
||||
) {
|
||||
LazyColumn(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(
|
||||
vertical = MainTheme.spacings.oneHalf,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.default),
|
||||
) {
|
||||
state.currentAccount?.let {
|
||||
AccountView(
|
||||
displayName = it.account.displayName,
|
||||
emailAddress = it.account.email,
|
||||
accountColor = it.account.chipColor,
|
||||
)
|
||||
|
||||
DividerHorizontal()
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
NavigationDrawerItem(
|
||||
@ -50,3 +70,4 @@ fun DrawerContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,15 @@
|
||||
package app.k9mail.feature.navigation.drawer.ui
|
||||
|
||||
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
|
||||
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
|
||||
|
||||
interface DrawerContract {
|
||||
|
||||
interface ViewModel : UnidirectionalViewModel<State, Event, Effect>
|
||||
|
||||
data class State(
|
||||
val currentAccount: DisplayAccount? = null,
|
||||
val accounts: List<DisplayAccount> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
|
@ -15,6 +15,8 @@ fun DrawerView(
|
||||
isRefreshing = state.value.isLoading,
|
||||
onRefresh = { dispatch(DrawerContract.Event.OnRefresh) },
|
||||
) {
|
||||
DrawerContent()
|
||||
DrawerContent(
|
||||
state = state.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,8 @@ package app.k9mail.feature.navigation.drawer.ui
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import app.k9mail.core.ui.compose.common.mvi.BaseViewModel
|
||||
import app.k9mail.feature.navigation.drawer.domain.DomainContract.UseCase
|
||||
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
|
||||
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Effect
|
||||
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event
|
||||
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
|
||||
@ -11,12 +13,35 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
class DrawerViewModel(
|
||||
private val getDisplayAccounts: UseCase.GetDisplayAccounts,
|
||||
initialState: State = State(),
|
||||
) : BaseViewModel<State, Event, Effect>(
|
||||
initialState = initialState,
|
||||
),
|
||||
ViewModel {
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
getDisplayAccounts().collect { accounts -> updateAccounts(accounts) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAccounts(accounts: List<DisplayAccount>) {
|
||||
val currentAccountUuid = state.value.currentAccount?.account?.uuid
|
||||
val isCurrentAccountAvailable = accounts.any { currentAccountUuid == it.account.uuid }
|
||||
|
||||
updateState {
|
||||
if (isCurrentAccountAvailable) {
|
||||
it.copy(accounts = accounts)
|
||||
} else {
|
||||
it.copy(
|
||||
currentAccount = accounts.firstOrNull(),
|
||||
accounts = accounts,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun event(event: Event) {
|
||||
when (event) {
|
||||
Event.OnRefresh -> refresh()
|
||||
|
@ -0,0 +1,32 @@
|
||||
package app.k9mail.feature.navigation.drawer.ui.account
|
||||
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.Surface
|
||||
import app.k9mail.core.ui.compose.theme2.MainTheme
|
||||
import app.k9mail.core.ui.compose.theme2.toHarmonizedColor
|
||||
|
||||
@Composable
|
||||
fun AccountIndicator(
|
||||
accountColor: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val color = if (accountColor == 0) {
|
||||
MainTheme.colors.primary
|
||||
} else {
|
||||
Color(accountColor).toHarmonizedColor(MainTheme.colors.surface)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(MainTheme.spacings.half)
|
||||
.defaultMinSize(
|
||||
minHeight = MainTheme.spacings.default,
|
||||
),
|
||||
color = color,
|
||||
shape = MainTheme.shapes.medium,
|
||||
) {}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
package app.k9mail.feature.navigation.drawer.ui.account
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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 app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyLarge
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.text.TextBodyMedium
|
||||
import app.k9mail.core.ui.compose.theme2.MainTheme
|
||||
|
||||
@Composable
|
||||
fun AccountView(
|
||||
displayName: String,
|
||||
emailAddress: String,
|
||||
accountColor: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(intrinsicSize = IntrinsicSize.Max)
|
||||
.padding(
|
||||
top = MainTheme.spacings.default,
|
||||
start = MainTheme.spacings.double,
|
||||
end = MainTheme.spacings.triple,
|
||||
bottom = MainTheme.spacings.oneHalf,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AccountIndicator(
|
||||
accountColor = accountColor,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.padding(
|
||||
end = MainTheme.spacings.default,
|
||||
),
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(MainTheme.spacings.half),
|
||||
) {
|
||||
TextBodyLarge(
|
||||
text = displayName,
|
||||
color = MainTheme.colors.onSurface,
|
||||
)
|
||||
TextBodyMedium(
|
||||
text = emailAddress,
|
||||
color = MainTheme.colors.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -2,12 +2,18 @@ package app.k9mail.feature.navigation.drawer.ui
|
||||
|
||||
import app.k9mail.core.ui.compose.testing.MainDispatcherRule
|
||||
import app.k9mail.core.ui.compose.testing.mvi.eventStateTest
|
||||
import app.k9mail.feature.navigation.drawer.domain.entity.DisplayAccount
|
||||
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.Event
|
||||
import app.k9mail.feature.navigation.drawer.ui.DrawerContract.State
|
||||
import app.k9mail.legacy.account.Account
|
||||
import app.k9mail.legacy.account.Identity
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import kotlin.test.Test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
@ -18,10 +24,10 @@ class DrawerViewModelTest {
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val testSubject = DrawerViewModel()
|
||||
|
||||
@Test
|
||||
fun `should change loading state when OnRefresh event is received`() = runTest {
|
||||
val testSubject = createTestSubject()
|
||||
|
||||
eventStateTest(
|
||||
viewModel = testSubject,
|
||||
initialState = State(isLoading = false),
|
||||
@ -34,4 +40,98 @@ class DrawerViewModelTest {
|
||||
|
||||
assertThat(testSubject.state.value.isLoading).isEqualTo(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should collect display accounts when created and select first as current`() = runTest {
|
||||
val displayAccounts = createDisplayAccountList(3)
|
||||
val getDisplayAccountsFlow = MutableStateFlow(displayAccounts)
|
||||
val testSubject = createTestSubject(
|
||||
getDisplayAccountsFlow = getDisplayAccountsFlow,
|
||||
)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(testSubject.state.value.accounts.size).isEqualTo(displayAccounts.size)
|
||||
assertThat(testSubject.state.value.accounts).isEqualTo(displayAccounts)
|
||||
assertThat(testSubject.state.value.currentAccount).isEqualTo(displayAccounts.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should reselect current account when old not present anymore`() = runTest {
|
||||
val displayAccounts = createDisplayAccountList(3)
|
||||
val getDisplayAccountsFlow = MutableStateFlow(displayAccounts)
|
||||
val testSubject = createTestSubject(
|
||||
getDisplayAccountsFlow = getDisplayAccountsFlow,
|
||||
)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
val newDisplayAccounts = displayAccounts.drop(1)
|
||||
getDisplayAccountsFlow.emit(newDisplayAccounts)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(testSubject.state.value.accounts.size).isEqualTo(newDisplayAccounts.size)
|
||||
assertThat(testSubject.state.value.accounts).isEqualTo(newDisplayAccounts)
|
||||
assertThat(testSubject.state.value.currentAccount).isEqualTo(newDisplayAccounts.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should set current account to null when no accounts are present`() = runTest {
|
||||
val getDisplayAccountsFlow = MutableStateFlow(emptyList<DisplayAccount>())
|
||||
val testSubject = createTestSubject(
|
||||
getDisplayAccountsFlow = getDisplayAccountsFlow,
|
||||
)
|
||||
|
||||
advanceUntilIdle()
|
||||
|
||||
assertThat(testSubject.state.value.accounts.size).isEqualTo(0)
|
||||
assertThat(testSubject.state.value.currentAccount).isEqualTo(null)
|
||||
}
|
||||
|
||||
private fun createTestSubject(
|
||||
getDisplayAccountsFlow: Flow<List<DisplayAccount>> = flow { emit(emptyList()) },
|
||||
): DrawerViewModel {
|
||||
return DrawerViewModel(
|
||||
getDisplayAccounts = { getDisplayAccountsFlow },
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDisplayAccount(
|
||||
uuid: String = "uuid",
|
||||
name: String = "name",
|
||||
email: String = "test@example.com",
|
||||
unreadCount: Int = 0,
|
||||
starredCount: Int = 0,
|
||||
): DisplayAccount {
|
||||
val account = Account(
|
||||
uuid = uuid,
|
||||
).also {
|
||||
it.identities = ArrayList()
|
||||
|
||||
val identity = Identity(
|
||||
signatureUse = false,
|
||||
signature = "",
|
||||
description = "",
|
||||
)
|
||||
it.identities.add(identity)
|
||||
|
||||
it.name = name
|
||||
it.email = email
|
||||
}
|
||||
|
||||
return DisplayAccount(
|
||||
account = account,
|
||||
unreadMessageCount = unreadCount,
|
||||
starredMessageCount = starredCount,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createDisplayAccountList(count: Int): List<DisplayAccount> {
|
||||
return List(count) { index ->
|
||||
createDisplayAccount(
|
||||
uuid = "uuid-$index",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user