<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/commontask/components/CommonTaskTags.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.commontask.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
import com.google.accompanist.flowlayout.FlowCrossAxisAlignment
import com.google.accompanist.flowlayout.FlowRow
import com.vanpra.composematerialdialogs.color.ColorPalette
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CommonTaskExtended
import io.eugenethedev.taigamobile.domain.entities.Tag
import io.eugenethedev.taigamobile.ui.components.Chip
import io.eugenethedev.taigamobile.ui.components.buttons.AddButton
import io.eugenethedev.taigamobile.ui.components.editors.TextFieldWithHint
import io.eugenethedev.taigamobile.ui.components.pickers.ColorPicker
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
import io.eugenethedev.taigamobile.ui.theme.dialogTonalElevation
import io.eugenethedev.taigamobile.ui.theme.shapes
import io.eugenethedev.taigamobile.ui.utils.surfaceColorAtElevation
import io.eugenethedev.taigamobile.ui.utils.textColor
import io.eugenethedev.taigamobile.ui.utils.toColor
import io.eugenethedev.taigamobile.ui.utils.toHex

fun LazyListScope.CommonTaskTags(
    commonTask: CommonTaskExtended,
    editActions: EditActions
) {
    item {
        FlowRow(
            crossAxisAlignment = FlowCrossAxisAlignment.Center,
            mainAxisSpacing = 8.dp,
            crossAxisSpacing = 8.dp
        ) {
            var isAddTagFieldVisible by remember { mutableStateOf(false) }

            commonTask.tags.forEach {
                TagItem(
                    tag = it,
                    onRemoveClick = { editActions.editTags.removeItem(it) }
                )
            }

            when {
                editActions.editTags.isResultLoading -> CircularProgressIndicator(
                    modifier = Modifier.size(28.dp),
                    strokeWidth = 2.dp,
                    color = MaterialTheme.colorScheme.primary
                )
                isAddTagFieldVisible -> AddTagField(
                    tags = editActions.editTags.items,
                    onInputChange = editActions.editTags.searchItems,
                    onSaveClick = { editActions.editTags.selectItem(it) }
                )
                else -> AddButton(
                    text = stringResource(R.string.add_tag),
                    onClick = { isAddTagFieldVisible = true }
                )
            }
        }
    }
}

@Composable
private fun TagItem(
    tag: Tag,
    onRemoveClick: () -> Unit
) {
    val bgColor = tag.color.toColor()
    val textColor = bgColor.textColor()

    Chip(
        color = bgColor,
        modifier = Modifier.padding(end = 4.dp, bottom = 4.dp)
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text(
                text = tag.name,
                color = textColor
            )

            Spacer(Modifier.width(2.dp))

            IconButton(
                onClick = onRemoveClick,
                modifier = Modifier.size(26.dp).clip(CircleShape)
            ) {
                Icon(
                    painter = painterResource(R.drawable.ic_remove),
                    contentDescription = null,
                    tint = textColor
                )
            }
        }
    }
}

@Composable
private fun AddTagField(
    tags: List<Tag>,
    onInputChange: (String) -> Unit,
    onSaveClick: (Tag) -> Unit
) = Row(
    verticalAlignment = Alignment.CenterVertically
) {
    var value by remember { mutableStateOf(TextFieldValue()) }
    var color by remember { mutableStateOf(ColorPalette.Primary.first()) }

    Column {
        TextFieldWithHint(
            hintId = R.string.tag,
            value = value,
            onValueChange = {
                value = it
                onInputChange(it.text)
            },
            width = 180.dp,
            hasBorder = true,
            singleLine = true
        )

        DropdownMenu(
            expanded = tags.isNotEmpty(),
            onDismissRequest = {},
            properties = PopupProperties(clippingEnabled = false),
            modifier = Modifier.heightIn(max = 200.dp)
                .background(MaterialTheme.colorScheme.surfaceColorAtElevation(dialogTonalElevation))
        ) {
            tags.forEach {
                DropdownMenuItem(onClick = { onSaveClick(it) }) {
                    Spacer(
                        Modifier.size(22.dp)
                            .background(
                                color = it.color.toColor(),
                                shape = shapes.small
                            )
                    )

                    Spacer(Modifier.width(4.dp))

                    Text(
                        text = it.name,
                        style = MaterialTheme.typography.bodyLarge
                    )
                }
            }
        }
    }

    Spacer(Modifier.width(4.dp))

    ColorPicker(
        size = 32.dp,
        color = color,
        onColorPicked = { color = it }
    )

    Spacer(Modifier.width(2.dp))

    IconButton(
        onClick = {
            value.text.takeIf { it.isNotEmpty() }?.let {
                onSaveClick(Tag(it, color.toHex()))
                value = TextFieldValue()
            }
        },
        modifier = Modifier.size(32.dp).clip(CircleShape)
    ) {
        Icon(
            painter = painterResource(R.drawable.ic_save),
            contentDescription = null,
            tint = MaterialTheme.colorScheme.outline
        )
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/buttons/AddButton.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.buttons

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material3.*
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 io.eugenethedev.taigamobile.R

/**
 * Text button with plus icon on the left
 */
@Composable
fun AddButton(
    text: String,
    onClick: () -> Unit
) = FilledTonalButton(onClick = onClick) {
    Row(verticalAlignment = Alignment.CenterVertically) {
        Icon(
            painter = painterResource(R.drawable.ic_add),
            contentDescription = null
        )

        Spacer(Modifier.width(6.dp))

        Text(text)
    }
}



<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/theme/Colors.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.theme

import androidx.compose.ui.graphics.Color

val taigaGreen = Color(0xFF25A28C)
val taigaGreenDark = Color(0xFF00796D)
val taigaGreenLight = Color(0xFF2CC0A6)

val taigaDarkBackground = Color(red = 27, green = 31, blue = 29)
val taigaLightBackground = Color(red = 251, green = 255, blue = 253)

val taigaOrange = Color(0xFFFF9900)
val taigaGreenPositive = Color(0xFF9DCE0A)
val taigaRed = Color(0xFFE44057)


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/lists/SimpleTasksListWithTitle.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.lists

import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemsIndexed as itemsIndexedLazy
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.components.texts.SectionTitle
import io.eugenethedev.taigamobile.ui.utils.NavigateToTask

/**
 * List of tasks with optional title.
 */
fun LazyListScope.SimpleTasksListWithTitle(
    navigateToTask: NavigateToTask,
    commonTasks: List<CommonTask> = emptyList(),
    commonTasksLazy: LazyPagingItems<CommonTask>? = null,
    keysHash: Int = 0,
    @StringRes titleText: Int? = null,
    topPadding: Dp = 0.dp,
    horizontalPadding: Dp = 0.dp,
    bottomPadding: Dp = 0.dp,
    isTasksLoading: Boolean = false,
    showExtendedTaskInfo: Boolean = false,
    navigateToCreateCommonTask: (() -> Unit)? = null
) {
    val isLoading = commonTasksLazy
        ?.run { loadState.refresh is LoadState.Loading || loadState.append is LoadState.Loading }
        ?: isTasksLoading

    val lastIndex = commonTasksLazy?.itemCount?.minus(1) ?: commonTasks.lastIndex

    val itemContent: @Composable LazyItemScope.(Int, CommonTask?) -> Unit = lambda@ { index, item ->
        if (item == null) return@lambda

        CommonTaskItem(
            commonTask = item,
            horizontalPadding = horizontalPadding,
            navigateToTask = navigateToTask,
            showExtendedInfo = showExtendedTaskInfo
        )

        if (index < lastIndex) {
            Divider(
                modifier = Modifier.padding(vertical = 4.dp, horizontal = horizontalPadding),
                color = MaterialTheme.colorScheme.outline
            )
        }
    }

    item {
        Spacer(Modifier.height(topPadding))
    }

    titleText?.let {
        item {
            SectionTitle(
                text = stringResource(it),
                horizontalPadding = horizontalPadding,
                onAddClick = navigateToCreateCommonTask
            )
        }
    }

    commonTasksLazy?.let {
            itemsIndexedLazy(
                items = it,
                key = { _, item -> item.id + keysHash },
                itemContent = itemContent
            )
    } ?: itemsIndexed(commonTasks, itemContent = itemContent)

    item {
        if (isLoading) {
            DotsLoader()
        }
        Spacer(Modifier.height(bottomPadding))
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/utils/TestUtils.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels.utils

import androidx.paging.compose.LazyPagingItems
import io.eugenethedev.taigamobile.domain.paging.CommonPagingSource
import io.eugenethedev.taigamobile.ui.utils.Result
import io.mockk.MockKAnswerScope
import io.mockk.MockKMatcherScope
import io.mockk.coEvery
import io.mockk.mockkClass
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response
import kotlin.math.min
import kotlin.reflect.KClass
import kotlin.test.assertEquals
import kotlin.test.assertFails


val accessDeniedException = HttpException(Response.error<String>(403, "".toResponseBody(null)))
val notFoundException = HttpException(Response.error<String>(404, "".toResponseBody(null)))
val createDeniedException = HttpException(Response.error<String>(405, "".toResponseBody(null)))

fun <T> assertResultEquals(expected: Result<T>, actual: Result<T>) {
    assertEquals(expected::class, actual::class)
    assertEquals(expected.data, actual.data)
    assertEquals(expected.message, actual.message)
}

/**
 * This little thingy helps us test LazyPagingItems (that everything loads exactly like it should)
 * Do not call this function directly, look at the next one
 */
fun <T : Any> testLazyPagingItems(
    klass: KClass<T>,
    items: LazyPagingItems<T>,
    offset: Int = 0,
    pageArg: MockKAnswerScope<List<T>, List<T>>.() -> Int = { firstArg() },
    stubBlock: suspend MockKMatcherScope.() -> List<T>
) {
    val pages = List(CommonPagingSource.PAGE_SIZE * 4) { mockkClass(klass, relaxed = true) }

    coEvery(stubBlock) answers {
        pageArg().let {
            println("First arg: $it")
            pages.subList((it - 1) * CommonPagingSource.PAGE_SIZE, min(it * CommonPagingSource.PAGE_SIZE, pages.size))
        }
    }
    items.refresh()

    pages.forEachIndexed { i, item -> assertEquals(item, items[i + offset]) }
    assertFails { items[pages.size + offset] }
}

inline fun <reified T : Any> testLazyPagingItems(
    items: LazyPagingItems<T>,
    offset: Int = 0,
    noinline pageArg: MockKAnswerScope<List<T>, List<T>>.() -> Int = { firstArg() },
    noinline stubBlock: suspend MockKMatcherScope.() -> List<T>
) = testLazyPagingItems(T::class, items, offset, pageArg, stubBlock)



<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/login/LoginViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.login

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.AuthType
import io.eugenethedev.taigamobile.domain.repositories.IAuthRepository
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.launch
import javax.inject.Inject

class LoginViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var authRepository: IAuthRepository

    val loginResult = MutableResultFlow<Unit>()

    init {
        appComponent.inject(this)
    }

    fun login(taigaServer: String, authType: AuthType, username: String, password: String) = viewModelScope.launch {
        loginResult.loadOrError(R.string.login_error_message) {
            authRepository.auth(taigaServer, authType, password, username)
        }
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/theme/Theme.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.theme

import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext

private val DarkColorPalette = darkColorScheme(
    primary = taigaGreen,
    onPrimary = Color.White,
    secondary = taigaGreenDark,
    secondaryContainer = taigaGreenDark.copy(alpha = 0.5f),
    surfaceVariant = taigaGreen.copy(alpha = 0.1f),
    background = taigaDarkBackground,
    surface = taigaDarkBackground
)

private val LightColorPalette = lightColorScheme(
    primary = taigaGreen,
    secondary = taigaGreenDark,
    secondaryContainer = taigaGreenLight.copy(alpha = 0.5f),
    surfaceVariant = taigaGreen.copy(alpha = 0.1f),
    background = taigaLightBackground,
    surface = taigaLightBackground
)

@Composable
fun TaigaMobileTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
    val colors = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        if (darkTheme) dynamicDarkColorScheme(LocalContext.current)
        else dynamicLightColorScheme(LocalContext.current)
    } else {
        if (darkTheme) DarkColorPalette
        else LightColorPalette
    }

    MaterialTheme(
        colorScheme = colors,
        typography = typography,
        content = content
    )
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/data/repositories/SearchRepository.kt<|end_filename|>
package io.eugenethedev.taigamobile.data.repositories

import io.eugenethedev.taigamobile.data.api.TaigaApi
import io.eugenethedev.taigamobile.domain.repositories.ISearchRepository
import javax.inject.Inject

class SearchRepository @Inject constructor(
    private val taigaApi: TaigaApi
) : ISearchRepository {

    override suspend fun searchProjects(query: String, page: Int) = withIO {
        handle404 {
            taigaApi.getProjects(query, page)
        }
    }
}

<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/repositories/SprintsRepositoryTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.repositories

import io.eugenethedev.taigamobile.data.repositories.SprintsRepository
import io.eugenethedev.taigamobile.domain.repositories.ISprintsRepository
import io.eugenethedev.taigamobile.testdata.TestData
import kotlinx.coroutines.runBlocking
import org.junit.Test
import java.time.LocalDate
import kotlin.test.BeforeTest
import kotlin.test.assertEquals
import kotlin.test.assertFails

class SprintsRepositoryTest : BaseRepositoryTest() {
    lateinit var sprintsRepository: ISprintsRepository

    @BeforeTest
    fun setupSprintsRepositoryTest() {
        sprintsRepository = SprintsRepository(mockTaigaApi, mockSession)
    }

    @Test
    fun `test get user stories`() = runBlocking {
        val sprints = sprintsRepository.getSprints(1)
        val testUserStories = TestData.projects[0].userstories.filter { it.sprint != null }

        sprints.forEach {
            sprintsRepository.getSprintUserStories(it.id).forEach { story ->
                assertEquals(
                    expected = testUserStories.find { it.title == story.title }!!.isClosed,
                    actual = story.isClosed
                )
            }
        }
    }

    @Test
    fun `test get sprints`() = runBlocking {
        val sprints = sprintsRepository.getSprints(1) + sprintsRepository.getSprints(1, isClosed = true)

        assertEquals(
            expected = TestData.projects[0]
                .sprints
                .sortedBy { it.name }
                .map { it.name },
            actual = sprints.sortedBy { it.name }
                .map { it.name }
        )
    }

    @Test
    fun `test get sprint`() = runBlocking {
        val sprints = sprintsRepository.getSprints(1)

        sprints.forEach {
            sprintsRepository.getSprint(it.id).let { sprint ->
                TestData.projects[0].sprints.find { it.name == sprint.name }!!.let {
                    assertEquals(it.start, sprint.start)
                    assertEquals(it.end, sprint.end)
                }
            }
        }
    }

    @Test
    fun `test get sprint tasks`() = runBlocking {
        val sprints = sprintsRepository.getSprints(1)
        val testTasks = TestData.projects[0].sprints.associate { it.name to it.tasks }

        sprints.forEach { sprint ->
            sprintsRepository.getSprintTasks(sprint.id).forEach { task ->
                assertEquals(
                    expected = testTasks[sprint.name]!!.find { it.title == task.title }!!.isClosed,
                    actual = task.isClosed
                )
            }
        }
    }

    @Test
    fun `test get sprint issues`() = runBlocking {
        val sprints = sprintsRepository.getSprints(1)
        val testIssues = TestData.projects[0].issues
            .filter { it.sprint != null }
            .groupBy { it.sprint!!.name }

        sprints.forEach { sprint ->
            sprintsRepository.getSprintIssues(sprint.id).forEach { issue ->
                assertEquals(
                    expected = testIssues[sprint.name]!!.find { it.title == issue.title }!!.isClosed,
                    actual = issue.isClosed
                )
            }
        }
    }

    @Test
    fun `test create sprint`() = runBlocking {
        sprintsRepository.createSprint(
            "testSprint",
            LocalDate.of(2000, 1, 1),
            LocalDate.of(3000, 1, 1)
        )

        val sprints = sprintsRepository.getSprints(1)
        assertEquals(
            expected = "testSprint",
            actual = sprints.last().name
        )
        assertEquals(
            expected = LocalDate.of(2000, 1, 1),
            actual = sprints.last().start
        )
        assertEquals(
            expected = LocalDate.of(3000, 1, 1),
            actual = sprints.last().end
        )
    }

    @Test
    fun `test edit sprint`() = runBlocking {
        val sprints = sprintsRepository.getSprints(1)
        sprints.map { it.id }.forEach { id ->
            val title =  "editSprint$id"
            val start = LocalDate.of(2000 + id.toInt(), 1, 1)
            val end = LocalDate.of(3000 + id.toInt(), 1, 1)

            sprintsRepository.editSprint(id, title, start, end)

            val sprint = sprintsRepository.getSprint(id)
            assertEquals(
                expected = title,
                actual = sprint.name
            )
            assertEquals(
                expected = start,
                actual = sprint.start
            )
            assertEquals(
                expected = end,
                actual = sprint.end
            )
        }
    }

    @Test
    fun `test delete sprint`() = runBlocking {
        val sprints = sprintsRepository.getSprints(1)

        sprints.forEach {
            sprintsRepository.deleteSprint(it.id)
            assertFails { sprintsRepository.getSprint(it.id) }
        }
    }
}


<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/manager/Creations.kt<|end_filename|>
package io.eugenethedev.taigamobile.manager

import io.eugenethedev.taigamobile.data.api.CommonTaskPathPlural
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.testdata.Comment
import io.eugenethedev.taigamobile.testdata.Task
import io.eugenethedev.taigamobile.testdata.TestData
import io.eugenethedev.taigamobile.testdata.User
import okhttp3.Request

interface Creations {
    fun TaigaTestInstanceManager.initData() = tx {
        if (runCatching { createStatement().execute("select * from users_user_$_snapshotPostfix where false") }.isSuccess) {
            println("Hot init")
            hotInit()
        } else {
            println("Cold init")
            coldInit()
        }

        authActiveUser()
    }

    private fun TaigaTestInstanceManager.coldInit() {
        val sleepTime = 300L

        createUsers()
        Thread.sleep(sleepTime)
        createProjects()
        Thread.sleep(sleepTime)
        createEpics()
        Thread.sleep(sleepTime)
        createSprints()
        Thread.sleep(sleepTime)
        createUserStories()
        Thread.sleep(sleepTime)
        createIssues()
        Thread.sleep(sleepTime)

        tx {
            getAllProdTables().forEach {
                println("Creating snapshot for '$it'")
                createStatement().execute("select * into ${it}_$_snapshotPostfix from $it")
            }
        }
    }

    private fun TaigaTestInstanceManager.hotInit() = tx {
        getAllProdTables().forEach {
            println("Getting snapshot for '$it'")
            createStatement().execute("alter table $it disable trigger all")
            createStatement().execute("delete from $it where true")
            createStatement().execute("insert into $it select * from ${it}_$_snapshotPostfix")
            createStatement().execute("alter table $it enable trigger all")
        }
    }

    private fun TaigaTestInstanceManager.authActiveUser() {
        activeUser = TestData.activeUser.let {
            val data = Request.Builder()
                .apiEndpoint("auth")
                .post(
                    """
                        {
                            "password": <PASSWORD>}",
                            "username": "${it.username}",
                            "type": "normal"
                        }
                    """.trimIndent().toJsonBody()
                )
                .build()
                .execute()
                .toJsonObject()
                .let {
                    UserData(
                        id = it.get("id").asLong,
                        accessToken = it.get("auth_token").asString,
                        refreshToken = it.get("refresh").asString
                    )
                }

            val projects = Request.Builder()
                .apiEndpoint("projects?member=${data.id}")
                .get()
                .header("Authorization", "Bearer ${data.accessToken}")
                .build()
                .execute()
                .toJsonArray()
                .map { it.asJsonObject }
                .map { it.get("id").asLong to it.get("name").asString }
                .map { (id, name) -> id to TestData.projects.find { it.name == name }!! }
                .toMap()

            UserInfo(it, data, projects)
        }
    }

    private fun TaigaTestInstanceManager.watchTask(taskType: CommonTaskType, taskId: Long, user: User) {
        Request.Builder()
            .apiEndpoint("${CommonTaskPathPlural(taskType).path}/$taskId/watch")
            .post("".toJsonBody())
            .withAuth(user)
            .build()
            .execute()
            .successOrThrow()
    }

    private fun TaigaTestInstanceManager.postComment(
        taskType: CommonTaskType,
        taskId: Long,
        comment: Comment,
        version: Int = 1
    ) {
        Request.Builder()
            .apiEndpoint("${CommonTaskPathPlural(taskType).path}/$taskId")
            .patch(
                """
                   {
                       "comment": "${comment.text}",
                       "version": $version
                   }
                """.trimIndent().toJsonBody()
            )
            .withAuth(comment.author)
            .build()
            .execute()
            .successOrThrow()
    }

    private fun TaigaTestInstanceManager.createTasks(
        tasks: List<Task>,
        projectId: Long,
        sprintId: Long? = null,
        userStoryId: Long? = null,
    ) {
        tasks.forEach {
            val taskId = Request.Builder()
                .apiEndpoint("tasks")
                .post(
                    """
                        {
                            "project": $projectId,
                            "subject": "${it.title}",
                            "description": "${it.description}",
                            "milestone": $sprintId,
                            "user_story": $userStoryId,
                            "assigned_to": ${_userToUserData[it.assignedTo]?.id}
                            ${
                                if (it.isClosed)
                                    """, "status": ${getClosedStatusId(CommonTaskType.Task, projectId, it.creator)}"""
                                else
                                    ""
                            }
                        }
                    """.trimIndent().toJsonBody()
                )
                .withAuth(it.creator)
                .build()
                .execute()
                .toJsonObject()
                .get("id")
                .asLong

            var version = 1
            it.comments.forEach {
                postComment(
                    taskId = taskId,
                    taskType = CommonTaskType.Task,
                    comment = it,
                    version = version++
                )
            }

            it.watchers.forEach {
                watchTask(CommonTaskType.Task, taskId, it)
            }
        }
    }

    private fun TaigaTestInstanceManager.createUsers() {
        _userToUserData = TestData.users.associateWith {
            Request.Builder()
                .apiEndpoint("auth/register")
                .post(
                    """
                        {
                            "accepted_terms": true,
                            "email": "${it.email}",
                            "full_name": "${it.fullName}",
                            "password": <PASSWORD>}",
                            "type": "public",
                            "username": "${it.username}"
                        }
                    """.trimIndent().toJsonBody()
                )
                .build()
                .execute()
                .toJsonObject()
                .let {
                    UserData(
                        id = it.get("id").asLong,
                        accessToken = it.get("auth_token").asString,
                        refreshToken = it.get("refresh").asString
                    )
                }
        }

        // some dirty tricks, because email has to be verified
        println("Manually verifying emails")

        tx {
            createStatement().execute("update users_user set verified_email = true where true")
        }
    }

    private fun TaigaTestInstanceManager.createProjects() {
        _projectToProjectData = TestData.projects.associateWith {
            val projectId = Request.Builder()
                .apiEndpoint("projects")
                .post(
                    """
                        {
                            "name": "${it.name}",
                            "description": "${it.description}",
                            "is_private": false,
                            "is_issues_activated": true,
                            "is_backlog_activated": true,
                            "is_wiki_activated": true
                        }
                    """.trimIndent().toJsonBody()
                )
                .withAuth(it.creator)
                .build()
                .execute()
                .toJsonObject()
                .get("id")
                .asLong

            val roleId = Request.Builder()
                .apiEndpoint("roles")
                .post(
                    """
                        {
                            "project": $projectId,
                            "name": "${it.defaultRoleName}",
                            "permissions": [
                                "add_issue",
                                "delete_us",
                                "modify_wiki_link",
                                "delete_epic",
                                "view_issues",
                                "add_wiki_page",
                                "comment_issue",
                                "modify_epic",
                                "delete_issue",
                                "delete_wiki_link",
                                "delete_task",
                                "view_wiki_pages",
                                "modify_wiki_page",
                                "delete_wiki_page",
                                "delete_milestone",
                                "comment_task",
                                "comment_wiki_page",
                                "view_project",
                                "add_task",
                                "view_wiki_links",
                                "view_tasks",
                                "add_us",
                                "add_milestone",
                                "modify_us",
                                "modify_milestone",
                                "comment_epic",
                                "modify_issue",
                                "view_milestones",
                                "view_epics",
                                "view_us",
                                "comment_us",
                                "modify_task",
                                "add_epic",
                                "add_wiki_link"
                            ]
                        }
                    """.trimIndent().toJsonBody()
                )
                .withAuth(it.creator)
                .build()
                .execute()
                .toJsonObject()
                .get("id")
                .asLong

            // for some reason these parameters are ignored in create project request
            Request.Builder()
                .apiEndpoint("projects/$projectId")
                .patch(
                    """
                        {
                            "is_epics_activated": true,
                            "is_kanban_activated": true
                        }
                    """.trimIndent().toJsonBody()
                )
                .withAuth(it.creator)
                .build()
                .execute()
                .successOrThrow()

            it.team.filter { user -> user != it.creator }.forEach { user ->
                Request.Builder()
                    .apiEndpoint("memberships")
                    .post(
                        """
                            {
                                "project": $projectId,
                                "role": $roleId,
                                "username": "${user.email}"
                            }
                        """.trimIndent().toJsonBody()
                        // ^^^ api logic 100% ¯\_(ツ)_/¯
                    )
                    .withAuth(it.creator)
                    .build()
                    .execute()
                    .successOrThrow()
            }

            ProjectData(projectId)
        }

    }

    private fun TaigaTestInstanceManager.createSprints() {
        _projectToProjectData.forEach { (project, data) ->
            data.sprintToId = project.sprints.associateWith {
                val sprintId = Request.Builder()
                    .apiEndpoint("milestones")
                    .post(
                        """
                           {
                               "project": ${data.id},
                               "name": "${it.name}",
                               "estimated_start": "${it.start}",
                               "estimated_finish": "${it.end}"
                           }
                        """.trimIndent().toJsonBody()
                    )
                    .withAuth(it.creator)
                    .build()
                    .execute()
                    .toJsonObject()
                    .get("id")
                    .asLong

                createTasks(it.tasks, data.id, sprintId = sprintId)

                sprintId
            }
        }
    }

    private fun TaigaTestInstanceManager.createEpics() {
        _projectToProjectData.forEach { (project, data) ->
            data.epicToId = project.epics.associateWith {
                val epicId = Request.Builder()
                    .apiEndpoint("epics")
                    .post(
                        """
                            {
                                "project": ${data.id},
                                "subject": "${it.title}",
                                "description": "${it.description}",
                                "assigned_to": ${_userToUserData[it.assignedTo]?.id}
                                ${
                                    if (it.isClosed)
                                        """, "status": ${getClosedStatusId(CommonTaskType.Epic, data.id, it.creator)}"""
                                    else
                                        ""
                                }
                            }
                        """.trimIndent().toJsonBody()
                    )
                    .withAuth(it.creator)
                    .build()
                    .execute()
                    .toJsonObject()
                    .get("id")
                    .asLong

                var version = 1
                it.comments.forEach {
                    postComment(
                        taskId = epicId,
                        taskType = CommonTaskType.Epic,
                        comment = it,
                        version = version++
                    )
                }

                // again, for some reason you can't specify watchers during epic creation, despite docs say opposite
                it.watchers.forEach {
                    watchTask(CommonTaskType.Epic, epicId, it)
                }

                epicId
            }
        }
    }

    private fun TaigaTestInstanceManager.createUserStories() {
        _projectToProjectData.forEach { (project, data) ->
            data.userstoryToId = project.userstories.associateWith {
                val userStoryId = Request.Builder()
                    .apiEndpoint("userstories")
                    .post(
                        """
                            {
                                "project": ${data.id},
                                "subject": "${it.title}",
                                "description": "${it.description}",
                                "assigned_to": ${_userToUserData[it.assignedTo]?.id},
                                "milestone": ${data.sprintToId[it.sprint]}
                                ${
                                    if (it.isClosed)
                                        """, "status": ${getClosedStatusId(CommonTaskType.UserStory, data.id, it.creator)}"""
                                    else
                                        ""
                                }
                            }
                        """.trimIndent().toJsonBody()
                    )
                    .withAuth(it.creator)
                    .build()
                    .execute()
                    .toJsonObject()
                    .get("id")
                    .asLong

                var version = 1
                it.comments.forEach {
                    postComment(
                        taskId = userStoryId,
                        taskType = CommonTaskType.UserStory,
                        comment = it,
                        version = version++
                    )
                }

                it.epics.forEach {
                    Request.Builder()
                        .apiEndpoint("epics/${data.epicToId[it]}/related_userstories")
                        .post(
                            """
                                {
                                    "epic": ${data.epicToId[it]},
                                    "user_story": $userStoryId
                                }
                            """.trimIndent().toJsonBody()
                        )
                        .withAuth(project.creator)
                        .build()
                        .execute()
                        .successOrThrow()
                }

                it.watchers.forEach {
                    watchTask(CommonTaskType.UserStory, userStoryId, it)
                }

                createTasks(it.tasks, data.id, userStoryId = userStoryId)

                userStoryId
            }
        }
    }

    private fun TaigaTestInstanceManager.createIssues() {
        _projectToProjectData.forEach { (project, data) ->
            data.issueToId = project.issues.associateWith {
                val issueId = Request.Builder()
                    .apiEndpoint("issues")
                    .post(
                        """
                            {
                                "project": ${data.id},
                                "subject": "${it.title}",
                                "description": "${it.description}",
                                "assigned_to": ${_userToUserData[it.assignedTo]?.id},
                                "milestone": ${data.sprintToId[it.sprint]}
                                ${
                                    if (it.isClosed)
                                        """, "status": ${getClosedStatusId(CommonTaskType.Issue, data.id, it.creator)}"""
                                    else
                                        ""
                                }
                            }
                        """.trimIndent().toJsonBody()
                    )
                    .withAuth(it.creator)
                    .build()
                    .execute()
                    .toJsonObject()
                    .get("id")
                    .asLong

                var version = 1
                it.comments.forEach {
                    postComment(
                        taskId = issueId,
                        taskType = CommonTaskType.Issue,
                        comment = it,
                        version = version++
                    )
                }

                it.watchers.forEach {
                    watchTask(CommonTaskType.Issue, issueId, it)
                }

                issueId
            }
        }
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/main/MainActivity.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.main

import android.annotation.SuppressLint
import android.os.Bundle
import android.provider.OpenableColumns
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ScaffoldState
import androidx.compose.material.Snackbar
import androidx.compose.material.SnackbarHost
import androidx.compose.material.rememberScaffoldState
import androidx.compose.material.ripple.LocalRippleTheme
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.view.WindowCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.*
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.*
import com.google.accompanist.insets.*
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.state.ThemeSetting
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.ui.components.containers.ContainerBox
import io.eugenethedev.taigamobile.ui.components.appbars.AppBarWithBackButton
import io.eugenethedev.taigamobile.ui.screens.login.LoginScreen
import io.eugenethedev.taigamobile.ui.screens.projectselector.ProjectSelectorScreen
import io.eugenethedev.taigamobile.ui.screens.scrum.ScrumScreen
import io.eugenethedev.taigamobile.ui.screens.sprint.SprintScreen
import io.eugenethedev.taigamobile.ui.screens.commontask.CommonTaskScreen
import io.eugenethedev.taigamobile.ui.screens.createtask.CreateTaskScreen
import io.eugenethedev.taigamobile.ui.screens.dashboard.DashboardScreen
import io.eugenethedev.taigamobile.ui.screens.epics.EpicsScreen
import io.eugenethedev.taigamobile.ui.screens.issues.IssuesScreen
import io.eugenethedev.taigamobile.ui.screens.kanban.KanbanScreen
import io.eugenethedev.taigamobile.ui.screens.settings.SettingsScreen
import io.eugenethedev.taigamobile.ui.screens.team.TeamScreen
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileRippleTheme
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.shapes
import java.io.InputStream

class MainActivity : AppCompatActivity() {

    @SuppressLint("Range")
    private val getContent = registerForActivityResult(ActivityResultContracts.GetContent()) {
        it ?: return@registerForActivityResult
        val inputStream = contentResolver.openInputStream(it) ?: return@registerForActivityResult
        val fileName = contentResolver.query(it, null, null, null, null)?.use { cursor ->
            cursor.moveToFirst()
            cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
        } ?: return@registerForActivityResult

        filePicker.filePicked(fileName, inputStream)
    }

    private val filePicker: FilePicker = object : FilePicker() {
        override fun requestFile(onFilePicked: (String, InputStream) -> Unit) {
            super.requestFile(onFilePicked)
            getContent.launch("*/*")
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        WindowCompat.setDecorFitsSystemWindows(window, false)
        setContent {
            val scaffoldState = rememberScaffoldState()
            val navController = rememberNavController()
            val systemUiController = rememberSystemUiController()

            val viewModel: MainViewModel = viewModel()
            val theme by viewModel.theme.collectAsState()

            val darkTheme = when (theme) {
                ThemeSetting.Light -> false
                ThemeSetting.Dark -> true
                ThemeSetting.System -> isSystemInDarkTheme()
            }

            TaigaMobileTheme(darkTheme) {
                ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
                    systemUiController.let {
                        it.setStatusBarColor(
                            Color.Transparent,
                            darkIcons = !darkTheme
                        )
                        it.setNavigationBarColor(
                            Color.Transparent,
                            darkIcons = !darkTheme
                        )
                    }

                    CompositionLocalProvider(
                        LocalFilePicker provides filePicker,
                        LocalRippleTheme provides TaigaMobileRippleTheme
                    ) {

                        // use Scaffold from material2, because material3 Scaffold lacks some functionality
                        androidx.compose.material.Scaffold(
                            scaffoldState = scaffoldState,
                            snackbarHost = {
                                SnackbarHost(
                                    hostState = it,
                                    modifier = Modifier.navigationBarsPadding()
                                ) {
                                    Snackbar(
                                        snackbarData = it,
                                        backgroundColor = MaterialTheme.colorScheme.surface,
                                        contentColor = contentColorFor(MaterialTheme.colorScheme.surface),
                                        shape = shapes.medium
                                    )
                                }
                            },
                            bottomBar = {
                                val items = Screens.values()
                                val routes = items.map { it.route }
                                val navBackStackEntry by navController.currentBackStackEntryAsState()
                                val currentRoute =
                                    navBackStackEntry?.destination?.hierarchy?.first()?.route

                                // hide bottom bar for other screens
                                if (currentRoute !in routes) return@Scaffold

                                NavigationBar(
                                    modifier = Modifier.navigationBarsHeight(70.dp)
                                ) {
                                    items.forEach { screen ->
                                        NavigationBarItem(
                                            modifier = Modifier.navigationBarsPadding()
                                                .clip(CircleShape),
                                            icon = {
                                                Icon(
                                                    painter = painterResource(screen.iconId),
                                                    contentDescription = null,
                                                    modifier = Modifier.size(22.dp)
                                                )
                                            },
                                            label = { Text(stringResource(screen.resourceId)) },
                                            selected = currentRoute == screen.route,
                                            onClick = {
                                                navController.navigate(screen.route) {
                                                    popUpTo(navController.graph.findStartDestination().id) { }
                                                    launchSingleTop = true
                                                }
                                            }
                                        )
                                    }
                                }
                            },
                            content = {
                                MainScreen(
                                    viewModel = viewModel,
                                    scaffoldState = scaffoldState,
                                    paddingValues = it,
                                    navController = navController
                                )
                            }
                        )
                    }
                }
            }
        }
    }
}

enum class Screens(val route: String, @StringRes val resourceId: Int, @DrawableRes val iconId: Int) {
    Dashboard(Routes.dashboard, R.string.dashboard_short, R.drawable.ic_dashboard),
    Scrum(Routes.scrum, R.string.scrum, R.drawable.ic_scrum),
    Epics(Routes.epics, R.string.epics, R.drawable.ic_epics),
    Issues(Routes.issues, R.string.issues, R.drawable.ic_issues),
    More(Routes.more, R.string.more, R.drawable.ic_more)
}

object Routes {
    const val login = "login"
    const val dashboard = "dashboard"
    const val scrum = "scrum"
    const val epics = "epics"
    const val issues = "issues"
    const val more = "more"
    const val team = "team"
    const val settings = "settings"
    const val kanban = "kanban"
    const val projectsSelector = "projectsSelector"
    const val sprint = "sprint"
    const val commonTask = "commonTask"
    const val createTask = "createTask"

    object Arguments {
        const val sprint = "sprint"
        const val sprintId = "sprintId"
        const val swimlaneId = "swimlaneId"
        const val commonTaskId = "taskId"
        const val commonTaskType = "taskType"
        const val ref = "ref"
        const val parentId = "parentId"
        const val statusId = "statusId"
    }
}

@Composable
fun MainScreen(
    viewModel: MainViewModel,
    scaffoldState: ScaffoldState,
    paddingValues: PaddingValues,
    navController: NavHostController
) {
    val onError: @Composable (Int) -> Unit = { message ->
        val strMessage = stringResource(message)
        LaunchedEffect(Unit) {
            scaffoldState.snackbarHostState.showSnackbar(strMessage)
        }
    }

    val isLogged by viewModel.isLogged.collectAsState()
    val isProjectSelected by viewModel.isProjectSelected.collectAsState()

    Surface(
        modifier = Modifier.fillMaxSize().padding(paddingValues),
        color = MaterialTheme.colorScheme.background
    ) {
        NavHost(
            navController = navController,
            startDestination = remember { if (isLogged) Routes.dashboard else Routes.login }
        ) {
            composable(Routes.login) {
                LoginScreen(
                    navController = navController,
                    onError = onError
                )
            }

            // start screen
            composable(Routes.dashboard) {
                DashboardScreen(
                    navController = navController,
                    onError = onError
                )
                // user must select project first
                LaunchedEffect(Unit) {
                    if (!isProjectSelected) {
                        navController.navigate(Routes.projectsSelector)
                    }
                }
            }

            composable(Routes.scrum) {
                ScrumScreen(
                    navController = navController,
                    onError = onError
                )
            }

            composable(Routes.epics) {
                EpicsScreen(
                    navController = navController,
                    onError = onError
                )
            }

            composable(Routes.issues) {
                IssuesScreen(
                    navController = navController,
                    onError = onError
                )
            }

            composable(Routes.more) {
                MoreScreen(
                    navController = navController
                )
            }

            composable(Routes.team) {
                TeamScreen(
                    navController = navController,
                    onError = onError
                )
            }

            composable(Routes.kanban) {
                KanbanScreen(
                    navController = navController,
                    onError = onError
                )
            }

            composable(Routes.settings) {
                SettingsScreen(
                    navController = navController,
                    onError = onError
                )
            }

            composable(Routes.projectsSelector) {
                ProjectSelectorScreen(
                    navController = navController,
                    onError = onError
                )
            }

            composable(
                "${Routes.sprint}/{${Routes.Arguments.sprintId}}",
                arguments = listOf(
                    navArgument(Routes.Arguments.sprintId) { type = NavType.LongType }
                )
            ) {
                SprintScreen(
                    navController = navController,
                    sprintId = it.arguments!!.getLong(Routes.Arguments.sprintId),
                    onError = onError
                )
            }

            composable(
                Routes.Arguments.run { "${Routes.commonTask}/{$commonTaskId}/{$commonTaskType}/{$ref}" },
                arguments = listOf(
                    navArgument(Routes.Arguments.commonTaskType) { type = NavType.StringType },
                    navArgument(Routes.Arguments.commonTaskId) { type = NavType.LongType },
                    navArgument(Routes.Arguments.ref) { type = NavType.IntType },
                )
            ) {
                CommonTaskScreen(
                    navController = navController,
                    commonTaskId = it.arguments!!.getLong(Routes.Arguments.commonTaskId),
                    commonTaskType = CommonTaskType.valueOf(it.arguments!!.getString(Routes.Arguments.commonTaskType, "")),
                    ref = it.arguments!!.getInt(Routes.Arguments.ref),
                    onError = onError
                )
            }

            composable(
                Routes.Arguments.run {"${Routes.createTask}/{$commonTaskType}?$parentId={$parentId}&$sprintId={$sprintId}&$statusId={$statusId}&$swimlaneId={$swimlaneId}" },
                arguments = listOf(
                    navArgument(Routes.Arguments.commonTaskType) { type = NavType.StringType },
                    navArgument(Routes.Arguments.parentId) {
                        type = NavType.LongType
                        defaultValue = -1L // long does not allow null values
                    },
                    navArgument(Routes.Arguments.sprintId) {
                        type = NavType.LongType
                        defaultValue = -1L
                    },
                    navArgument(Routes.Arguments.statusId) {
                        type = NavType.LongType
                        defaultValue = -1L
                    },
                    navArgument(Routes.Arguments.swimlaneId) {
                        type = NavType.LongType
                        defaultValue = -1L
                    },
                )
            ) {
                CreateTaskScreen(
                    navController = navController,
                    commonTaskType = CommonTaskType.valueOf(it.arguments!!.getString(Routes.Arguments.commonTaskType, "")),
                    parentId = it.arguments!!.getLong(Routes.Arguments.parentId).takeIf { it >= 0 },
                    sprintId = it.arguments!!.getLong(Routes.Arguments.sprintId).takeIf { it >= 0 },
                    statusId = it.arguments!!.getLong(Routes.Arguments.statusId).takeIf { it >= 0 },
                    swimlaneId = it.arguments!!.getLong(Routes.Arguments.swimlaneId).takeIf { it >= 0 },
                    onError = onError
                )
            }
        }
    }
}

@Composable
fun MoreScreen(
    navController: NavController
) = Column(Modifier.fillMaxSize()) {
    AppBarWithBackButton(
        title = { Text(stringResource(R.string.more)) }
    )

    @Composable
    fun Item(
        @DrawableRes iconId: Int,
        @StringRes nameId: Int,
        route: String
    ) = ContainerBox(onClick = { navController.navigate(route) }) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Icon(
                painter = painterResource(iconId),
                contentDescription = null,
                modifier = Modifier.size(24.dp),
                tint = MaterialTheme.colorScheme.outline
            )

            Spacer(Modifier.width(8.dp))

            Text(stringResource(nameId))
        }
    }

    val space = 2.dp

    Item(R.drawable.ic_team, R.string.team, Routes.team)
    Spacer(Modifier.height(space))
    Item(R.drawable.ic_kanban, R.string.kanban, Routes.kanban)
    Spacer(Modifier.height(space))
    Item(R.drawable.ic_settings, R.string.settings, Routes.settings)
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/epics/EpicsScreen.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.epics

import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.paging.compose.LazyPagingItems
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.ui.components.TasksFiltersWithLazyList
import io.eugenethedev.taigamobile.ui.components.buttons.PlusButton
import io.eugenethedev.taigamobile.ui.components.appbars.ProjectAppBar
import io.eugenethedev.taigamobile.ui.components.lists.SimpleTasksListWithTitle
import io.eugenethedev.taigamobile.ui.screens.main.Routes
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.commonVerticalPadding
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
import io.eugenethedev.taigamobile.ui.utils.*

@Composable
fun EpicsScreen(
    navController: NavController,
    onError: @Composable (message: Int) -> Unit = {},
) {
    val viewModel: EpicsViewModel = viewModel()
    LaunchedEffect(Unit) {
        viewModel.onOpen()
    }

    val projectName by viewModel.projectName.collectAsState()

    val epics = viewModel.epics
    epics.subscribeOnError(onError)

    val filters by viewModel.filters.collectAsState()
    filters.subscribeOnError(onError)

    val activeFilters by viewModel.activeFilters.collectAsState()

    EpicsScreenContent(
        projectName = projectName,
        onTitleClick = { navController.navigate(Routes.projectsSelector) },
        navigateToCreateTask = { navController.navigateToCreateTaskScreen(CommonTaskType.Epic) },
        epics = epics,
        filters = filters.data ?: FiltersData(),
        activeFilters = activeFilters,
        selectFilters = viewModel::selectFilters,
        navigateToTask = navController::navigateToTaskScreen,
    )
}

@Composable
fun EpicsScreenContent(
    projectName: String,
    onTitleClick: () -> Unit = {},
    navigateToCreateTask: () -> Unit = {},
    epics: LazyPagingItems<CommonTask>? = null,
    filters: FiltersData = FiltersData(),
    activeFilters: FiltersData = FiltersData(),
    selectFilters: (FiltersData) -> Unit = {},
    navigateToTask: NavigateToTask = { _, _, _ -> }
) = Column(
    modifier = Modifier.fillMaxSize(),
    horizontalAlignment = Alignment.Start
) {
    ProjectAppBar(
        projectName = projectName,
        actions = { PlusButton(onClick = navigateToCreateTask) },
        onTitleClick = onTitleClick
    )

    TasksFiltersWithLazyList(
        filters = filters,
        activeFilters = activeFilters,
        selectFilters = selectFilters
    ) {
        SimpleTasksListWithTitle(
            commonTasksLazy = epics,
            keysHash = activeFilters.hashCode(),
            navigateToTask = navigateToTask,
            horizontalPadding = mainHorizontalScreenPadding,
            bottomPadding = commonVerticalPadding
        )
    }
}

@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
fun EpicsScreenPreview() = TaigaMobileTheme {
    EpicsScreenContent(
        projectName = "Cool project"
    )
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/sprint/SprintViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.sprint

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.entities.Sprint
import io.eugenethedev.taigamobile.domain.entities.Status
import io.eugenethedev.taigamobile.domain.repositories.ISprintsRepository
import io.eugenethedev.taigamobile.domain.repositories.ITasksRepository
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.state.postUpdate
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.NothingResult
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject

class SprintViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var tasksRepository: ITasksRepository
    @Inject lateinit var sprintsRepository: ISprintsRepository
    @Inject lateinit var session: Session

    private var sprintId: Long = -1

    val sprint = MutableResultFlow<Sprint>()
    val statuses = MutableResultFlow<List<Status>>()
    val storiesWithTasks = MutableResultFlow<Map<CommonTask, List<CommonTask>>>()
    val storylessTasks = MutableResultFlow<List<CommonTask>>()
    val issues = MutableResultFlow<List<CommonTask>>()

    private var shouldReload = true

    init {
        appComponent.inject(this)
    }

    fun onOpen(sprintId: Long) {
        if (!shouldReload) return
        this.sprintId = sprintId
        loadData(isReloading = false)
        shouldReload = false
    }

    private fun loadData(isReloading: Boolean = true) = viewModelScope.launch {
        sprint.loadOrError(showLoading = !isReloading) {
            sprintsRepository.getSprint(sprintId).also {
                joinAll(
                    launch {
                        statuses.loadOrError(showLoading = false) { tasksRepository.getStatuses(CommonTaskType.Task) }
                    },
                    launch {
                        storiesWithTasks.loadOrError(showLoading = false) {
                            coroutineScope {
                                sprintsRepository.getSprintUserStories(sprintId)
                                    .map { it to async { tasksRepository.getUserStoryTasks(it.id) } }
                                    .map { (story, tasks) -> story to tasks.await() }
                                    .toMap()
                            }
                        }
                    },
                    launch {
                        issues.loadOrError(showLoading = false) { sprintsRepository.getSprintIssues(sprintId) }
                    },
                    launch {
                        storylessTasks.loadOrError(showLoading = false) { sprintsRepository.getSprintTasks(sprintId) }
                    }
                )
            }
        }
    }

    val editResult = MutableResultFlow<Unit>()
    fun editSprint(name: String, start: LocalDate, end: LocalDate) = viewModelScope.launch {
        editResult.loadOrError(R.string.permission_error) {
            sprintsRepository.editSprint(sprintId, name, start, end)
            session.sprintEdit.postUpdate()
            loadData().join()
        }
    }

    val deleteResult = MutableResultFlow<Unit>()
    fun deleteSprint() = viewModelScope.launch {
        deleteResult.loadOrError(R.string.permission_error) {
            sprintsRepository.deleteSprint(sprintId)
            session.sprintEdit.postUpdate()
        }
    }

    init {
        session.taskEdit.onEach {
            sprintId = -1
            sprint.value = NothingResult()
            statuses.value = NothingResult()
            storiesWithTasks.value = NothingResult()
            storylessTasks.value = NothingResult()
            issues.value = NothingResult()
            shouldReload = true
        }.launchIn(viewModelScope)
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/badges/Badge.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.badges

import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.shapes
import io.eugenethedev.taigamobile.ui.utils.textColor

@Composable
fun Badge(
    text: String,
    isActive: Boolean = true
) {
    val color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.inverseOnSurface
    Surface(
        shape = shapes.small,
        color = color,
        contentColor = color.textColor(),
        tonalElevation = 2.dp
    ) {
        Text(
            text = text,
            style = MaterialTheme.typography.bodyMedium,
            modifier = Modifier.padding(horizontal = 2.dp),
        )
    }
}

@Preview
@Composable
fun BadgePreview() = TaigaMobileTheme {
    Row(modifier = Modifier.padding(10.dp)) {
        Badge("1", isActive = false)
        Spacer(Modifier.width(4.dp))
        Badge("12", isActive = true)
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/domain/repositories/IAuthRepository.kt<|end_filename|>
package io.eugenethedev.taigamobile.domain.repositories

import io.eugenethedev.taigamobile.domain.entities.AuthType

interface IAuthRepository {
    suspend fun auth(taigaServer: String, authType: AuthType, password: String, username: String)
}

<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/ProjectSelectorViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.ui.screens.projectselector.ProjectSelectorViewModel
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import io.eugenethedev.taigamobile.viewmodels.utils.testLazyPagingItems
import io.mockk.coVerify
import kotlin.test.Test

class ProjectSelectorViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: ProjectSelectorViewModel

    @BeforeTest
    fun setup() {
        viewModel = ProjectSelectorViewModel(mockAppComponent)
    }

    @Test
    fun `test list of projects`(): Unit = runBlocking {
        testLazyPagingItems(viewModel.projects, pageArg = { secondArg() }) {
            mockSearchRepository.searchProjects(any(), any())
        }
    }

    @Test
    fun `test select project`(): Unit = runBlocking {
        val mockProject = mockk<Project>(relaxed = true)
        viewModel.selectProject(mockProject)
        coVerify { mockSession.changeCurrentProject(any(), any()) }
    }
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/scrum/ScrumViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.scrum

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.domain.paging.CommonPagingSource
import io.eugenethedev.taigamobile.domain.repositories.ISprintsRepository
import io.eugenethedev.taigamobile.domain.repositories.ITasksRepository
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.NothingResult
import io.eugenethedev.taigamobile.ui.utils.asLazyPagingItems
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject

class ScrumViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var tasksRepository: ITasksRepository
    @Inject lateinit var sprintsRepository: ISprintsRepository
    @Inject lateinit var session: Session

    val projectName by lazy { session.currentProjectName }

    private var shouldReload = true

    init {
        appComponent.inject(this)
    }

    fun onOpen() {
        if (!shouldReload) return
        viewModelScope.launch {
            filters.loadOrError { tasksRepository.getFiltersData(CommonTaskType.UserStory) }
        }
        shouldReload = false
    }

    // stories

    val filters = MutableResultFlow<FiltersData>()
    val activeFilters = MutableStateFlow(FiltersData())
    @OptIn(ExperimentalCoroutinesApi::class)
    val stories by lazy {
        activeFilters.flatMapLatest { filters ->
            Pager(PagingConfig(CommonPagingSource.PAGE_SIZE, enablePlaceholders = false)) {
                CommonPagingSource { tasksRepository.getBacklogUserStories(it, filters) }
            }.flow
        }.asLazyPagingItems(viewModelScope)
    }
    
    fun selectFilters(filters: FiltersData) {
        activeFilters.value = filters
    }

    // sprints

    val openSprints by sprints(isClosed = false)
    val closedSprints by sprints(isClosed = true)

    private fun sprints(isClosed: Boolean) = lazy {
        Pager(PagingConfig(CommonPagingSource.PAGE_SIZE)) {
            CommonPagingSource { sprintsRepository.getSprints(it, isClosed) }
        }.flow.asLazyPagingItems(viewModelScope)
    }

    val createSprintResult = MutableResultFlow<Unit>(NothingResult())

    fun createSprint(name: String, start: LocalDate, end: LocalDate) = viewModelScope.launch {
        createSprintResult.loadOrError(R.string.permission_error) {
            sprintsRepository.createSprint(name, start, end)
            openSprints.refresh()
        }
    }

    init {
        session.currentProjectId.onEach {
            activeFilters.value = FiltersData()
            createSprintResult.value = NothingResult()
            stories.refresh()
            openSprints.refresh()
            closedSprints.refresh()
            shouldReload = true
        }.launchIn(viewModelScope)

        session.taskEdit.onEach {
            stories.refresh()
        }.launchIn(viewModelScope)

        session.sprintEdit.onEach {
            openSprints.refresh()
            closedSprints.refresh()
        }.launchIn(viewModelScope)
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/commontask/components/Selectors.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.commontask.components

import androidx.compose.foundation.layout.Column
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.ui.components.containers.ContainerBox
import io.eugenethedev.taigamobile.ui.components.lists.UserItem
import io.eugenethedev.taigamobile.ui.components.editors.SelectorList
import io.eugenethedev.taigamobile.ui.components.texts.CommonTaskTitle
import io.eugenethedev.taigamobile.ui.screens.commontask.CommonTaskViewModel
import io.eugenethedev.taigamobile.ui.screens.commontask.EditAction
import io.eugenethedev.taigamobile.ui.utils.toColor
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle

/**
 * Bunch of common selectors
 */
@Composable
fun Selectors(
    statusEntry: SelectorEntry<Status> = SelectorEntry(),
    typeEntry: SelectorEntry<Status> = SelectorEntry(),
    severityEntry: SelectorEntry<Status> = SelectorEntry(),
    priorityEntry: SelectorEntry<Status> = SelectorEntry(),
    sprintEntry: SelectorEntry<Sprint> = SelectorEntry(),
    epicsEntry: SelectorEntry<CommonTask> = SelectorEntry(),
    assigneesEntry: SelectorEntry<User> = SelectorEntry(),
    watchersEntry: SelectorEntry<User> = SelectorEntry(),
    swimlaneEntry: SelectorEntry<Swimlane> = SelectorEntry()
) {
    // status editor
    SelectorList(
        titleHintId = R.string.choose_status,
        items = statusEntry.edit.items,
        isVisible = statusEntry.isVisible,
        isSearchable = false,
        searchData = statusEntry.edit.searchItems,
        navigateBack = statusEntry.hide
    ) {
        StatusItem(
            status = it,
            onClick = {
                statusEntry.edit.selectItem(it)
                statusEntry.hide()
            }
        )
    }

    // type editor
    SelectorList(
        titleHintId = R.string.choose_type,
        items = typeEntry.edit.items,
        isVisible = typeEntry.isVisible,
        isSearchable = false,
        searchData = typeEntry.edit.searchItems,
        navigateBack = typeEntry.hide
    ) {
        StatusItem(
            status = it,
            onClick = {
                typeEntry.edit.selectItem(it)
                typeEntry.hide()
            }
        )
    }
    
    // severity editor
    SelectorList(
        titleHintId = R.string.choose_severity,
        items = severityEntry.edit.items,
        isVisible = severityEntry.isVisible,
        isSearchable = false,
        searchData = severityEntry.edit.searchItems,
        navigateBack = severityEntry.hide
    ) {
        StatusItem(
            status = it,
            onClick = {
                severityEntry.edit.selectItem(it)
                severityEntry.hide()
            }
        )
    }
    
    // priority editor
    SelectorList(
        titleHintId = R.string.choose_priority,
        items = priorityEntry.edit.items,
        isVisible = priorityEntry.isVisible,
        isSearchable = false,
        searchData = priorityEntry.edit.searchItems,
        navigateBack = priorityEntry.hide
    ) {
        StatusItem(
            status = it,
            onClick = {
                priorityEntry.edit.selectItem(it)
                priorityEntry.hide()
            }
        )
    }

    // sprint editor
    SelectorList(
        titleHintId = R.string.choose_sprint,
        itemsLazy = sprintEntry.edit.itemsLazy,
        isVisible = sprintEntry.isVisible,
        isSearchable = false,
        navigateBack = sprintEntry.hide
    ) {
        SprintItem(
            sprint = it,
            onClick = {
                sprintEntry.edit.selectItem(it)
                sprintEntry.hide()
            }
        )
    }

    // epics editor
    SelectorList(
        titleHintId = R.string.search_epics,
        itemsLazy = epicsEntry.edit.itemsLazy,
        isVisible = epicsEntry.isVisible,
        searchData = epicsEntry.edit.searchItems,
        navigateBack = epicsEntry.hide
    ) {
        EpicItem(
            epic = it,
            onClick = {
                epicsEntry.edit.selectItem(it)
                epicsEntry.hide()
            }
        )
    }


    // assignees editor
    SelectorList(
        titleHintId = R.string.search_members,
        items = assigneesEntry.edit.items,
        isVisible = assigneesEntry.isVisible,
        searchData = assigneesEntry.edit.searchItems,
        navigateBack = assigneesEntry.hide
    ) {
        MemberItem(
            member = it,
            onClick = {
                assigneesEntry.edit.selectItem(it)
                assigneesEntry.hide()
            }
        )
    }

    // watchers editor
    SelectorList(
        titleHintId = R.string.search_members,
        items = watchersEntry.edit.items,
        isVisible = watchersEntry.isVisible,
        searchData = watchersEntry.edit.searchItems,
        navigateBack = watchersEntry.hide
    ) {
        MemberItem(
            member = it,
            onClick = {
                watchersEntry.edit.selectItem(it)
                watchersEntry.hide()
            }
        )
    }
    
    // swimlane editor
    SelectorList(
        titleHintId = R.string.choose_swimlane,
        items = swimlaneEntry.edit.items,
        isVisible = swimlaneEntry.isVisible,
        isSearchable = false,
        navigateBack = swimlaneEntry.hide
    ) {
        SwimlaneItem(
            swimlane = it,
            onClick = {
                swimlaneEntry.edit.selectItem(it)
                swimlaneEntry.hide()
            }
        )
    }
}

class SelectorEntry<T : Any> (
    val edit: EditAction<T> = EditAction(),
    val isVisible: Boolean = false,
    val hide: () -> Unit = {}
)

@Composable
private fun StatusItem(
    status: Status,
    onClick: () -> Unit
) = ContainerBox(
    verticalPadding = 16.dp,
    onClick = onClick
) {
    Text(
        text = status.name,
        color = status.color.toColor()
    )
}

@Composable
private fun SprintItem(
    sprint: Sprint?,
    onClick: () -> Unit
) = ContainerBox(
    verticalPadding = 16.dp,
    onClick = onClick
) {
    val dateFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) }

    sprint.takeIf { it != CommonTaskViewModel.SPRINT_HEADER }?.also {
        Surface(
            contentColor = if (it.isClosed) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.onSurface
        ) {
            Column {
                Text(
                    if (it.isClosed) {
                        stringResource(R.string.closed_sprint_name_template).format(it.name)
                    } else {
                        it.name
                    }
                )

                Text(
                    text = stringResource(R.string.sprint_dates_template).format(
                        it.start.format(dateFormatter),
                        it.end.format(dateFormatter)
                    ),
                    style = MaterialTheme.typography.bodyMedium
                )
            }
        }
    } ?: run {
        Text(
            text = stringResource(R.string.move_to_backlog),
            color = MaterialTheme.colorScheme.primary
        )
    }
}

@Composable
private fun MemberItem(
    member: User,
    onClick: () -> Unit
) = ContainerBox(
    verticalPadding = 16.dp,
    onClick = onClick
) {
    UserItem(member)
}

@Composable
private fun EpicItem(
    epic: CommonTask,
    onClick: () -> Unit
) = ContainerBox(
    verticalPadding = 16.dp,
    onClick = onClick
) {
   CommonTaskTitle(
       ref = epic.ref,
       title = epic.title,
       indicatorColorsHex = epic.colors,
       isInactive = epic.isClosed
   )
}

@Composable
private fun SwimlaneItem(
    swimlane: Swimlane?,
    onClick: () -> Unit
) = ContainerBox(
    verticalPadding = 16.dp,
    onClick = onClick
) {
    fun getOrNull() = swimlane.takeIf { it != CommonTaskViewModel.SWIMLANE_HEADER }

    Text(
        text = getOrNull()?.name ?: stringResource(R.string.unclassifed),
        style = MaterialTheme.typography.bodyLarge,
        color = getOrNull()?.let { MaterialTheme.colorScheme.onSurface } ?: MaterialTheme.colorScheme.primary
    )
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/state/Session.kt<|end_filename|>
package io.eugenethedev.taigamobile.state

import android.content.Context
import androidx.core.content.edit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

/**
 * Global app state
 */
class Session(context: Context) {

    private val sharedPreferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
    private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())

    private val _refreshToken = MutableStateFlow(sharedPreferences.getString(REFRESH_TOKEN_KEY, "").orEmpty())
    private val _token = MutableStateFlow(sharedPreferences.getString(TOKEN_KEY, "").orEmpty())

    val refreshToken: StateFlow<String> = _refreshToken
    val token: StateFlow<String> = _token

    fun changeAuthCredentials(token: String, refreshToken: String) {
        sharedPreferences.edit {
            putString(TOKEN_KEY, token)
            putString(REFRESH_TOKEN_KEY, refreshToken)
        }
        _token.value = token
        _refreshToken.value = refreshToken
    }

    private val _server = MutableStateFlow(sharedPreferences.getString(SERVER_KEY, "").orEmpty())
    val server: StateFlow<String> = _server
    fun changeServer(value: String) {
        sharedPreferences.edit { putString(SERVER_KEY, value) }
        _server.value = value
    }

    private val _currentUserId = MutableStateFlow(sharedPreferences.getLong(USER_ID_KEY, -1))
    val currentUserId: StateFlow<Long> = _currentUserId
    fun changeCurrentUserId(value: Long) {
        sharedPreferences.edit { putLong(USER_ID_KEY, value) }
        _currentUserId.value = value
    }

    private val _currentProjectId = MutableStateFlow(sharedPreferences.getLong(PROJECT_ID_KEY, -1))
    private val _currentProjectName = MutableStateFlow(sharedPreferences.getString(PROJECT_NAME_KEY, "").orEmpty())

    val currentProjectId: StateFlow<Long> = _currentProjectId
    val currentProjectName: StateFlow<String> = _currentProjectName

    fun changeCurrentProject(id: Long, name: String) {
        sharedPreferences.edit {
            putLong(PROJECT_ID_KEY, id)
            putString(PROJECT_NAME_KEY, name)
        }
        _currentProjectId.value = id
        _currentProjectName.value = name
    }

    private fun checkLogged(token: String, refresh: String) = listOf(token, refresh).all { it.isNotEmpty() }
    val isLogged = combine(token, refreshToken, ::checkLogged)
        .stateIn(scope, SharingStarted.Eagerly, initialValue = checkLogged(token.value, refreshToken.value))

    private fun checkProjectSelected(id: Long) = id >= 0
    val isProjectSelected = currentProjectId.map(::checkProjectSelected)
        .stateIn(scope, SharingStarted.Eagerly, initialValue = checkProjectSelected(currentProjectId.value))

    fun reset() {
        changeAuthCredentials("", "")
        changeServer("")
        changeCurrentUserId(-1)
        changeCurrentProject(-1, "")
    }

    companion object {
        private const val PREFERENCES_NAME = "session"
        private const val TOKEN_KEY = "token"
        private const val REFRESH_TOKEN_KEY = "refresh_token"
        private const val SERVER_KEY = "server"
        private const val PROJECT_NAME_KEY = "project_name"
        private const val PROJECT_ID_KEY = "project_id"
        private const val USER_ID_KEY = "user_id"
    }

    // Events (no data, just dispatch update to subscribers)

    val taskEdit = EventFlow() // some task was edited
    val sprintEdit = EventFlow() // sprint was edited
}

/**
 * An empty class which describes basic event without any data (for the sake of update only)
 */
class Event
@Suppress("FunctionName")
fun EventFlow() = MutableSharedFlow<Event>()

suspend fun MutableSharedFlow<Event>.postUpdate() = emit(Event())
fun MutableSharedFlow<Event>.tryPostUpdate() = tryEmit(Event())

fun CoroutineScope.subscribeToAll(vararg flows: Flow<*>, action: () -> Unit) {
    flows.forEach {
        launch {
            it.collect { action() }
        }
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/theme/Dimens.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.theme

import androidx.compose.ui.unit.dp

val mainHorizontalScreenPadding = 12.dp
val commonVerticalPadding = 24.dp
val cardShadowElevation = 8.dp
val dialogTonalElevation = 6.dp
val kanbanBoardTonalElevation = 4.dp

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/dialogs/LoadingDialog.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.dialogs

import androidx.compose.foundation.layout.*
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.theme.shapes

/**
 * Alert with loader and text
 */
@Composable
fun LoadingDialog() = Dialog(onDismissRequest = { /* cannot dismiss */ }) {
    Surface(shape = shapes.medium) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            modifier = Modifier.padding(20.dp)
        ) {
            CircularProgressIndicator(
                modifier = Modifier.size(32.dp),
                strokeWidth = 3.dp,
                color = MaterialTheme.colorScheme.primary
            )

            Spacer(Modifier.width(16.dp))

            Text(stringResource(R.string.loading))
        }
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/repositories/utils/TestUtils.kt<|end_filename|>
package io.eugenethedev.taigamobile.repositories.utils

import io.eugenethedev.taigamobile.testdata.Task
import io.eugenethedev.taigamobile.testdata.TestData

class TestCommonTask(
    val title: String,
    val assigneeFullName: String? = null,
    val isClosed: Boolean
)

fun getTestTasks(projectId: Int): List<Task> {
    val fromSprints = TestData.projects[projectId].sprints.flatMap { it.tasks }
    val fromUserStories = TestData.projects[projectId].userstories.flatMap { it.tasks }

    return fromSprints + fromUserStories
}

<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/testdata/CommonTasks.kt<|end_filename|>
package io.eugenethedev.taigamobile.testdata

import io.eugenethedev.taigamobile.domain.entities.CommonTaskType

class Comment(
    val text: String,
    val author: User
)

sealed interface GenericTask {
    val title: String
    val creator: User
    val description: String
    val comments: List<Comment>
    val assignedTo: User?
    val watchers: List<User>
    val isClosed: Boolean
    val commonTaskType: CommonTaskType
}

class Epic(
    override val title: String,
    override val creator: User,
    override val description: String = "",
    override val comments: List<Comment> = emptyList(),
    override val assignedTo: User? = null,
    override val watchers: List<User> = emptyList(),
    override val isClosed: Boolean = false
) : GenericTask {
    override val commonTaskType = CommonTaskType.Epic
}

class UserStory(
    override val title: String,
    override val creator: User,
    override val description: String = "",
    override val comments: List<Comment> = emptyList(),
    override val assignedTo: User? = null,
    override val watchers: List<User> = emptyList(),
    override val isClosed: Boolean = false,
    val epics: List<Epic> = emptyList(),
    val tasks: List<Task> = emptyList(),
    val sprint: Sprint? = null
) : GenericTask {
    override val commonTaskType = CommonTaskType.UserStory
}

class Task(
    override val title: String,
    override val creator: User,
    override val description: String = "",
    override val comments: List<Comment> = emptyList(),
    override val assignedTo: User? = null,
    override val watchers: List<User> = emptyList(),
    override val isClosed: Boolean = false
) : GenericTask {
    override val commonTaskType = CommonTaskType.Task
}

class Issue(
    override val title: String,
    override val creator: User,
    override val description: String = "",
    override val comments: List<Comment> = emptyList(),
    override val assignedTo: User? = null,
    override val watchers: List<User> = emptyList(),
    override val isClosed: Boolean = false,
    val sprint: Sprint? = null
) : GenericTask {
    override val commonTaskType = CommonTaskType.Issue
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/utils/ComposableUtils.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.utils

import android.annotation.SuppressLint
import android.content.Context
import android.content.ContextWrapper
import androidx.activity.OnBackPressedCallback
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import io.eugenethedev.taigamobile.R
import timber.log.Timber
import kotlin.math.ln

/**
 * Utility function to handle press on back button
 */
@SuppressLint("ComposableNaming")
@Composable
fun onBackPressed(action: () -> Unit) {
    LocalContext
    (LocalContext.current as? OnBackPressedDispatcherOwner)?.onBackPressedDispatcher?.let { dispatcher ->
        val callback = remember {
            object : OnBackPressedCallback(true) {
                override fun handleOnBackPressed() {
                    action()
                    remove()
                }
            }.also {
                dispatcher.addCallback(it)
            }
        }

        DisposableEffect(Unit) {
            onDispose(callback::remove)
        }
    }
}


@SuppressLint("UnnecessaryComposedModifier")
fun Modifier.clickableUnindicated(
    enabled: Boolean = true,
    onClick: () -> Unit
) = composed {
    Modifier.clickable(
        interactionSource = remember { MutableInteractionSource() },
        indication = null,
        enabled = enabled,
        onClickLabel = null,
        role = null,
        onClick
    )
}


// Error functions
@Composable
inline fun Result<*>.subscribeOnError(onError: @Composable (message: Int) -> Unit) = (this as? ErrorResult)?.message?.let { onError(it) }

@SuppressLint("ComposableNaming")
@Composable
inline fun <T : Any> LazyPagingItems<T>.subscribeOnError(onError: @Composable (message: Int) -> Unit) {
    if (loadState.run { listOf(refresh, prepend, append) }.any { it is LoadState.Error }) {
        onError(R.string.common_error_message)
    }
}


val Context.activity: AppCompatActivity get() = when (this) {
    is AppCompatActivity -> this
    is ContextWrapper -> baseContext.activity
    else -> throw IllegalStateException("Context is not an Activity")
}

// Color functions

fun String.toColor(): Color = try {
    Color(android.graphics.Color.parseColor(this))
} catch (e: Exception) {
    Timber.w("'$this' $e")
    Color.Transparent
}

fun Color.toHex() = "#%08X".format(toArgb()).replace("#FF", "#")
// calculate optimal text color for colored background background
fun Color.textColor() = if (luminance() < 0.5) Color.White else Color.Black
// copy from library, because it is internal in library
fun ColorScheme.surfaceColorAtElevation(elevation: Dp, ): Color {
    if (elevation == 0.dp) return surface
    val alpha = ((4.5f * ln(elevation.value + 1)) + 2f) / 100f
    return primary.copy(alpha = alpha).compositeOver(surface)
}


<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/manager/TaigaTestInstanceManager.kt<|end_filename|>
package io.eugenethedev.taigamobile.manager

import com.google.gson.Gson
import com.google.gson.JsonArray
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import io.eugenethedev.taigamobile.data.api.CommonTaskPathSingular
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.testdata.Project
import io.eugenethedev.taigamobile.testdata.User
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.logging.HttpLoggingInterceptor
import java.net.ConnectException
import java.sql.Connection
import java.sql.DriverManager
import java.util.*

class TaigaTestInstanceManager(
    val host: String = "localhost",
) : Creations, Deletions {
    private val port: Int = 9000

    val baseUrl = "http://$host:$port"

    private val gson = Gson()
    private val client = OkHttpClient.Builder()
        .addInterceptor (
            HttpLoggingInterceptor(::println)
                .setLevel(HttpLoggingInterceptor.Level.BODY)
        )
        .build()

    // for internal use only, will be initialized only on .coldInit()
    lateinit var _userToUserData: Map<User, UserData>
    lateinit var _projectToProjectData: Map<Project, ProjectData>


    lateinit var activeUser: UserInfo

    fun Request.Builder.apiEndpoint(endpoint: String): Request.Builder {
        if (endpoint.startsWith("/") || endpoint.endsWith("/")) throw IllegalArgumentException("Endpoint must not have leading or trailing slashes")
        return url("$baseUrl/api/v1/$endpoint")
    }

    fun String.toJsonBody() = toRequestBody("application/json".toMediaType())
    fun Request.Builder.withAuth(user: User) = header("Authorization", "Bearer ${_userToUserData[user]!!.accessToken}")
    fun Request.execute() = client.newCall(this).execute()
    fun Response.toJsonObject(): JsonObject = gson.fromJson(body?.string(), JsonElement::class.java).asJsonObject
    fun Response.toJsonArray(): JsonArray = gson.fromJson(body?.string(), JsonElement::class.java).asJsonArray

    fun Response.successOrThrow() {
        if (!isSuccessful) throw IllegalStateException("Got response code $code")
    }

    inline fun tx(action: Connection.() -> Unit) {
        DriverManager.getConnection(
            "jdbc:postgresql://$host:5432/taiga",
            Properties().apply {
                put("user", "taiga")
                put("password", "<PASSWORD>")
                put("driver", "org.postgresql.Driver")
            }
        ).use { it.action() }
    }

    val _snapshotPostfix = "snapshot"

    fun Connection.getAllProdTables() = sequence<String> {
        createStatement()
            .executeQuery("select * from information_schema.tables where table_schema = 'public' and table_name not like '%$_snapshotPostfix' and table_name not like '%django%'").let {
                while (it.next()) {
                    yield(it.getString("table_name"))
                }
            }
    }

    fun setup() {
        try {
            checkInstanceRunning()

            tx {
                createStatement().executeQuery("select count(*) as cnt from users_user where email_token is not null").let {
                    it.next()
                    if (it.getLong("cnt") != 0L) throw IllegalStateException("Taiga instance is already initialized. You need to .clear() it first")
                }
            }

            initData()
        } catch (e: Exception) {
            throw IllegalStateException("Some of the init steps were not finished successfully", e)
        }

    }

    fun clear() {
        try {
            clearTables()
            _userToUserData = emptyMap()
            _projectToProjectData = emptyMap()
        } catch (e: Exception) {
            throw IllegalStateException("Some of the clear steps were not finished successfully", e)
        }
    }

    private fun checkInstanceRunning() {
        val checkRequest = Request.Builder()
            .apiEndpoint("")
            .build()

        try {
            return checkRequest.execute().successOrThrow()
        } catch (e: ConnectException) {
            throw IllegalArgumentException("No Taiga instance is running on $baseUrl")
        }
    }

    /**
     * Some util functions
     */
    fun getClosedStatusId(commonTaskType: CommonTaskType, projectId: Long, user: User) = Request.Builder()
            .apiEndpoint("${CommonTaskPathSingular(commonTaskType).path}-statuses?project=$projectId")
            .get()
            .withAuth(user)
            .build()
            .execute()
            .toJsonArray()
            .map { it.asJsonObject }
            .find { it.get("is_closed").asBoolean }
            ?.get("id")
            ?.asLong ?: throw IllegalStateException("No closed statuses? Really?")
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/editors/SelectorList.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.editors

import androidx.annotation.StringRes
import androidx.compose.animation.*
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemsIndexed as itemsIndexedLazy
import com.google.accompanist.insets.navigationBarsHeight
import io.eugenethedev.taigamobile.ui.components.appbars.AppBarWithBackButton
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.utils.onBackPressed

/**
 * Selector list, which expands from bottom to top.
 * Could be used to search and select something
 */
@Composable
fun <T : Any> SelectorList(
    @StringRes titleHintId: Int,
    items: List<T> = emptyList(),
    itemsLazy: LazyPagingItems<T>? = null,
    key: ((index: Int, item: T) -> Any)? = null, // used to preserve position with lazy items
    isVisible: Boolean = false,
    isItemsLoading: Boolean = false,
    isSearchable: Boolean = true,
    searchData: (String) -> Unit = {},
    navigateBack: () -> Unit = {},
    animationDurationMillis: Int = SelectorListConstants.defaultAnimDurationMillis,
    itemContent: @Composable (T) -> Unit
) = AnimatedVisibility(
    visibleState = remember { MutableTransitionState(false) }
        .apply { targetState = isVisible },
    enter = slideInVertically(initialOffsetY = { it }, animationSpec = tween(animationDurationMillis)),
    exit = slideOutVertically(targetOffsetY = { it }, animationSpec = tween(animationDurationMillis))
) {
    var query by remember { mutableStateOf(TextFieldValue()) }

    onBackPressed(navigateBack)

    val isLoading = itemsLazy
        ?.run { loadState.refresh is LoadState.Loading || loadState.append is LoadState.Loading }
        ?: isItemsLoading

    val lastIndex = itemsLazy?.itemCount?.minus(1) ?: items.lastIndex

    val listItemContent: @Composable LazyItemScope.(Int, T?) -> Unit = lambda@ { index, item ->
        if (item == null) return@lambda

        itemContent(item)

        if (index < lastIndex) {
            Divider(
                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
                color = MaterialTheme.colorScheme.outline
            )
        }
    }

    Column(
        modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        AppBarWithBackButton(
            title = {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.CenterStart
                ) {
                    if (isSearchable) {
                        TextFieldWithHint(
                            hintId = titleHintId,
                            value = query,
                            onValueChange = { query = it },
                            singleLine = true,
                            onSearchClick = { searchData(query.text) }
                        )
                    } else {
                        Text(stringResource(titleHintId))
                    }
                }
            },
            navigateBack = navigateBack
        )

        LazyColumn {
            itemsLazy?.let {
                itemsIndexedLazy(
                    items = it,
                    key = key,
                    itemContent = listItemContent
                )
            } ?: itemsIndexed(items, itemContent = listItemContent)

            item {
                if (isLoading) {
                    DotsLoader()
                }
                Spacer(Modifier.navigationBarsHeight(8.dp))
            }
        }
    }
}

object SelectorListConstants {
    const val defaultAnimDurationMillis = 200
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/kanban/KanbanViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.kanban

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.domain.repositories.ITasksRepository
import io.eugenethedev.taigamobile.domain.repositories.IUsersRepository
import io.eugenethedev.taigamobile.state.subscribeToAll
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.NothingResult
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import javax.inject.Inject

class KanbanViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var tasksRepository: ITasksRepository
    @Inject lateinit var usersRepository: IUsersRepository
    @Inject lateinit var session: Session

    val projectName by lazy { session.currentProjectName }

    val statuses = MutableResultFlow<List<Status>>()
    val team = MutableResultFlow<List<User>>()
    val stories = MutableResultFlow<List<CommonTaskExtended>>()
    val swimlanes = MutableResultFlow<List<Swimlane?>>()

    val selectedSwimlane = MutableStateFlow<Swimlane?>(null)

    private var shouldReload = true

    init {
        appComponent.inject(this)
    }

    fun onOpen() = viewModelScope.launch {
        if (!shouldReload) return@launch
        joinAll(
            launch {
                statuses.loadOrError(preserveValue = false) { tasksRepository.getStatuses(CommonTaskType.UserStory) }
            },
            launch {
                team.loadOrError(preserveValue = false) { usersRepository.getTeam().map { it.toUser() } }
            },
            launch {
                stories.loadOrError(preserveValue = false) { tasksRepository.getAllUserStories() }
            },
            launch {
                swimlanes.loadOrError {
                    listOf(null) + tasksRepository.getSwimlanes() // prepend null to show "unclassified" swimlane
                }
            }
        )
        shouldReload = false
    }

    fun selectSwimlane(swimlane: Swimlane?) {
        selectedSwimlane.value = swimlane
    }

    init {
        viewModelScope.subscribeToAll(session.currentProjectId, session.taskEdit) {
            statuses.value = NothingResult()
            team.value = NothingResult()
            stories.value = NothingResult()
            swimlanes.value = NothingResult()
            shouldReload = true
        }
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/EpicsViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.ui.screens.epics.EpicsViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.eugenethedev.taigamobile.viewmodels.utils.notFoundException
import io.eugenethedev.taigamobile.viewmodels.utils.testLazyPagingItems
import io.mockk.coEvery
import kotlinx.coroutines.runBlocking
import kotlin.test.*

class EpicsViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: EpicsViewModel

    @BeforeTest
    fun setup() {
        viewModel = EpicsViewModel(mockAppComponent)
    }

    @Test
    fun `test on open`() = runBlocking {
        val filtersData = FiltersData()
        coEvery { mockTaskRepository.getFiltersData(CommonTaskType.Epic) } returns filtersData
        viewModel.onOpen()
        assertResultEquals(SuccessResult(filtersData), viewModel.filters.value)
    }

    @Test
    fun `test on open error`(): Unit = runBlocking {
        coEvery { mockTaskRepository.getFiltersData(any()) } throws notFoundException
        viewModel.onOpen()
        assertIs<ErrorResult<FiltersData>>(viewModel.filters.value)
    }

    @Test
    fun `test epics list with filters`(): Unit = runBlocking {
        val query = "query"
        testLazyPagingItems(viewModel.epics) { mockTaskRepository.getEpics(any(), eq(FiltersData())) }
        viewModel.selectFilters(FiltersData(query = query))
        testLazyPagingItems(viewModel.epics) { mockTaskRepository.getEpics(any(), eq(FiltersData(query = query))) }
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/theme/TaigaMobileRippleTheme.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.theme

import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material.ripple.RippleTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color

object TaigaMobileRippleTheme : RippleTheme {
    @Composable
    override fun defaultColor(): Color = MaterialTheme.colorScheme.secondary

    @Composable
    override fun rippleAlpha() = RippleAlpha(
        pressedAlpha = 0.10f,
        focusedAlpha = 0.12f,
        draggedAlpha = 0.08f,
        hoveredAlpha = 0.04f
    )
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/domain/entities/AuthType.kt<|end_filename|>
package io.eugenethedev.taigamobile.domain.entities

enum class AuthType {
    Normal,
    LDAP
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/utils/ViewModelUtils.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.utils

import androidx.annotation.StringRes
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.compose.LazyPagingItems
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import kotlin.reflect.full.callSuspend
import kotlin.reflect.full.declaredFunctions
import timber.log.Timber
import io.eugenethedev.taigamobile.R

inline fun <T> MutableResultFlow<T>.loadOrError(
    @StringRes messageId: Int = R.string.common_error_message,
    preserveValue: Boolean = true,
    showLoading: Boolean = true,
    load: () -> T?
) {
    if (showLoading) {
        value = LoadingResult(value.data.takeIf { preserveValue })
    }

    value = try {
        SuccessResult(load())
    } catch (e: Exception) {
        Timber.wtf(e)
        ErrorResult(messageId)
    }
}

/**
 * Convert Flow to instance of LazyPagingItems
 * TODO fix of https://issuetracker.google.com/issues/177245496
 */
@Suppress("UNCHECKED_CAST")
fun <T : Any> Flow<PagingData<T>>.asLazyPagingItems(scope: CoroutineScope) = cachedIn(scope).let { flow ->
    // yep, working with instance of LazyPagingItems via reflection
    LazyPagingItems::class.constructors.toList().first().run {
        call(flow) as LazyPagingItems<T>
    }.also { items ->
        scope.launch {
            LazyPagingItems::class.declaredFunctions.find { it.name == "collectPagingData" }!!.apply {
                callSuspend(items)
            }
        }
        scope.launch {
            LazyPagingItems::class.declaredFunctions.find { it.name == "collectLoadState" }!!.apply {
                callSuspend(items)
            }
        }
    }
}

<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/LoginViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.AuthType
import io.eugenethedev.taigamobile.ui.screens.login.LoginViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.accessDeniedException
import io.mockk.*
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs

class LoginViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: LoginViewModel

    @BeforeTest
    fun setup() {
        viewModel = LoginViewModel(mockAppComponent)
    }

    @Test
    fun `test login`(): Unit = runBlocking {
        val password = "password"

        coEvery { mockAuthRepository.auth(any(), any(), neq(password), any()) } throws accessDeniedException

        viewModel.login("", AuthType.Normal, "", password)
        assertIs<SuccessResult<Unit>>(viewModel.loginResult.value)

        viewModel.login("", AuthType.Normal, "", password + "<PASSWORD>")
        assertIs<ErrorResult<Unit>>(viewModel.loginResult.value)
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/IssuesViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.ui.screens.issues.IssuesViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.eugenethedev.taigamobile.viewmodels.utils.notFoundException
import io.eugenethedev.taigamobile.viewmodels.utils.testLazyPagingItems
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs

class IssuesViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: IssuesViewModel

    @BeforeTest
    fun setup() {
        viewModel = IssuesViewModel(mockAppComponent)
    }

    @Test
    fun `test on open`(): Unit = runBlocking {
        val mockFiltersData = mockk<FiltersData>(relaxed = true)

        coEvery { mockTaskRepository.getFiltersData(any()) } returns mockFiltersData
        viewModel.onOpen()

        assertResultEquals(SuccessResult(mockFiltersData), viewModel.filters.value)
    }

    @Test
    fun `test on open error`(): Unit = runBlocking {
        coEvery { mockTaskRepository.getFiltersData(any()) } throws notFoundException
        viewModel.onOpen()

        assertIs<ErrorResult<FiltersData>>(viewModel.filters.value)
    }

    @Test
    fun `test select filters`(): Unit = runBlocking {
        val mockFiltersData = mockk<FiltersData>()

        viewModel.selectFilters(mockFiltersData)
        assertIs<FiltersData>(viewModel.activeFilters.value)
    }

    @Test
    fun `test issues list with filters`(): Unit = runBlocking {
        val query = "query"
        testLazyPagingItems(viewModel.issues) {
            mockTaskRepository.getIssues(
                any(),
                eq(FiltersData())
            )
        }
        viewModel.selectFilters(FiltersData(query = query))
        testLazyPagingItems(viewModel.issues) {
            mockTaskRepository.getIssues(
                any(),
                eq(FiltersData(query = query))
            )
        }
    }
}

<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/testdata/Sprint.kt<|end_filename|>
package io.eugenethedev.taigamobile.testdata

import java.time.LocalDate

class Sprint(
    val name: String,
    val start: LocalDate,
    val end: LocalDate,
    val creator: User,
    val tasks: List<Task> = emptyList()
)

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/loaders/DotsLoader.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.loaders

import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
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.scale
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme

/**
 * Three dots pulsing
 */
@Composable
fun DotsLoader() {
    val delayUnit = 300

    val infiniteTransition = rememberInfiniteTransition()

    @Composable
    fun animateScaleWithDelay(delay: Int) = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 0f,
        animationSpec = infiniteRepeatable(
            animation = keyframes {
                durationMillis = delayUnit * 4
                0f at delay with LinearEasing
                1f at delay + delayUnit with LinearEasing
                0f at delay + delayUnit * 2
            }
        )
    )

    val scale1 by animateScaleWithDelay(0)
    val scale2 by animateScaleWithDelay(delayUnit)
    val scale3 by animateScaleWithDelay(delayUnit * 2)

    Row(
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.Center,
        modifier = Modifier.fillMaxWidth().padding(4.dp)
    ) {
        val spaceSize = 2.dp

        Dot(scale1)
        Spacer(Modifier.width(spaceSize))
        Dot(scale2)
        Spacer(Modifier.width(spaceSize))
        Dot(scale3)
    }
}

@Composable
private fun Dot(
    scale: Float
) = Spacer(
    Modifier.size(12.dp)
        .scale(scale)
        .background(
            color = MaterialTheme.colorScheme.primary,
            shape = CircleShape
        )
)

@Preview(showBackground = true)
@Composable
fun DotsLoaderPreview() = TaigaMobileTheme {
    DotsLoader()
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/projectselector/ProjectSelectorViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.projectselector

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.Project
import io.eugenethedev.taigamobile.domain.paging.CommonPagingSource
import io.eugenethedev.taigamobile.domain.repositories.ISearchRepository
import io.eugenethedev.taigamobile.ui.utils.asLazyPagingItems
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import javax.inject.Inject

class ProjectSelectorViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var searchRepository: ISearchRepository
    @Inject lateinit var session: Session

    val currentProjectId by lazy { session.currentProjectId }

    init {
        appComponent.inject(this)
    }

    fun onOpen() {
        projects.refresh()
    }

    private val projectsQuery = MutableStateFlow("")
    @OptIn(ExperimentalCoroutinesApi::class)
    val projects by lazy {
        projectsQuery.flatMapLatest { query ->
            Pager(PagingConfig(CommonPagingSource.PAGE_SIZE)) {
                CommonPagingSource { searchRepository.searchProjects(query, it) }
            }.flow
        }.asLazyPagingItems(viewModelScope)
    }

    fun searchProjects(query: String) {
        projectsQuery.value = query
    }

    fun selectProject(project: Project) {
        session.changeCurrentProject(project.id, project.name)
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/kanban/KanbanScreen.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.kanban

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.ui.utils.LoadingResult
import io.eugenethedev.taigamobile.ui.components.appbars.ProjectAppBar
import io.eugenethedev.taigamobile.ui.components.loaders.CircularLoader
import io.eugenethedev.taigamobile.ui.screens.main.Routes
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.utils.navigateToCreateTaskScreen
import io.eugenethedev.taigamobile.ui.utils.navigateToTaskScreen
import io.eugenethedev.taigamobile.ui.utils.subscribeOnError

@Composable
fun KanbanScreen(
    navController: NavController,
    onError: @Composable (message: Int) -> Unit = {}
) {
    val viewModel: KanbanViewModel = viewModel()
    LaunchedEffect(Unit) {
        viewModel.onOpen()
    }

    val projectName by viewModel.projectName.collectAsState()

    val swimlanes by viewModel.swimlanes.collectAsState()
    swimlanes.subscribeOnError(onError)

    val statuses by viewModel.statuses.collectAsState()
    statuses.subscribeOnError(onError)

    val team by viewModel.team.collectAsState()
    team.subscribeOnError(onError)

    val stories by viewModel.stories.collectAsState()
    stories.subscribeOnError(onError)

    val selectedSwimlane by viewModel.selectedSwimlane.collectAsState()

    KanbanScreenContent(
        projectName = projectName,
        isLoading = listOf(swimlanes, team, stories).any { it is LoadingResult },
        statuses = statuses.data.orEmpty(),
        stories = stories.data.orEmpty(),
        team = team.data.orEmpty(),
        swimlanes = swimlanes.data.orEmpty(),
        selectSwimlane = viewModel::selectSwimlane,
        selectedSwimlane = selectedSwimlane,
        navigateToStory = { id, ref -> navController.navigateToTaskScreen(id, CommonTaskType.UserStory, ref) },
        onTitleClick = { navController.navigate(Routes.projectsSelector) },
        navigateBack = navController::popBackStack,
        navigateToCreateTask = { statusId, swimlaneId ->
            navController.navigateToCreateTaskScreen(CommonTaskType.UserStory, statusId = statusId, swimlaneId = swimlaneId)
        }
    )
}

@Composable
fun KanbanScreenContent(
    projectName: String,
    isLoading: Boolean = false,
    statuses: List<Status> = emptyList(),
    stories: List<CommonTaskExtended> = emptyList(),
    team: List<User> = emptyList(),
    swimlanes: List<Swimlane?> = emptyList(),
    selectSwimlane: (Swimlane?) -> Unit = {},
    selectedSwimlane: Swimlane? = null,
    navigateToStory: (id: Long, ref: Int) -> Unit = { _, _ -> },
    onTitleClick: () -> Unit = {},
    navigateBack: () -> Unit = {},
    navigateToCreateTask: (statusId: Long, swinlanaeId: Long?) -> Unit = { _, _ -> }
) = Column(
    modifier = Modifier.fillMaxSize(),
    horizontalAlignment = Alignment.Start
) {
    ProjectAppBar(
        projectName = projectName,
        onTitleClick = onTitleClick,
        navigateBack = navigateBack
    )

    if (isLoading) {
        Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier.fillMaxSize()
        ) {
            CircularLoader()
        }
    } else {
        KanbanBoard(
            statuses = statuses,
            stories = stories,
            team = team,
            swimlanes = swimlanes,
            selectSwimlane = selectSwimlane,
            selectedSwimlane = selectedSwimlane,
            navigateToStory = navigateToStory,
            navigateToCreateTask = navigateToCreateTask
        )
    }
}

@Preview(showBackground = true)
@Composable
fun KanbanScreenPreview() = TaigaMobileTheme {
    KanbanScreenContent(
        projectName = "Cool project"
    )
}


<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/manager/Data.kt<|end_filename|>
package io.eugenethedev.taigamobile.manager

import io.eugenethedev.taigamobile.testdata.*

class UserData(
    val id: Long,
    val accessToken: String,
    val refreshToken: String
)

class UserInfo(
    val user: User,
    val data: UserData,
    val projects: Map<Long, Project>
)

class ProjectData(
    val id: Long
) {
    lateinit var epicToId: Map<Epic, Long>
    lateinit var sprintToId: Map<Sprint, Long>
    lateinit var userstoryToId: Map<UserStory, Long>
    lateinit var issueToId: Map<Issue, Long>
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/SettingsViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.User
import io.eugenethedev.taigamobile.state.ThemeSetting
import io.eugenethedev.taigamobile.ui.screens.settings.SettingsViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import kotlinx.coroutines.runBlocking
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.eugenethedev.taigamobile.viewmodels.utils.notFoundException
import io.mockk.*
import io.mockk.coVerify
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs


class SettingsViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: SettingsViewModel

    @BeforeTest
    fun setup() {
        viewModel = SettingsViewModel(mockAppComponent)
    }

    @BeforeTest
    fun settingOfUsers() {
        coEvery { mockUsersRepository.getMe() } returns mockUser
    }

    companion object {
        val mockUser = mockk<User>(relaxed = true)
    }

    @Test
    fun `test on open`(): Unit = runBlocking {
        viewModel.onOpen()
        assertResultEquals(SuccessResult(mockUser), viewModel.user.value)

        coEvery { mockUsersRepository.getMe() } throws notFoundException
        viewModel.onOpen()
        assertIs<ErrorResult<User>>(viewModel.user.value)
    }

    @Test
    fun `test logout`(): Unit = runBlocking {
        viewModel.logout()
        coVerify { mockSession.reset() }
    }

    @Test
    fun `test switch theme`(): Unit = runBlocking {
        val themeSetting = ThemeSetting.Light
        viewModel.switchTheme(themeSetting)
        coVerify { mockSettings.changeThemeSetting(eq(themeSetting)) }
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/KanbanViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.ui.screens.kanban.KanbanViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.eugenethedev.taigamobile.viewmodels.utils.notFoundException
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs

class KanbanViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: KanbanViewModel

    @BeforeTest
    fun setup() {
        viewModel = KanbanViewModel(mockAppComponent)
    }

    @Test
    fun `test on open`(): Unit = runBlocking {
        val listStatuses = mockk<List<Status>>(relaxed = true)
        val listTeamMembers = mockk<List<TeamMember>>(relaxed = true)
        val listCommonTaskExtended = mockk<List<CommonTaskExtended>>(relaxed = true)
        val listSwimlanes = mockk<List<Swimlane>>(relaxed = true)

        coEvery { mockTaskRepository.getStatuses(any()) } returns listStatuses
        coEvery { mockUsersRepository.getTeam() } returns listTeamMembers
        coEvery { mockTaskRepository.getAllUserStories() } returns listCommonTaskExtended
        coEvery { mockTaskRepository.getSwimlanes() } returns listSwimlanes
        viewModel.onOpen()

        assertResultEquals(SuccessResult(listStatuses), viewModel.statuses.value)
        assertResultEquals(SuccessResult(listTeamMembers.map { it.toUser() }), viewModel.team.value)
        assertResultEquals(SuccessResult(listCommonTaskExtended), viewModel.stories.value)
        assertResultEquals(SuccessResult(listOf(null) + listSwimlanes), viewModel.swimlanes.value)
    }

    @Test
    fun `test on open error`(): Unit = runBlocking {
        coEvery { mockTaskRepository.getStatuses(any()) } throws notFoundException
        coEvery { mockUsersRepository.getTeam() } throws notFoundException
        coEvery { mockTaskRepository.getAllUserStories() } throws notFoundException
        coEvery { mockTaskRepository.getSwimlanes() } throws notFoundException
        viewModel.onOpen()

        assertIs<ErrorResult<List<Status>>>(viewModel.statuses.value)
        assertIs<ErrorResult<List<User>>>(viewModel.team.value)
        assertIs<ErrorResult<List<CommonTaskExtended>>>(viewModel.stories.value)
        assertIs<ErrorResult<List<Swimlane?>>>(viewModel.swimlanes.value)
    }

    @Test
    fun `test select swimlane`(): Unit = runBlocking {
        val mockSwimlane = mockk<Swimlane>(relaxed = true)

        viewModel.selectSwimlane(mockSwimlane)
        assertIs<Swimlane?>(viewModel.selectedSwimlane.value)

        viewModel.selectSwimlane(null)
        assertIs<Swimlane?>(viewModel.selectedSwimlane.value)
    }
}

<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/manager/Deletions.kt<|end_filename|>
package io.eugenethedev.taigamobile.manager

interface Deletions {
    fun TaigaTestInstanceManager.clearTables() = tx {
        getAllProdTables().forEach {
            createStatement().execute("alter table $it disable trigger all")
            createStatement().execute("delete from $it where true")
            createStatement().execute("alter table $it enable trigger all")
        }
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/main/MainViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.main

import androidx.lifecycle.*
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.state.Settings
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import javax.inject.Inject

class MainViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var session: Session
    @Inject lateinit var settings: Settings

    val isLogged by lazy { session.isLogged }
    val isProjectSelected by lazy { session.isProjectSelected }

    val theme by lazy { settings.themeSetting }

    init {
        appComponent.inject(this)
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/createtask/CreateTaskScreen.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.createtask

import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.ui.utils.LoadingResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.ui.components.editors.TaskEditor
import io.eugenethedev.taigamobile.ui.components.dialogs.LoadingDialog
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.utils.navigateToTaskScreen
import io.eugenethedev.taigamobile.ui.utils.subscribeOnError

@Composable
fun CreateTaskScreen(
    navController: NavController,
    commonTaskType: CommonTaskType,
    parentId: Long? = null,
    sprintId: Long? = null,
    statusId: Long? = null,
    swimlaneId: Long? = null,
    onError: @Composable (message: Int) -> Unit = {},
) {
    val viewModel: CreateTaskViewModel = viewModel()

    val creationResult by viewModel.creationResult.collectAsState()
    creationResult.subscribeOnError(onError)

    creationResult.takeIf { it is SuccessResult }?.data?.let {
        LaunchedEffect(Unit) {
            navController.popBackStack()
            navController.navigateToTaskScreen(it.id, it.taskType, it.ref)
        }
    }

    CreateTaskScreenContent(
        title = stringResource(
            when (commonTaskType) {
                CommonTaskType.UserStory -> R.string.create_userstory
                CommonTaskType.Task -> R.string.create_task
                CommonTaskType.Epic -> R.string.create_epic
                CommonTaskType.Issue -> R.string.create_issue
            }
        ),
        isLoading = creationResult is LoadingResult,
        createTask = { title, description -> viewModel.createTask(commonTaskType, title, description, parentId, sprintId, statusId, swimlaneId) },
        navigateBack = navController::popBackStack
    )
}

@Composable
fun CreateTaskScreenContent(
    title: String,
    isLoading: Boolean = false,
    createTask: (title: String, description: String) -> Unit = { _, _ -> },
    navigateBack: () -> Unit = {}
) = Box(Modifier.fillMaxSize()) {
    TaskEditor(
        toolbarText = title,
        onSaveClick = createTask,
        navigateBack = navigateBack
    )

    if (isLoading) {
        LoadingDialog()
    }
}

@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
fun CreateTaskScreenPreview() = TaigaMobileTheme {
    CreateTaskScreenContent(
        title = "Create task"
    )
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/buttons/PlusButton.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.buttons

import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R

@Composable
fun PlusButton(
    modifier: Modifier = Modifier,
    tint: Color = MaterialTheme.colorScheme.primary,
    onClick: () -> Unit = {}
) = IconButton(
    onClick = onClick,
    modifier = Modifier.padding(top = 2.dp)
        .size(32.dp)
        .clip(CircleShape)
) {
    Icon(
        painter = painterResource(R.drawable.ic_add),
        contentDescription = null,
        tint = tint,
        modifier = modifier.size(26.dp)
    )
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/scrum/ScrumScreen.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.scrum

import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.material3.ButtonDefaults.buttonColors
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.items
import com.google.accompanist.insets.navigationBarsHeight
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.rememberPagerState
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.Sprint
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.ui.components.TasksFiltersWithLazyList
import io.eugenethedev.taigamobile.ui.utils.LoadingResult
import io.eugenethedev.taigamobile.ui.components.appbars.ProjectAppBar
import io.eugenethedev.taigamobile.ui.components.buttons.PlusButton
import io.eugenethedev.taigamobile.ui.components.containers.ContainerBox
import io.eugenethedev.taigamobile.ui.components.containers.HorizontalTabbedPager
import io.eugenethedev.taigamobile.ui.components.containers.Tab
import io.eugenethedev.taigamobile.ui.components.dialogs.EditSprintDialog
import io.eugenethedev.taigamobile.ui.components.dialogs.LoadingDialog
import io.eugenethedev.taigamobile.ui.components.lists.SimpleTasksListWithTitle
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.components.texts.NothingToSeeHereText
import io.eugenethedev.taigamobile.ui.screens.main.Routes
import io.eugenethedev.taigamobile.ui.theme.commonVerticalPadding
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
import io.eugenethedev.taigamobile.ui.utils.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle


@Composable
fun ScrumScreen(
    navController: NavController,
    onError: @Composable (message: Int) -> Unit = {},
) {
    val viewModel: ScrumViewModel = viewModel()
    LaunchedEffect(Unit) {
        viewModel.onOpen()
    }

    val projectName by viewModel.projectName.collectAsState()

    val stories = viewModel.stories
    stories.subscribeOnError {
        onError(R.string.common_error_message)
    }

    val openSprints = viewModel.openSprints
    openSprints.subscribeOnError(onError)

    val closedSprints = viewModel.closedSprints
    closedSprints.subscribeOnError(onError)

    val createSprintResult by viewModel.createSprintResult.collectAsState()
    createSprintResult.subscribeOnError(onError)

    val filters by viewModel.filters.collectAsState()
    filters.subscribeOnError(onError)

    val activeFilters by viewModel.activeFilters.collectAsState()

    ScrumScreenContent(
        projectName = projectName,
        onTitleClick = { navController.navigate(Routes.projectsSelector) },
        stories = stories,
        filters = filters.data ?: FiltersData(),
        activeFilters = activeFilters,
        selectFilters = viewModel::selectFilters,
        openSprints = openSprints,
        closedSprints = closedSprints,
        isCreateSprintLoading = createSprintResult is LoadingResult,
        navigateToBoard = {
            navController.navigateToSprint(it.id)
        },
        navigateToTask = navController::navigateToTaskScreen,
        navigateToCreateTask = { navController.navigateToCreateTaskScreen(CommonTaskType.UserStory) },
        createSprint = viewModel::createSprint
    )
}

@OptIn(ExperimentalPagerApi::class)
@Composable
fun ScrumScreenContent(
    projectName: String,
    onTitleClick: () -> Unit = {},
    stories: LazyPagingItems<CommonTask>? = null,
    filters: FiltersData = FiltersData(),
    activeFilters: FiltersData = FiltersData(),
    selectFilters: (FiltersData) -> Unit = {},
    openSprints: LazyPagingItems<Sprint>? = null,
    closedSprints: LazyPagingItems<Sprint>? = null,
    isCreateSprintLoading: Boolean = false,
    navigateToBoard: (Sprint) -> Unit = {},
    navigateToTask: NavigateToTask = { _, _, _ -> },
    navigateToCreateTask: () -> Unit = {},
    createSprint: (name: String, start: LocalDate, end: LocalDate) -> Unit = { _, _, _ -> }
) = Column(
    modifier = Modifier.fillMaxSize(),
    horizontalAlignment = Alignment.Start
) {
    val pagerState = rememberPagerState(pageCount = Tabs.values().size)
    var isCreateSprintDialogVisible by remember { mutableStateOf(false) }

    ProjectAppBar(
        projectName = projectName,
        actions = {
            PlusButton(
                onClick = when (Tabs.values()[pagerState.currentPage]) {
                    Tabs.Backlog -> navigateToCreateTask
                    Tabs.Sprints -> { { isCreateSprintDialogVisible = true } }
                }
            )
        },
        onTitleClick = onTitleClick
    )

    if (isCreateSprintDialogVisible) {
        EditSprintDialog(
            onConfirm = { name, start, end ->
                createSprint(name, start, end)
                isCreateSprintDialogVisible = false
            },
            onDismiss = { isCreateSprintDialogVisible = false }
        )
    }

    if (isCreateSprintLoading) {
        LoadingDialog()
    }

    HorizontalTabbedPager(
        tabs = Tabs.values(),
        modifier = Modifier.fillMaxSize(),
        pagerState = pagerState
    ) { page ->
        when (Tabs.values()[page]) {
            Tabs.Backlog -> BacklogTabContent(
                stories = stories,
                filters = filters,
                activeFilters = activeFilters,
                selectFilters = selectFilters,
                navigateToTask = navigateToTask
            )
            Tabs.Sprints -> SprintsTabContent(
                openSprints = openSprints,
                closedSprints = closedSprints,
                navigateToBoard = navigateToBoard
            )
        }
    }

}

private enum class Tabs(@StringRes override val titleId: Int) : Tab {
    Backlog(R.string.backlog),
    Sprints(R.string.sprints_title)
}

@Composable
private fun BacklogTabContent(
    stories: LazyPagingItems<CommonTask>?,
    filters: FiltersData = FiltersData(),
    activeFilters: FiltersData = FiltersData(),
    selectFilters: (FiltersData) -> Unit = {},
    navigateToTask: NavigateToTask
) = Column(
    horizontalAlignment = Alignment.CenterHorizontally,
    modifier = Modifier.fillMaxWidth()
) {
    TasksFiltersWithLazyList(
        filters = filters,
        activeFilters = activeFilters,
        selectFilters = selectFilters
    ) {
        SimpleTasksListWithTitle(
            commonTasksLazy = stories,
            keysHash = activeFilters.hashCode(),
            navigateToTask = navigateToTask,
            horizontalPadding = mainHorizontalScreenPadding,
            bottomPadding = commonVerticalPadding
        )
    }
}

@Composable
private fun SprintsTabContent(
    openSprints: LazyPagingItems<Sprint>?,
    closedSprints: LazyPagingItems<Sprint>?,
    navigateToBoard: (Sprint) -> Unit,
) {
    if (openSprints == null || closedSprints == null) return

    var isClosedSprintsVisible by rememberSaveable { mutableStateOf(false) }

    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        items(openSprints, key = { it.id }) {
            if (it == null) return@items
            SprintItem(
                sprint = it,
                navigateToBoard = navigateToBoard
            )
        }

        item {
            if (openSprints.loadState.refresh is LoadState.Loading || openSprints.loadState.append is LoadState.Loading) {
                DotsLoader()
            }
        }

        item {
            FilledTonalButton(onClick = { isClosedSprintsVisible = !isClosedSprintsVisible }) {
                Text(stringResource(if (isClosedSprintsVisible) R.string.hide_closed_sprints else R.string.show_closed_sprints))
            }
        }

        if (isClosedSprintsVisible) {
            items(closedSprints, key = { it.id }) {
                if (it == null) return@items
                SprintItem(
                    sprint = it,
                    navigateToBoard = navigateToBoard
                )
            }

            item {
                if (closedSprints.loadState.refresh is LoadState.Loading || closedSprints.loadState.append is LoadState.Loading) {
                    DotsLoader()
                }
            }
        }

        item {
            if (openSprints.itemCount == 0 && closedSprints.itemCount == 0) {
                NothingToSeeHereText()
            }

            Spacer(Modifier.navigationBarsHeight(8.dp))
        }
    }
}

@Composable
private fun SprintItem(
    sprint: Sprint,
    navigateToBoard: (Sprint) -> Unit = {}
) = ContainerBox {
    val dateFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) }

    Row(
        horizontalArrangement = Arrangement.SpaceBetween,
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier.fillMaxWidth()
    ) {
        Column(Modifier.weight(0.7f)) {
            Text(
                text = sprint.name,
                style = MaterialTheme.typography.titleMedium
            )

            Text(
                stringResource(R.string.sprint_dates_template).format(
                    sprint.start.format(dateFormatter),
                    sprint.end.format(dateFormatter)
                )
            )

            Row {
                Text(
                    text = stringResource(R.string.stories_count_template).format(sprint.storiesCount),
                    color = MaterialTheme.colorScheme.primary,
                    style = MaterialTheme.typography.bodyMedium
                )

                Spacer(Modifier.width(6.dp))

                if (sprint.isClosed) {
                    Text(
                        text = stringResource(R.string.closed),
                        color = MaterialTheme.colorScheme.outline,
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }

        buttonColors().let {
            val containerColor by it.containerColor(!sprint.isClosed)
            val contentColor by it.contentColor(!sprint.isClosed)

            Button(
                onClick = { navigateToBoard(sprint) },
                modifier = Modifier.weight(0.3f),
                colors = buttonColors(
                    containerColor = containerColor,
                    contentColor = contentColor
                )
            ) {
                Text(stringResource(R.string.taskboard))
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun SprintPreview() = TaigaMobileTheme {
    SprintItem(
        Sprint(
            id = 0L,
            name = "1 sprint",
            order = 0,
            start = LocalDate.now(),
            end = LocalDate.now(),
            storiesCount = 4,
            isClosed = true
        )
    )
}

@Preview(showBackground = true)
@Composable
fun ScrumScreenPreview() = TaigaMobileTheme {
    ScrumScreenContent("Lol")
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/state/Settings.kt<|end_filename|>
package io.eugenethedev.taigamobile.state

import android.content.Context
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

class Settings(context: Context) {
    private val sharedPreferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)

    private val _themeSetting =  MutableStateFlow(ThemeSetting.values()[sharedPreferences.getInt(THEME, 0)])
    val themeSetting: StateFlow<ThemeSetting> = _themeSetting
    fun changeThemeSetting(value: ThemeSetting) {
        sharedPreferences.edit { putInt(THEME, value.ordinal) }
        _themeSetting.value = value
    }

    companion object {
        private const val PREFERENCES_NAME = "settings"
        private const val THEME = "theme"
    }
}

enum class ThemeSetting {
    System,
    Light,
    Dark
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/domain/repositories/ITasksRepository.kt<|end_filename|>
package io.eugenethedev.taigamobile.domain.repositories

import io.eugenethedev.taigamobile.domain.entities.*
import java.io.InputStream
import java.time.LocalDate

interface ITasksRepository {
    suspend fun getWorkingOn(): List<CommonTask>
    suspend fun getWatching(): List<CommonTask>

    suspend fun getStatuses(commonTaskType: CommonTaskType): List<Status>
    suspend fun getStatusByType(commonTaskType: CommonTaskType, statusType: StatusType): List<Status>

    suspend fun getFiltersData(commonTaskType: CommonTaskType): FiltersData

    suspend fun getEpics(page: Int, filters: FiltersData): List<CommonTask>

    suspend fun getAllUserStories(): List<CommonTaskExtended> // for stories kanban
    suspend fun getBacklogUserStories(page: Int, filters: FiltersData): List<CommonTask>
    suspend fun getEpicUserStories(epicId: Long): List<CommonTask>

    suspend fun getUserStoryTasks(storyId: Long): List<CommonTask>

    suspend fun getIssues(page: Int, filters: FiltersData): List<CommonTask>

    suspend fun getCommonTask(commonTaskId: Long, type: CommonTaskType): CommonTaskExtended

    suspend fun getComments(commonTaskId: Long, type: CommonTaskType): List<Comment>

    suspend fun getAttachments(commonTaskId: Long, type: CommonTaskType): List<Attachment>

    suspend fun getCustomFields(commonTaskId: Long, type: CommonTaskType): CustomFields

    suspend fun getAllTags(commonTaskType: CommonTaskType): List<Tag>

    suspend fun getSwimlanes(): List<Swimlane>

    // edit related
    suspend fun changeStatus(commonTaskId: Long, commonTaskType: CommonTaskType, statusId: Long, statusType: StatusType, version: Int)
    suspend fun changeSprint(commonTaskId: Long, commonTaskType: CommonTaskType, sprintId: Long?, version: Int)
    suspend fun linkToEpic(epicId: Long, userStoryId: Long)
    suspend fun unlinkFromEpic(epicId: Long, userStoryId: Long)
    suspend fun changeAssignees(commonTaskId: Long, commonTaskType: CommonTaskType, assignees: List<Long>, version: Int)
    suspend fun changeWatchers(commonTaskId: Long, commonTaskType: CommonTaskType, watchers: List<Long>, version: Int)
    suspend fun changeDueDate(commonTaskId: Long, commonTaskType: CommonTaskType, date: LocalDate?, version: Int)
    suspend fun createComment(commonTaskId: Long, commonTaskType: CommonTaskType, comment: String, version: Int)
    suspend fun deleteComment(commonTaskId: Long, commonTaskType: CommonTaskType, commentId: String)
    suspend fun editCommonTask(commonTaskId: Long, commonTaskType: CommonTaskType, title: String, description: String, version: Int)

    suspend fun createCommonTask(
        commonTaskType: CommonTaskType,
        title: String,
        description: String,
        parentId: Long?,
        sprintId: Long?,
        statusId: Long?,
        swimlaneId: Long?
    ): CommonTask

    suspend fun deleteCommonTask(commonTaskType: CommonTaskType, commonTaskId: Long)

    suspend fun promoteCommonTaskToUserStory(commonTaskId: Long, commonTaskType: CommonTaskType): CommonTask

    suspend fun addAttachment(commonTaskId: Long, commonTaskType: CommonTaskType, fileName: String, inputStream: InputStream)
    suspend fun deleteAttachment(commonTaskType: CommonTaskType, attachmentId: Long)

    suspend fun editCustomFields(commonTaskType: CommonTaskType, commonTaskId: Long, fields: Map<Long, CustomFieldValue?>, version: Int)

    suspend fun editTags(commonTaskType: CommonTaskType, commonTaskId: Long, tags: List<Tag>, version: Int)

    suspend fun changeUserStorySwimlane(userStoryId: Long, swimlaneId: Long?, version: Int)

    suspend fun changeEpicColor(epicId: Long, color: String, version: Int)
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/dashboard/DashboardViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.dashboard

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.repositories.ITasksRepository
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.NothingResult
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import javax.inject.Inject

class DashboardViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var tasksRepository: ITasksRepository
    @Inject lateinit var session: Session

    val workingOn = MutableResultFlow<List<CommonTask>>()
    val watching = MutableResultFlow<List<CommonTask>>()

    private var shouldReload = true

    init {
        appComponent.inject(this)
    }

    fun onOpen() = viewModelScope.launch {
        if (!shouldReload) return@launch
        joinAll(
            launch { workingOn.loadOrError(preserveValue = false) { tasksRepository.getWorkingOn() } },
            launch { watching.loadOrError(preserveValue = false) { tasksRepository.getWatching() } }
        )
        shouldReload = false
    }

    fun changeCurrentProject(commonTask: CommonTask) {
        commonTask.projectInfo.apply {
            session.changeCurrentProject(id, name)
        }
    }

    init {
        session.taskEdit.onEach {
            workingOn.value = NothingResult()
            watching.value = NothingResult()
            shouldReload = true
        }.launchIn(viewModelScope)
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/editors/TaskEditor.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.editors

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.insets.imePadding
import com.google.accompanist.insets.navigationBarsHeight
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.components.appbars.AppBarWithBackButton
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
import io.eugenethedev.taigamobile.ui.utils.onBackPressed

@Composable
fun TaskEditor(
    toolbarText: String,
    title: String = "",
    description: String = "",
    onSaveClick: (title: String, description: String) -> Unit = { _, _ -> },
    navigateBack: () -> Unit = {}
) = Column(
    modifier = Modifier
        .fillMaxSize()
        .background(MaterialTheme.colorScheme.surface)
        .imePadding()
) {
    onBackPressed(navigateBack)

    var titleInput by remember { mutableStateOf(TextFieldValue(title)) }
    var descriptionInput by remember { mutableStateOf(TextFieldValue(description)) }

    AppBarWithBackButton(
        title = { Text(toolbarText) },
        actions = {
            IconButton(
                onClick = {
                    titleInput.text.trim().takeIf { it.isNotEmpty() }?.let {
                        onSaveClick(it, descriptionInput.text.trim())
                    }
                }
            ) {
                Icon(
                    painter = painterResource(R.drawable.ic_save),
                    contentDescription = null,
                    tint = MaterialTheme.colorScheme.primary
                )
            }
        },
        navigateBack = navigateBack
    )

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
            .padding(horizontal = mainHorizontalScreenPadding)
    ) {

        Spacer(Modifier.height(8.dp))

        TextFieldWithHint(
            hintId = R.string.title_hint,
            value = titleInput,
            onValueChange = { titleInput = it },
            style = MaterialTheme.typography.headlineSmall
        )

        Spacer(Modifier.height(16.dp))

        TextFieldWithHint(
            hintId = R.string.description_hint,
            value = descriptionInput,
            onValueChange = { descriptionInput = it },
        )

        Spacer(Modifier.navigationBarsHeight(8.dp))
    }

}

@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
fun TaskEditorPreview() = TaigaMobileTheme {
    TaskEditor("Edit")
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/team/TeamViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.team

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.TeamMember
import io.eugenethedev.taigamobile.domain.repositories.IUsersRepository
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.NothingResult
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject

class TeamViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var usersRepository: IUsersRepository
    @Inject lateinit var session: Session

    val projectName by lazy { session.currentProjectName }
    val team = MutableResultFlow<List<TeamMember>?>()

    private var shouldReload = true

    init {
        appComponent.inject(this)
    }

    fun onOpen() {
        if (!shouldReload) return
        viewModelScope.launch {
            team.loadOrError { usersRepository.getTeam() }
        }
        shouldReload = false
    }

    init {
        session.currentProjectId.onEach {
            team.value = NothingResult()
            shouldReload = true
        }.launchIn(viewModelScope)
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/createtask/CreateTaskViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.createtask

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.repositories.ITasksRepository
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.state.postUpdate
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.launch
import javax.inject.Inject

class CreateTaskViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var tasksRepository: ITasksRepository
    @Inject lateinit var session: Session

    init {
        appComponent.inject(this)
    }

    val creationResult = MutableResultFlow<CommonTask>()

    fun createTask(
        commonTaskType: CommonTaskType,
        title: String,
        description: String,
        parentId: Long? = null,
        sprintId: Long? = null,
        statusId: Long? = null,
        swimlaneId: Long? = null
    ) = viewModelScope.launch {
        creationResult.loadOrError(preserveValue = false) {
            tasksRepository.createCommonTask(commonTaskType, title, description, parentId, sprintId, statusId, swimlaneId).also {
                session.taskEdit.postUpdate()
            }
        }
    }
}

<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/repositories/AuthRepositoryTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.repositories

import io.eugenethedev.taigamobile.data.repositories.AuthRepository
import io.eugenethedev.taigamobile.domain.entities.AuthType
import io.eugenethedev.taigamobile.domain.repositories.IAuthRepository
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse

class AuthRepositoryTest : BaseRepositoryTest() {
    lateinit var authRepository: IAuthRepository

    @BeforeTest
    fun setupAuthRepositoryTest() {
        authRepository = AuthRepository(mockTaigaApi, mockSession)
    }

    @Test
    fun `test basic auth`() = runBlocking {
        val serverUrl = mockSession.server.value
        mockSession.reset()

        assertFalse(mockSession.isLogged.value)

        with(activeUser) {
            authRepository.auth(serverUrl, AuthType.Normal, user.password, user.username)
            assertEquals(serverUrl, mockSession.server.value)
            assertEquals(data.id, mockSession.currentUserId.value)
        }
    }

    @Test
    fun `test refresh auth token`() = runBlocking {
        mockSession.changeAuthCredentials("wrong token", mockSession.refreshToken.value) // simulate token expiration (token is not valid anymore)
        mockTaigaApi.getProject(activeUser.projects.keys.first()) // successful response (because refresh happens)
        return@runBlocking
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/repositories/UsersRepositoryTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.repositories

import io.eugenethedev.taigamobile.data.repositories.UsersRepository
import io.eugenethedev.taigamobile.domain.repositories.IUsersRepository
import io.eugenethedev.taigamobile.testdata.TestData
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import org.junit.Test
import kotlin.test.assertEquals

class UsersRepositoryTest : BaseRepositoryTest() {
    lateinit var usersRepository: IUsersRepository

    @BeforeTest
    fun setupUsersRepositoryTest() {
        usersRepository = UsersRepository(mockTaigaApi, mockSession)
    }

    @Test
    fun `test getMe`() = runBlocking {
        val user = usersRepository.getMe()

        assertEquals(
            expected = user.username,
            actual = TestData.activeUser.username
        )
        assertEquals(
            expected = user.fullName,
            actual = TestData.activeUser.fullName
        )
    }

    @Test
    fun `test getTeam`() = runBlocking {
        val team = usersRepository.getTeam().sortedBy { it.username }
        val testTeam = TestData.users.sortedBy { it.username }

        team.forEachIndexed { index, member ->
            assertEquals(
                expected = testTeam[index].username,
                actual = member.username
            )
            assertEquals(
                expected = testTeam[index].fullName,
                actual = member.name
            )

            var count = 0
            count += TestData.projects[0].issues.filter { it.creator.username == member.username }
                .count()
            count += TestData.projects[0].issues.filter { it.isClosed && it.assignedTo?.username == member.username }
                .count()
            TestData.projects[0].sprints.map { sprint ->
                count += sprint.tasks.filter {
                    it.isClosed && it.assignedTo?.username == member.username
                }.count()
            }
            TestData.projects[0].userstories.map { userStory ->
                count += userStory.tasks.filter {
                    it.isClosed && it.assignedTo?.username == member.username
                }.count()
            }
            assertEquals(
                expected = count,
                actual = member.totalPower
            )
        }
    }

    @Test
    fun `test getUser`() = runBlocking {
        val testUsers = TestData.users.sortedBy { it.username }

        usersRepository.getTeam().sortedBy { it.username }.forEachIndexed { index, member ->
            val user = usersRepository.getUser(member.id)
            assertEquals(
                expected = testUsers[index].username,
                actual = user.username
            )
            assertEquals(
                expected = testUsers[index].fullName,
                actual = user.fullName
            )
        }
    }
}

<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/testdata/TestData.kt<|end_filename|>
package io.eugenethedev.taigamobile.testdata

import java.time.LocalDate

object TestData {
    val users = listOf(
        User(
            username = "test",
            password = "<PASSWORD>",
            email = "<EMAIL>",
            fullName = "Test Test"
        ),
        User(
            username = "another_test",
            password = "<PASSWORD>",
            email = "<EMAIL>",
            fullName = "Another Test"
        )
    )

    val activeUser = users[1]

    val projects = listOf(
        object : Project(
            name = "Cool Test Project",
            description = "And cool description",
            creator = users[0],
            team = users,
            defaultRoleName = "Testing role"
        ) {
            override val epics = listOf(
                Epic(
                    title = "Epic #1",
                    creator = team[0],
                    description = "Epic 1",
                    comments = listOf(
                        Comment("Important", team[0]),
                        Comment("Yes", team[1])
                    ),
                    assignedTo = team[0],
                    isClosed = true
                ),
                Epic(
                    title = "Epic #2",
                    creator = team[0],
                    watchers = team
                )
            )

            override val sprints = listOf(
                Sprint(
                    name = "Sprint 1",
                    start = LocalDate.of(2021, 9, 1),
                    end = LocalDate.of(2021, 9, 15),
                    creator = team[0]
                ),

                Sprint(
                    name = "Sprint 2",
                    start = LocalDate.of(2021, 9, 16),
                    end = LocalDate.of(2021, 9, 30),
                    creator = team[1],
                    tasks = listOf(
                        Task(
                            title = "Storyless task 1",
                            creator = team[1],
                            description = "Desc",
                            comments = listOf(
                                Comment("Some", team[0]),
                                Comment("Comment", team[1])
                            ),
                            assignedTo = team[1],
                            isClosed = true
                        ),
                        Task("Storyless task 2", team[0])
                    )
                )
            )

            override val userstories = listOf(
                UserStory(
                    title = "Cool user story",
                    creator = team[0],
                    description = "Very interesting description",
                    epics = epics,
                    comments = listOf(
                        Comment("Comment", team[0])
                    )
                ),
                UserStory(
                    title = "Another user story",
                    creator = team[1],
                    description = "Description",
                    assignedTo = team[0],
                    isClosed = true,
                    sprint = sprints[0],
                    tasks = listOf(
                        Task(
                            title = "Subtask 1",
                            creator = team[1],
                            assignedTo = team[0],
                            isClosed = true
                        ),
                        Task(
                            title = "Subtask 2",
                            creator = team[0],
                            isClosed = true
                        )
                    )
                ),
                UserStory(
                    title = "Great story",
                    creator = team[0],
                    description = "",
                    watchers = listOf(team[0]),
                    sprint = sprints[1],
                    tasks = listOf(
                        Task("Task #42", team[0], watchers = listOf(team[1])),
                        Task("Task #24", team[1])
                    )
                )
            )

            override val issues = listOf(
                Issue(
                    title = "Issue #1",
                    creator = team[1],
                    description = "Problem #1",
                    assignedTo = team[0],
                    isClosed = true,
                    sprint = sprints[0]
                ),
                Issue(
                    title = "Issue #2",
                    creator = team[1],
                    description = "Problem #2",
                    sprint = sprints[1]
                ),
                Issue(
                    title = "Issue #3",
                    creator = team[0],
                    description = "Problem #3"
                )
            )
        },
        object : Project(
            name = "Cool Test Project",
            description = "And cool description",
            creator = users[1],
            team = users,
            defaultRoleName = "Testing role"
        ) {
            override val epics = listOf(
                Epic(
                    title = "Epic #1",
                    creator = team[1],
                    description = "Epic 1",
                    comments = listOf(
                        Comment("Important", team[1]),
                        Comment("Yes", team[0])
                    ),
                    assignedTo = team[1],
                    isClosed = true
                ),
                Epic(
                    title = "Epic #2",
                    creator = team[0],
                    watchers = team
                )
            )

            override val sprints = listOf(
                Sprint(
                    name = "Sprint 1",
                    start = LocalDate.of(2021, 9, 1),
                    end = LocalDate.of(2021, 9, 15),
                    creator = team[1]
                ),

                Sprint(
                    name = "Sprint 2",
                    start = LocalDate.of(2021, 9, 16),
                    end = LocalDate.of(2021, 9, 30),
                    creator = team[0],
                    tasks = listOf(
                        Task(
                            title = "Storyless task 1",
                            creator = team[0],
                            description = "Desc",
                            comments = listOf(
                                Comment("Some", team[1]),
                                Comment("Comment", team[0])
                            ),
                            assignedTo = team[0],
                            isClosed = true
                        ),
                        Task("Storyless task 2", team[1])
                    )
                )
            )

            override val userstories = listOf(
                UserStory(
                    title = "Cool user story",
                    creator = team[1],
                    description = "Very interesting description",
                    epics = epics,
                    comments = listOf(
                        Comment("Comment", team[1])
                    )
                ),
                UserStory(
                    title = "Another user story",
                    creator = team[0],
                    description = "Description",
                    assignedTo = team[1],
                    isClosed = true,
                    sprint = sprints[0],
                    tasks = listOf(
                        Task(
                            title = "Subtask 1",
                            creator = team[1],
                            assignedTo = team[1],
                            isClosed = true
                        ),
                        Task(
                            title = "Subtask 2",
                            creator = team[0],
                            isClosed = true
                        )
                    )
                ),
                UserStory(
                    title = "Great story",
                    creator = team[1],
                    description = "",
                    watchers = listOf(team[1]),
                    sprint = sprints[1],
                    tasks = listOf(
                        Task("Task #42", team[0], watchers = listOf(team[0])),
                        Task("Task #24", team[1])
                    )
                )
            )

            override val issues = listOf(
                Issue(
                    title = "Issue #1",
                    creator = team[1],
                    description = "Problem #1",
                    assignedTo = team[1],
                    isClosed = true,
                    sprint = sprints[0]
                ),
                Issue(
                    title = "Issue #2",
                    creator = team[0],
                    description = "Problem #2",
                    sprint = sprints[1]
                ),
                Issue(
                    title = "Issue #3",
                    creator = team[0],
                    description = "Problem #3"
                )
            )
        }
    )
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/repositories/SearchRepositoryTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.repositories

import io.eugenethedev.taigamobile.data.repositories.SearchRepository
import io.eugenethedev.taigamobile.domain.repositories.ISearchRepository
import io.eugenethedev.taigamobile.testdata.TestData
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals

class SearchRepositoryTest : BaseRepositoryTest() {
    lateinit var searchRepository: ISearchRepository

    @BeforeTest
    fun setupSearchRepositoryTest() {
        searchRepository = SearchRepository(mockTaigaApi)
    }

    @Test
    fun `test simple search projects`() = runBlocking {
        val projects = searchRepository.searchProjects("", 1)
        assertEquals(TestData.projects.size, projects.size)
        assertEquals(
            expected = TestData.projects.map { it.name },
            actual = projects.map { it.name }
        )
    }

    @Test
    fun `test empty response on wrong query or page`() = runBlocking {
        assertEquals(0, searchRepository.searchProjects("", 100).size)
        assertEquals(0, searchRepository.searchProjects("dumb string", 1).size)
    }
}


<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/testdata/User.kt<|end_filename|>
package io.eugenethedev.taigamobile.testdata

class User(
    val username: String,
    val password: String,
    val email: String,
    val fullName: String
)


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/utils/Result.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.utils

import androidx.annotation.StringRes
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow

/**
 * Convenient way to dispatch events
 */
sealed class Result<T>(
    val data: T? = null,
    @StringRes val message: Int? = null
)

class SuccessResult<T>(data: T?) : Result<T>(data = data)
class ErrorResult<T>(@StringRes message: Int? = null) : Result<T>(message = message)
class LoadingResult<T>(data: T? = null) : Result<T>(data = data)
class NothingResult<T> : Result<T>()

typealias MutableResultFlow<T> = MutableStateFlow<Result<T>>
fun <T> MutableResultFlow(value: Result<T> = NothingResult()) = MutableStateFlow(value)
typealias ResultFlow<T> = StateFlow<Result<T>>


<|start_filename|>app/src/sharedTest/kotlin/io/eugenethedev/taigamobile/testdata/Project.kt<|end_filename|>
package io.eugenethedev.taigamobile.testdata

abstract class Project(
    val name: String,
    val description: String,
    val creator: User,
    val team: List<User>,
    val defaultRoleName: String
) {
    /**
     * Test data for content inside project. Note, that you have to name every task DIFFERENT.
     * Data is placed here in the same order in which it will be initialized
     */
    open val epics: List<Epic> = emptyList()
    open val sprints: List<Sprint> = emptyList()
    open val userstories: List<UserStory> = emptyList()
    open val issues: List<Issue> = emptyList()
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/commontask/components/CommonTaskCustomFields.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.commontask.components

import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Divider
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CustomField
import io.eugenethedev.taigamobile.domain.entities.CustomFieldValue
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.components.texts.SectionTitle
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
import io.eugenethedev.taigamobile.ui.screens.commontask.Loaders

fun LazyListScope.CommonTaskCustomFields(
    customFields: List<CustomField>,
    customFieldsValues: Map<Long, CustomFieldValue?>,
    onValueChange: (Long, CustomFieldValue?) -> Unit,
    editActions: EditActions,
    loaders: Loaders
) {
    item {
        SectionTitle(text = stringResource(R.string.custom_fields))
    }

    itemsIndexed(customFields) { index, item ->
        CustomField(
            customField = item,
            value = customFieldsValues[item.id],
            onValueChange = { onValueChange(item.id, it) },
            onSaveClick = { editActions.editCustomField(item, customFieldsValues[item.id]) }
        )

        if (index < customFields.lastIndex) {
            Divider(
                modifier = Modifier.padding(top = 16.dp, bottom = 12.dp),
                color = MaterialTheme.colorScheme.outline
            )
        }
    }

    item {
        if (loaders.isCustomFieldsLoading) {
            Spacer(Modifier.height(8.dp))
            DotsLoader()
        }
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/commontask/components/CommonTaskBelongsTo.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.commontask.components

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CommonTaskExtended
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.entities.EpicShortInfo
import io.eugenethedev.taigamobile.domain.entities.UserStoryShortInfo
import io.eugenethedev.taigamobile.ui.components.dialogs.ConfirmActionDialog
import io.eugenethedev.taigamobile.ui.components.buttons.AddButton
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.components.texts.CommonTaskTitle
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
import io.eugenethedev.taigamobile.ui.screens.commontask.NavigationActions
import io.eugenethedev.taigamobile.ui.utils.clickableUnindicated

fun LazyListScope.CommonTaskBelongsTo(
    commonTask: CommonTaskExtended,
    navigationActions: NavigationActions,
    editActions: EditActions,
    showEpicsSelector: () -> Unit
) {
    // belongs to (epics)
    if (commonTask.taskType == CommonTaskType.UserStory) {
        items(commonTask.epicsShortInfo) {
            EpicItemWithAction(
                epic = it,
                onClick = { navigationActions.navigateToTask(it.id, CommonTaskType.Epic, it.ref) },
                onRemoveClick = { editActions.unlinkFromEpic(it) }
            )

            Spacer(Modifier.height(2.dp))
        }

        item {
            if (editActions.editEpics.isResultLoading) {
                DotsLoader()
            }

            AddButton(
                text = stringResource(R.string.link_to_epic),
                onClick = { showEpicsSelector() }
            )
        }
    }

    // belongs to (story)
    if (commonTask.taskType == CommonTaskType.Task) {
        commonTask.userStoryShortInfo?.let {
            item {
                UserStoryItem(
                    story = it,
                    onClick = {
                        navigationActions.navigateToTask(it.id, CommonTaskType.UserStory, it.ref)
                    }
                )
            }
        }
    }
}

@Composable
private fun EpicItemWithAction(
    epic: EpicShortInfo,
    onClick: () -> Unit,
    onRemoveClick: () -> Unit,
) = Row(
    verticalAlignment = Alignment.CenterVertically,
    horizontalArrangement = Arrangement.SpaceBetween
) {
    var isAlertVisible by remember { mutableStateOf(false) }

    if (isAlertVisible) {
        ConfirmActionDialog(
            title = stringResource(R.string.unlink_epic_title),
            text = stringResource(R.string.unlink_epic_text),
            onConfirm = {
                isAlertVisible = false
                onRemoveClick()
            },
            onDismiss = { isAlertVisible = false },
            iconId = R.drawable.ic_remove
        )
    }

    CommonTaskTitle(
        ref = epic.ref,
        title = epic.title,
        textColor = MaterialTheme.colorScheme.primary,
        indicatorColorsHex = listOf(epic.color),
        modifier = Modifier
            .weight(1f)
            .padding(end = 4.dp)
            .clickableUnindicated(onClick = onClick)
    )

    IconButton(
        onClick = { isAlertVisible = true },
        modifier = Modifier
            .size(32.dp)
            .clip(CircleShape)
    ) {
        Icon(
            painter = painterResource(R.drawable.ic_remove),
            contentDescription = null,
            tint = MaterialTheme.colorScheme.outline
        )
    }
}

@Composable
private fun UserStoryItem(
    story: UserStoryShortInfo,
    onClick: () -> Unit
) = CommonTaskTitle(
    ref = story.ref,
    title = story.title,
    textColor = MaterialTheme.colorScheme.primary,
    indicatorColorsHex = story.epicColors,
    modifier = Modifier.clickableUnindicated(onClick = onClick)
)


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/DashboardViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.ui.screens.dashboard.DashboardViewModel
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.eugenethedev.taigamobile.viewmodels.utils.notFoundException
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs

class DashboardViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: DashboardViewModel

    @BeforeTest
    fun setup() {
        viewModel = DashboardViewModel(mockAppComponent)
    }

    @BeforeTest
    fun settingsOfLaunch() {
        coEvery { mockTaskRepository.getWorkingOn() } returns mockWorkingOn
        coEvery { mockTaskRepository.getWatching() } returns mockWatching
    }

    companion object {
        val mockWorkingOn = mockk<List<CommonTask>>(relaxed = true)
        val mockWatching = mockk<List<CommonTask>>(relaxed = true)
    }

    @Test
    fun `test on open`(): Unit = runBlocking {
        viewModel.onOpen()
        assertResultEquals(SuccessResult(mockWorkingOn), viewModel.workingOn.value)
        assertResultEquals(SuccessResult(mockWatching), viewModel.watching.value)
    }

    @Test
    fun `test on open error`(): Unit = runBlocking {
        coEvery { mockTaskRepository.getWorkingOn() } throws notFoundException
        viewModel.onOpen()
        assertIs<ErrorResult<List<CommonTask>>>(viewModel.workingOn.value)
    }

    @Test
    fun `change current project`(): Unit = runBlocking {
        val mockCommonTask = mockk<CommonTask>(relaxed = true)
        viewModel.changeCurrentProject(mockCommonTask)
        coVerify { mockSession.changeCurrentProject(any(), any()) }
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/TeamViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.TeamMember
import io.eugenethedev.taigamobile.ui.screens.team.TeamViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.eugenethedev.taigamobile.viewmodels.utils.notFoundException
import io.mockk.*
import kotlinx.coroutines.runBlocking
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs


class TeamViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: TeamViewModel

    @BeforeTest
    fun setup() {
        viewModel = TeamViewModel(mockAppComponent)
    }

    @Test
    fun `test on open`(): Unit = runBlocking {
        val listTeamMember = mockk<List<TeamMember>>(relaxed = true)

        coEvery { mockUsersRepository.getTeam() } returns listTeamMember
        viewModel.onOpen()

        assertResultEquals(SuccessResult(listTeamMember), viewModel.team.value)

    }

    @Test
    fun `test on open error`(): Unit = runBlocking {
        coEvery { mockUsersRepository.getTeam() } throws notFoundException
        viewModel.onOpen()

        assertIs<ErrorResult<List<TeamMember>>>(viewModel.team.value)

    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/texts/CommonTaskTitle.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.texts

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowRow
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.Tag
import io.eugenethedev.taigamobile.ui.components.Chip
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.utils.textColor
import io.eugenethedev.taigamobile.ui.utils.toColor

/**
 * Text with colored dots (indicators) at the end and tags
 */
@Composable
fun CommonTaskTitle(
    ref: Int,
    title: String,
    modifier: Modifier = Modifier,
    isInactive: Boolean = false,
    textColor: Color = MaterialTheme.colorScheme.onSurface,
    indicatorColorsHex: List<String> = emptyList(),
    tags: List<Tag> = emptyList()
) = Column(modifier = modifier) {
    Text(
        text = buildAnnotatedString {
            if (isInactive) pushStyle(SpanStyle(color = MaterialTheme.colorScheme.outline, textDecoration = TextDecoration.LineThrough))
            append(stringResource(R.string.title_with_ref_pattern).format(ref, title))
            if (isInactive) pop()

            append(" ")

            indicatorColorsHex.forEach {
                pushStyle(SpanStyle(color = it.toColor()))
                append("⬤") // 2B24
                pop()
            }
        },
        color = textColor,
        style = MaterialTheme.typography.titleMedium
    )

    if (tags.isNotEmpty()) {
        Spacer(Modifier.height(4.dp))

        FlowRow(
            mainAxisSpacing = 4.dp,
            crossAxisSpacing = 4.dp
        ) {
            tags.forEach {
                val bgColor = it.color.toColor()

                Chip(color = bgColor) {
                    Text(
                        text = it.name,
                        color = bgColor.textColor(),
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun CommonTaskTitlePreview() = TaigaMobileTheme {
    CommonTaskTitle(
        ref = 42,
        title = "Some title",
        tags = listOf(Tag("one", "#25A28C"), Tag("two", "#25A28C"))
    )
}



<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/data/repositories/SprintsRepository.kt<|end_filename|>
package io.eugenethedev.taigamobile.data.repositories

import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.data.api.CreateSprintRequest
import io.eugenethedev.taigamobile.data.api.EditSprintRequest
import io.eugenethedev.taigamobile.data.api.TaigaApi
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.repositories.ISprintsRepository
import java.time.LocalDate
import javax.inject.Inject

class SprintsRepository @Inject constructor(
    private val taigaApi: TaigaApi,
    private val session: Session
) : ISprintsRepository {
    private val currentProjectId get() = session.currentProjectId.value
    
    override suspend fun getSprintUserStories(sprintId: Long) = withIO {
        taigaApi.getUserStories(project = currentProjectId, sprint = sprintId)
            .map { it.toCommonTask(CommonTaskType.UserStory) }
    }

    override suspend fun getSprints(page: Int, isClosed: Boolean) = withIO {
        handle404 {
            taigaApi.getSprints(currentProjectId, page, isClosed).map { it.toSprint() }
        }
    }

    override suspend fun getSprint(sprintId: Long) = withIO {
        taigaApi.getSprint(sprintId).toSprint()
    }

    override suspend fun getSprintTasks(sprintId: Long) = withIO {
        taigaApi.getTasks(userStory = "null", project = currentProjectId, sprint = sprintId)
            .map { it.toCommonTask(CommonTaskType.Task) }
    }

    override suspend fun getSprintIssues(sprintId: Long) = withIO {
        taigaApi.getIssues(project = currentProjectId, sprint = sprintId)
            .map { it.toCommonTask(CommonTaskType.Issue) }

    }

    override suspend fun createSprint(name: String, start: LocalDate, end: LocalDate) = withIO {
        taigaApi.createSprint(CreateSprintRequest(name, start, end, currentProjectId))
    }

    override suspend fun editSprint(
        sprintId: Long,
        name: String,
        start: LocalDate,
        end: LocalDate
    ) = withIO {
        taigaApi.editSprint(
            id = sprintId,
            request = EditSprintRequest(name, start, end)
        )
    }

    override suspend fun deleteSprint(sprintId: Long) = withIO {
        taigaApi.deleteSprint(sprintId)
        return@withIO
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/TaskFilters.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components

import androidx.annotation.StringRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetLayout
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.google.accompanist.flowlayout.FlowRow
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.ui.components.badges.Badge
import io.eugenethedev.taigamobile.ui.components.editors.TextFieldWithHint
import io.eugenethedev.taigamobile.ui.components.editors.searchFieldHorizontalPadding
import io.eugenethedev.taigamobile.ui.components.editors.searchFieldVerticalPadding
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.dialogTonalElevation
import io.eugenethedev.taigamobile.ui.theme.shapes
import io.eugenethedev.taigamobile.ui.utils.clickableUnindicated
import io.eugenethedev.taigamobile.ui.utils.toColor
import kotlinx.coroutines.launch

/**
 * TaskFilters which reacts to LazyList scroll state
 */
@Composable
fun TasksFiltersWithLazyList(
    filters: FiltersData = FiltersData(),
    activeFilters: FiltersData = FiltersData(),
    selectFilters: (FiltersData) -> Unit = {},
    content: LazyListScope.() -> Unit
) {

    LazyColumn(
        modifier = Modifier.fillMaxSize()
    ) {
        item {
            TaskFilters(
                selected = activeFilters,
                onSelect = selectFilters,
                data = filters
            )
        }

        content()
    }
}

/**
 * Filters for tasks (like status, assignees etc.).
 * Filters are placed in bottom sheet dialog as expandable options
 */

@OptIn(ExperimentalMaterialApi::class, ExperimentalComposeUiApi::class)
@Composable
fun TaskFilters(
    selected: FiltersData,
    onSelect: (FiltersData) -> Unit,
    data: FiltersData
) = Column(
    horizontalAlignment = Alignment.CenterHorizontally,
    modifier = Modifier.fillMaxWidth()
) {
    // search field

    var query by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(selected.query)) }

    TextFieldWithHint(
        hintId = R.string.tasks_search_hint,
        value = query,
        onValueChange = { query = it },
        onSearchClick = { onSelect(selected.copy(query = query.text)) },
        horizontalPadding = searchFieldHorizontalPadding,
        verticalPadding = searchFieldVerticalPadding,
        hasBorder = true
    )

    // filters

    val unselectedFilters = data - selected

    val space = 6.dp
    val coroutineScope = rememberCoroutineScope()

    // compose version of BottomSheetDialog (from Dialog and ModalBottomSheetLayout)
    val bottomSheetState =  remember { ModalBottomSheetState(ModalBottomSheetValue.Expanded) } // fix to handle dialog closed state properly
    var isVisible by remember { mutableStateOf(false) }

    FilledTonalButton(
        onClick = {
            coroutineScope.launch {
                isVisible = true
                bottomSheetState.show()
            }
        }
    ) {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Icon(
                painter = painterResource(R.drawable.ic_filter),
                contentDescription = null
            )

            Spacer(Modifier.width(space))

            Text(stringResource(R.string.show_filters))

            selected.filtersNumber.takeIf { it > 0 }?.let {
                Spacer(Modifier.width(space))
                Badge(it.toString())
            }
        }
    }

    Spacer(Modifier.height(space))

    if (isVisible) {
        Dialog(
            properties = DialogProperties(usePlatformDefaultWidth = false),
            onDismissRequest = {
                coroutineScope.launch {
                    bottomSheetState.hide()
                    isVisible = false
                }
            }
        ) {
            if (bottomSheetState.currentValue == ModalBottomSheetValue.Hidden && bottomSheetState.targetValue == ModalBottomSheetValue.Hidden) {
                isVisible = false
            }

            ModalBottomSheetLayout(
                modifier = Modifier.fillMaxSize(),
                sheetState = bottomSheetState,
                sheetShape = shapes.medium,
                scrimColor = Color.Transparent,
                content = {},
                sheetContent = {
                    Surface(
                        tonalElevation = dialogTonalElevation
                    ) {
                        Column(
                            modifier = Modifier
                                .fillMaxWidth()
                                .verticalScroll(rememberScrollState())
                                .padding(space)
                        ) {
                            Text(
                                text = stringResource(R.string.filters),
                                style = MaterialTheme.typography.headlineSmall,
                                modifier = Modifier.padding(start = space)
                            )

                            Spacer(Modifier.height(space))

                            Column(modifier = Modifier.fillMaxWidth()) {
                                FlowRow(mainAxisSpacing = 4.dp) {
                                    selected.types.forEach {
                                        FilterChip(
                                            filter = it,
                                            onRemoveClick = { onSelect(selected.copy(types = selected.types - it)) }
                                        )
                                    }

                                    selected.severities.forEach {
                                        FilterChip(
                                            filter = it,
                                            onRemoveClick = { onSelect(selected.copy(severities = selected.severities - it)) }
                                        )
                                    }

                                    selected.priorities.forEach {
                                        FilterChip(
                                            filter = it,
                                            onRemoveClick = { onSelect(selected.copy(priorities = selected.priorities - it)) }
                                        )
                                    }

                                    selected.statuses.forEach {
                                        FilterChip(
                                            filter = it,
                                            onRemoveClick = { onSelect(selected.copy(statuses = selected.statuses - it)) }
                                        )
                                    }

                                    selected.tags.forEach {
                                        FilterChip(
                                            filter = it,
                                            onRemoveClick = { onSelect(selected.copy(tags = selected.tags - it)) }
                                        )
                                    }

                                    selected.assignees.forEach {
                                        FilterChip(
                                            filter = it,
                                            noNameId = R.string.unassigned,
                                            onRemoveClick = { onSelect(selected.copy(assignees = selected.assignees - it)) }
                                        )
                                    }

                                    selected.roles.forEach {
                                        FilterChip(
                                            filter = it,
                                            onRemoveClick = { onSelect(selected.copy(roles = selected.roles - it)) }
                                        )
                                    }

                                    selected.createdBy.forEach {
                                        FilterChip(
                                            filter = it,
                                            onRemoveClick = { onSelect(selected.copy(createdBy = selected.createdBy - it)) }
                                        )
                                    }

                                    selected.epics.forEach {
                                        FilterChip(
                                            filter = it,
                                            noNameId = R.string.not_in_an_epic,
                                            onRemoveClick = { onSelect(selected.copy(epics = selected.epics - it)) }
                                        )
                                    }
                                }

                                if (selected.filtersNumber > 0) {
                                    Spacer(Modifier.height(space))
                                }

                                val sectionsSpace = 6.dp

                                unselectedFilters.types.ifHasData {
                                    Section(
                                        titleId = R.string.type_title,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(types = selected.types + it)) }
                                    )
                                    Spacer(Modifier.height(sectionsSpace))
                                }

                                unselectedFilters.severities.ifHasData {
                                    Section(
                                        titleId = R.string.severity_title,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(severities = selected.severities + it)) }
                                    )
                                    Spacer(Modifier.height(sectionsSpace))
                                }

                                unselectedFilters.priorities.ifHasData {
                                    Section(
                                        titleId = R.string.priority_title,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(priorities = selected.priorities + it)) }
                                    )
                                    Spacer(Modifier.height(sectionsSpace))
                                }

                                unselectedFilters.statuses.ifHasData {
                                    Section(
                                        titleId = R.string.status_title,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(statuses = selected.statuses + it)) }
                                    )
                                    Spacer(Modifier.height(sectionsSpace))
                                }

                                unselectedFilters.tags.ifHasData {
                                    Section(
                                        titleId = R.string.tags_title,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(tags = selected.tags + it)) }
                                    )
                                    Spacer(Modifier.height(sectionsSpace))
                                }

                                unselectedFilters.assignees.ifHasData {
                                    Section(
                                        titleId = R.string.assignees_title,
                                        noNameId = R.string.unassigned,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(assignees = selected.assignees + it)) }
                                    )
                                    Spacer(Modifier.height(sectionsSpace))
                                }

                                unselectedFilters.roles.ifHasData {
                                    Section(
                                        titleId = R.string.role_title,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(roles = selected.roles + it)) }
                                    )
                                    Spacer(Modifier.height(sectionsSpace))
                                }

                                unselectedFilters.createdBy.ifHasData {
                                    Section(
                                        titleId = R.string.created_by_title,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(createdBy = selected.createdBy + it)) }
                                    )
                                    Spacer(Modifier.height(sectionsSpace))
                                }

                                unselectedFilters.epics.ifHasData {
                                    Section(
                                        titleId = R.string.epic_title,
                                        noNameId = R.string.not_in_an_epic,
                                        filters = it,
                                        onSelect = { onSelect(selected.copy(epics = selected.epics + it)) }
                                    )
                                }
                            }

                            Spacer(Modifier.height(space))
                        }
                    }
                }
            )
        }
    }
}

private inline fun <T : Filter> List<T>.ifHasData(action: (List<T>) -> Unit) =
    takeIf { it.hasData() }?.let(action)

@Composable
private fun <T : Filter> Section(
    @StringRes titleId: Int,
    @StringRes noNameId: Int? = null,
    filters: List<T>,
    onSelect: (T) -> Unit
) = Column(
    modifier = Modifier.fillMaxWidth()
) {
    var isExpanded by remember { mutableStateOf(false) }

    val transitionState = remember { MutableTransitionState(isExpanded) }
    transitionState.targetState = isExpanded

    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier.clickableUnindicated { isExpanded = !isExpanded }
    ) {
        val arrowRotation by updateTransition(
            transitionState,
            label = "arrow"
        ).animateFloat { if (it) 0f else -90f }
        Icon(
            painter = painterResource(R.drawable.ic_arrow_down),
            tint = MaterialTheme.colorScheme.primary,
            modifier = Modifier.rotate(arrowRotation),
            contentDescription = null
        )

        Text(
            text = stringResource(titleId),
            style = MaterialTheme.typography.titleLarge,
            modifier = Modifier.padding(bottom = 2.dp)
        )
    }

    AnimatedVisibility(visible = isExpanded) {
        FlowRow(
            modifier = Modifier.padding(vertical = 2.dp, horizontal = 6.dp),
            mainAxisSpacing = 4.dp
        ) {
            filters.forEach {
                FilterChip(
                    filter = it,
                    noNameId = noNameId,
                    onClick = { onSelect(it) }
                )
            }
        }
    }

}

@Composable
private fun FilterChip(
    filter: Filter,
    @StringRes noNameId: Int? = null,
    onClick: () -> Unit = {},
    onRemoveClick: (() -> Unit)? = null
) = Chip(
    onClick = onClick,
    color = filter.color?.toColor() ?: MaterialTheme.colorScheme.outline
) {
    val space = 6.dp

    Row(verticalAlignment = Alignment.CenterVertically) {
        onRemoveClick?.let {
            IconButton(
                onClick = it,
                modifier = Modifier
                    .size(24.dp)
                    .clip(CircleShape)
            ) {
                Icon(
                    painter = painterResource(R.drawable.ic_remove),
                    contentDescription = null,
                    modifier = Modifier.size(22.dp)
                )
            }
            Spacer(Modifier.width(space))
        }

        Text(
            modifier = Modifier.weight(1f, fill = false),
            text = filter.name.takeIf { it.isNotEmpty() } ?: stringResource(noNameId!!),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis
        )

        Spacer(Modifier.width(space))

        Badge(
            text = filter.count.toString(),
            isActive = false
        )
    }
}


@Preview(showBackground = true)
@Composable
fun TaskFiltersPreview() = TaigaMobileTheme {
    var selected by remember { mutableStateOf(FiltersData()) }

    Column {
        Text("test")

        TaskFilters(
            selected = selected,
            onSelect = { selected = it },
            data = FiltersData(
                assignees = listOf(
                    UsersFilter(null, "", 2),
                    *List(10) { UsersFilter(it.toLong(), "Human $it", it % 3) }.toTypedArray()
                ),
                roles = listOf(
                    RolesFilter(0, "UX", 1),
                    RolesFilter(1, "Developer", 4),
                    RolesFilter(2, "Stakeholder", 0),
                ),
                tags = listOf(
                    *List(10) {
                        listOf(
                            TagsFilter("#7E57C2", "tag ${it * 3}", 3),
                            TagsFilter("#F57C00", "tag ${it * 3 + 1}", 4),
                            TagsFilter("#C62828", "tag ${it * 3 + 2}", 0),
                        )
                    }.flatten().toTypedArray()
                ),
                statuses = listOf(
                    StatusesFilter(0, "#B0BEC5", "Backlog", 2),
                    StatusesFilter(1, "#1E88E5", "In progress", 1),
                    StatusesFilter(2, "#43A047", "Done", 3),
                ),
                priorities = listOf(
                    StatusesFilter(0, "#29B6F6", "Low", 2),
                    StatusesFilter(1, "#43A047", "Normal", 1),
                    StatusesFilter(2, "#FBC02D", "High", 2),
                ),
                severities = listOf(
                    StatusesFilter(0, "#29B6F6", "Minor", 2),
                    StatusesFilter(1, "#43A047", "Normal", 1),
                    StatusesFilter(2, "#FBC02D", "Major", 2),
                    StatusesFilter(0, "#29B6F6", "Minor", 2),
                    StatusesFilter(1, "#43A047", "Normal", 1),
                    StatusesFilter(2, "#FBC02D", "Major", 2)
                ),
                types = listOf(
                    StatusesFilter(0, "#F44336", "Bug", 2),
                    StatusesFilter(1, "#C8E6C9", "Question", 1),
                    StatusesFilter(2, "#C8E6C9", "Enhancement", 2),
                )
            )
        )

        Text("Text")
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/commontask/components/CommonTaskAttachments.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.commontask.components

import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.Attachment
import io.eugenethedev.taigamobile.ui.components.dialogs.ConfirmActionDialog
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.components.texts.SectionTitle
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
import io.eugenethedev.taigamobile.ui.screens.main.LocalFilePicker
import io.eugenethedev.taigamobile.ui.utils.activity

fun LazyListScope.CommonTaskAttachments(
    attachments: List<Attachment>,
    editActions: EditActions
) {
    item {
        val filePicker = LocalFilePicker.current
        SectionTitle(
            text = stringResource(R.string.attachments_template).format(attachments.size),
            onAddClick = {
                filePicker.requestFile(editActions.editAttachments.addAttachment)
            }
        )
    }

    items(attachments) {
        AttachmentItem(
            attachment = it,
            onRemoveClick = { editActions.editAttachments.deleteAttachment(it) }
        )
    }

    item {
        if (editActions.editAttachments.isResultLoading) {
            DotsLoader()
        }
    }
}

@Composable
private fun AttachmentItem(
    attachment: Attachment,
    onRemoveClick: () -> Unit
) = Row(
    verticalAlignment = Alignment.CenterVertically,
    horizontalArrangement = Arrangement.SpaceBetween,
    modifier = Modifier.fillMaxWidth()
) {
    var isAlertVisible by remember { mutableStateOf(false) }

    if (isAlertVisible) {
        ConfirmActionDialog(
            title = stringResource(R.string.remove_attachment_title),
            text = stringResource(R.string.remove_attachment_text),
            onConfirm = {
                isAlertVisible = false
                onRemoveClick()
            },
            onDismiss = { isAlertVisible = false },
            iconId = R.drawable.ic_remove
        )
    }
    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier
            .weight(1f, fill = false)
            .padding(end = 4.dp)
    ) {
        val activity = LocalContext.current.activity
        Icon(
            painter = painterResource(R.drawable.ic_attachment),
            contentDescription = null,
            tint = MaterialTheme.colorScheme.outline,
            modifier = Modifier.padding(end = 2.dp)
        )

        Text(
            text = attachment.name,
            color = MaterialTheme.colorScheme.primary,
            modifier = Modifier.clickable {
                activity.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(attachment.url)))
            }
        )
    }

    IconButton(
        onClick = { isAlertVisible = true },
        modifier = Modifier
            .size(32.dp)
            .clip(CircleShape)
    ) {
        Icon(
            painter = painterResource(R.drawable.ic_delete),
            contentDescription = null,
            tint = MaterialTheme.colorScheme.error
        )
    }

}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/issues/IssuesViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.issues

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.TaigaApp
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.domain.paging.CommonPagingSource
import io.eugenethedev.taigamobile.domain.repositories.ITasksRepository
import io.eugenethedev.taigamobile.state.subscribeToAll
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.asLazyPagingItems
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject

class IssuesViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var session: Session
    @Inject lateinit var tasksRepository: ITasksRepository

    val projectName by lazy { session.currentProjectName }

    private var shouldReload = true

    init {
        appComponent.inject(this)
    }
    
    fun onOpen() {
        if (!shouldReload) return
        viewModelScope.launch {
            filters.loadOrError { tasksRepository.getFiltersData(CommonTaskType.Issue) }
        }
        shouldReload = false
    }

    val filters = MutableResultFlow<FiltersData>()
    val activeFilters = MutableStateFlow(FiltersData())
    @OptIn(ExperimentalCoroutinesApi::class)
    val issues by lazy {
        activeFilters.flatMapLatest { filters ->
            Pager(PagingConfig(CommonPagingSource.PAGE_SIZE, enablePlaceholders = false)) {
                CommonPagingSource { tasksRepository.getIssues(it, filters) }
            }.flow
        }.asLazyPagingItems(viewModelScope)
    }

    fun selectFilters(filters: FiltersData) {
        activeFilters.value = filters
    }
    
    init {
        session.currentProjectId.onEach {
            activeFilters.value = FiltersData()
            shouldReload = true
        }.launchIn(viewModelScope)

        viewModelScope.subscribeToAll(session.currentProjectId, session.taskEdit) {
            issues.refresh()
        }
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/data/api/TaigaApi.kt<|end_filename|>
package io.eugenethedev.taigamobile.data.api

import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.domain.paging.CommonPagingSource
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.*

/**
 * All API endpoints
 */
interface TaigaApi {
    companion object {
        const val API_PREFIX = "api/v1"
        const val AUTH_ENDPOINTS = "auth"
        const val REFRESH_ENDPOINT = "auth/refresh"
    }

    @POST("auth")
    suspend fun auth(@Body authRequest: AuthRequest): AuthResponse


    /**
     * Projects
     */

    @GET("projects?order_by=user_order&slight=true")
    suspend fun getProjects(
        @Query("q") query: String,
        @Query("page") page: Int,
        @Query("page_size") pageSize: Int = CommonPagingSource.PAGE_SIZE
    ): List<Project>

    @GET("projects/{id}")
    suspend fun getProject(@Path("id") projectId: Long): ProjectResponse


    /**
     * Users
     */

    @GET("users/{id}")
    suspend fun getUser(@Path("id") userId: Long): User

    @GET("users/me")
    suspend fun getMyProfile(): User

    @GET("projects/{id}/member_stats")
    suspend fun getMemberStats(@Path("id") projectId: Long): MemberStatsResponse


    /**
     * Sprints
     */
    @GET("milestones")
    suspend fun getSprints(
        @Query("project") project: Long,
        @Query("page") page: Int,
        @Query("closed") isClosed: Boolean
    ): List<SprintResponse>

    @GET("milestones/{id}")
    suspend fun getSprint(@Path("id") sprintId: Long): SprintResponse

    @PATCH("{taskPath}/{id}")
    suspend fun changeCommonTaskSprint(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long,
        @Body changeSprintRequest: ChangeSprintRequest
    )

    @POST("milestones")
    suspend fun createSprint(@Body request: CreateSprintRequest)

    @PATCH("milestones/{id}")
    suspend fun editSprint(
        @Path("id") id: Long,
        @Body request: EditSprintRequest
    )

    @DELETE("milestones/{id}")
    suspend fun deleteSprint(@Path("id") id: Long): Response<Void>

    /**
     * Everything related to common tasks (epics, user stories, etc.)
     */

    @GET("{taskPath}/filters_data")
    suspend fun getCommonTaskFiltersData(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Query("project") project: Long
    ): FiltersDataResponse

    @GET("userstories")
    suspend fun getUserStories(
        @Query("project") project: Long? = null,
        @Query("milestone") sprint: Any? = null,
        @Query("status") status: Long? = null,
        @Query("epic") epic: Long? = null,
        @Query("page") page: Int? = null,
        @Query("assigned_users") assignedId: Long? = null,
        @Query("status__is_closed") isClosed: Boolean? = null,
        @Query("watchers") watcherId: Long? = null,
        @Query("dashboard") isDashboard: Boolean? = null,
        @Query("q") query: String? = null,
        @Query("page_size") pageSize: Int = CommonPagingSource.PAGE_SIZE,

        // List<Long?>?
        @Query("assigned_to", encoded = true) assignedIds: String? = null,
        @Query("epic", encoded = true) epics: String? = null,

        // List<Long>?
        @Query("owner", encoded = true) ownerIds: String? = null,
        @Query("role", encoded = true) roles: String? = null,
        @Query("status", encoded = true) statuses: String? = null,

        // List<String>?
        @Query("tags", encoded = true) tags: String? = null,

        // here and below instead of setting header to "false" remove it,
        // because api always returns unpaginated result if header persists, regardless of its value
        @Header("x-disable-pagination") disablePagination: Boolean? = (page == null).takeIf { it }
    ): List<CommonTaskResponse>

    @GET("tasks?order_by=us_order")
    suspend fun getTasks(
        @Query("user_story") userStory: Any? = null,
        @Query("project") project: Long? = null,
        @Query("milestone") sprint: Long? = null,
        @Query("page") page: Int? = null,
        @Query("assigned_to") assignedId: Long? = null,
        @Query("status__is_closed") isClosed: Boolean? = null,
        @Query("watchers") watcherId: Long? = null,

        @Header("x-disable-pagination") disablePagination: Boolean? = (page == null).takeIf { it }
    ): List<CommonTaskResponse>

    @GET("epics")
    suspend fun getEpics(
        @Query("page") page: Int? = null,
        @Query("project") project: Long? = null,
        @Query("q") query: String? = null,
        @Query("assigned_to") assignedId: Long? = null,
        @Query("status__is_closed") isClosed: Boolean? = null,
        @Query("watchers") watcherId: Long? = null,
        @Query("page_size") pageSize: Int = CommonPagingSource.PAGE_SIZE,

        // List<Long?>?
        @Query("assigned_to", encoded = true) assignedIds: String? = null,

        // List<Long>?
        @Query("owner", encoded = true) ownerIds: String? = null,
        @Query("status", encoded = true) statuses: String? = null,

        // List<String>?
        @Query("tags", encoded = true) tags: String? = null,

        @Header("x-disable-pagination") disablePagination: Boolean? = (page == null).takeIf { it }
    ): List<CommonTaskResponse>

    @GET("issues")
    suspend fun getIssues(
        @Query("page") page: Int? = null,
        @Query("project") project: Long? = null,
        @Query("q") query: String? = null,
        @Query("milestone") sprint: Long? = null,
        @Query("status__is_closed") isClosed: Boolean? = null,
        @Query("watchers") watcherId: Long? = null,
        @Query("page_size") pageSize: Int = CommonPagingSource.PAGE_SIZE,

        // List<Long?>?
        @Query("assigned_to", encoded = true) assignedIds: String? = null,

        // List<Long>?
        @Query("owner", encoded = true) ownerIds: String? = null,
        @Query("priority", encoded = true) priorities: String? = null,
        @Query("severity", encoded = true) severities: String? = null,
        @Query("type", encoded = true) types: String? = null,
        @Query("role", encoded = true) roles: String? = null,
        @Query("status", encoded = true) statuses: String? = null,

        // List<String>?
        @Query("tags", encoded = true) tags: String? = null,

        @Header("x-disable-pagination") disablePagination: Boolean? = (page == null).takeIf { it }
    ): List<CommonTaskResponse>

    @GET("userstories/by_ref")
    suspend fun getUserStoryByRef(
        @Query("project") projectId: Long,
        @Query("ref") ref: Int
    ): CommonTaskResponse

    @GET("{taskPath}/{id}")
    suspend fun getCommonTask(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long
    ): CommonTaskResponse

    @PATCH("{taskPath}/{id}")
    suspend fun changeCommonTaskStatus(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long,
        @Body changeStatusRequest: ChangeStatusRequest
    )

    @PATCH("issues/{id}")
    suspend fun changeIssueType(
        @Path("id") id: Long,
        @Body changeTypeRequest: ChangeTypeRequest
    )

    @PATCH("issues/{id}")
    suspend fun changeIssueSeverity(
        @Path("id") id: Long,
        @Body changeSeverityRequest: ChangeSeverityRequest
    )

    @PATCH("issues/{id}")
    suspend fun changeIssuePriority(
        @Path("id") id: Long,
        @Body changePriorityRequest: ChangePriorityRequest
    )

    @PATCH("userstories/{id}")
    suspend fun changeUserStoryAssignees(
        @Path("id") id: Long,
        @Body changeAssigneesRequest: ChangeUserStoryAssigneesRequest
    )

    @PATCH("{taskPath}/{id}")
    suspend fun changeCommonTaskAssignees(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long,
        @Body changeAssigneesRequest: ChangeCommonTaskAssigneesRequest
    )

    @PATCH("{taskPath}/{id}")
    suspend fun changeCommonTaskWatchers(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long,
        @Body changeWatchersRequest: ChangeWatchersRequest
    )

    @PATCH("{taskPath}/{id}")
    suspend fun changeCommonTaskDueDate(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long,
        @Body request: ChangeCommonTaskDueDateRequest
    )

    @PATCH("epics/{id}")
    suspend fun changeEpicColor(
        @Path("id") id: Long,
        @Body request: ChangeEpicColor
    )

    @PATCH("{taskPath}/{id}")
    suspend fun editCommonTask(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long,
        @Body editRequest: EditCommonTaskRequest
    )

    @POST("{taskPath}")
    suspend fun createCommonTask(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Body createRequest: CreateCommonTaskRequest
    ): CommonTaskResponse

    @POST("tasks")
    suspend fun createTask(@Body createTaskRequest: CreateTaskRequest): CommonTaskResponse

    @POST("issues")
    suspend fun createIssue(@Body createIssueRequest: CreateIssueRequest): CommonTaskResponse

    @POST("userstories")
    suspend fun createUserstory(@Body createUserStoryRequest: CreateUserStoryRequest): CommonTaskResponse

    @DELETE("{taskPath}/{id}")
    suspend fun deleteCommonTask(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long
    ): Response<Void>

    @POST("epics/{id}/related_userstories")
    suspend fun linkToEpic(
        @Path("id") epicId: Long,
        @Body linkToEpicRequest: LinkToEpicRequest
    )

    @DELETE("epics/{epicId}/related_userstories/{userStoryId}")
    suspend fun unlinkFromEpic(
        @Path("epicId") epicId: Long,
        @Path("userStoryId") userStoryId: Long
    ): Response<Void>

    @POST("{taskPath}/{id}/promote_to_user_story")
    suspend fun promoteCommonTaskToUserStory(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") taskId: Long,
        @Body promoteToUserStoryRequest: PromoteToUserStoryRequest
    ): List<Int>

    // Tasks comments

    @PATCH("{taskPath}/{id}")
    suspend fun createCommonTaskComment(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") id: Long,
        @Body createCommentRequest: CreateCommentRequest
    )

    @GET("history/{taskPath}/{id}?type=comment")
    suspend fun getCommonTaskComments(
        @Path("taskPath") taskPath: CommonTaskPathSingular,
        @Path("id") id: Long
    ): List<Comment>

    @POST("history/{taskPath}/{id}/delete_comment")
    suspend fun deleteCommonTaskComment(
        @Path("taskPath") taskPath: CommonTaskPathSingular,
        @Path("id") id: Long,
        @Query("id") commentId: String
    )

    // Tasks attachments

    @GET("{taskPath}/attachments")
    suspend fun getCommonTaskAttachments(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Query("object_id") storyId: Long,
        @Query("project") projectId: Long
    ): List<Attachment>

    @DELETE("{taskPath}/attachments/{id}")
    suspend fun deleteCommonTaskAttachment(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") attachmentId: Long
    ): Response<Void>

    @POST("{taskPath}/attachments")
    @Multipart
    suspend fun uploadCommonTaskAttachment(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Part file: MultipartBody.Part,
        @Part project: MultipartBody.Part,
        @Part objectId: MultipartBody.Part
    )

    // Custom attributes

    @GET("{taskPath}-custom-attributes")
    suspend fun getCustomAttributes(
        @Path("taskPath") taskPath: CommonTaskPathSingular,
        @Query("project") projectId: Long
    ): List<CustomAttributeResponse>

    @GET("{taskPath}/custom-attributes-values/{id}")
    suspend fun getCustomAttributesValues(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") taskId: Long
    ): CustomAttributesValuesResponse

    @PATCH("{taskPath}/custom-attributes-values/{id}")
    suspend fun editCustomAttributesValues(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") taskId: Long,
        @Body editRequest: EditCustomAttributesValuesRequest
    )

    // Tags

    @PATCH("{taskPath}/{id}")
    suspend fun editTags(
        @Path("taskPath") taskPath: CommonTaskPathPlural,
        @Path("id") taskId: Long,
        @Body editRequest: EditTagsRequest
    )

    // Swimlanes

    @GET("swimlanes")
    suspend fun getSwimlanes(@Query("project") project: Long): List<Swimlane>

    @PATCH("userstories/{id}")
    suspend fun changeUserStorySwimlane(
        @Path("id") id: Long,
        @Body request: ChangeUserStorySwimlaneRequest
    )
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/domain/entities/Filters.kt<|end_filename|>
package io.eugenethedev.taigamobile.domain.entities

data class FiltersData(
    val query: String = "",
    val assignees: List<UsersFilter> = emptyList(),
    val roles: List<RolesFilter> = emptyList(),
    val tags: List<TagsFilter> = emptyList(),
    val statuses: List<StatusesFilter> = emptyList(),
    val createdBy: List<UsersFilter> = emptyList(),

    // user story filters
    val epics: List<EpicsFilter> = emptyList(),

    // issue filters
    val priorities: List<StatusesFilter> = emptyList(),
    val severities: List<StatusesFilter> = emptyList(),
    val types: List<StatusesFilter> = emptyList()
) {
    operator fun minus(other: FiltersData) = FiltersData(
        assignees = assignees - other.assignees.toSet(),
        roles = roles - other.roles.toSet(),
        tags = tags - other.tags.toSet(),
        statuses = statuses - other.statuses.toSet(),
        createdBy = createdBy - other.createdBy.toSet(),
        priorities = priorities - other.priorities.toSet(),
        severities = severities - other.severities.toSet(),
        types = types - other.types.toSet(),
        epics = epics - other.epics.toSet()
    )

    val filtersNumber = listOf(assignees, roles, tags, statuses, createdBy, priorities, severities, types, epics).sumOf { it.size }
}

fun List<Filter>.hasData() = any { it.count > 0 }

sealed interface Filter {
    val id: Long?
    val name: String
    val count: Int
    val color: String?
}

data class StatusesFilter(
    override val id: Long,
    override val color: String,
    override val name: String,
    override val count: Int
) : Filter

data class UsersFilter(
    override val id: Long?,
    override val name: String,
    override val count: Int
) : Filter {
    override val color: String? = null
}

data class RolesFilter(
    override val id: Long,
    override val name: String,
    override val count: Int
) : Filter {
    override val color: String? = null
}

data class TagsFilter(
    override val name: String,
    override val color: String,
    override val count: Int
) : Filter {
    override val id: Long? = null
}

data class EpicsFilter(
    override val id: Long?,
    override val name: String,
    override val count: Int
) : Filter {
    override val color: String? = null
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/login/LoginScreen.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.login

import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.*
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.google.accompanist.insets.imePadding
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.AuthType
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.LoadingResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.ui.components.dialogs.ConfirmActionDialog
import io.eugenethedev.taigamobile.ui.screens.main.Routes
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.shapes

@Composable
fun LoginScreen(
    navController: NavController,
    onError: @Composable (message: Int) -> Unit = {},
) {
    val viewModel: LoginViewModel = viewModel()

    val loginResult by viewModel.loginResult.collectAsState()
    loginResult.also {
        when(it) {
            is ErrorResult -> onError(it.message!!)
            is SuccessResult -> {
                LaunchedEffect(Unit) {
                    navController.navigate(Routes.dashboard) {
                        popUpTo(Routes.login) { inclusive = true }
                    }
                }
            }
            else -> {}
        }
    }

    LoginScreenContent(
        login = viewModel::login,
        isLoadingValue = loginResult is LoadingResult || loginResult is SuccessResult
    )
}

@Composable
fun LoginScreenContent(
    login: (server: String, authType: AuthType, login: String, password: String) -> Unit = { _, _, _, _ -> },
    isLoadingValue: Boolean = false,
) = ConstraintLayout(
    modifier = Modifier.fillMaxSize(),
) {
    val taigaGlobalHost = stringResource(R.string.global_taiga_host)
    var taigaServerInput by remember { mutableStateOf(TextFieldValue(taigaGlobalHost)) }
    var loginInput by remember { mutableStateOf(TextFieldValue()) }
    var passwordInput by remember { mutableStateOf(TextFieldValue()) }

    var isServerInputError by remember { mutableStateOf(false) }
    var isLoginInputError by remember { mutableStateOf(false) }
    var isPasswordInputError by remember { mutableStateOf(false) }

    val (logo, loginForm, button) = createRefs()

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .constrainAs(logo) {
                start.linkTo(parent.start)
                end.linkTo(parent.end)
                top.linkTo(parent.top)
                bottom.linkTo(loginForm.top)
            }
            .imePadding()
            .padding(bottom = 24.dp)
    ) {

        Image(
            painter = painterResource(R.drawable.ic_taiga_tree),
            contentDescription = null,
            modifier = Modifier
                .size(130.dp)
                .padding(bottom = 8.dp)
        )

        Text(
            text = stringResource(R.string.app_name),
            style = MaterialTheme.typography.headlineSmall,
        )
    }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier
            .constrainAs(loginForm) {
                start.linkTo(parent.start)
                end.linkTo(parent.end)
                top.linkTo(parent.top)
                bottom.linkTo(parent.bottom)
            }
            .imePadding()
    ) {
        LoginTextField(
            value = taigaServerInput,
            labelId = R.string.login_taiga_server,
            onValueChange = {
                isServerInputError = false
                taigaServerInput = it
            },
            isError = isServerInputError
        )

        LoginTextField(
            value = loginInput,
            labelId = R.string.login_username,
            onValueChange = {
                isLoginInputError = false
                loginInput = it
            },
            isError = isLoginInputError
        )

        LoginTextField(
            value = passwordInput,
            labelId = R.string.login_password,
            onValueChange = {
                isPasswordInputError = false
                passwordInput = it
            },
            visualTransformation = PasswordVisualTransformation(),
            keyboardType = KeyboardType.Password,
            isError = isPasswordInputError
        )
    }

    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.constrainAs(button) {
            start.linkTo(parent.start)
            end.linkTo(parent.end)
            top.linkTo(loginForm.bottom, 24.dp)
        }
    ) {
        var authType = AuthType.Normal

        val loginAction = {
            login(
                taigaServerInput.text.trim(),
                authType,
                loginInput.text.trim(),
                passwordInput.text.trim()
            )
        }

        var isAlertVisible by remember { mutableStateOf(false) }

        if (isAlertVisible) {
            ConfirmActionDialog(
                title = stringResource(R.string.login_alert_title),
                text = stringResource(R.string.login_alert_text),
                onConfirm = {
                    isAlertVisible = false
                    loginAction()
                },
                onDismiss = { isAlertVisible = false },
                iconId = R.drawable.ic_insecure
            )
        }

        if (isLoadingValue) {
            CircularProgressIndicator(
                modifier = Modifier.size(48.dp),
                color = MaterialTheme.colorScheme.primary
            )
        } else {
            val onClick = {
                isServerInputError = !taigaServerInput.text.matches(Regex("""(http|https)://([\w\d-]+\.)+[\w\d-]+(:\d+)?"""))
                isLoginInputError = loginInput.text.isBlank()
                isPasswordInputError = passwordInput.text.isBlank()

                if (!(isServerInputError || isLoginInputError || isPasswordInputError)) {
                    if (taigaServerInput.text.startsWith("http://")) {
                        isAlertVisible = true
                    } else {
                        loginAction()
                    }
                }
            }

            Button(
                onClick = {
                    authType = AuthType.Normal
                    onClick()
                },
                contentPadding = PaddingValues(horizontal = 40.dp)
            ) {
                Text(stringResource(R.string.login_continue))
            }

            Spacer(Modifier.height(8.dp))

            OutlinedButton(
                onClick = {
                    authType = AuthType.LDAP
                    onClick()
                }
            ) {
                Text(stringResource(R.string.login_ldap))
            }
        }

    }
}

@Composable
fun LoginTextField(
    value: TextFieldValue,
    @StringRes labelId: Int,
    onValueChange: (TextFieldValue) -> Unit,
    visualTransformation: VisualTransformation = VisualTransformation.None,
    keyboardType: KeyboardType = KeyboardType.Text,
    isError: Boolean = false
) {
    val focusManager = LocalFocusManager.current

    val textStyle = MaterialTheme.typography.titleMedium.merge(TextStyle(fontWeight = FontWeight.Normal))
    OutlinedTextField(
        value = value,
        onValueChange = onValueChange,
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 40.dp)
            .padding(bottom = 6.dp),
        textStyle = textStyle,
        singleLine = true,
        label = { Text(text = stringResource(labelId), style = textStyle) },
        visualTransformation = visualTransformation,
        keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = ImeAction.Done),
        keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
        isError = isError,
        shape = shapes.medium,
        colors = TextFieldDefaults.outlinedTextFieldColors(
            textColor = androidx.compose.material3.LocalContentColor.current.copy(LocalContentAlpha.current),
            cursorColor = MaterialTheme.colorScheme.primary,
            errorCursorColor = MaterialTheme.colorScheme.error,
            focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high),
            unfocusedBorderColor = MaterialTheme.colorScheme.onSurface.copy(alpha = ContentAlpha.disabled),
            errorBorderColor = MaterialTheme.colorScheme.error,
            leadingIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = TextFieldDefaults.IconOpacity),
            trailingIconColor = MaterialTheme.colorScheme.onSurface.copy(alpha = TextFieldDefaults.IconOpacity),
            errorTrailingIconColor = MaterialTheme.colorScheme.error,
            focusedLabelColor = MaterialTheme.colorScheme.primary.copy(alpha = ContentAlpha.high),
            unfocusedLabelColor = MaterialTheme.colorScheme.onSurface.copy(ContentAlpha.medium),
            errorLabelColor = MaterialTheme.colorScheme.error,
            placeholderColor = MaterialTheme.colorScheme.onSurface.copy(ContentAlpha.medium),
        )
    )
}

@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
fun LoginScreenPreview() = TaigaMobileTheme {
    LoginScreenContent()
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/settings/SettingsViewModel.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.settings

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.eugenethedev.taigamobile.*
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.domain.entities.User
import io.eugenethedev.taigamobile.domain.repositories.IUsersRepository
import io.eugenethedev.taigamobile.state.*
import io.eugenethedev.taigamobile.ui.utils.MutableResultFlow
import io.eugenethedev.taigamobile.ui.utils.loadOrError
import kotlinx.coroutines.launch
import javax.inject.Inject

class SettingsViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
    @Inject lateinit var session: Session
    @Inject lateinit var settings: Settings
    @Inject lateinit var userRepository: IUsersRepository

    val user = MutableResultFlow<User>()
    val serverUrl by lazy { session.server }

    val themeSetting by lazy { settings.themeSetting }

    init {
        appComponent.inject(this)
    }

    fun onOpen() = viewModelScope.launch {
        user.loadOrError(preserveValue = false) { userRepository.getMe() }
    }

    fun logout() {
        session.reset()
    }

    fun switchTheme(theme: ThemeSetting) {
        settings.changeThemeSetting(theme)
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/ScrumViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.ui.screens.scrum.ScrumViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.eugenethedev.taigamobile.viewmodels.utils.createDeniedException
import io.eugenethedev.taigamobile.viewmodels.utils.notFoundException
import io.eugenethedev.taigamobile.viewmodels.utils.testLazyPagingItems
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import java.time.LocalDate
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs

class ScrumViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: ScrumViewModel

    @BeforeTest
    fun setup() {
        viewModel = ScrumViewModel(mockAppComponent)
    }

    @Test
    fun `test on open`(): Unit = runBlocking {
        val mockFiltersData = mockk<FiltersData>()

        coEvery { mockTaskRepository.getFiltersData(any()) } returns mockFiltersData

        viewModel.onOpen()
        assertResultEquals(SuccessResult(mockFiltersData), viewModel.filters.value)
    }

    @Test
    fun `test on open error`(): Unit = runBlocking {
        coEvery { mockTaskRepository.getFiltersData(any()) } throws notFoundException

        viewModel.onOpen()
        assertIs<ErrorResult<FiltersData>>(viewModel.filters.value)
    }

    @Test
    fun `test select filters`(): Unit = runBlocking {
        val mockFiltersData = mockk<FiltersData>()

        viewModel.selectFilters(mockFiltersData)
        assertIs<FiltersData>(viewModel.activeFilters.value)
    }

    @Test
    fun `test create sprint`(): Unit = runBlocking {
        val testName = "test name"
        val startLocalDate = LocalDate.of(2000, 1, 1)
        val endLocalDate = LocalDate.of(3000, 1, 1)

        coEvery {
            mockSprintsRepository.createSprint(
                neq(testName),
                any(),
                any()
            )
        } throws createDeniedException

        viewModel.createSprint(testName, startLocalDate, endLocalDate)
        assertResultEquals(SuccessResult(Unit), viewModel.createSprintResult.value)

        viewModel.createSprint(testName + "wrong", startLocalDate, endLocalDate)
        assertIs<ErrorResult<Unit>>(viewModel.createSprintResult.value)
    }

    @Test
    fun `test open sprints list`(): Unit = runBlocking {
        testLazyPagingItems(viewModel.openSprints) {
            mockSprintsRepository.getSprints(
                any(),
                eq(false)
            )
        }
    }

    @Test
    fun `test closed sprints list`(): Unit = runBlocking {
        testLazyPagingItems(viewModel.closedSprints) {
            mockSprintsRepository.getSprints(
                any(),
                eq(true)
            )
        }
    }

    @Test
    fun `test stories list with filters`(): Unit = runBlocking {
        val query = "query"
        testLazyPagingItems(viewModel.stories) {
            mockTaskRepository.getBacklogUserStories(
                any(), eq(
                    FiltersData()
                )
            )
        }
        viewModel.selectFilters(FiltersData(query = query))
        testLazyPagingItems(viewModel.stories) {
            mockTaskRepository.getBacklogUserStories(
                any(), eq(
                    FiltersData(query = query)
                )
            )
        }
    }
}

<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/SprintViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.ui.screens.sprint.SprintViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.eugenethedev.taigamobile.viewmodels.utils.notFoundException
import io.mockk.mockk
import io.mockk.coEvery
import kotlinx.coroutines.runBlocking
import java.time.LocalDate
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertIs

class SprintViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: SprintViewModel

    @BeforeTest
    fun setup() {
        viewModel = SprintViewModel(mockAppComponent)
    }

    @BeforeTest
    fun settingOfLoadData() {

        coEvery { mockSprintsRepository.getSprint(any()) } returns mockSprint
        coEvery { mockTaskRepository.getStatuses(any()) } returns mockStatuses
        coEvery { mockSprintsRepository.getSprintUserStories(any()) } returns mockSprintUserStories
        coEvery { mockTaskRepository.getUserStoryTasks(any()) } returns mockUserStoryTasks
        coEvery { mockSprintsRepository.getSprintIssues(any()) } returns mockSprintIssues
        coEvery { mockSprintsRepository.getSprintTasks(any()) } returns mockSprintTasks
    }

    companion object {
        val mockSprint = mockk<Sprint>(relaxed = true)
        val mockStatuses = mockk<List<Status>>(relaxed = true)
        val mockSprintUserStories = mockk<List<CommonTask>>(relaxed = true)
        val mockUserStoryTasks = mockk<List<CommonTask>>(relaxed = true)
        val mockSprintIssues = mockk<List<CommonTask>>(relaxed = true)
        val mockSprintTasks = mockk<List<CommonTask>>(relaxed = true)
    }

    fun asserts() {
        val mapOfSprintUserStoriesTasks =
            mockSprintUserStories.map { it to mockUserStoryTasks }.toMap()
        assertResultEquals(SuccessResult(mockSprint), viewModel.sprint.value)
        assertResultEquals(SuccessResult(mockStatuses), viewModel.statuses.value)
        assertResultEquals(
            SuccessResult(mapOfSprintUserStoriesTasks),
            viewModel.storiesWithTasks.value
        )
        assertResultEquals(SuccessResult(mockSprintIssues), viewModel.issues.value)
        assertResultEquals(SuccessResult(mockSprintTasks), viewModel.storylessTasks.value)
    }

    @Test
    fun `test on open`(): Unit = runBlocking {
        val sprintId = 1L
        viewModel.onOpen(sprintId)
        asserts()
    }

    @Test
    fun `test on open error`(): Unit = runBlocking {
        val sprintId = 1L
        coEvery { mockSprintsRepository.getSprint(any()) } throws notFoundException
        viewModel.onOpen(sprintId)
        assertIs<ErrorResult<Sprint>>(viewModel.sprint.value)
    }

    @Test
    fun `edit sprint`(): Unit = runBlocking {
        val sprintId = 1L
        val name = "lol"
        val start = LocalDate.of(2000, 1, 1)
        val end = LocalDate.of(3000, 1, 1)
        viewModel.onOpen(sprintId)
        coEvery {
            mockSprintsRepository.editSprint(
                any(),
                neq(name),
                any(),
                any()
            )
        } throws notFoundException
        viewModel.editSprint(name, start, end)
        asserts()
        assertResultEquals(SuccessResult(Unit), viewModel.editResult.value)
        viewModel.editSprint(name + "error", start, end)
        assertIs<ErrorResult<Unit>>(viewModel.editResult.value)
    }

    @Test
    fun `delete sprint`(): Unit = runBlocking {
        val sprintId = 1L
        viewModel.onOpen(sprintId)
        viewModel.deleteSprint()
        asserts()
        assertResultEquals(SuccessResult(Unit), viewModel.deleteResult.value)

    }

    @Test
    fun `delete sprint error`(): Unit = runBlocking {
        val sprintId = 1L
        viewModel.onOpen(sprintId)
        coEvery { mockSprintsRepository.deleteSprint(any()) } throws notFoundException
        asserts()
        viewModel.deleteSprint()
        assertIs<ErrorResult<Unit>>(viewModel.deleteResult.value)
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/domain/paging/CommonPagingSource.kt<|end_filename|>
package io.eugenethedev.taigamobile.domain.paging

import androidx.paging.PagingSource
import androidx.paging.PagingState
import timber.log.Timber

class CommonPagingSource<T : Any>(
    private val loadBackend: suspend (Int) -> List<T>
) : PagingSource<Int, T>() {
    companion object {
        const val PAGE_SIZE = 20
    }

    override fun getRefreshKey(state: PagingState<Int, T>): Int? {
        return state.anchorPosition?.let { anchorPosition ->
            val anchorPage = state.closestPageToPosition(anchorPosition)
            val page = anchorPage?.prevKey?.plus(1)
            page
        }
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
        val page = params.key ?: 1
        return try {
            val response = loadBackend(page)
            LoadResult.Page(
                data = response,
                prevKey = (page - 1).takeIf { it > 0 },
                nextKey = if (response.isNotEmpty()) page + 1 else null
            )
        } catch (e: Exception) {
            Timber.w(e)
            LoadResult.Error(e)
        }
    }
}


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/data/repositories/AuthRepository.kt<|end_filename|>
package io.eugenethedev.taigamobile.data.repositories

import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.data.api.AuthRequest
import io.eugenethedev.taigamobile.data.api.TaigaApi
import io.eugenethedev.taigamobile.domain.entities.AuthType
import io.eugenethedev.taigamobile.domain.repositories.IAuthRepository
import javax.inject.Inject

class AuthRepository @Inject constructor(
    private val taigaApi: TaigaApi,
    private val session: Session
) : IAuthRepository {
    override suspend fun auth(taigaServer: String, authType: AuthType, password: String, username: String) = withIO {
        session.changeServer(taigaServer)
        taigaApi.auth(
            AuthRequest(
                username = username,
                password = password,
                type = when (authType) {
                    AuthType.Normal -> "normal"
                    AuthType.LDAP -> "ldap"
                }
            )
        ).let {
            session.changeAuthCredentials(
                token = it.auth_token,
                refreshToken = it.refresh ?: "missing" // compatibility with older Taiga versions without refresh token
            )
            session.changeCurrentUserId(it.id)
        }
    }
}


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/viewmodels/CreateTaskViewModelTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.viewmodels

import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.ui.screens.createtask.CreateTaskViewModel
import io.eugenethedev.taigamobile.ui.utils.ErrorResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.viewmodels.utils.accessDeniedException
import io.eugenethedev.taigamobile.viewmodels.utils.assertResultEquals
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import kotlin.test.*

class CreateTaskViewModelTest : BaseViewModelTest() {
    private lateinit var viewModel: CreateTaskViewModel

    @BeforeTest
    fun setup() = runBlocking {
        viewModel = CreateTaskViewModel(mockAppComponent)
    }

    @Test
    fun `test create task`(): Unit = runBlocking {
        val mockCommonTaskType = mockk<CommonTaskType>(relaxed = true)
        val mockCommonTask = mockk<CommonTask>(relaxed = true)
        val title = "title"
        val description = "description"

        coEvery {
            mockTaskRepository.createCommonTask(
                any(),
                any(),
                any(),
                any(),
                any(),
                any(),
                any()
            )
        } returns mockCommonTask
        viewModel.createTask(mockCommonTaskType, title, description)
        assertResultEquals(SuccessResult(mockCommonTask), viewModel.creationResult.value)

        coEvery {
            mockTaskRepository.createCommonTask(
                any(),
                neq(title),
                any(),
                any(),
                any(),
                any(),
                any()
            )
        } throws accessDeniedException
        viewModel.createTask(mockCommonTaskType, title + "error", description)
        assertIs<ErrorResult<CommonTask>>(viewModel.creationResult.value)
    }
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/dialogs/ConfirmActionDialog.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.dialogs

import androidx.annotation.DrawableRes
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R

/**
 * Standard confirmation alert with "yes" "no" buttons, title and text
 */
@Composable
fun ConfirmActionDialog(
    title: String,
    text: String,
    onConfirm: () -> Unit,
    onDismiss: () -> Unit,
    @DrawableRes iconId: Int? = null
) = AlertDialog(
    onDismissRequest = onDismiss,
    confirmButton = {
        TextButton(onClick = onConfirm) {
            Text(
                text = stringResource(R.string.yes),
                style = MaterialTheme.typography.titleMedium
            )
        }
    },
    dismissButton = {
        TextButton(onClick = onDismiss) {
            Text(
                text = stringResource(R.string.no),
                style = MaterialTheme.typography.titleMedium
            )
        }
    },
    title = {
        Text(
            text = title,
            style = MaterialTheme.typography.titleLarge
        )
    },
    text = { Text(text) },
    icon = iconId?.let {
        {
            Icon(
                modifier = Modifier.size(26.dp),
                painter = painterResource(it),
                contentDescription = null
            )
        }
    }
)


<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/components/editors/TextFieldWithHint.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.components.editors

import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
import io.eugenethedev.taigamobile.ui.theme.shapes

/**
 * You've read it right. Text field. With hint.
 */
@Composable
fun TextFieldWithHint(
    @StringRes hintId: Int,
    value: TextFieldValue,
    onValueChange: (TextFieldValue) -> Unit,
    horizontalPadding: Dp = 0.dp,
    verticalPadding: Dp = 0.dp,
    width: Dp? = null,
    style: TextStyle = MaterialTheme.typography.bodyLarge,
    singleLine: Boolean = false,
    keyboardType: KeyboardType = KeyboardType.Text,
    onFocusChange: (Boolean) -> Unit = {},
    focusRequester: FocusRequester = remember { FocusRequester() },
    maxLines: Int = Int.MAX_VALUE,
    textColor: Color = MaterialTheme.colorScheme.onSurface,
    onSearchClick: (() -> Unit)? = null,
    hasBorder: Boolean = false
) {
    val primaryColor = MaterialTheme.colorScheme.primary
    val unfocusedColor = MaterialTheme.colorScheme.outline
    var outlineColor by remember { mutableStateOf(unfocusedColor) }

    Box(
        contentAlignment = Alignment.CenterStart,
        modifier = Modifier.let { m -> width?.let { m.width(it) } ?: m.fillMaxWidth() }
            .padding(horizontal = horizontalPadding, vertical = verticalPadding)
            .let {
                if (hasBorder) {
                    it.border(width = 2.dp, color = outlineColor, shape = shapes.large)
                        .padding(horizontal = 16.dp, vertical = 10.dp)
                } else {
                    it
                }
            }
    ) {
        if (value.text.isEmpty()) {
            Text(
                text = stringResource(hintId),
                style = style,
                color = unfocusedColor
            )
        }

        BasicTextField(
            value = value,
            onValueChange = onValueChange,
            modifier = Modifier.fillMaxWidth()
                .focusRequester(focusRequester)
                .onFocusChanged {
                    onFocusChange(it.isFocused)
                    outlineColor = if (it.isFocused) primaryColor else unfocusedColor
                },
            textStyle = style.merge(TextStyle(color = textColor)),
            cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface),
            singleLine = singleLine,
            maxLines = maxLines,
            keyboardOptions = KeyboardOptions(
                imeAction = onSearchClick?.let { ImeAction.Search } ?: ImeAction.Default,
                keyboardType = keyboardType
            ),
            keyboardActions = KeyboardActions(onSearch = { onSearchClick?.invoke() })
        )
    }
}

val searchFieldHorizontalPadding = mainHorizontalScreenPadding
val searchFieldVerticalPadding = 8.dp


<|start_filename|>app/src/test/kotlin/io/eugenethedev/taigamobile/repositories/TasksRepositoryTest.kt<|end_filename|>
package io.eugenethedev.taigamobile.repositories

import io.eugenethedev.taigamobile.data.repositories.SprintsRepository
import io.eugenethedev.taigamobile.data.repositories.TasksRepository
import io.eugenethedev.taigamobile.data.repositories.UsersRepository
import io.eugenethedev.taigamobile.domain.entities.CommonTaskExtended
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.domain.repositories.ISprintsRepository
import io.eugenethedev.taigamobile.domain.repositories.ITasksRepository
import io.eugenethedev.taigamobile.domain.repositories.IUsersRepository
import io.eugenethedev.taigamobile.repositories.utils.TestCommonTask
import io.eugenethedev.taigamobile.repositories.utils.getTestTasks
import io.eugenethedev.taigamobile.testdata.TestData
import kotlinx.coroutines.runBlocking
import org.junit.Test
import java.time.LocalDate
import kotlin.test.*


class TasksRepositoryTest : BaseRepositoryTest() {
    lateinit var tasksRepository: ITasksRepository
    lateinit var sprintsRepository: ISprintsRepository
    lateinit var usersRepository: IUsersRepository

    @BeforeTest
    fun setupSprintsRepositoryTest() {
        tasksRepository = TasksRepository(mockTaigaApi, mockSession)
        sprintsRepository = SprintsRepository(mockTaigaApi, mockSession)
        usersRepository = UsersRepository(mockTaigaApi, mockSession)
    }

    private fun checkCommonTasksEquality(
        expectedList: List<TestCommonTask>,
        actualList: List<TestCommonTask>
    ) {

        assertEquals(
            expected = expectedList.size,
            actual = actualList.size
        )
        actualList.forEachIndexed { index, data ->
            assertEquals(
                expected = expectedList[index].title,
                actual = data.title
            )
            assertEquals(
                expected = expectedList[index].assigneeFullName,
                actual = data.assigneeFullName
            )
            assertEquals(
                expected = expectedList[index].isClosed,
                actual = data.isClosed
            )
        }
    }

    private fun checkCommonTaskExtendedEquality(
        expectedTask: CommonTaskExtended,
        actualTask: CommonTaskExtended
    ) {
        assertEquals(
            expected = expectedTask.id,
            actual = actualTask.id
        )
        assertEquals(
            expected = expectedTask.version + 1,
            actual = actualTask.version
        )
    }

    private suspend fun getCommonTasksExt(
        types: List<CommonTaskType>
    ): MutableMap<CommonTaskType, List<CommonTaskExtended>> {
        val dataForTest = mutableMapOf<CommonTaskType, List<CommonTaskExtended>>()
        val userStories = tasksRepository.getAllUserStories()
        types.forEach { type ->
            when (type) {
                CommonTaskType.Epic -> {
                    dataForTest[CommonTaskType.Epic] = tasksRepository
                        .getEpics(1, FiltersData())
                        .map {
                            tasksRepository.getCommonTask(it.id, it.taskType)
                        }
                }
                CommonTaskType.Issue -> {
                    dataForTest[CommonTaskType.Issue] = tasksRepository
                        .getIssues(1, FiltersData())
                        .map {
                            tasksRepository.getCommonTask(it.id, it.taskType)
                        }
                }
                CommonTaskType.Task -> {
                    dataForTest[CommonTaskType.Task] = userStories
                        .flatMap { tasksRepository.getUserStoryTasks(it.id) }
                        .map {
                            tasksRepository.getCommonTask(it.id, it.taskType)
                        }
                }
                CommonTaskType.UserStory -> {
                    dataForTest[CommonTaskType.UserStory] = userStories
                }
            }
        }
        return dataForTest
    }

    @Test
    fun `test get working on`() = runBlocking {
        val listCommonTasks = tasksRepository.getWorkingOn()
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignee?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }
        val totalTestCommonTasks = TestData.projects
            .flatMapIndexed { index, project -> project.epics + getTestTasks(index) + project.issues + project.userstories }
            .filter { it.assignedTo == TestData.activeUser && !it.isClosed }
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignedTo?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }

        checkCommonTasksEquality(totalTestCommonTasks, listCommonTasks)
    }

    @Test
    fun `test get watching`() = runBlocking {
        val listCommonTasks = tasksRepository.getWatching()
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignee?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }
        val totalTestCommonTasks = TestData.projects
            .flatMapIndexed { index, project -> project.epics + getTestTasks(index) + project.issues + project.userstories }
            .filter { commonTask ->
                (TestData.activeUser in commonTask.watchers || TestData.activeUser in commonTask.comments.map { it.author })
                        && !commonTask.isClosed
            }
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignedTo?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }

        checkCommonTasksEquality(totalTestCommonTasks, listCommonTasks)
    }

    @Test
    fun `test get epics`() = runBlocking {
        val epics = tasksRepository.getEpics(1, FiltersData())
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignee?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }
        val testEpics = TestData.projects[0].epics
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignedTo?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }

        checkCommonTasksEquality(testEpics, epics)
    }

    @Test
    fun `test get all user stories`() = runBlocking {
        val userStories = tasksRepository.getAllUserStories()
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = null,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }
        val testUsersStory = TestData.projects[0].userstories
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = null,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }

        checkCommonTasksEquality(testUsersStory, userStories)
    }

    @Test
    fun `test get backlog user stories`() = runBlocking {
        val listUserStories = tasksRepository
            .getBacklogUserStories(1, FiltersData())
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignee?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }
        val listTestUserStories = TestData.projects[0].userstories
            .filter { it.sprint == null }
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignedTo?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }

        checkCommonTasksEquality(listTestUserStories, listUserStories)
    }

    @Test
    fun `test get epic user stories`() = runBlocking {
        tasksRepository.getEpics(1, FiltersData()).forEach { epic ->
            val epicsUserStories = tasksRepository.getEpicUserStories(epic.id)
                .map {
                    TestCommonTask(
                        title = it.title,
                        assigneeFullName = it.assignee?.fullName,
                        isClosed = it.isClosed
                    )
                }
                .sortedBy { it.title }
            val testEpicsUserStories = TestData.projects[0].userstories
                .filter { epic.title in it.epics.map { it.title } }
                .map {
                    TestCommonTask(
                        title = it.title,
                        assigneeFullName = it.assignedTo?.fullName,
                        isClosed = it.isClosed
                    )
                }
                .sortedBy { it.title }

            checkCommonTasksEquality(testEpicsUserStories, epicsUserStories)
        }
    }

    @Test
    fun `test get user story tasks`() = runBlocking {
        tasksRepository.getAllUserStories().forEach { story ->
            val tasks = tasksRepository.getUserStoryTasks(story.id)
                .map {
                    TestCommonTask(
                        title = it.title,
                        assigneeFullName = it.assignee?.fullName,
                        isClosed = it.isClosed
                    )
                }
                .sortedBy { it.title }
            val testTask = TestData.projects[0].userstories
                .find { it.title == story.title }
                ?.tasks
                ?.map {
                    TestCommonTask(
                        title = it.title,
                        assigneeFullName = it.assignedTo?.fullName,
                        isClosed = it.isClosed
                    )
                }
                ?.sortedBy { it.title }

            testTask?.let {
                checkCommonTasksEquality(it, tasks)
            }
        }
    }

    @Test
    fun `test get issues`() = runBlocking {
        val issues = tasksRepository.getIssues(1, FiltersData())
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignee?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }
        val testIssues = TestData.projects[0].issues
            .map {
                TestCommonTask(
                    title = it.title,
                    assigneeFullName = it.assignedTo?.fullName,
                    isClosed = it.isClosed
                )
            }
            .sortedBy { it.title }

        checkCommonTasksEquality(testIssues, issues)
    }

    @Test
    fun `test get common task`() = runBlocking {
        val userStories = TestData.projects[0].userstories + TestData.projects[1].userstories
        val tasks =
            TestData.projects[0].sprints.flatMap { it.tasks } + TestData.projects[1].sprints.flatMap { it.tasks }
        val epics = TestData.projects[0].epics + TestData.projects[1].epics
        val issue = TestData.projects[0].issues + TestData.projects[1].issues
        val dataForTest = mapOf(
            CommonTaskType.UserStory to userStories,
            CommonTaskType.Task to tasks,
            CommonTaskType.Epic to epics,
            CommonTaskType.Issue to issue
        )

        CommonTaskType.values().forEach { type ->
            dataForTest[type]!!.forEachIndexed { index, task ->
                val commonTaskExt = tasksRepository.getCommonTask(index.toLong() + 1, type)
                assertEquals(
                    expected = task.title,
                    actual = commonTaskExt.title
                )
                assertEquals(
                    expected = task.isClosed,
                    actual = commonTaskExt.isClosed
                )
                assertEquals(
                    expected = task.description,
                    actual = commonTaskExt.description
                )
            }
        }
    }

    @Test
    fun `test get comments`() = runBlocking {
        val userStories = TestData.projects[0].userstories + TestData.projects[1].userstories
        val tasks =
            TestData.projects[0].sprints.flatMap { it.tasks } + TestData.projects[1].sprints.flatMap { it.tasks }
        val epics = TestData.projects[0].epics + TestData.projects[1].epics
        val issue = TestData.projects[0].issues + TestData.projects[1].issues
        val dataForTest = hashMapOf(
            CommonTaskType.UserStory to userStories,
            CommonTaskType.Task to tasks,
            CommonTaskType.Epic to epics,
            CommonTaskType.Issue to issue
        )

        CommonTaskType.values().forEach { type ->
            dataForTest[type]!!.forEachIndexed { index, task ->
                val comments = tasksRepository.getComments(index.toLong() + 1, type)
                val testComments = task.comments

                assertEquals(
                    expected = testComments.size,
                    actual = comments.size
                )
                comments.forEachIndexed { ind, comment ->
                    assertEquals(
                        expected = testComments[ind].text,
                        actual = comment.text
                    )
                }
            }
        }
    }

    @Test
    fun `test change status`() = runBlocking {
        val issues = tasksRepository.getIssues(1, FiltersData())
        val statuses = tasksRepository.getStatuses(CommonTaskType.Issue)

        issues.forEachIndexed { index, issue ->
            val commonTask = tasksRepository.getCommonTask(issue.id, CommonTaskType.Issue)
            val status = statuses[index % statuses.size]
            tasksRepository.changeStatus(
                commonTask.id,
                commonTask.taskType,
                status.id,
                status.type,
                commonTask.version
            )
            val commonTaskAfterChange =
                tasksRepository.getCommonTask(issue.id, CommonTaskType.Issue)

            checkCommonTaskExtendedEquality(commonTask, commonTaskAfterChange)
            assertEquals(
                expected = status.id,
                actual = commonTaskAfterChange.status.id
            )
            assertEquals(
                expected = status.type,
                actual = commonTaskAfterChange.status.type
            )
        }
    }

    @Test
    fun `test change sprint`() = runBlocking {
        val issues = tasksRepository.getIssues(1, FiltersData())
        val userStories = tasksRepository.getAllUserStories()
        val sprints = sprintsRepository.getSprints(1) + sprintsRepository.getSprints(1, true)

        issues.forEachIndexed { index, issue ->
            val commonTask = tasksRepository.getCommonTask(issue.id, CommonTaskType.Issue)
            val sprint = sprints[index % sprints.size]
            tasksRepository.changeSprint(
                commonTask.id,
                commonTask.taskType,
                sprint.id,
                commonTask.version
            )
            val commonTaskAfterChange =
                tasksRepository.getCommonTask(issue.id, CommonTaskType.Issue)

            checkCommonTaskExtendedEquality(commonTask, commonTaskAfterChange)
            assertEquals(
                expected = sprint.id,
                actual = commonTaskAfterChange.sprint?.id
            )
            assertEquals(
                expected = sprint.name,
                actual = commonTaskAfterChange.sprint?.name
            )
        }

        userStories.forEachIndexed { index, story ->
            val sprint = sprints[index % sprints.size]
            tasksRepository.changeSprint(
                story.id,
                story.taskType,
                sprint.id,
                story.version
            )
            val storyAfterChange = tasksRepository.getCommonTask(story.id, CommonTaskType.UserStory)

            checkCommonTaskExtendedEquality(story, storyAfterChange)
            assertEquals(
                expected = sprint.id,
                actual = storyAfterChange.sprint?.id
            )
            assertEquals(
                expected = sprint.name,
                actual = storyAfterChange.sprint?.name
            )
        }
    }

    @Test
    fun `test link to epic`() = runBlocking {
        val epics = tasksRepository.getEpics(1, FiltersData())
        val userStories = tasksRepository.getAllUserStories()

        epics.forEachIndexed { index, epic ->
            tasksRepository.linkToEpic(epic.id, userStories[(index + 1) % userStories.size].id)
            val userStoryLink = tasksRepository.getEpicUserStories(epic.id)
                .find { it.id == userStories[(index + 1) % userStories.size].id }
            val userStory = userStories[(index + 1) % userStories.size]

            assertEquals(
                expected = userStory.title,
                actual = userStoryLink?.title
            )
            assertEquals(
                expected = userStory.isClosed,
                actual = userStoryLink?.isClosed
            )
            assertEquals(
                expected = userStory.status,
                actual = userStoryLink?.status
            )
        }
    }

    @Test
    fun `test unlink to epic`() = runBlocking {
        val epics = tasksRepository.getEpics(1, FiltersData())

        epics.forEach { epic ->
            tasksRepository.getEpicUserStories(epic.id).forEach { story ->
                tasksRepository.unlinkFromEpic(epic.id, story.id)
                val currentUserStories = tasksRepository.getEpicUserStories(epic.id)
                assertTrue(
                    story.id !in currentUserStories.map { it.id }
                )
            }
        }
    }


    @Test
    fun `test change assignees`() = runBlocking {
        val users = usersRepository.getTeam()
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Epic,
                CommonTaskType.Issue,
                CommonTaskType.Task,
                CommonTaskType.UserStory
            )
        )

        CommonTaskType.values().forEach { type ->
            dataForTest[type]!!.forEachIndexed { index, data ->
                val assigneeUsers = listOf(users[index % users.size].id)
                tasksRepository.changeAssignees(
                    data.id,
                    data.taskType,
                    assigneeUsers,
                    data.version
                )
                val commonTaskAfterChange = tasksRepository.getCommonTask(data.id, data.taskType)

                checkCommonTaskExtendedEquality(data, commonTaskAfterChange)
                assertEquals(
                    expected = assigneeUsers,
                    actual = commonTaskAfterChange.assignedIds
                )
            }
        }
    }

    @Test
    fun `test change watchers`() = runBlocking {
        val users = usersRepository.getTeam()
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Epic,
                CommonTaskType.Issue,
                CommonTaskType.Task,
                CommonTaskType.UserStory
            )
        )

        CommonTaskType.values().forEach { type ->
            dataForTest[type]!!.forEachIndexed { index, data ->
                val watchers = listOf(users[index % users.size].id)
                tasksRepository.changeWatchers(
                    data.id,
                    data.taskType,
                    watchers,
                    data.version
                )
                val commonTaskAfterChange = tasksRepository.getCommonTask(data.id, data.taskType)

                checkCommonTaskExtendedEquality(data, commonTaskAfterChange)
                assertEquals(
                    expected = watchers,
                    actual = commonTaskAfterChange.watcherIds
                )
            }
        }
    }

    @Test
    fun `test change due date`() = runBlocking {
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Issue,
                CommonTaskType.Task,
                CommonTaskType.UserStory
            )
        )

        dataForTest.forEach {
            it.value.forEachIndexed { index, data ->
                val currentDate = LocalDate.now()
                tasksRepository.changeDueDate(
                    data.id,
                    data.taskType,
                    currentDate,
                    data.version
                )
                val commonTaskAfterChange = tasksRepository.getCommonTask(data.id, data.taskType)

                checkCommonTaskExtendedEquality(data, commonTaskAfterChange)
                assertEquals(
                    expected = currentDate,
                    actual = commonTaskAfterChange.dueDate
                )
            }
        }
    }

    @Test
    fun `test create comment`() = runBlocking {
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Epic,
                CommonTaskType.Issue,
                CommonTaskType.Task,
                CommonTaskType.UserStory
            )
        )

        dataForTest.forEach {
            it.value.forEachIndexed { index, data ->
                val comment = "Comment${index}"
                tasksRepository.createComment(
                    data.id,
                    data.taskType,
                    comment,
                    data.version
                )
                assertNotNull(
                    tasksRepository.getComments(data.id, data.taskType)
                        .find { it.text == comment }
                )
            }
        }
    }

    @Test
    fun `test delete comment`() = runBlocking {
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Issue,
                CommonTaskType.Task
            )
        )

        dataForTest.forEach {
            it.value.forEachIndexed { index, data ->
                var comments = tasksRepository.getComments(data.id, data.taskType)
                if (comments.isEmpty()) {
                    tasksRepository.createComment(
                        data.id,
                        data.taskType,
                        "Comment${index}",
                        data.version
                    )
                    comments = tasksRepository.getComments(data.id, data.taskType)
                }
                comments.forEach { comment ->
                    tasksRepository.deleteComment(data.id, data.taskType, comment.id)
                    assertTrue {
                        comment !in tasksRepository.getComments(data.id, data.taskType)
                    }
                }
            }
        }
    }

    @Test
    fun `test edit common task`() = runBlocking {
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Epic,
                CommonTaskType.Issue,
                CommonTaskType.Task,
                CommonTaskType.UserStory
            )
        )

        dataForTest.forEach {
            it.value.forEachIndexed { index, commonTask ->
                val title = "Title${index}"
                val description = "Description${index}"
                tasksRepository.editCommonTask(
                    commonTask.id,
                    commonTask.taskType,
                    title,
                    description,
                    commonTask.version
                )

                val taskAfterChange =
                    tasksRepository.getCommonTask(commonTask.id, commonTask.taskType)
                assertEquals(
                    expected = title,
                    actual = taskAfterChange.title
                )
                assertEquals(
                    expected = description,
                    actual = taskAfterChange.description
                )
            }
        }
    }

    @Test
    fun `test create common task`() = runBlocking {
        val sprints = sprintsRepository.getSprints(1)
        val swimlanes = tasksRepository.getSwimlanes()
        CommonTaskType.values().forEachIndexed { index, type ->
            val statuses = tasksRepository.getStatuses(type)
            val title = "Title${index}"
            val description = "Description${index}"
            val commonTask = tasksRepository.createCommonTask(
                commonTaskType = type,
                title = title,
                description = description,
                parentId = null,
                sprintId = if (sprints.isNotEmpty()) sprints[index % sprints.size].id else null,
                statusId = if (statuses.isNotEmpty()) statuses[index % statuses.size].id else null,
                swimlaneId = if (swimlanes.isNotEmpty()) swimlanes[index % swimlanes.size].id else null
            )
            val extendedCommonTask =
                tasksRepository.getCommonTask(commonTask.id, commonTask.taskType)

            assertEquals(
                expected = type,
                actual = extendedCommonTask.taskType
            )
            assertEquals(
                expected = title,
                actual = extendedCommonTask.title
            )
            assertEquals(
                expected = description,
                actual = extendedCommonTask.description
            )
        }
    }

    @Test
    fun `test delete common task`() = runBlocking {
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Epic,
                CommonTaskType.Issue,
                CommonTaskType.Task,
                CommonTaskType.UserStory
            )
        )

        dataForTest.forEach {
            it.value.forEachIndexed { index, commonTask ->
                tasksRepository.deleteCommonTask(commonTask.taskType, commonTask.id)
                assertFailsWith<retrofit2.HttpException> {
                    tasksRepository.getCommonTask(commonTask.id, commonTask.taskType)
                }
            }
        }
    }

    @Test
    fun `test promote common task to userstory`() = runBlocking {
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Issue,
                CommonTaskType.Task
            )
        )

        dataForTest.forEach {
            it.value.forEachIndexed { index, commonTask ->
                val promotedCommonTask =
                    tasksRepository.promoteCommonTaskToUserStory(commonTask.id, commonTask.taskType)

                assertEquals(
                    expected = CommonTaskType.UserStory,
                    actual = promotedCommonTask.taskType
                )
                assertEquals(
                    expected = commonTask.title,
                    actual = promotedCommonTask.title
                )
                assertEquals(
                    expected = commonTask.tags,
                    actual = promotedCommonTask.tags
                )
            }
        }
    }

    @Test
    fun `test edit tags`() = runBlocking {
        val dataForTest = getCommonTasksExt(
            listOf(
                CommonTaskType.Epic,
                CommonTaskType.Issue,
                CommonTaskType.Task,
                CommonTaskType.UserStory
            )
        )

        dataForTest.forEach {
            val tags = tasksRepository.getAllTags(it.key)
            it.value.forEachIndexed { index, commonTask ->
                val currentTags =
                    if (tags.isNotEmpty()) listOf(tags[index % tags.size]) else listOf()
                tasksRepository.editTags(
                    commonTask.taskType,
                    commonTask.id,
                    currentTags,
                    commonTask.version
                )
                val commonTaskAfterChange =
                    tasksRepository.getCommonTask(commonTask.id, commonTask.taskType)

                checkCommonTaskExtendedEquality(commonTask, commonTaskAfterChange)
                assertEquals(
                    expected = currentTags,
                    actual = commonTaskAfterChange.tags,
                )
            }
        }
    }

    @Test
    fun `test change epic color`() = runBlocking {
        val epics = tasksRepository.getEpics(1, FiltersData()).map {
            tasksRepository.getCommonTask(it.id, it.taskType)
        }
        val colors = listOf("#108D32", "#C207BF", "#950A52", "#BD33EE")

        epics.forEachIndexed { index, epic ->
            val color = colors[index % colors.size]
            tasksRepository.changeEpicColor(
                epic.id,
                color,
                epic.version
            )
            val epicAfterChange = tasksRepository.getCommonTask(epic.id, CommonTaskType.Epic)

            checkCommonTaskExtendedEquality(epic, epicAfterChange)
            assertEquals(
                expected = color,
                actual = epicAfterChange.color
            )
        }
    }
}

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/theme/Typography.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.theme

import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp

val typography = Typography(
    titleMedium = TextStyle(
        fontWeight = FontWeight.Medium,
        fontSize = 16.sp,
        letterSpacing = 0.5.sp
    ),
    bodyMedium = TextStyle(
        fontWeight = FontWeight.Normal,
        fontSize = 15.sp,
        letterSpacing = 0.25.sp
    ),
    titleLarge = TextStyle(
        fontWeight = FontWeight.Medium,
        fontSize = 18.sp,
        letterSpacing = 0.15.sp
    )
)

<|start_filename|>app/src/main/kotlin/io/eugenethedev/taigamobile/ui/screens/sprint/SprintScreen.kt<|end_filename|>
package io.eugenethedev.taigamobile.ui.screens.sprint

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.DropdownMenu
import androidx.compose.material.DropdownMenuItem
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.Sprint
import io.eugenethedev.taigamobile.domain.entities.Status
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.ui.utils.LoadingResult
import io.eugenethedev.taigamobile.ui.utils.SuccessResult
import io.eugenethedev.taigamobile.ui.components.appbars.AppBarWithBackButton
import io.eugenethedev.taigamobile.ui.components.dialogs.ConfirmActionDialog
import io.eugenethedev.taigamobile.ui.components.dialogs.EditSprintDialog
import io.eugenethedev.taigamobile.ui.components.dialogs.LoadingDialog
import io.eugenethedev.taigamobile.ui.components.loaders.CircularLoader
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.dialogTonalElevation
import io.eugenethedev.taigamobile.ui.utils.*
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle

@Composable
fun SprintScreen(
    navController: NavController,
    sprintId: Long,
    onError: @Composable (message: Int) -> Unit = {},
) {
    val viewModel: SprintViewModel = viewModel()
    LaunchedEffect(Unit) {
        viewModel.onOpen(sprintId)
    }

    val sprint by viewModel.sprint.collectAsState()
    sprint.subscribeOnError(onError)

    val statuses by viewModel.statuses.collectAsState()
    statuses.subscribeOnError(onError)

    val storiesWithTasks by viewModel.storiesWithTasks.collectAsState()
    storiesWithTasks.subscribeOnError(onError)

    val storylessTasks by viewModel.storylessTasks.collectAsState()
    storylessTasks.subscribeOnError(onError)

    val issues by viewModel.issues.collectAsState()
    issues.subscribeOnError(onError)

    val editResult by viewModel.editResult.collectAsState()
    editResult.subscribeOnError(onError)

    val deleteResult by viewModel.deleteResult.collectAsState()
    deleteResult.subscribeOnError(onError)
    deleteResult.takeIf { it is SuccessResult }?.let {
        LaunchedEffect(Unit) {
            navController.popBackStack()
        }
    }

    SprintScreenContent(
        sprint = sprint.data,
        isLoading = sprint is LoadingResult,
        isEditLoading = editResult is LoadingResult,
        isDeleteLoading = deleteResult is LoadingResult,
        statuses = statuses.data.orEmpty(),
        storiesWithTasks = storiesWithTasks.data.orEmpty(),
        storylessTasks = storylessTasks.data.orEmpty(),
        issues = issues.data.orEmpty(),
        editSprint = viewModel::editSprint,
        deleteSprint = viewModel::deleteSprint,
        navigateBack = navController::popBackStack,
        navigateToTask = navController::navigateToTaskScreen,
        navigateToCreateTask = { type, parentId -> navController.navigateToCreateTaskScreen(type, parentId, sprintId) }
    )
}


@Composable
fun SprintScreenContent(
    sprint: Sprint?,
    isLoading: Boolean = false,
    isEditLoading: Boolean = false,
    isDeleteLoading: Boolean = false,
    statuses: List<Status> = emptyList(),
    storiesWithTasks: Map<CommonTask, List<CommonTask>> = emptyMap(),
    storylessTasks: List<CommonTask> = emptyList(),
    issues: List<CommonTask> = emptyList(),
    editSprint: (name: String, start: LocalDate, end: LocalDate) -> Unit = { _, _, _ -> },
    deleteSprint: () -> Unit = {},
    navigateBack: () -> Unit = {},
    navigateToTask: NavigateToTask = { _, _, _ -> },
    navigateToCreateTask: (type: CommonTaskType, parentId: Long?) -> Unit = { _, _ -> }
) = Column(
    modifier = Modifier.fillMaxSize(),
    horizontalAlignment = Alignment.Start
) {
    val dateFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) }

    var isMenuExpanded by remember { mutableStateOf(false) }
    AppBarWithBackButton(
        title = {
            Column {
                Text(
                    text = sprint?.name.orEmpty(),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )

                Text(
                    text = stringResource(R.string.sprint_dates_template).format(
                        sprint?.start?.format(dateFormatter).orEmpty(),
                        sprint?.end?.format(dateFormatter).orEmpty()
                    ),
                    style = MaterialTheme.typography.bodyMedium,
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis
                )
            }
        },
        actions = {
            Box {
                IconButton(onClick = { isMenuExpanded = true }) {
                    Icon(
                        painter = painterResource(R.drawable.ic_options),
                        contentDescription = null,
                        tint = MaterialTheme.colorScheme.primary
                    )
                }

                // delete alert dialog
                var isDeleteAlertVisible by remember { mutableStateOf(false) }
                if (isDeleteAlertVisible) {
                    ConfirmActionDialog(
                        title = stringResource(R.string.delete_sprint_title),
                        text = stringResource(R.string.delete_sprint_text),
                        onConfirm = {
                            isDeleteAlertVisible = false
                            deleteSprint()
                        },
                        onDismiss = { isDeleteAlertVisible = false },
                        iconId = R.drawable.ic_delete
                    )
                }

                var isEditDialogVisible by remember { mutableStateOf(false) }
                if (isEditDialogVisible) {
                    EditSprintDialog(
                        initialName = sprint?.name.orEmpty(),
                        initialStart = sprint?.start,
                        initialEnd = sprint?.end,
                        onConfirm = { name, start, end ->
                            editSprint(name, start, end)
                            isEditDialogVisible = false
                        },
                        onDismiss = { isEditDialogVisible = false }
                    )
                }

                DropdownMenu(
                    modifier = Modifier.background(
                        MaterialTheme.colorScheme.surfaceColorAtElevation(dialogTonalElevation)
                    ),
                    expanded = isMenuExpanded,
                    onDismissRequest = { isMenuExpanded = false }
                ) {
                    // edit
                    DropdownMenuItem(
                        onClick = {
                            isMenuExpanded = false
                            isEditDialogVisible = true
                        }
                    ) {
                        Text(
                            text = stringResource(R.string.edit),
                            style = MaterialTheme.typography.bodyLarge
                        )
                    }

                    // delete
                    DropdownMenuItem(
                        onClick = {
                            isMenuExpanded = false
                            isDeleteAlertVisible = true
                        }
                    ) {
                        Text(
                            text = stringResource(R.string.delete),
                            style = MaterialTheme.typography.bodyLarge
                        )
                    }
                }
            }
        },
        navigateBack = navigateBack
    )

    when {
        isLoading || sprint == null -> Box(
            contentAlignment = Alignment.Center,
            modifier = Modifier.fillMaxSize()
        ) {
            CircularLoader()
        }

        isEditLoading || isDeleteLoading -> LoadingDialog()

        else -> SprintKanban(
            statuses = statuses,
            storiesWithTasks = storiesWithTasks,
            storylessTasks = storylessTasks,
            issues = issues,
            navigateToTask = navigateToTask,
            navigateToCreateTask = navigateToCreateTask
        )
    }
}


@Preview(showBackground = true)
@Composable
fun SprintScreenPreview() = TaigaMobileTheme {
    SprintScreenContent(
        sprint = Sprint(
            id = 0L,
            name = "0 sprint",
            start = LocalDate.now(),
            end = LocalDate.now(),
            order = 0,
            storiesCount = 0,
            isClosed = false
        )
    )
}
