TG-185 Первый коммит:

* Оригинальный репозиторий TaigaMobile
master
commit d1b3cf5412

@ -0,0 +1,7 @@
## Tools
It is recommended to use latest version of Android Studio
## Tests
Tests need local Taiga instance to run.
Local instance is created and stopped automatically for every `Test` gradle task, so usually there is no need to start and stop Taiga instance manually.
See [taiga-test-instance](taiga-test-instance) folder for more info about helper scripts and configurations.

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

@ -0,0 +1,50 @@
# ⚠️ Archived ⚠️
This repository has been archived and is no longer maintained.
# TaigaMobile
<a href='https://play.google.com/store/apps/details?id=io.eugenethedev.taigamobile&utm_source=github'><img alt='Get it on Google Play' src='https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png' width=300/></a>
This is the **unofficial** android app for the agile project management system [taiga.io](https://www.taiga.io/). It was built with [Jetpack Compose](https://developer.android.com/jetpack/compose), featuring Material You with dynamic colors.
<img src="screenshots/m3_1.png" width=200/> <img src="screenshots/m3_2.png" width=200/>
<img src="screenshots/m3_3.png" width=200/> <img src="screenshots/m3_4.png" width=200/>
## Features
* View:
* Projects
* Epics
* User stories
* Tasks
* Issues
* Sprints
* Profiles
* Wiki
* Working on / Watching (aka Dashboard)
* Create, edit and delete:
* Epics
* User stories
* Tasks
* Issues
* Sprints
* Wiki pages
* Leave and delete comments
* Kanban (for sprint and for user stories)
* Filters for user stories, epics, issues
## Some screens
<img src="screenshots/login.png" width=300/> <img src="screenshots/login_dark.png" width=300/>
<img src="screenshots/story.png" width=300/> <img src="screenshots/story_dark.png" width=300/>
<img src="screenshots/sprint.png" width=300/> <img src="screenshots/sprint_dark.png" width=300/>
## Stack
* Kotlin
* Jetpack Compose
* Clean Architecture
* MVVM
* Coroutines
* ... and other cool things
## Design
Probably sucks. I'm not very good at designing UI, but I did my best.
## Download
Check out the app on [Google Play](https://play.google.com/store/apps/details?id=io.eugenethedev.taigamobile&utm_source=github) or go to the [Releases page](https://github.com/EugeneTheDev/TaigaMobile/releases) to download the latest apk.

2
app/.gitignore vendored

@ -0,0 +1,2 @@
/build
signing.properties

@ -0,0 +1,225 @@
import java.util.Properties
import com.android.build.api.dsl.AndroidSourceSet
plugins {
id("com.android.application")
kotlin("android")
kotlin("kapt")
}
val composeVersion = "1.1.1"
android {
compileSdk = 31
buildToolsVersion = "30.0.3"
namespace = "io.eugenethedev.taigamobile"
defaultConfig {
applicationId = namespace!!
minSdk = 21
targetSdk = 31
versionCode = 29
versionName = "1.9"
project.base.archivesName.set("TaigaMobile-$versionName")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
getByName("debug") {
storeFile = file("./keystores/debug.keystore")
storePassword = "android"
keyAlias = "debug"
keyPassword = "android"
}
create("release") {
val properties = Properties().also {
it.load(file("./signing.properties").inputStream())
}
storeFile = file("./keystores/release.keystore")
storePassword = properties.getProperty("password")
keyAlias = properties.getProperty("alias")
keyPassword = properties.getProperty("password")
}
}
buildTypes {
getByName("debug") {
signingConfig = signingConfigs.getByName("debug")
}
getByName("release") {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
signingConfig = signingConfigs.getByName("release")
}
}
testOptions.unitTests {
isIncludeAndroidResources = true
}
sourceSets {
fun AndroidSourceSet.setupTestSrcDirs() {
kotlin.srcDir("src/sharedTest/kotlin")
resources.srcDir("src/sharedTest/resources")
}
getByName("test").setupTestSrcDirs()
getByName("androidTest").setupTestSrcDirs()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
jvmTarget = "11"
freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn")
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = composeVersion
}
lint {
abortOnError = false
}
}
dependencies {
// Enforce correct kotlin version for all dependencies
implementation(enforcedPlatform(kotlin("bom")))
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
implementation(kotlin("reflect"))
implementation("androidx.core:core-ktx:1.7.0")
implementation("androidx.appcompat:appcompat:1.4.1")
implementation("com.google.android.material:material:1.6.0")
// ============================================================================================
// CAREFUL WHEN UPDATING COMPOSE RELATED DEPENDENCIES - THEY CAN USE DIFFERENT COMPOSE VERSION!
// ============================================================================================
// Main Compose dependencies
implementation("androidx.compose.ui:ui:$composeVersion")
implementation("androidx.compose.material:material:$composeVersion")
// Material You
implementation("androidx.compose.material3:material3:1.0.0-alpha09")
implementation("androidx.compose.ui:ui-tooling:$composeVersion")
implementation("androidx.compose.animation:animation:$composeVersion")
// compose activity
implementation("androidx.activity:activity-compose:1.4.0")
// view model support
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1")
// compose constraint layout
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
// Accompanist
val accompanistVersion = "0.23.1"
implementation("com.google.accompanist:accompanist-pager:$accompanistVersion")
implementation("com.google.accompanist:accompanist-pager-indicators:$accompanistVersion")
implementation("com.google.accompanist:accompanist-systemuicontroller:$accompanistVersion")
implementation("com.google.accompanist:accompanist-insets:$accompanistVersion")
implementation("com.google.accompanist:accompanist-flowlayout:$accompanistVersion")
// Coil
implementation("io.coil-kt:coil-compose:1.3.2")
// Navigation Component (with Compose)
implementation("androidx.navigation:navigation-compose:2.5.0-rc01")
// Paging (with Compose)
implementation("androidx.paging:paging-compose:1.0.0-alpha14")
// Coroutines
val coroutinesVersion = "1.6.2"
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion")
// Moshi
val moshiVersion = "1.13.0"
implementation("com.squareup.moshi:moshi:$moshiVersion")
kapt("com.squareup.moshi:moshi-kotlin-codegen:$moshiVersion")
// Retrofit 2
val retrofitVersion = "2.9.0"
implementation("com.squareup.retrofit2:retrofit:$retrofitVersion")
implementation("com.squareup.retrofit2:converter-moshi:$retrofitVersion")
// OkHttp
val okHttpVersion = "4.9.3"
implementation("com.squareup.okhttp3:okhttp:$okHttpVersion")
implementation("com.squareup.okhttp3:logging-interceptor:$okHttpVersion")
// Dagger 2
val daggerVersion = "2.42"
implementation("com.google.dagger:dagger-android:$daggerVersion")
kapt("com.google.dagger:dagger-android-processor:$daggerVersion")
kapt("com.google.dagger:dagger-compiler:$daggerVersion")
// Timber
implementation("com.jakewharton.timber:timber:5.0.1")
// Markdown support (Markwon)
val markwonVersion = "4.6.2"
implementation("io.noties.markwon:core:$markwonVersion")
implementation("io.noties.markwon:image-coil:$markwonVersion")
// Compose material dialogs (color picker)
implementation("io.github.vanpra.compose-material-dialogs:color:0.7.0")
/**
* Test frameworks & dependencies
*/
allTestsImplementation(kotlin("test-junit"))
// Robolectric (run android tests on local host)
testRuntimeOnly("org.robolectric:robolectric:4.8.1")
allTestsImplementation("androidx.test:core-ktx:1.4.0")
allTestsImplementation("androidx.test:runner:1.4.0")
allTestsImplementation("androidx.test.ext:junit-ktx:1.1.3")
// since we need to connect to test db instance
val postgresDriverVersion = "42.3.6"
testRuntimeOnly("org.postgresql:postgresql:$postgresDriverVersion")
androidTestRuntimeOnly("org.postgresql:postgresql:$postgresDriverVersion")
// manual json parsing when filling test instance
implementation("com.google.code.gson:gson:2.9.0")
// MockK
testImplementation("io.mockk:mockk:1.12.4")
}
fun DependencyHandler.allTestsImplementation(dependencyNotation: Any) {
testImplementation(dependencyNotation)
androidTestImplementation(dependencyNotation)
}
tasks.register<Exec>("launchTestInstance") {
commandLine("../taiga-test-instance/launch-taiga.sh")
}
tasks.register<Exec>("stopTestInstance") {
commandLine("../taiga-test-instance/stop-taiga.sh")
}
tasks.withType<Test> {
dependsOn("launchTestInstance")
finalizedBy("stopTestInstance")
}

Binary file not shown.

Binary file not shown.

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.kts.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

@ -0,0 +1,21 @@
package io.eugenethedev.taigamobile
import androidx.test.platform.app.InstrumentationRegistry
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.eugenethedev.taigamobile", appContext.packageName)
}
}

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:name=".TaigaApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.TaigaMobile"
tools:ignore="UnusedAttribute">
<activity
android:name=".ui.screens.main.MainActivity"
android:theme="@style/Theme.TaigaMobile"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="io.eugenethedev.taigamobile.provider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/my_paths" />
</provider>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@ -0,0 +1,46 @@
package io.eugenethedev.taigamobile
import android.app.Application
import android.util.Log
import com.google.android.material.color.DynamicColors
import io.eugenethedev.taigamobile.dagger.AppComponent
import io.eugenethedev.taigamobile.dagger.DaggerAppComponent
import io.eugenethedev.taigamobile.utils.FileLoggingTree
import timber.log.Timber
class TaigaApp : Application() {
// logging
private var fileLoggingTree: FileLoggingTree? = null
val currentLogFile get() = fileLoggingTree?.currentFile
override fun onCreate() {
super.onCreate()
appComponent = DaggerAppComponent.builder()
.context(this)
.build()
// logging configs
val minLoggingPriority = if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
Log.DEBUG
} else {
Log.WARN
}
try {
fileLoggingTree = FileLoggingTree(applicationContext.getExternalFilesDir("logs")!!.absolutePath, minLoggingPriority)
Timber.plant(fileLoggingTree!!)
} catch (e: NullPointerException) {
Timber.w("Cannot setup FileLoggingTree, skipping")
}
// Apply dynamic color
DynamicColors.applyToActivitiesIfAvailable(this)
}
companion object {
lateinit var appComponent: AppComponent
}
}

@ -0,0 +1,52 @@
package io.eugenethedev.taigamobile.dagger
import android.content.Context
import dagger.BindsInstance
import dagger.Component
import io.eugenethedev.taigamobile.ui.screens.login.LoginViewModel
import io.eugenethedev.taigamobile.ui.screens.main.MainViewModel
import io.eugenethedev.taigamobile.ui.screens.projectselector.ProjectSelectorViewModel
import io.eugenethedev.taigamobile.ui.screens.scrum.ScrumViewModel
import io.eugenethedev.taigamobile.ui.screens.sprint.SprintViewModel
import io.eugenethedev.taigamobile.ui.screens.commontask.CommonTaskViewModel
import io.eugenethedev.taigamobile.ui.screens.createtask.CreateTaskViewModel
import io.eugenethedev.taigamobile.ui.screens.dashboard.DashboardViewModel
import io.eugenethedev.taigamobile.ui.screens.epics.EpicsViewModel
import io.eugenethedev.taigamobile.ui.screens.issues.IssuesViewModel
import io.eugenethedev.taigamobile.ui.screens.kanban.KanbanViewModel
import io.eugenethedev.taigamobile.ui.screens.profile.ProfileViewModel
import io.eugenethedev.taigamobile.ui.screens.settings.SettingsViewModel
import io.eugenethedev.taigamobile.ui.screens.team.TeamViewModel
import io.eugenethedev.taigamobile.ui.screens.wiki.createpage.WikiCreatePageViewModel
import io.eugenethedev.taigamobile.ui.screens.wiki.page.WikiPageViewModel
import io.eugenethedev.taigamobile.ui.screens.wiki.list.WikiListViewModel
import javax.inject.Singleton
@Singleton
@Component(modules = [DataModule::class, RepositoriesModule::class])
interface AppComponent {
@Component.Builder
interface Builder {
@BindsInstance fun context(context: Context): Builder
fun build(): AppComponent
}
fun inject(mainViewModel: MainViewModel)
fun inject(loginViewModel: LoginViewModel)
fun inject(dashboardViewModel: DashboardViewModel)
fun inject(scrumViewModel: ScrumViewModel)
fun inject(epicsViewModel: EpicsViewModel)
fun inject(projectSelectorViewModel: ProjectSelectorViewModel)
fun inject(sprintViewModel: SprintViewModel)
fun inject(commonTaskViewModel: CommonTaskViewModel)
fun inject(teamViewModel: TeamViewModel)
fun inject(settingsViewModel: SettingsViewModel)
fun inject(createTaskViewModel: CreateTaskViewModel)
fun inject(issuesViewModel: IssuesViewModel)
fun inject(kanbanViewModel: KanbanViewModel)
fun inject(profileViewModel: ProfileViewModel)
fun inject(wikiSelectorViewModel: WikiListViewModel)
fun inject(wikiPageViewModel: WikiPageViewModel)
fun inject(wikiCreatePageViewModel: WikiCreatePageViewModel)
}

@ -0,0 +1,34 @@
package io.eugenethedev.taigamobile.dagger
import com.squareup.moshi.FromJson
import com.squareup.moshi.ToJson
import java.time.*
import java.time.format.DateTimeFormatter
class LocalDateTypeAdapter {
@ToJson
fun toJson(value: LocalDate): String = DateTimeFormatter.ISO_LOCAL_DATE.format(value)
@FromJson
fun fromJson(input: String): LocalDate = input.toLocalDate()
}
class LocalDateTimeTypeAdapter {
@ToJson
fun toJson(value: LocalDateTime): String {
return value.atZone(ZoneId.systemDefault())
.toInstant()
.toString()
}
@FromJson
fun fromJson(input: String): LocalDateTime {
return Instant.parse(input)
.atZone(ZoneId.systemDefault())
.toLocalDateTime()
}
}
// used in TaskRepository
fun String.toLocalDate(): LocalDate = LocalDate.parse(this)

@ -0,0 +1,146 @@
package io.eugenethedev.taigamobile.dagger
import android.content.Context
import com.squareup.moshi.Moshi
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.eugenethedev.taigamobile.BuildConfig
import io.eugenethedev.taigamobile.data.api.*
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.state.Settings
import io.eugenethedev.taigamobile.data.repositories.*
import io.eugenethedev.taigamobile.domain.repositories.*
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import timber.log.Timber
import javax.inject.Singleton
@Module
class DataModule {
@Singleton
@Provides
fun provideTaigaApi(session: Session, moshi: Moshi): TaigaApi {
val baseUrlPlaceholder = "https://nothing.nothing"
fun getApiUrl() = // for compatibility with older app versions
if (!session.server.value.run { startsWith("https://") || startsWith("http://") }) {
"https://"
} else {
""
} + "${session.server.value}/${TaigaApi.API_PREFIX}"
val okHttpBuilder = OkHttpClient.Builder()
.addInterceptor {
it.run {
val url = it.request().url.toUrl().toExternalForm()
proceed(
request()
.newBuilder()
.url(url.replace(baseUrlPlaceholder, getApiUrl()))
.header("User-Agent", "TaigaMobile/${BuildConfig.VERSION_NAME}")
.also {
if ("/${TaigaApi.AUTH_ENDPOINTS}" !in url) { // do not add Authorization header to authorization requests
it.header("Authorization", "Bearer ${session.token.value}")
}
}
.build()
)
}
}
.addInterceptor(
HttpLoggingInterceptor(Timber::d)
.setLevel(HttpLoggingInterceptor.Level.BODY)
.also { it.redactHeader("Authorization") }
)
val tokenClient = okHttpBuilder.build()
return Retrofit.Builder()
.baseUrl(baseUrlPlaceholder) // base url is set dynamically in interceptor
.addConverterFactory(MoshiConverterFactory.create(moshi).withNullSerialization())
.client(
okHttpBuilder.authenticator { _, response ->
response.request.header("Authorization")?.let {
try {
// prevent multiple refresh requests from different threads
synchronized(session) {
// refresh token only if it was not refreshed in another thread
if (it.replace("Bearer ", "") == session.token.value) {
val body = RefreshTokenRequestJsonAdapter(moshi)
.toJson(RefreshTokenRequest(session.refreshToken.value))
val request = Request.Builder()
.url("$baseUrlPlaceholder/${TaigaApi.REFRESH_ENDPOINT}")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
val refreshResponse = RefreshTokenResponseJsonAdapter(moshi)
.fromJson(tokenClient.newCall(request).execute().body?.string().orEmpty()) ?: throw IllegalStateException("Cannot parse RefreshResponse")
session.changeAuthCredentials(refreshResponse.auth_token, refreshResponse.refresh)
}
}
response.request.newBuilder()
.header("Authorization", "Bearer ${session.token.value}")
.build()
} catch (e: Exception) {
Timber.w(e)
session.changeAuthCredentials("", "")
null
}
}
}
.build()
)
.build()
.create(TaigaApi::class.java)
}
@Provides
fun provideMoshi(): Moshi = Moshi.Builder()
.add(LocalDateTypeAdapter())
.add(LocalDateTimeTypeAdapter())
.build()
@Provides
@Singleton
fun provideSession(context: Context, moshi: Moshi) = Session(context, moshi)
@Provides
@Singleton
fun provideSettings(context: Context) = Settings(context)
}
@Module
abstract class RepositoriesModule {
@Singleton
@Binds
abstract fun bindIAuthRepository(authRepository: AuthRepository): IAuthRepository
@Singleton
@Binds
abstract fun bindIProjectsRepository(searchRepository: ProjectsRepository): IProjectsRepository
@Singleton
@Binds
abstract fun bindIStoriesRepository(storiesRepository: TasksRepository): ITasksRepository
@Singleton
@Binds
abstract fun bindIUsersRepository(usersRepository: UsersRepository): IUsersRepository
@Singleton
@Binds
abstract fun bindISprintsRepository(sprintsRepository: SprintsRepository): ISprintsRepository
@Singleton
@Binds
abstract fun bindIWikiRepository(wikiRepository: WikiRepository): IWikiRepository
}

@ -0,0 +1,122 @@
package io.eugenethedev.taigamobile.data.api
import com.squareup.moshi.JsonClass
import java.time.LocalDate
@JsonClass(generateAdapter = true)
data class AuthRequest(
val password: String,
val username: String,
val type: String
)
@JsonClass(generateAdapter = true)
data class RefreshTokenRequest(
val refresh: String
)
@JsonClass(generateAdapter = true)
data class EditCommonTaskRequest(
val subject: String,
val description: String,
val status: Long,
val type: Long?,
val severity: Long?,
val priority: Long?,
val milestone: Long?,
val assigned_to: Long?,
val assigned_users: List<Long>,
val watchers: List<Long>,
val swimlane: Long?,
val due_date: LocalDate?,
val color: String?,
val tags: List<List<String>>,
val blocked_note: String,
val is_blocked: Boolean,
val version: Int
)
@JsonClass(generateAdapter = true)
data class CreateCommentRequest(
val comment: String,
val version: Int
)
@JsonClass(generateAdapter = true)
data class CreateCommonTaskRequest(
val project: Long,
val subject: String,
val description: String,
val status: Long?
)
@JsonClass(generateAdapter = true)
data class CreateTaskRequest(
val project: Long,
val subject: String,
val description: String,
val milestone: Long?,
val user_story: Long?
)
@JsonClass(generateAdapter = true)
data class CreateIssueRequest(
val project: Long,
val subject: String,
val description: String,
val milestone: Long?,
)
@JsonClass(generateAdapter = true)
data class CreateUserStoryRequest(
val project: Long,
val subject: String,
val description: String,
val status: Long?,
val swimlane: Long?
)
@JsonClass(generateAdapter = true)
data class LinkToEpicRequest(
val epic: String,
val user_story: Long
)
@JsonClass(generateAdapter = true)
data class PromoteToUserStoryRequest(
val project_id: Long
)
@JsonClass(generateAdapter = true)
data class EditCustomAttributesValuesRequest(
val attributes_values: Map<Long, Any?>,
val version: Int
)
@JsonClass(generateAdapter = true)
data class CreateSprintRequest(
val name: String,
val estimated_start: LocalDate,
val estimated_finish: LocalDate,
val project: Long
)
@JsonClass(generateAdapter = true)
data class EditSprintRequest(
val name: String,
val estimated_start: LocalDate,
val estimated_finish: LocalDate,
)
@JsonClass(generateAdapter = true)
data class EditWikiPageRequest(
val content: String,
val version: Int
)
@JsonClass(generateAdapter = true)
data class NewWikiLinkRequest(
val href: String,
val project: Long,
val title: String
)

@ -0,0 +1,162 @@
package io.eugenethedev.taigamobile.data.api
import com.squareup.moshi.JsonClass
import io.eugenethedev.taigamobile.domain.entities.*
import java.time.LocalDate
import java.time.LocalDateTime
/**
* Some complicated api responses
*/
@JsonClass(generateAdapter = true)
data class AuthResponse(
val auth_token: String,
val refresh: String?,
val id: Long
)
@JsonClass(generateAdapter = true)
data class RefreshTokenResponse(
val auth_token: String,
val refresh: String
)
@JsonClass(generateAdapter = true)
data class ProjectResponse(
val id: Long,
val name: String,
val members: List<Member>
) {
@JsonClass(generateAdapter = true)
data class Member(
val id: Long,
val photo: String?,
val full_name_display: String,
val role_name: String,
val username: String
)
}
@JsonClass(generateAdapter = true)
data class FiltersDataResponse(
val statuses: List<Filter>,
val tags: List<Filter>?,
val roles: List<Filter>?,
val assigned_to: List<UserFilter>,
val owners: List<UserFilter>,
// user story filters
val epics: List<EpicsFilter>?,
// issue filters
val priorities: List<Filter>?,
val severities: List<Filter>?,
val types: List<Filter>?
) {
@JsonClass(generateAdapter = true)
data class Filter(
val id: Long?,
val name: String?,
val color: String?,
val count: Int
)
@JsonClass(generateAdapter = true)
data class UserFilter(
val id: Long?,
val full_name: String,
val count: Int
)
@JsonClass(generateAdapter = true)
data class EpicsFilter(
val id: Long?,
val ref: Int?,
val subject: String?,
val count: Int
)
}
@JsonClass(generateAdapter = true)
data class CommonTaskResponse(
val id: Long,
val subject: String,
val created_date: LocalDateTime,
val status: Long,
val ref: Int,
val assigned_to_extra_info: User?,
val status_extra_info: StatusExtra,
val project_extra_info: Project,
val milestone: Long?,
val assigned_users: List<Long>?,
val assigned_to: Long?,
val watchers: List<Long>?,
val owner: Long?,
val description: String?,
val epics: List<EpicShortInfo>?,
val user_story_extra_info: UserStoryShortInfo?,
val version: Int,
val is_closed: Boolean,
val tags: List<List<String?>>?,
val swimlane: Long?,
val due_date: LocalDate?,
val due_date_status: DueDateStatus?,
val blocked_note: String,
val is_blocked: Boolean,
// for epic
val color: String?,
// for issue
val type: Long?,
val severity: Long?,
val priority: Long?
) {
@JsonClass(generateAdapter = true)
data class StatusExtra(
val color: String,
val name: String
)
}
@JsonClass(generateAdapter = true)
data class SprintResponse(
val id: Long,
val name: String,
val estimated_start: LocalDate,
val estimated_finish: LocalDate,
val closed: Boolean,
val order: Int,
val user_stories: List<UserStory>
) {
@JsonClass(generateAdapter = true)
data class UserStory(
val id: Long
)
}
@JsonClass(generateAdapter = true)
data class MemberStatsResponse(
val closed_bugs: Map<String, Int>, // because api returns "null" key along with id keys, so...
val closed_tasks: Map<String, Int>,
val created_bugs: Map<String, Int>,
val iocaine_tasks: Map<String, Int>,
val wiki_changes: Map<String, Int>
)
@JsonClass(generateAdapter = true)
data class CustomAttributeResponse(
val id: Long,
val name: String,
val description: String?,
val order: Int,
val type: CustomFieldType,
val extra: List<String>?
)
@JsonClass(generateAdapter = true)
data class CustomAttributesValuesResponse(
val attributes_values: Map<Long, Any?>,
val version: Int
)

@ -0,0 +1,375 @@
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? = null,
@Query("page") page: Int? = null,
@Query("member") memberId: Long? = null,
@Query("page_size") pageSize: Int? = null
): 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("users/{id}/stats")
suspend fun getUserStats(@Path("id") userId: Long): Stats
@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
@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,
@Query("milestone") milestone: Any? = null
): 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 editCommonTask(
@Path("taskPath") taskPath: CommonTaskPathPlural,
@Path("id") id: Long,
@Body editCommonTaskRequest: 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
)
// Swimlanes
@GET("swimlanes")
suspend fun getSwimlanes(@Query("project") project: Long): List<Swimlane>
// Wiki
@GET("wiki")
suspend fun getProjectWikiPages(
@Query("project") projectId: Long
): List<WikiPage>
@GET("wiki/by_slug")
suspend fun getProjectWikiPageBySlug(
@Query("project") projectId: Long,
@Query("slug") slug: String
): WikiPage
@PATCH("wiki/{id}")
suspend fun editWikiPage(
@Path("id") pageId: Long,
@Body editWikiPageRequest: EditWikiPageRequest
)
@GET("wiki/attachments")
suspend fun getPageAttachments(
@Query("object_id") pageId: Long,
@Query("project") projectId: Long
): List<Attachment>
@POST("wiki/attachments")
@Multipart
suspend fun uploadPageAttachment(
@Part file: MultipartBody.Part,
@Part project: MultipartBody.Part,
@Part objectId: MultipartBody.Part
)
@DELETE("wiki/attachments/{id}")
suspend fun deletePageAttachment(
@Path("id") attachmentId: Long
): Response<Void>
@DELETE("wiki/{id}")
suspend fun deleteWikiPage(
@Path("id") pageId: Long
): Response<Void>
@GET("wiki-links")
suspend fun getWikiLink(
@Query("project") projectId: Long
): List<WikiLink>
@POST("wiki-links")
suspend fun createWikiLink(
@Body newWikiLinkRequest: NewWikiLinkRequest
)
@DELETE("wiki-links/{id}")
suspend fun deleteWikiLink(
@Path("id") linkId: Long
): Response<Void>
}

@ -0,0 +1,34 @@
package io.eugenethedev.taigamobile.data.api
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
/**
* Since API endpoints for different types of tasks are often the same (only part in the path is different),
* here are some value classes to simplify interactions with API
*/
// plural form
@JvmInline
value class CommonTaskPathPlural private constructor(val path: String) {
constructor(commonTaskType: CommonTaskType): this(
when (commonTaskType) {
CommonTaskType.UserStory -> "userstories"
CommonTaskType.Task -> "tasks"
CommonTaskType.Epic -> "epics"
CommonTaskType.Issue -> "issues"
}
)
}
// singular form
@JvmInline
value class CommonTaskPathSingular private constructor(val path: String) {
constructor(commonTaskType: CommonTaskType): this(
when (commonTaskType) {
CommonTaskType.UserStory -> "userstory"
CommonTaskType.Task -> "task"
CommonTaskType.Epic -> "epic"
CommonTaskType.Issue -> "issue"
}
)
}

@ -0,0 +1,33 @@
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)
}
}
}

@ -0,0 +1,31 @@
package io.eugenethedev.taigamobile.data.repositories
import io.eugenethedev.taigamobile.data.api.TaigaApi
import io.eugenethedev.taigamobile.domain.paging.CommonPagingSource
import io.eugenethedev.taigamobile.domain.repositories.IProjectsRepository
import io.eugenethedev.taigamobile.state.Session
import javax.inject.Inject
class ProjectsRepository @Inject constructor(
private val taigaApi: TaigaApi,
private val session: Session
) : IProjectsRepository {
override suspend fun searchProjects(query: String, page: Int) = withIO {
handle404 {
taigaApi.getProjects(
query = query,
page = page,
pageSize = CommonPagingSource.PAGE_SIZE
)
}
}
override suspend fun getMyProjects() = withIO {
taigaApi.getProjects(memberId = session.currentUserId.value)
}
override suspend fun getUserProjects(userId: Long) = withIO {
taigaApi.getProjects(memberId = userId)
}
}

@ -0,0 +1,64 @@
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
}
}

@ -0,0 +1,619 @@
package io.eugenethedev.taigamobile.data.repositories
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.dagger.toLocalDate
import io.eugenethedev.taigamobile.data.api.*
import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.domain.repositories.ITasksRepository
import kotlinx.coroutines.async
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
import java.time.LocalDate
import javax.inject.Inject
class TasksRepository @Inject constructor(
private val taigaApi: TaigaApi,
private val session: Session
) : ITasksRepository {
private val currentProjectId get() = session.currentProjectId.value
private val currentUserId get() = session.currentUserId.value
private fun StatusesFilter.toStatus(statusType: StatusType) = Status(
id = id,
name = name,
color = color,
type = statusType
)
override suspend fun getFiltersData(commonTaskType: CommonTaskType, isCommonTaskFromBacklog: Boolean) = withIO {
taigaApi.getCommonTaskFiltersData(
taskPath = CommonTaskPathPlural(commonTaskType),
project = currentProjectId,
milestone = if (isCommonTaskFromBacklog) "null" else null
).let {
FiltersData(
assignees = it.assigned_to.map {
UsersFilter(
id = it.id,
name = it.full_name,
count = it.count
)
},
roles = it.roles.orEmpty().map {
RolesFilter(
id = it.id!!,
name = it.name!!,
count = it.count
)
},
tags = it.tags.orEmpty().map {
TagsFilter(
name = it.name!!,
color = it.color.fixNullColor(),
count = it.count
)
},
statuses = it.statuses.map {
StatusesFilter(
id = it.id!!,
color = it.color.fixNullColor(),
name = it.name!!,
count = it.count
)
},
createdBy = it.owners.map {
UsersFilter(
id = it.id!!,
name = it.full_name,
count = it.count
)
},
priorities = it.priorities.orEmpty().map {
StatusesFilter(
id = it.id!!,
color = it.color.fixNullColor(),
name = it.name!!,
count = it.count
)
},
severities = it.severities.orEmpty().map {
StatusesFilter(
id = it.id!!,
color = it.color.fixNullColor(),
name = it.name!!,
count = it.count
)
},
types = it.types.orEmpty().map {
StatusesFilter(
id = it.id!!,
color = it.color.fixNullColor(),
name = it.name!!,
count = it.count
)
},
epics = it.epics.orEmpty().map {
EpicsFilter(
id = it.id,
name = it.subject?.let { s -> "#${it.ref} $s" }.orEmpty(),
count = it.count
)
}
)
}
}
override suspend fun getWorkingOn() = withIO {
val epics = async {
taigaApi.getEpics(assignedId = currentUserId, isClosed = false)
.map { it.toCommonTask(CommonTaskType.Epic) }
}
val stories = async {
taigaApi.getUserStories(assignedId = currentUserId, isClosed = false, isDashboard = true)
.map { it.toCommonTask(CommonTaskType.UserStory) }
}
val tasks = async {
taigaApi.getTasks(assignedId = currentUserId, isClosed = false)
.map { it.toCommonTask(CommonTaskType.Task) }
}
val issues = async {
taigaApi.getIssues(assignedIds = currentUserId.toString(), isClosed = false)
.map { it.toCommonTask(CommonTaskType.Issue) }
}
epics.await() + stories.await() + tasks.await() + issues.await()
}
override suspend fun getWatching() = withIO {
val epics = async {
taigaApi.getEpics(watcherId = currentUserId, isClosed = false)
.map { it.toCommonTask(CommonTaskType.Epic) }
}
val stories = async {
taigaApi.getUserStories(watcherId = currentUserId, isClosed = false, isDashboard = true)
.map { it.toCommonTask(CommonTaskType.UserStory) }
}
val tasks = async {
taigaApi.getTasks(watcherId = currentUserId, isClosed = false)
.map { it.toCommonTask(CommonTaskType.Task) }
}
val issues = async {
taigaApi.getIssues(watcherId = currentUserId, isClosed = false)
.map { it.toCommonTask(CommonTaskType.Issue) }
}
epics.await() + stories.await() + tasks.await() + issues.await()
}
override suspend fun getStatuses(commonTaskType: CommonTaskType) = withIO {
getFiltersData(commonTaskType).statuses.map { it.toStatus(StatusType.Status) }
}
override suspend fun getStatusByType(commonTaskType: CommonTaskType, statusType: StatusType) = withIO {
if (commonTaskType != CommonTaskType.Issue && statusType != StatusType.Status) {
throw UnsupportedOperationException("Cannot get $statusType for $commonTaskType")
}
getFiltersData(commonTaskType).let {
when (statusType) {
StatusType.Status -> it.statuses.map { it.toStatus(statusType) }
StatusType.Type -> it.types.map { it.toStatus(statusType) }
StatusType.Severity -> it.severities.map { it.toStatus(statusType) }
StatusType.Priority -> it.priorities.map { it.toStatus(statusType) }
}
}
}
override suspend fun getEpics(page: Int, filters: FiltersData) = withIO {
handle404 {
taigaApi.getEpics(
page = page,
project = currentProjectId,
query = filters.query,
assignedIds = filters.assignees.commaString(),
ownerIds = filters.createdBy.commaString(),
statuses = filters.statuses.commaString(),
tags = filters.tags.tagsCommaString()
)
.map { it.toCommonTask(CommonTaskType.Epic) }
}
}
override suspend fun getAllUserStories() = withIO {
val filters = async { getFiltersData(CommonTaskType.UserStory) }
val swimlanes = async { getSwimlanes() }
taigaApi.getUserStories(project = currentProjectId)
.map {
it.toCommonTaskExtended(
commonTaskType = CommonTaskType.UserStory,
filters = filters.await(),
swimlanes = swimlanes.await(),
loadSprint = false
)
}
}
override suspend fun getBacklogUserStories(page: Int, filters: FiltersData) = withIO {
handle404 {
taigaApi.getUserStories(
project = currentProjectId,
sprint = "null",
page = page,
query = filters.query,
assignedIds = filters.assignees.commaString(),
ownerIds = filters.createdBy.commaString(),
roles = filters.roles.commaString(),
statuses = filters.statuses.commaString(),
epics = filters.epics.commaString(),
tags = filters.tags.tagsCommaString()
)
.map { it.toCommonTask(CommonTaskType.UserStory) }
}
}
override suspend fun getEpicUserStories(epicId: Long) = withIO {
taigaApi.getUserStories(epic = epicId)
.map { it.toCommonTask(CommonTaskType.UserStory) }
}
override suspend fun getUserStoryTasks(storyId: Long) = withIO {
handle404 {
taigaApi.getTasks(userStory = storyId, project = currentProjectId)
.map { it.toCommonTask(CommonTaskType.Task) }
}
}
override suspend fun getIssues(page: Int, filters: FiltersData) = withIO {
handle404 {
taigaApi.getIssues(
page = page,
project = currentProjectId,
query = filters.query,
assignedIds = filters.assignees.commaString(),
ownerIds = filters.createdBy.commaString(),
priorities = filters.priorities.commaString(),
severities = filters.severities.commaString(),
types = filters.types.commaString(),
statuses = filters.statuses.commaString(),
roles = filters.roles.commaString(),
tags = filters.tags.tagsCommaString()
)
.map { it.toCommonTask(CommonTaskType.Issue) }
}
}
private fun List<Filter>.commaString() = map { it.id }
.joinToString(separator = ",")
.takeIf { it.isNotEmpty() }
private fun List<TagsFilter>.tagsCommaString() = joinToString(separator = ",") { it.name.replace(" ", "+") }
.takeIf { it.isNotEmpty() }
override suspend fun getCommonTask(commonTaskId: Long, type: CommonTaskType) = withIO {
val filters = async { getFiltersData(type) }
val swimlanes = async { getSwimlanes() }
taigaApi.getCommonTask(CommonTaskPathPlural(type), commonTaskId).toCommonTaskExtended(
commonTaskType = type,
filters = filters.await(),
swimlanes = swimlanes.await(),
)
}
override suspend fun getComments(commonTaskId: Long, type: CommonTaskType) = withIO {
taigaApi.getCommonTaskComments(CommonTaskPathSingular(type), commonTaskId)
.sortedBy { it.postDateTime }
.filter { it.deleteDate == null }
.map { it.also { it.canDelete = it.author.id == currentUserId } }
}
override suspend fun getAttachments(commonTaskId: Long, type: CommonTaskType) = withIO {
taigaApi.getCommonTaskAttachments(CommonTaskPathPlural(type), commonTaskId, currentProjectId)
}
override suspend fun getCustomFields(commonTaskId: Long, type: CommonTaskType) = withIO {
val attributes = async { taigaApi.getCustomAttributes(CommonTaskPathSingular(type), currentProjectId) }
val values = taigaApi.getCustomAttributesValues(CommonTaskPathPlural(type), commonTaskId)
CustomFields(
version = values.version,
fields = attributes.await().sortedBy { it.order }
.map {
CustomField(
id = it.id,
type = it.type,
name = it.name,
description = it.description?.takeIf { it.isNotEmpty() },
value = values.attributes_values[it.id]?.let { value ->
CustomFieldValue(
when (it.type) {
CustomFieldType.Date -> (value as? String)?.takeIf { it.isNotEmpty() }?.toLocalDate()
CustomFieldType.Checkbox -> value as? Boolean
else -> value
} ?: return@let null
)
},
options = it.extra.orEmpty()
)
}
)
}
override suspend fun getAllTags(commonTaskType: CommonTaskType) = withIO {
getFiltersData(commonTaskType).tags.map { Tag(it.name, it.color) }
}
override suspend fun getSwimlanes() = withIO {
taigaApi.getSwimlanes(currentProjectId)
}
private fun transformTaskTypeForCopyLink(commonTaskType: CommonTaskType) = when (commonTaskType) {
CommonTaskType.UserStory -> PATH_TO_USERSTORY
CommonTaskType.Task -> PATH_TO_TASK
CommonTaskType.Epic -> PATH_TO_EPIC
CommonTaskType.Issue -> PATH_TO_ISSUE
}
private suspend fun CommonTaskResponse.toCommonTaskExtended(
commonTaskType: CommonTaskType,
filters: FiltersData,
swimlanes: List<Swimlane>,
loadSprint: Boolean = true
): CommonTaskExtended {
return CommonTaskExtended(
id = id,
status = Status(
id = status,
name = status_extra_info.name,
color = status_extra_info.color,
type = StatusType.Status
),
taskType = commonTaskType,
createdDateTime = created_date,
sprint = if (loadSprint) milestone?.let { taigaApi.getSprint(it).toSprint() } else null,
assignedIds = assigned_users ?: listOfNotNull(assigned_to),
watcherIds = watchers.orEmpty(),
creatorId = owner ?: throw IllegalArgumentException("CommonTaskResponse requires not null 'owner' field"),
ref = ref,
title = subject,
isClosed = is_closed,
description = description ?: "",
epicsShortInfo = epics.orEmpty(),
projectSlug = project_extra_info.slug,
tags = tags.orEmpty().map { Tag(name = it[0]!!, color = it[1].fixNullColor()) },
swimlane = swimlanes.find { it.id == swimlane },
dueDate = due_date,
dueDateStatus = due_date_status,
userStoryShortInfo = user_story_extra_info,
version = version,
color = color,
type = type?.let { id -> filters.types.find { it.id == id } }?.toStatus(StatusType.Type),
severity = severity?.let { id -> filters.severities.find { it.id == id } }?.toStatus(StatusType.Severity),
priority = priority?.let { id -> filters.priorities.find { it.id == id } }?.toStatus(StatusType.Priority),
url = "${session.server.value}/project/${project_extra_info.slug}/${transformTaskTypeForCopyLink(commonTaskType)}/$ref",
blockedNote = blocked_note.takeIf { is_blocked }
)
}
/**
* Edit related
*/
// edit task itself
private fun Tag.toList() = listOf(name, color)
private fun CommonTaskExtended.toEditRequest() = EditCommonTaskRequest(
subject = title,
description = description,
status = status.id,
type = type?.id,
severity = severity?.id,
priority = priority?.id,
milestone = sprint?.id,
assigned_to = assignedIds.firstOrNull(),
assigned_users = assignedIds,
watchers = watcherIds,
swimlane = swimlane?.id,
due_date = dueDate,
color = color,
tags = tags.map { it.toList() },
blocked_note = blockedNote.orEmpty(),
is_blocked = blockedNote != null,
version = version
)
private suspend fun editCommonTask(commonTask: CommonTaskExtended, request: EditCommonTaskRequest) {
taigaApi.editCommonTask(CommonTaskPathPlural(commonTask.taskType), commonTask.id, request)
}
override suspend fun editStatus(
commonTask: CommonTaskExtended,
statusId: Long,
statusType: StatusType
) = withIO {
if (commonTask.taskType != CommonTaskType.Issue && statusType != StatusType.Status) {
throw UnsupportedOperationException("Cannot change $statusType for ${commonTask.taskType}")
}
val request = commonTask.toEditRequest().let {
when (statusType) {
StatusType.Status -> it.copy(status = statusId)
StatusType.Type -> it.copy(type = statusId)
StatusType.Severity -> it.copy(severity = statusId)
StatusType.Priority -> it.copy(priority = statusId)
}
}
editCommonTask(commonTask, request)
}
override suspend fun editSprint(commonTask: CommonTaskExtended, sprintId: Long?) = withIO {
if (commonTask.taskType in listOf(CommonTaskType.Epic, CommonTaskType.Task)) {
throw UnsupportedOperationException("Cannot change sprint for ${commonTask.taskType}")
}
editCommonTask(commonTask, commonTask.toEditRequest().copy(milestone = sprintId))
}
override suspend fun editAssignees(commonTask: CommonTaskExtended, assignees: List<Long>) = withIO {
val request = commonTask.toEditRequest().let {
if (commonTask.taskType == CommonTaskType.UserStory) {
it.copy(assigned_to = assignees.firstOrNull(), assigned_users = assignees)
} else {
it.copy(assigned_to = assignees.lastOrNull())
}
}
editCommonTask(commonTask, request)
}
override suspend fun editWatchers(commonTask: CommonTaskExtended, watchers: List<Long>) = withIO {
editCommonTask(commonTask, commonTask.toEditRequest().copy(watchers = watchers))
}
override suspend fun editDueDate(commonTask: CommonTaskExtended, date: LocalDate?) = withIO {
editCommonTask(commonTask, commonTask.toEditRequest().copy(due_date = date))
}
override suspend fun editCommonTaskBasicInfo(
commonTask: CommonTaskExtended,
title: String,
description: String,
) = withIO {
editCommonTask(commonTask, commonTask.toEditRequest().copy(subject = title, description = description))
}
override suspend fun editTags(commonTask: CommonTaskExtended, tags: List<Tag>) = withIO {
editCommonTask(commonTask, commonTask.toEditRequest().copy(tags = tags.map { it.toList() }))
}
override suspend fun editUserStorySwimlane(commonTask: CommonTaskExtended, swimlaneId: Long?) = withIO {
if (commonTask.taskType != CommonTaskType.UserStory) {
throw UnsupportedOperationException("Cannot change swimlane for ${commonTask.taskType}")
}
editCommonTask(commonTask, commonTask.toEditRequest().copy(swimlane = swimlaneId))
}
override suspend fun editEpicColor(commonTask: CommonTaskExtended, color: String) = withIO {
if (commonTask.taskType != CommonTaskType.Epic) {
throw UnsupportedOperationException("Cannot change color for ${commonTask.taskType}")
}
editCommonTask(commonTask, commonTask.toEditRequest().copy(color = color))
}
override suspend fun editBlocked(commonTask: CommonTaskExtended, blockedNote: String?) = withIO {
editCommonTask(
commonTask,
commonTask.toEditRequest().copy(is_blocked = blockedNote != null, blocked_note = blockedNote.orEmpty())
)
}
// edit other related parts
override suspend fun linkToEpic(epicId: Long, userStoryId: Long) = withIO {
taigaApi.linkToEpic(
epicId = epicId,
linkToEpicRequest = LinkToEpicRequest(epicId.toString(), userStoryId)
)
}
override suspend fun unlinkFromEpic(epicId: Long, userStoryId: Long) = withIO {
taigaApi.unlinkFromEpic(epicId, userStoryId)
return@withIO
}
override suspend fun createComment(
commonTaskId: Long,
commonTaskType: CommonTaskType,
comment: String,
version: Int
) = withIO {
taigaApi.createCommonTaskComment(
taskPath = CommonTaskPathPlural(commonTaskType),
id = commonTaskId,
createCommentRequest = CreateCommentRequest(comment, version)
)
}
override suspend fun deleteComment(
commonTaskId: Long,
commonTaskType: CommonTaskType,
commentId: String
) = withIO {
taigaApi.deleteCommonTaskComment(
taskPath = CommonTaskPathSingular(commonTaskType),
id = commonTaskId,
commentId = commentId
)
}
override suspend fun createCommonTask(
commonTaskType: CommonTaskType,
title: String,
description: String,
parentId: Long?,
sprintId: Long?,
statusId: Long?,
swimlaneId: Long?
) = withIO {
when (commonTaskType) {
CommonTaskType.Task -> taigaApi.createTask(
createTaskRequest = CreateTaskRequest(currentProjectId, title, description, sprintId, parentId)
)
CommonTaskType.Issue -> taigaApi.createIssue(
createIssueRequest = CreateIssueRequest(currentProjectId, title, description, sprintId)
)
CommonTaskType.UserStory -> taigaApi.createUserstory(
createUserStoryRequest = CreateUserStoryRequest(currentProjectId, title, description, statusId, swimlaneId)
)
else -> taigaApi.createCommonTask(
taskPath = CommonTaskPathPlural(commonTaskType),
createRequest = CreateCommonTaskRequest(currentProjectId, title, description, statusId)
)
}.toCommonTask(commonTaskType)
}
override suspend fun deleteCommonTask(commonTaskType: CommonTaskType, commonTaskId: Long) = withIO {
taigaApi.deleteCommonTask(
taskPath = CommonTaskPathPlural(commonTaskType),
id = commonTaskId
)
return@withIO
}
override suspend fun promoteCommonTaskToUserStory(commonTaskId: Long, commonTaskType: CommonTaskType) = withIO {
if (commonTaskType in listOf(CommonTaskType.Epic, CommonTaskType.UserStory)) {
throw UnsupportedOperationException("Cannot promote to user story $commonTaskType")
}
taigaApi.promoteCommonTaskToUserStory(
taskPath = CommonTaskPathPlural(commonTaskType),
taskId = commonTaskId,
promoteToUserStoryRequest = PromoteToUserStoryRequest(currentProjectId)
).first()
.let { taigaApi.getUserStoryByRef(currentProjectId, it).toCommonTask(CommonTaskType.UserStory) }
}
override suspend fun addAttachment(commonTaskId: Long, commonTaskType: CommonTaskType, fileName: String, inputStream: InputStream) = withIO {
val file = MultipartBody.Part.createFormData(
name = "attached_file",
filename = fileName,
body = inputStream.readBytes().toRequestBody("*/*".toMediaType())
)
val project = MultipartBody.Part.createFormData("project", currentProjectId.toString())
val objectId = MultipartBody.Part.createFormData("object_id", commonTaskId.toString())
inputStream.use {
taigaApi.uploadCommonTaskAttachment(
taskPath = CommonTaskPathPlural(commonTaskType),
file = file,
project = project,
objectId = objectId
)
}
}
override suspend fun deleteAttachment(commonTaskType: CommonTaskType, attachmentId: Long) = withIO {
taigaApi.deleteCommonTaskAttachment(
taskPath = CommonTaskPathPlural(commonTaskType),
attachmentId = attachmentId
)
return@withIO
}
override suspend fun editCustomFields(
commonTaskType: CommonTaskType,
commonTaskId: Long,
fields: Map<Long, CustomFieldValue?>,
version: Int
) = withIO {
taigaApi.editCustomAttributesValues(
taskPath = CommonTaskPathPlural(commonTaskType),
taskId = commonTaskId,
editRequest = EditCustomAttributesValuesRequest(fields.mapValues { it.value?.value }, version)
)
}
companion object {
const val PATH_TO_USERSTORY = "us"
const val PATH_TO_TASK = "task"
const val PATH_TO_EPIC = "epic"
const val PATH_TO_ISSUE = "issue"
}
}

@ -0,0 +1,51 @@
package io.eugenethedev.taigamobile.data.repositories
import io.eugenethedev.taigamobile.state.Session
import io.eugenethedev.taigamobile.data.api.TaigaApi
import io.eugenethedev.taigamobile.domain.entities.Stats
import io.eugenethedev.taigamobile.domain.entities.TeamMember
import io.eugenethedev.taigamobile.domain.entities.User
import io.eugenethedev.taigamobile.domain.repositories.IUsersRepository
import kotlinx.coroutines.async
import javax.inject.Inject
class UsersRepository @Inject constructor(
private val taigaApi: TaigaApi,
private val session: Session
) : IUsersRepository {
private val currentProjectId get() = session.currentProjectId.value
override suspend fun getMe() = withIO { taigaApi.getMyProfile() }
override suspend fun getUser(userId: Long) = withIO { taigaApi.getUser(userId) }
override suspend fun getUserStats(userId: Long): Stats = withIO { taigaApi.getUserStats(userId) }
override suspend fun getTeam() = withIO {
val team = async { taigaApi.getProject(currentProjectId).members }
val stats = async {
taigaApi.getMemberStats(currentProjectId).run {
// calculating total number of points for each id
(closed_bugs.toList() + closed_tasks.toList() + created_bugs.toList() +
iocaine_tasks.toList() + wiki_changes.toList())
.mapNotNull { p -> p.first.toLongOrNull()?.let { it to p.second } }
.groupBy { it.first }
.map { (k, v) -> k to v.sumOf { it.second } }
.toMap()
}
}
stats.await().let { stat ->
team.await().map {
TeamMember(
id = it.id,
avatarUrl = it.photo,
name = it.full_name_display,
role = it.role_name,
username = it.username,
totalPower = stat[it.id] ?: 0
)
}
}
}
}

@ -0,0 +1,53 @@
package io.eugenethedev.taigamobile.data.repositories
import io.eugenethedev.taigamobile.data.api.CommonTaskResponse
import io.eugenethedev.taigamobile.data.api.SprintResponse
import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.ui.theme.taigaGray
import io.eugenethedev.taigamobile.ui.utils.toHex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.HttpException
suspend fun <T> withIO(block: suspend CoroutineScope.() -> T): T = withContext(Dispatchers.IO, block)
inline fun <T> handle404(action: () -> List<T>): List<T> = try {
action()
} catch (e: HttpException) {
// suppress error if page not found (maximum page was reached)
e.takeIf { it.code() == 404 }?.let { emptyList() } ?: throw e
}
fun CommonTaskResponse.toCommonTask(commonTaskType: CommonTaskType) = CommonTask(
id = id,
createdDate = created_date,
title = subject,
ref = ref,
status = Status(
id = status,
name = status_extra_info.name,
color = status_extra_info.color,
type = StatusType.Status
),
assignee = assigned_to_extra_info,
projectInfo = project_extra_info,
taskType = commonTaskType,
colors = color?.let { listOf(it) } ?: epics.orEmpty().map { it.color },
isClosed = is_closed,
tags = tags.orEmpty().map { Tag(name = it[0]!!, color = it[1].fixNullColor()) },
blockedNote = blocked_note.takeIf { is_blocked }
)
private val taigaGrayHex by lazy { taigaGray.toHex() }
fun String?.fixNullColor() = this ?: taigaGrayHex // gray, because api returns null instead of gray -_-
fun SprintResponse.toSprint() = Sprint(
id = id,
name = name,
order = order,
start = estimated_start,
end = estimated_finish,
storiesCount = user_stories.size,
isClosed = closed
)

@ -0,0 +1,103 @@
package io.eugenethedev.taigamobile.data.repositories
import io.eugenethedev.taigamobile.data.api.EditWikiPageRequest
import io.eugenethedev.taigamobile.data.api.NewWikiLinkRequest
import io.eugenethedev.taigamobile.data.api.TaigaApi
import io.eugenethedev.taigamobile.domain.entities.Attachment
import io.eugenethedev.taigamobile.domain.repositories.IWikiRepository
import io.eugenethedev.taigamobile.state.Session
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.InputStream
import javax.inject.Inject
class WikiRepository @Inject constructor(
private val taigaApi: TaigaApi,
private val session: Session
) : IWikiRepository {
private val currentProjectId get() = session.currentProjectId.value
override suspend fun getProjectWikiPages() = withIO {
taigaApi.getProjectWikiPages(
projectId = currentProjectId
)
}
override suspend fun getProjectWikiPageBySlug(slug: String) = withIO {
taigaApi.getProjectWikiPageBySlug(
projectId = currentProjectId,
slug = slug
)
}
override suspend fun editWikiPage(pageId: Long, content: String, version: Int) = withIO {
taigaApi.editWikiPage(
pageId = pageId,
editWikiPageRequest = EditWikiPageRequest(content, version)
)
}
override suspend fun deleteWikiPage(pageId: Long) = withIO {
taigaApi.deleteWikiPage(
pageId = pageId
)
return@withIO
}
override suspend fun getPageAttachments(pageId: Long): List<Attachment> = withIO {
taigaApi.getPageAttachments(
pageId = pageId,
projectId = currentProjectId
)
}
override suspend fun addPageAttachment(pageId: Long, fileName: String, inputStream: InputStream) = withIO {
val file = MultipartBody.Part.createFormData(
name = "attached_file",
filename = fileName,
body = inputStream.readBytes().toRequestBody("*/*".toMediaType())
)
val project = MultipartBody.Part.createFormData("project", currentProjectId.toString())
val objectId = MultipartBody.Part.createFormData("object_id", pageId.toString())
inputStream.use {
taigaApi.uploadPageAttachment(
file = file,
project = project,
objectId = objectId
)
}
}
override suspend fun deletePageAttachment(attachmentId: Long) = withIO {
taigaApi.deletePageAttachment(
attachmentId = attachmentId
)
return@withIO
}
override suspend fun getWikiLinks() = withIO {
taigaApi.getWikiLink(
projectId = currentProjectId
)
}
override suspend fun createWikiLink(href: String, title: String) = withIO {
taigaApi.createWikiLink(
newWikiLinkRequest = NewWikiLinkRequest(
href = href,
project = currentProjectId,
title = title
)
)
}
override suspend fun deleteWikiLink(linkId: Long) = withIO {
taigaApi.deleteWikiLink(
linkId = linkId
)
return@withIO
}
}

@ -0,0 +1,12 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Attachment(
val id: Long,
val name: String,
@Json(name = "size") val sizeInBytes: Long,
val url: String
)

@ -0,0 +1,6 @@
package io.eugenethedev.taigamobile.domain.entities
enum class AuthType {
Normal,
LDAP
}

@ -0,0 +1,16 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.time.LocalDateTime
@JsonClass(generateAdapter = true)
data class Comment(
val id: String,
@Json(name = "user") val author: User,
@Json(name = "comment") val text: String,
@Json(name = "created_at") val postDateTime: LocalDateTime,
@Json(name = "delete_comment_date") val deleteDate: LocalDateTime?
) {
var canDelete: Boolean = false
}

@ -0,0 +1,49 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.time.LocalDate
@JsonClass(generateAdapter = false)
enum class CustomFieldType {
@Json(name = "text") Text,
@Json(name = "multiline") Multiline,
@Json(name = "richtext") RichText,
@Json(name = "date") Date,
@Json(name = "url") Url,
@Json(name = "dropdown") Dropdown,
@Json(name = "number") Number,
@Json(name = "checkbox") Checkbox
}
data class CustomField(
val id: Long,
val type: CustomFieldType,
val name: String,
val description: String?,
val value: CustomFieldValue?,
val options: List<String>? = null // for CustomFieldType.Dropdown
)
@JvmInline
value class CustomFieldValue(val value: Any) {
init {
require(
value is String ||
value is LocalDate ||
value is Double ||
value is Boolean
)
}
val stringValue get() = value as? String ?: throw IllegalArgumentException("value is not String")
val doubleValue get() = value as? Double ?: throw IllegalArgumentException("value is not Int")
val dateValue get() = value as? LocalDate ?: throw IllegalArgumentException("value is not Date")
val booleanValue get() = value as? Boolean ?: throw IllegalArgumentException("value is not Boolean")
}
data class CustomFields(
val fields: List<CustomField>,
val version: Int
)

@ -0,0 +1,128 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
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()
)
// updates current filters data using other filters data
// (helpful for updating already selected filters)
fun updateData(other: FiltersData): FiltersData {
fun List<UsersFilter>.updateUsers(other: List<UsersFilter>) = map { current ->
other.find { new -> current.id == new.id }?.let {
current.copy(name = it.name, count = it.count)
} ?: current.copy(count = 0)
}
fun List<StatusesFilter>.updateStatuses(other: List<StatusesFilter>) = map { current ->
other.find { new -> current.id == new.id }?.let {
current.copy(name = it.name, color = it.color, count = it.count)
} ?: current.copy(count = 0)
}
return FiltersData(
assignees = assignees.updateUsers(other.assignees),
roles = roles.map { current ->
other.roles.find { new -> current.id == new.id }?.let {
current.copy(name = it.name, count = it.count)
} ?: current.copy(count = 0)
},
tags = tags.map { current ->
other.tags.find { new -> current.name == new.name }?.let {
current.copy(color = it.color, count = it.count)
} ?: current.copy(count = 0)
},
statuses = statuses.updateStatuses(other.statuses),
createdBy = createdBy.updateUsers(other.createdBy),
epics = epics.map { current ->
other.epics.find { new -> current.id == new.id }?.let {
current.copy(name = it.name, count = it.count)
} ?: current.copy(count = 0)
},
priorities = priorities.updateStatuses(other.priorities),
severities = severities.updateStatuses(other.severities),
types = types.updateStatuses(other.types)
)
}
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?
}
@JsonClass(generateAdapter = true)
data class StatusesFilter(
override val id: Long,
override val color: String,
override val name: String,
override val count: Int
) : Filter
@JsonClass(generateAdapter = true)
data class UsersFilter(
override val id: Long?,
override val name: String,
override val count: Int
) : Filter {
override val color: String? = null
}
@JsonClass(generateAdapter = true)
data class RolesFilter(
override val id: Long,
override val name: String,
override val count: Int
) : Filter {
override val color: String? = null
}
@JsonClass(generateAdapter = true)
data class TagsFilter(
override val name: String,
override val color: String,
override val count: Int
) : Filter {
override val id: Long? = null
}
@JsonClass(generateAdapter = true)
data class EpicsFilter(
override val id: Long?,
override val name: String,
override val count: Int
) : Filter {
override val color: String? = null
}

@ -0,0 +1,24 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Project related entities
*/
@JsonClass(generateAdapter = true)
data class Project(
val id: Long,
val name: String,
val slug: String,
@Json(name = "i_am_member") val isMember: Boolean = false,
@Json(name = "i_am_admin") val isAdmin: Boolean = false,
@Json(name = "i_am_owner") val isOwner: Boolean = false,
val description: String? = null,
@Json(name = "logo_small_url") val avatarUrl: String? = null,
val members: List<Long> = emptyList(),
@Json(name = "total_fans") val fansCount: Int = 0,
@Json(name = "total_watchers") val watchersCount: Int = 0,
@Json(name = "is_private") val isPrivate: Boolean = false
)

@ -0,0 +1,13 @@
package io.eugenethedev.taigamobile.domain.entities
import java.time.LocalDate
data class Sprint(
val id: Long,
val name: String,
val order: Int,
val start: LocalDate,
val end: LocalDate,
val storiesCount: Int,
val isClosed: Boolean
)

@ -0,0 +1,10 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class Swimlane(
val id: Long,
val name: String,
val order: Long
)

@ -0,0 +1,6 @@
package io.eugenethedev.taigamobile.domain.entities
data class Tag(
val name: String,
val color: String
)

@ -0,0 +1,108 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.time.LocalDate
import java.time.LocalDateTime
/**
* Tasks related entities
*/
data class Status(
val id: Long,
val name: String,
val color: String,
val type: StatusType
)
enum class StatusType {
Status,
Type,
Severity,
Priority
}
enum class CommonTaskType {
UserStory,
Task,
Epic,
Issue
}
data class CommonTask(
val id: Long,
val createdDate: LocalDateTime,
val title: String,
val ref: Int,
val status: Status,
val assignee: User? = null,
val projectInfo: Project,
val taskType: CommonTaskType,
val isClosed: Boolean,
val tags: List<Tag> = emptyList(),
val colors: List<String> = emptyList(), // colored indicators (for stories and epics)
val blockedNote: String? = null
)
@JsonClass(generateAdapter = false)
enum class DueDateStatus {
@Json(name = "not_set") NotSet,
@Json(name = "set") Set,
@Json(name = "due_soon") DueSoon,
@Json(name = "past_due") PastDue,
@Json(name = "no_longer_applicable") NoLongerApplicable
}
data class CommonTaskExtended(
val id: Long,
val status: Status,
val taskType: CommonTaskType,
val createdDateTime: LocalDateTime,
val sprint: Sprint?,
val assignedIds: List<Long>,
val watcherIds: List<Long>,
val creatorId: Long,
val ref: Int,
val title: String,
val isClosed: Boolean,
val description: String,
val projectSlug: String,
val version: Int,
val epicsShortInfo: List<EpicShortInfo> = emptyList(),
val tags: List<Tag> = emptyList(),
val swimlane: Swimlane?,
val dueDate: LocalDate?,
val dueDateStatus: DueDateStatus?,
val userStoryShortInfo: UserStoryShortInfo? = null,
val url: String,
val blockedNote: String? = null,
// for epic
val color: String? = null,
// for issue
val type: Status? = null,
val priority: Status? = null,
val severity: Status? = null
)
@JsonClass(generateAdapter = true)
data class EpicShortInfo(
val id: Long,
@Json(name = "subject") val title: String,
val ref: Int,
val color: String
)
@JsonClass(generateAdapter = true)
data class UserStoryShortInfo(
val id: Long,
val ref: Int,
@Json(name = "subject") val title: String,
val epics: List<EpicShortInfo>?
) {
val epicColors get() = epics?.map { it.color }.orEmpty()
}

@ -0,0 +1,53 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* Users related entities
*/
@JsonClass(generateAdapter = true)
data class User(
@Json(name = "id") val _id: Long?,
@Json(name = "full_name_display") val fullName: String?,
val photo: String?,
@Json(name = "big_photo") val bigPhoto: String?,
val username: String,
val name: String? = null, // sometimes name appears here
val pk: Long? = null
) {
val displayName get() = fullName ?: name!!
val avatarUrl get() = bigPhoto ?: photo
val id get() = _id ?: pk!!
}
data class TeamMember(
val id: Long,
val avatarUrl: String?,
val name: String,
val role: String,
val username: String,
val totalPower: Int
) {
fun toUser() = User(
_id = id,
fullName = name,
photo = avatarUrl,
bigPhoto = null,
username = username
)
}
@JsonClass(generateAdapter = true)
data class Stats(
val roles: List<String> = emptyList(),
@Json(name = "total_num_closed_userstories")
val totalNumClosedUserStories: Int,
@Json(name = "total_num_contacts")
val totalNumContacts: Int,
@Json(name = "total_num_projects")
val totalNumProjects: Int,
)

@ -0,0 +1,27 @@
package io.eugenethedev.taigamobile.domain.entities
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.time.LocalDateTime
@JsonClass(generateAdapter = true)
data class WikiPage(
val id: Long,
val version: Int,
val content: String,
val editions: Long,
@Json(name = "created_date") val cratedDate: LocalDateTime,
@Json(name = "is_watcher") val isWatcher: Boolean,
@Json(name = "last_modifier") val lastModifier: Long,
@Json(name = "modified_date") val modifiedDate: LocalDateTime,
@Json(name = "total_watchers") val totalWatchers: Long,
@Json(name = "slug")val slug: String
)
@JsonClass(generateAdapter = true)
data class WikiLink(
@Json(name = "href") val ref: String,
val id: Long,
val order: Long,
val title: String
)

@ -0,0 +1,36 @@
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)
}
}
}

@ -0,0 +1,7 @@
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)
}

@ -0,0 +1,9 @@
package io.eugenethedev.taigamobile.domain.repositories
import io.eugenethedev.taigamobile.domain.entities.Project
interface IProjectsRepository {
suspend fun searchProjects(query: String, page: Int): List<Project>
suspend fun getMyProjects(): List<Project>
suspend fun getUserProjects(userId: Long): List<Project>
}

@ -0,0 +1,18 @@
package io.eugenethedev.taigamobile.domain.repositories
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.entities.Sprint
import java.time.LocalDate
interface ISprintsRepository {
suspend fun getSprints(page: Int, isClosed: Boolean = false): List<Sprint>
suspend fun getSprint(sprintId: Long): Sprint
suspend fun getSprintIssues(sprintId: Long): List<CommonTask>
suspend fun getSprintUserStories(sprintId: Long): List<CommonTask>
suspend fun getSprintTasks(sprintId: Long): List<CommonTask>
suspend fun createSprint(name: String, start: LocalDate, end: LocalDate)
suspend fun editSprint(sprintId: Long, name: String, start: LocalDate, end: LocalDate)
suspend fun deleteSprint(sprintId: Long)
}

@ -0,0 +1,78 @@
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, isCommonTaskFromBacklog: Boolean = false): 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 methods
// ============
// edit task
suspend fun editStatus(commonTask: CommonTaskExtended, statusId: Long, statusType: StatusType)
suspend fun editSprint(commonTask: CommonTaskExtended, sprintId: Long?)
suspend fun editAssignees(commonTask: CommonTaskExtended, assignees: List<Long>)
suspend fun editWatchers(commonTask: CommonTaskExtended, watchers: List<Long>)
suspend fun editDueDate(commonTask: CommonTaskExtended, date: LocalDate?)
suspend fun editCommonTaskBasicInfo(commonTask: CommonTaskExtended, title: String, description: String)
suspend fun editTags(commonTask: CommonTaskExtended, tags: List<Tag>)
suspend fun editUserStorySwimlane(commonTask: CommonTaskExtended, swimlaneId: Long?)
suspend fun editEpicColor(commonTask: CommonTaskExtended, color: String)
suspend fun editBlocked(commonTask: CommonTaskExtended, blockedNote: String?)
// related edits
suspend fun createComment(commonTaskId: Long, commonTaskType: CommonTaskType, comment: String, version: Int)
suspend fun deleteComment(commonTaskId: Long, commonTaskType: CommonTaskType, commentId: String)
suspend fun linkToEpic(epicId: Long, userStoryId: Long)
suspend fun unlinkFromEpic(epicId: Long, userStoryId: Long)
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)
}

@ -0,0 +1,12 @@
package io.eugenethedev.taigamobile.domain.repositories
import io.eugenethedev.taigamobile.domain.entities.Stats
import io.eugenethedev.taigamobile.domain.entities.TeamMember
import io.eugenethedev.taigamobile.domain.entities.User
interface IUsersRepository {
suspend fun getMe(): User
suspend fun getUser(userId: Long): User
suspend fun getUserStats(userId: Long): Stats
suspend fun getTeam(): List<TeamMember>
}

@ -0,0 +1,20 @@
package io.eugenethedev.taigamobile.domain.repositories
import io.eugenethedev.taigamobile.domain.entities.Attachment
import io.eugenethedev.taigamobile.domain.entities.WikiLink
import io.eugenethedev.taigamobile.domain.entities.WikiPage
import java.io.InputStream
interface IWikiRepository {
suspend fun getProjectWikiPages(): List<WikiPage>
suspend fun getProjectWikiPageBySlug(slug: String): WikiPage
suspend fun editWikiPage(pageId: Long, content: String, version: Int)
suspend fun deleteWikiPage(pageId: Long)
suspend fun getPageAttachments(pageId: Long): List<Attachment>
suspend fun addPageAttachment(pageId: Long, fileName: String, inputStream: InputStream)
suspend fun deletePageAttachment(attachmentId: Long)
suspend fun getWikiLinks(): List<WikiLink>
suspend fun createWikiLink(href: String, title: String)
suspend fun deleteWikiLink(linkId: Long)
}

@ -0,0 +1,160 @@
package io.eugenethedev.taigamobile.state
import android.content.Context
import androidx.core.content.edit
import com.squareup.moshi.Moshi
import io.eugenethedev.taigamobile.domain.entities.FiltersData
import io.eugenethedev.taigamobile.domain.entities.FiltersDataJsonAdapter
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, moshi: Moshi) {
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
resetFilters()
}
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))
// Filters
private val filtersJsonAdapter = FiltersDataJsonAdapter(moshi)
private fun getFiltersOrEmpty(key: String) = sharedPreferences.getString(key, null)?.takeIf { it.isNotBlank() }?.let { filtersJsonAdapter.fromJson(it) } ?: FiltersData()
private val _scrumFilters = MutableStateFlow(getFiltersOrEmpty(FILTERS_SCRUM))
val scrumFilters: StateFlow<FiltersData> = _scrumFilters
fun changeScrumFilters(filters: FiltersData) {
sharedPreferences.edit {
putString(FILTERS_SCRUM, filtersJsonAdapter.toJson(filters))
}
_scrumFilters.value = filters
}
private val _epicsFilters = MutableStateFlow(getFiltersOrEmpty(FILTERS_EPICS))
val epicsFilters: StateFlow<FiltersData> = _epicsFilters
fun changeEpicsFilters(filters: FiltersData) {
sharedPreferences.edit {
putString(FILTERS_EPICS, filtersJsonAdapter.toJson(filters))
}
_epicsFilters.value = filters
}
private val _issuesFilters = MutableStateFlow(getFiltersOrEmpty(FILTERS_ISSUES))
val issuesFilters: StateFlow<FiltersData> = _issuesFilters
fun changeIssuesFilters(filters: FiltersData) {
sharedPreferences.edit {
putString(FILTERS_ISSUES, filtersJsonAdapter.toJson(filters))
}
_issuesFilters.value = filters
}
private fun resetFilters() {
changeScrumFilters(FiltersData())
changeEpicsFilters(FiltersData())
changeIssuesFilters(FiltersData())
}
fun reset() {
changeAuthCredentials("", "")
changeServer("")
changeCurrentUserId(-1)
changeCurrentProject(-1, "")
resetFilters()
}
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"
private const val FILTERS_SCRUM = "filters_scrum"
private const val FILTERS_EPICS = "filters_epics"
private const val FILTERS_ISSUES = "filters_issues"
}
// 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() }
}
}
}

@ -0,0 +1,28 @@
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
}

@ -0,0 +1,64 @@
package io.eugenethedev.taigamobile.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.utils.textColor
/**
* Material chip component (rounded rectangle)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Chip(
modifier: Modifier = Modifier,
onClick: (() -> Unit)? = null,
color: Color = MaterialTheme.colorScheme.outline,
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalMinimumTouchTargetEnforcement.provides(onClick != null)
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = color,
contentColor = color.textColor(),
shadowElevation = 1.dp
) {
Box(
modifier = Modifier.clickable(
indication = rememberRipple(),
onClick = onClick ?: {},
enabled = onClick != null,
interactionSource = remember { MutableInteractionSource() }
).padding(vertical = 4.dp, horizontal = 10.dp)
) {
content()
}
}
}
}
@Preview
@Composable
fun ChipPreview() = TaigaMobileTheme {
Box(modifier = Modifier.padding(10.dp)) {
Chip {
Text("Testing chip")
}
}
}

@ -0,0 +1,93 @@
package io.eugenethedev.taigamobile.ui.components
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.updateTransition
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.theme.dialogTonalElevation
import io.eugenethedev.taigamobile.ui.utils.clickableUnindicated
import io.eugenethedev.taigamobile.ui.utils.surfaceColorAtElevation
/**
* Dropdown selector with animated arrow
*/
@Composable
fun <T> DropdownSelector(
items: List<T>,
selectedItem: T,
onItemSelected: (T) -> Unit,
itemContent: @Composable (T) -> Unit,
selectedItemContent: @Composable (T) -> Unit,
takeMaxWidth: Boolean = false,
horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
tint: Color = MaterialTheme.colorScheme.primary,
onExpanded: () -> Unit = {},
onDismissRequest: () -> Unit = {}
) {
var isExpanded by remember { mutableStateOf(false) }
val transitionState = remember { MutableTransitionState(isExpanded) }
transitionState.targetState = isExpanded
if (isExpanded) onExpanded()
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = horizontalArrangement,
modifier = Modifier
.let { if (takeMaxWidth) it.fillMaxWidth() else it }
.clickableUnindicated {
isExpanded = !isExpanded
}
) {
selectedItemContent(selectedItem)
val arrowRotation by updateTransition(
transitionState,
label = "arrow"
).animateFloat { if (it) -180f else 0f }
Icon(
painter = painterResource(R.drawable.ic_arrow_down),
contentDescription = null,
tint = tint,
modifier = Modifier.rotate(arrowRotation)
)
}
DropdownMenu(
modifier = Modifier.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(dialogTonalElevation)
),
expanded = isExpanded,
onDismissRequest = {
isExpanded = false
onDismissRequest()
}
) {
items.forEach {
DropdownMenuItem(
onClick = {
isExpanded = false
onItemSelected(it)
},
text = {
itemContent(it)
}
)
}
}
}
}

@ -0,0 +1,509 @@
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.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 = MaterialTheme.shapes.small,
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,
crossAxisSpacing = 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")
}
}

@ -0,0 +1,35 @@
package io.eugenethedev.taigamobile.ui.components.appbars
import androidx.compose.foundation.layout.RowScope
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.unit.dp
import com.google.accompanist.insets.statusBarsPadding
import io.eugenethedev.taigamobile.R
@Composable
fun AppBarWithBackButton(
modifier: Modifier = Modifier,
title: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {},
navigateBack: (() -> Unit)? = null
) = SmallTopAppBar(
title = title,
navigationIcon = navigateBack?.let {
{
IconButton(onClick = it) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(28.dp)
)
}
}
} ?: {},
actions = actions,
modifier = modifier.statusBarsPadding()
)

@ -0,0 +1,44 @@
package io.eugenethedev.taigamobile.ui.components.appbars
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.utils.clickableUnindicated
@Composable
fun ClickableAppBar(
projectName: String,
onTitleClick: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
navigateBack: (() -> Unit)? = null
) = AppBarWithBackButton(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickableUnindicated(onClick = onTitleClick)
) {
Text(
text = projectName.takeIf { it.isNotEmpty() }
?: stringResource(R.string.choose_project_title),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
Icon(
painter = painterResource(R.drawable.ic_arrow_down),
contentDescription = null
)
}
},
actions = actions,
navigateBack = navigateBack
)

@ -0,0 +1,45 @@
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.utils.textColor
@Composable
fun Badge(
text: String,
isActive: Boolean = true
) {
val color = if (isActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.inverseOnSurface
Surface(
shape = MaterialTheme.shapes.extraSmall,
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)
}
}

@ -0,0 +1,116 @@
package io.eugenethedev.taigamobile.ui.components.badges
import androidx.compose.animation.core.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.utils.textColor
import io.eugenethedev.taigamobile.ui.utils.toColor
/**
* Badge on which you can click. With cool shimmer loading animation
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ClickableBadge(
text: String,
color: Color,
onClick: () -> Unit = {},
isLoading: Boolean = false,
isClickable: Boolean = true
) {
val textColor = color.textColor()
val infiniteTransition = rememberInfiniteTransition()
val animationDuration = 800
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f, // BoxWithConstraints won't work there, because maxWidth always changing when this element is part of a list
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = animationDuration, easing = FastOutSlowInEasing),
)
)
CompositionLocalProvider(
LocalMinimumTouchTargetEnforcement provides false
) {
Surface(
shape = MaterialTheme.shapes.large,
color = color
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(
indication = rememberRipple(),
onClick = onClick ,
interactionSource = remember { MutableInteractionSource() }
).padding(horizontal = 12.dp, vertical = 4.dp)
) {
Text(
text = text,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 120.dp)
)
if (isClickable) {
Image(
painter = painterResource(R.drawable.ic_arrow_down),
contentDescription = null,
colorFilter = ColorFilter.tint(textColor),
modifier = Modifier.rotate(if (isLoading) rotation else 0f)
)
} else {
Spacer(Modifier.width(6.dp))
}
}
}
}
}
@Composable
fun ClickableBadge(
text: String,
colorHex: String,
onClick: () -> Unit = {},
isLoading: Boolean = false,
isClickable: Boolean = true
) = ClickableBadge(
text,
colorHex.toColor(),
onClick,
isLoading,
isClickable
)
@Preview(showBackground = true)
@Composable
fun ClickableBadgePreview() = TaigaMobileTheme {
ClickableBadge(
text = "Sample",
colorHex = "#25A28C",
isLoading = true
)
}

@ -0,0 +1,33 @@
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)
}
}

@ -0,0 +1,34 @@
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)
)
}

@ -0,0 +1,41 @@
package io.eugenethedev.taigamobile.ui.components.buttons
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
/**
* Text button just with text
*/
@Composable
fun TextButton(
text: String,
icon: Int? = null,
onClick: () -> Unit
) = FilledTonalButton(onClick = onClick) {
Row(verticalAlignment = Alignment.CenterVertically) {
icon?.let {
Icon(
painter = painterResource(it),
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(Modifier.width(6.dp))
}
Text(
text = text,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}

@ -0,0 +1,38 @@
package io.eugenethedev.taigamobile.ui.components.containers
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
/**
* Common for app view which is used as container for different items (for example list items).
* It is clickable, has padding inside and ripple effect
*/
@Composable
fun ContainerBox(
horizontalPadding: Dp = mainHorizontalScreenPadding,
verticalPadding: Dp = 8.dp,
onClick: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit = {}
) = Box(
modifier = Modifier.fillMaxWidth()
.clickable(
indication = rememberRipple(),
onClick = onClick ?: {},
enabled = onClick != null,
interactionSource = remember { MutableInteractionSource() },
)
.padding(horizontalPadding, verticalPadding),
content = content
)

@ -0,0 +1,94 @@
package io.eugenethedev.taigamobile.ui.components.containers
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.google.accompanist.pager.*
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
import kotlinx.coroutines.launch
/**
* Swipeable tabs
*/
@OptIn(ExperimentalPagerApi::class)
@Composable
fun HorizontalTabbedPager(
tabs: Array<out Tab>,
modifier: Modifier = Modifier,
pagerState: PagerState = rememberPagerState(),
scrollable: Boolean = true,
edgePadding: Dp = mainHorizontalScreenPadding,
content: @Composable PagerScope.(page: Int) -> Unit
) = Column(modifier = modifier) {
val coroutineScope = rememberCoroutineScope()
val indicator: @Composable (tabPositions: List<TabPosition>) -> Unit = { tabPositions ->
TabRowDefaults.Indicator(
Modifier.pagerTabIndicatorOffset(pagerState, tabPositions)
)
}
val tabsRow: @Composable () -> Unit = {
tabs.forEachIndexed { index, tab ->
val selected = pagerState.run { targetPage.takeIf { it != currentPage } ?: currentPage == index }
Tab(
selected = selected,
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
},
text = {
Text(
text = stringResource(tab.titleId),
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
}
)
}
}
if (scrollable) {
ScrollableTabRow(
selectedTabIndex = pagerState.currentPage,
modifier = Modifier.fillMaxWidth(),
contentColor = MaterialTheme.colorScheme.primary,
backgroundColor = MaterialTheme.colorScheme.surface,
indicator = indicator,
tabs = tabsRow,
divider = {},
edgePadding = edgePadding
)
} else {
TabRow(
selectedTabIndex = pagerState.currentPage,
modifier = Modifier.fillMaxWidth(),
contentColor = MaterialTheme.colorScheme.primary,
backgroundColor = MaterialTheme.colorScheme.surface,
indicator = indicator,
tabs = tabsRow,
divider = {}
)
}
Spacer(Modifier.height(8.dp))
HorizontalPager(
state = pagerState,
content = content,
count = tabs.size
)
}
interface Tab {
val titleId: Int
}

@ -0,0 +1,57 @@
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
)
}
}
)

@ -0,0 +1,107 @@
package io.eugenethedev.taigamobile.ui.components.dialogs
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.components.editors.TextFieldWithHint
import io.eugenethedev.taigamobile.ui.components.pickers.DatePicker
import java.time.LocalDate
@Composable
fun EditSprintDialog(
initialName: String = "",
initialStart: LocalDate? = null,
initialEnd: LocalDate? = null,
onConfirm: (name: String, start: LocalDate, end: LocalDate) -> Unit,
onDismiss: () -> Unit
) {
var name by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(initialName)) }
var start by remember { mutableStateOf(initialStart ?: LocalDate.now()) }
var end by remember { mutableStateOf(initialEnd ?: LocalDate.now().plusDays(14)) }
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
TextButton(onClick = onDismiss) {
Text(
text = stringResource(R.string.cancel),
style = MaterialTheme.typography.titleMedium
)
}
},
confirmButton = {
TextButton(
onClick = {
name.text.trim()
.takeIf { it.isNotEmpty() }
?.let { onConfirm(it, start, end) }
}
) {
Text(
text = stringResource(R.string.ok),
style = MaterialTheme.typography.titleMedium
)
}
},
title = {
TextFieldWithHint(
hintId = R.string.sprint_name_hint,
value = name,
onValueChange = { name = it },
style = MaterialTheme.typography.titleLarge
)
},
text = {
val pickerStyle = MaterialTheme.typography.titleMedium.merge(TextStyle(fontWeight = FontWeight.Normal))
val pickerModifier = Modifier
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = MaterialTheme.shapes.small
)
.padding(6.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
DatePicker(
date = start,
onDatePicked = { start = it!! },
showClearButton = false,
style = pickerStyle,
modifier = pickerModifier
)
Spacer(
Modifier
.width(16.dp)
.height(1.5.dp)
.background(MaterialTheme.colorScheme.onSurface))
DatePicker(
date = end,
onDatePicked = { end = it!! },
showClearButton = false,
style = pickerStyle,
modifier = pickerModifier
)
}
}
)
}

@ -0,0 +1,37 @@
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
/**
* Alert with loader and text
*/
@Composable
fun LoadingDialog() = Dialog(onDismissRequest = { /* cannot dismiss */ }) {
Surface(shape = MaterialTheme.shapes.small) {
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))
}
}
}

@ -0,0 +1,103 @@
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.getValue
import androidx.compose.runtime.saveable.rememberSaveable
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 Editor(
toolbarText: String,
title: String = "",
description: String = "",
showTitle: Boolean = true,
onSaveClick: (title: String, description: String) -> Unit = { _, _ -> },
navigateBack: () -> Unit = {}
) = Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.imePadding()
) {
onBackPressed(navigateBack)
var titleInput by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(title)) }
var descriptionInput by rememberSaveable(stateSaver = TextFieldValue.Saver) { 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))
if (showTitle) {
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 {
Editor("Edit")
}

@ -0,0 +1,124 @@
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.runtime.saveable.rememberSaveable
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 rememberSaveable(stateSaver = TextFieldValue.Saver) { 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
}

@ -0,0 +1,100 @@
package io.eugenethedev.taigamobile.ui.components.editors
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
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
/**
* 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,
minHeight: 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,
contentAlignment: Alignment = Alignment.CenterStart
) {
val primaryColor = MaterialTheme.colorScheme.primary
val unfocusedColor = MaterialTheme.colorScheme.outline
var outlineColor by remember { mutableStateOf(unfocusedColor) }
Box(
contentAlignment = contentAlignment,
modifier = Modifier.let { m -> width?.let { m.width(it) } ?: m.fillMaxWidth() }
.heightIn(min = minHeight ?: Dp.Unspecified)
.padding(horizontal = horizontalPadding, vertical = verticalPadding)
.let {
if (hasBorder) {
it.border(width = 1.dp, color = outlineColor, shape = MaterialTheme.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

@ -0,0 +1,120 @@
package io.eugenethedev.taigamobile.ui.components.lists
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.EditAction
import io.eugenethedev.taigamobile.ui.screens.main.LocalFilePicker
import io.eugenethedev.taigamobile.ui.utils.activity
import java.io.InputStream
@Suppress("FunctionName")
fun LazyListScope.Attachments(
attachments: List<Attachment>,
editAttachments: EditAction<Pair<String, InputStream>, Attachment>
) {
item {
val filePicker = LocalFilePicker.current
SectionTitle(
text = stringResource(R.string.attachments_template).format(attachments.size),
onAddClick = {
filePicker.requestFile { file, stream -> editAttachments.select(file to stream) }
}
)
}
items(attachments) {
AttachmentItem(
attachment = it,
onRemoveClick = { editAttachments.remove(it) }
)
}
item {
if (editAttachments.isLoading) {
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
)
}
}

@ -0,0 +1,119 @@
package io.eugenethedev.taigamobile.ui.components.lists
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.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.texts.CommonTaskTitle
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
import io.eugenethedev.taigamobile.ui.utils.NavigateToTask
import io.eugenethedev.taigamobile.ui.utils.toColor
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Single task item
*/
@Composable
fun CommonTaskItem(
commonTask: CommonTask,
horizontalPadding: Dp = mainHorizontalScreenPadding,
verticalPadding: Dp = 8.dp,
showExtendedInfo: Boolean = false,
navigateToTask: NavigateToTask = { _, _, _ -> }
) = ContainerBox(
horizontalPadding, verticalPadding,
onClick = { navigateToTask(commonTask.id, commonTask.taskType, commonTask.ref) }
) {
val dateTimeFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) }
Column(modifier = Modifier.fillMaxWidth()) {
if (showExtendedInfo) {
Text(commonTask.projectInfo.name)
Text(
text = stringResource(
when (commonTask.taskType) {
CommonTaskType.UserStory -> R.string.userstory
CommonTaskType.Task -> R.string.task
CommonTaskType.Epic -> R.string.epic
CommonTaskType.Issue -> R.string.issue
}
).uppercase(),
color = MaterialTheme.colorScheme.secondary
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = commonTask.status.name,
color = commonTask.status.color.toColor(),
style = MaterialTheme.typography.bodyMedium
)
Text(
text = commonTask.createdDate.format(dateTimeFormatter),
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
}
CommonTaskTitle(
ref = commonTask.ref,
title = commonTask.title,
indicatorColorsHex = commonTask.colors,
isInactive = commonTask.isClosed,
tags = commonTask.tags,
isBlocked = commonTask.blockedNote != null
)
Text(
text = commonTask.assignee?.fullName?.let { stringResource(R.string.assignee_pattern)
.format(it) } ?: stringResource(R.string.unassigned),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium
)
}
}
@Preview(showBackground = true)
@Composable
fun CommonTaskItemPreview() = TaigaMobileTheme {
CommonTaskItem(
CommonTask(
id = 0L,
createdDate = LocalDateTime.now(),
title = "Very cool story",
ref = 100,
status = Status(
id = 0L,
name = "In progress",
color = "#729fcf",
type = StatusType.Status
),
assignee = null,
projectInfo = Project(0, "Name", "slug"),
taskType = CommonTaskType.UserStory,
isClosed = false,
blockedNote = "Block reason"
),
showExtendedInfo = true
)
}

@ -0,0 +1,23 @@
package io.eugenethedev.taigamobile.ui.components.lists
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import io.eugenethedev.taigamobile.ui.components.texts.MarkdownText
import io.eugenethedev.taigamobile.ui.components.texts.NothingToSeeHereText
@Suppress("FunctionName")
fun LazyListScope.Description(
description: String
) {
item {
if (description.isNotEmpty()) {
MarkdownText(
text = description,
modifier = Modifier.fillMaxWidth()
)
} else {
NothingToSeeHereText()
}
}
}

@ -0,0 +1,154 @@
package io.eugenethedev.taigamobile.ui.components.lists
import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
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.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.Project
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
@OptIn(ExperimentalCoilApi::class)
@Composable
fun ProjectCard(
project: Project,
isCurrent: Boolean,
onClick: () -> Unit = {}
) = Surface(
shape = MaterialTheme.shapes.small,
border = if (isCurrent) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
} else {
BorderStroke(1.dp, MaterialTheme.colorScheme.outline)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = mainHorizontalScreenPadding, vertical = 4.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(),
onClick = onClick
)
.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = rememberImagePainter(
data = project.avatarUrl ?: R.drawable.default_avatar,
builder = {
error(R.drawable.default_avatar)
crossfade(true)
},
),
contentDescription = null,
modifier = Modifier.size(46.dp)
)
Spacer(Modifier.width(8.dp))
Column {
Text(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
text = stringResource(
when {
project.isOwner -> R.string.project_owner
project.isAdmin -> R.string.project_admin
else -> R.string.project_member
}
)
)
Spacer(Modifier.height(4.dp))
Text(
text = project.name,
style = MaterialTheme.typography.bodyLarge
)
}
}
project.description?.let {
Spacer(Modifier.height(4.dp))
Text(
text = it,
style = MaterialTheme.typography.bodySmall
)
}
Spacer(Modifier.height(8.dp))
CompositionLocalProvider(
LocalContentColor provides MaterialTheme.colorScheme.outline
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
val iconSize = 18.dp
val indicatorsSpacing = 8.dp
@Composable
fun Indicator(@DrawableRes icon: Int, value: Int) {
Icon(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(iconSize)
)
Spacer(Modifier.width(4.dp))
Text(
text = value.toString(),
style = MaterialTheme.typography.bodySmall
)
}
Indicator(R.drawable.ic_favorite, project.fansCount)
Spacer(Modifier.width(indicatorsSpacing))
Indicator(R.drawable.ic_watch, project.watchersCount)
Spacer(Modifier.width(indicatorsSpacing))
Indicator(R.drawable.ic_team, project.members.size)
if (project.isPrivate) {
Spacer(Modifier.width(indicatorsSpacing))
Icon(
painter = painterResource(R.drawable.ic_key),
contentDescription = null,
modifier = Modifier.size(iconSize)
)
}
}
}
}
}

@ -0,0 +1,93 @@
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))
}
}

@ -0,0 +1,132 @@
package io.eugenethedev.taigamobile.ui.components.lists
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
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.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.User
import io.eugenethedev.taigamobile.ui.components.dialogs.ConfirmActionDialog
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.utils.clickableUnindicated
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* User info (name and avatar).
*/
@OptIn(ExperimentalCoilApi::class)
@Composable
fun UserItem(
user: User,
dateTime: LocalDateTime? = null,
onUserItemClick: () -> Unit = { }
) = Row(
modifier = Modifier.clickableUnindicated { onUserItemClick() },
verticalAlignment = Alignment.CenterVertically
) {
val dateTimeFormatter = remember { DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) }
val imageSize = if (dateTime != null) 46.dp else 40.dp
Image(
painter = rememberImagePainter(
data = user.avatarUrl ?: R.drawable.default_avatar,
builder = {
error(R.drawable.default_avatar)
crossfade(true)
}
),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(imageSize)
.clip(CircleShape)
)
Spacer(Modifier.width(6.dp))
Column {
Text(
text = user.displayName,
style = MaterialTheme.typography.titleMedium
)
dateTime?.let {
Text(
text = it.format(dateTimeFormatter),
color = MaterialTheme.colorScheme.outline,
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
fun UserItemWithAction(
user: User,
onRemoveClick: () -> Unit,
onUserItemClick: () -> Unit = { }
) {
var isAlertVisible by remember { mutableStateOf(false) }
if (isAlertVisible) {
ConfirmActionDialog(
title = stringResource(R.string.remove_user_title),
text = stringResource(R.string.remove_user_text),
onConfirm = {
isAlertVisible = false
onRemoveClick()
},
onDismiss = { isAlertVisible = false },
iconId = R.drawable.ic_remove
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
UserItem(
user = user,
onUserItemClick = onUserItemClick
)
IconButton(onClick = { isAlertVisible = true }) {
Icon(
painter = painterResource(R.drawable.ic_remove),
contentDescription = null,
tint = MaterialTheme.colorScheme.outline
)
}
}
}
@Preview(showBackground = true)
@Composable
fun UserItemPreview() = TaigaMobileTheme {
UserItem(
user = User(
_id = 0L,
fullName = "Full Name",
photo = null,
bigPhoto = null,
username = "username"
)
)
}

@ -0,0 +1,26 @@
package io.eugenethedev.taigamobile.ui.components.loaders
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
/**
* Centered circular loader for some screens
*/
@Composable
fun CircularLoader() = Box(
modifier = Modifier.fillMaxWidth().padding(8.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(40.dp),
color = MaterialTheme.colorScheme.primary
)
}

@ -0,0 +1,75 @@
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()
}

@ -0,0 +1,55 @@
package io.eugenethedev.taigamobile.ui.components.pickers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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.unit.Dp
import com.vanpra.composematerialdialogs.MaterialDialog
import com.vanpra.composematerialdialogs.color.ARGBPickerState
import com.vanpra.composematerialdialogs.color.ColorPalette
import com.vanpra.composematerialdialogs.color.colorChooser
import com.vanpra.composematerialdialogs.rememberMaterialDialogState
import com.vanpra.composematerialdialogs.title
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.utils.clickableUnindicated
/**
* Color picker with material dialog
*/
@Composable
fun ColorPicker(
size: Dp,
color: Color,
onColorPicked: (Color) -> Unit = {}
) {
val dialogState = rememberMaterialDialogState()
MaterialDialog(
dialogState = dialogState,
buttons = {
// TODO update buttons to comply with material3 color schema?
positiveButton(res = R.string.ok)
negativeButton(res = R.string.cancel)
}
) {
title(stringResource(R.string.select_color))
colorChooser(
colors = (listOf(color) + ColorPalette.Primary).toSet().toList(),
onColorSelected = onColorPicked,
argbPickerState = ARGBPickerState.WithoutAlphaSelector
)
}
Spacer(
Modifier.size(size)
.background(color = color, shape = MaterialTheme.shapes.small)
.clickableUnindicated { dialogState.show() }
)
}

@ -0,0 +1,101 @@
package io.eugenethedev.taigamobile.ui.components.pickers
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
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.Composable
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.text.TextStyle
import androidx.compose.ui.unit.dp
import com.google.android.material.datepicker.MaterialDatePicker
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.utils.activity
import io.eugenethedev.taigamobile.ui.utils.clickableUnindicated
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/**
* Date picker with material dialog. Null passed to onDatePicked() means selection was cleared
*/
@Composable
fun DatePicker(
date: LocalDate?,
onDatePicked: (LocalDate?) -> Unit,
modifier: Modifier = Modifier,
@StringRes hintId: Int = R.string.date_hint,
showClearButton: Boolean = true,
style: TextStyle = MaterialTheme.typography.bodyLarge,
onClose: () -> Unit = {},
onOpen: () -> Unit = {}
) = Box {
val dateFormatter = remember { DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) }
val dialog = MaterialDatePicker.Builder
.datePicker()
.setTitleText(R.string.select_date)
.setTheme(R.style.DatePicker)
.setSelection(
date?.atStartOfDay(ZoneOffset.UTC)
?.toInstant()
?.toEpochMilli()
)
.build()
.apply {
addOnDismissListener { onClose() }
addOnPositiveButtonClickListener {
onDatePicked(
Instant.ofEpochMilli(it)
.atOffset(ZoneOffset.UTC)
.toLocalDate()
)
}
}
val fragmentManager = LocalContext.current.activity.supportFragmentManager
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
) {
Text(
text = date?.format(dateFormatter) ?: stringResource(hintId),
style = style,
modifier = Modifier.clickableUnindicated {
onOpen()
dialog.show(fragmentManager, dialog.toString())
},
color = date?.let { MaterialTheme.colorScheme.onSurface } ?: MaterialTheme.colorScheme.outline
)
if (showClearButton && date != null) { // do not show clear button if there is no date (sounds right to me)
Spacer(Modifier.width(4.dp))
IconButton(
onClick = { onDatePicked(null) },
modifier = Modifier.size(22.dp).clip(CircleShape)
) {
Icon(
painter = painterResource(R.drawable.ic_remove),
contentDescription = null,
tint = MaterialTheme.colorScheme.outline
)
}
}
}
}

@ -0,0 +1,90 @@
package io.eugenethedev.taigamobile.ui.components.texts
import androidx.compose.foundation.layout.*
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.theme.taigaRed
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(),
isBlocked: Boolean = false
) = Column(modifier = modifier) {
val space = 4.dp
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 = if (isBlocked) taigaRed else textColor,
style = MaterialTheme.typography.titleMedium
)
if (tags.isNotEmpty()) {
Spacer(Modifier.height(space))
FlowRow(
mainAxisSpacing = space,
crossAxisSpacing = space
) {
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")),
isBlocked = true
)
}

@ -0,0 +1,41 @@
package io.eugenethedev.taigamobile.ui.components.texts
import android.widget.TextView
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import io.noties.markwon.Markwon
import io.noties.markwon.image.coil.CoilImagesPlugin
/**
* Use android TextView because Compose does not support Markdown yet
*/
@Composable
fun MarkdownText(
text: String,
modifier: Modifier = Modifier,
isSelectable: Boolean = true
) {
if (!::markwon.isInitialized) {
markwon = Markwon.builder(LocalContext.current)
.usePlugin(CoilImagesPlugin.create(LocalContext.current))
.build()
}
val textSize = MaterialTheme.typography.bodyLarge.fontSize.value
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()
AndroidView(
factory = ::TextView,
modifier = modifier
) {
it.textSize = textSize
it.setTextColor(textColor)
it.setTextIsSelectable(isSelectable)
markwon.setMarkdown(it, text)
}
}
// Hold Markwon object (use existing instead of recreating on each recomposition)
private lateinit var markwon: Markwon

@ -0,0 +1,27 @@
package io.eugenethedev.taigamobile.ui.components.texts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
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 io.eugenethedev.taigamobile.R
/**
* Common nothing to see here text
*/
@Composable
fun NothingToSeeHereText() {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = stringResource(R.string.nothing_to_see),
color = MaterialTheme.colorScheme.outline
)
}
}

@ -0,0 +1,68 @@
package io.eugenethedev.taigamobile.ui.components.texts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.semantics.Role
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
/**
* Title with optional add button
*/
@Composable
fun SectionTitle(
text: String,
horizontalPadding: Dp = 0.dp,
bottomPadding: Dp = 6.dp,
onAddClick: (() -> Unit)? = null
) = Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.padding(horizontal = horizontalPadding)
.padding(bottom = bottomPadding)
.background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.small)
) {
Text(
text = text,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(6.dp)
)
onAddClick?.let {
Box(
modifier = Modifier.fillMaxHeight()
.aspectRatio(1f)
.background(MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small)
.clip(MaterialTheme.shapes.small)
.clickable(
onClick = it,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = true)
)
.padding(6.dp)
) {
Icon(
painter = painterResource(R.drawable.ic_add),
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.fillMaxSize()
)
}
}
}

@ -0,0 +1,629 @@
package io.eugenethedev.taigamobile.ui.screens.commontask
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.*
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 com.google.accompanist.insets.navigationBarsWithImePadding
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.domain.entities.CustomField
import io.eugenethedev.taigamobile.ui.components.editors.Editor
import io.eugenethedev.taigamobile.ui.components.lists.SimpleTasksListWithTitle
import io.eugenethedev.taigamobile.ui.components.loaders.CircularLoader
import io.eugenethedev.taigamobile.ui.components.dialogs.LoadingDialog
import io.eugenethedev.taigamobile.ui.components.lists.Attachments
import io.eugenethedev.taigamobile.ui.components.lists.Description
import io.eugenethedev.taigamobile.ui.screens.commontask.components.*
import io.eugenethedev.taigamobile.ui.screens.main.FilePicker
import io.eugenethedev.taigamobile.ui.screens.main.LocalFilePicker
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
import io.eugenethedev.taigamobile.ui.utils.*
import java.time.LocalDateTime
@Composable
fun CommonTaskScreen(
navController: NavController,
commonTaskId: Long,
commonTaskType: CommonTaskType,
ref: Int,
showMessage: (message: Int) -> Unit = {}
) {
val viewModel: CommonTaskViewModel = viewModel()
LaunchedEffect(Unit) {
viewModel.onOpen(commonTaskId, commonTaskType)
}
val commonTask by viewModel.commonTask.collectAsState()
commonTask.subscribeOnError(showMessage)
val creator by viewModel.creator.collectAsState()
creator.subscribeOnError(showMessage)
val assignees by viewModel.assignees.collectAsState()
assignees.subscribeOnError(showMessage)
val watchers by viewModel.watchers.collectAsState()
watchers.subscribeOnError(showMessage)
val userStories by viewModel.userStories.collectAsState()
userStories.subscribeOnError(showMessage)
val tasks by viewModel.tasks.collectAsState()
tasks.subscribeOnError(showMessage)
val comments by viewModel.comments.collectAsState()
comments.subscribeOnError(showMessage)
val editBasicInfoResult by viewModel.editBasicInfoResult.collectAsState()
editBasicInfoResult.subscribeOnError(showMessage)
val statuses by viewModel.statuses.collectAsState()
statuses.subscribeOnError(showMessage)
val editStatusResult by viewModel.editStatusResult.collectAsState()
editStatusResult.subscribeOnError(showMessage)
val swimlanes by viewModel.swimlanes.collectAsState()
swimlanes.subscribeOnError(showMessage)
val sprints = viewModel.sprints
sprints.subscribeOnError(showMessage)
val editSprintResult by viewModel.editSprintResult.collectAsState()
editSprintResult.subscribeOnError(showMessage)
val epics = viewModel.epics
epics.subscribeOnError(showMessage)
val linkToEpicResult by viewModel.linkToEpicResult.collectAsState()
linkToEpicResult.subscribeOnError(showMessage)
val team by viewModel.team.collectAsState()
team.subscribeOnError(showMessage)
val teamSearched by viewModel.teamSearched.collectAsState()
val customFields by viewModel.customFields.collectAsState()
customFields.subscribeOnError(showMessage)
val attachments by viewModel.attachments.collectAsState()
attachments.subscribeOnError(showMessage)
val tags by viewModel.tags.collectAsState()
tags.subscribeOnError(showMessage)
val tagsSearched by viewModel.tagsSearched.collectAsState()
val editEpicColorResult by viewModel.editEpicColorResult.collectAsState()
editEpicColorResult.subscribeOnError(showMessage)
val editBlockedResult by viewModel.editBlockedResult.collectAsState()
editBlockedResult.subscribeOnError(showMessage)
val editDueDateResult by viewModel.editDueDateResult.collectAsState()
editDueDateResult.subscribeOnError(showMessage)
val deleteResult by viewModel.deleteResult.collectAsState()
deleteResult.subscribeOnError(showMessage)
deleteResult.takeIf { it is SuccessResult }?.let {
LaunchedEffect(Unit) {
navController.popBackStack()
}
}
val promoteResult by viewModel.promoteResult.collectAsState()
promoteResult.subscribeOnError(showMessage)
promoteResult.takeIf { it is SuccessResult }?.data?.let {
LaunchedEffect(Unit) {
navController.popBackStack()
navController.navigateToTaskScreen(it.id, CommonTaskType.UserStory, it.ref)
}
}
val projectName by viewModel.projectName.collectAsState()
val isAssignedToMe by viewModel.isAssignedToMe.collectAsState()
val isWatchedByMe by viewModel.isWatchedByMe.collectAsState()
fun createEditStatusAction(statusType: StatusType) = SimpleEditAction(
items = statuses.data?.get(statusType).orEmpty(),
select = viewModel::editStatus,
isLoading = (editStatusResult as? LoadingResult)?.data == statusType
)
CommonTaskScreenContent(
commonTaskType = commonTaskType,
toolbarTitle = stringResource(
when (commonTaskType) {
CommonTaskType.UserStory -> R.string.userstory_slug
CommonTaskType.Task -> R.string.task_slug
CommonTaskType.Epic -> R.string.epic_slug
CommonTaskType.Issue -> R.string.issue_slug
}
).format(ref),
toolbarSubtitle = projectName,
commonTask = commonTask.data,
creator = creator.data,
isLoading = commonTask is LoadingResult,
customFields = customFields.data?.fields.orEmpty(),
attachments = attachments.data.orEmpty(),
assignees = assignees.data.orEmpty(),
watchers = watchers.data.orEmpty(),
isAssignedToMe = isAssignedToMe,
isWatchedByMe = isWatchedByMe,
userStories = userStories.data.orEmpty(),
tasks = tasks.data.orEmpty(),
comments = comments.data.orEmpty(),
editActions = EditActions(
editStatus = createEditStatusAction(StatusType.Status),
editType = createEditStatusAction(StatusType.Type),
editSeverity = createEditStatusAction(StatusType.Severity),
editPriority = createEditStatusAction(StatusType.Priority),
editSwimlane = SimpleEditAction(
items = swimlanes.data.orEmpty(),
select = viewModel::editSwimlane,
isLoading = swimlanes is LoadingResult
),
editSprint = SimpleEditAction(
itemsLazy = sprints,
select = viewModel::editSprint,
isLoading = editSprintResult is LoadingResult
),
editEpics = EditAction(
itemsLazy = epics,
searchItems = viewModel::searchEpics,
select = viewModel::linkToEpic,
isLoading = linkToEpicResult is LoadingResult,
remove = viewModel::unlinkFromEpic
),
editAttachments = EditAction(
select = { (file, stream) -> viewModel.addAttachment(file, stream) },
remove = viewModel::deleteAttachment,
isLoading = attachments is LoadingResult
),
editAssignees = SimpleEditAction(
items = teamSearched,
searchItems = viewModel::searchTeam,
select = { viewModel.addAssignee(it.id) },
isLoading = assignees is LoadingResult,
remove = { viewModel.removeAssignee(it.id) }
),
editWatchers = SimpleEditAction(
items = teamSearched,
searchItems = viewModel::searchTeam,
select = { viewModel.addWatcher(it.id) },
isLoading = watchers is LoadingResult,
remove = { viewModel.removeWatcher(it.id) }
),
editComments = EditAction(
select = viewModel::createComment,
remove = viewModel::deleteComment,
isLoading = comments is LoadingResult
),
editBasicInfo = SimpleEditAction(
select = { (title, description) -> viewModel.editBasicInfo(title, description) },
isLoading = editBasicInfoResult is LoadingResult
),
deleteTask = EmptyEditAction(
select = { viewModel.deleteTask() },
isLoading = deleteResult is LoadingResult
),
promoteTask = EmptyEditAction(
select = { viewModel.promoteToUserStory() },
isLoading = promoteResult is LoadingResult
),
editCustomField = SimpleEditAction(
select = { (field, value) -> viewModel.editCustomField(field, value) },
isLoading = customFields is LoadingResult
),
editTags = EditAction(
items = tagsSearched,
searchItems = viewModel::searchTags,
select = viewModel::addTag,
remove = viewModel::deleteTag,
isLoading = tags is LoadingResult
),
editDueDate = EditAction(
select = viewModel::editDueDate,
remove = { viewModel.editDueDate(null) },
isLoading = editDueDateResult is LoadingResult
),
editEpicColor = SimpleEditAction(
select = viewModel::editEpicColor,
isLoading = editEpicColorResult is LoadingResult
),
editAssign = EmptyEditAction(
select = { viewModel.addAssignee() },
remove = { viewModel.removeAssignee() },
isLoading = assignees is LoadingResult,
),
editWatch = EmptyEditAction(
select = { viewModel.addWatcher() },
remove = { viewModel.removeWatcher() },
isLoading = watchers is LoadingResult,
),
editBlocked = EditAction(
select = { viewModel.editBlocked(it) },
remove = { viewModel.editBlocked(null) },
isLoading = editBlockedResult is LoadingResult
)
),
navigationActions = NavigationActions(
navigateBack = navController::popBackStack,
navigateToCreateTask = { navController.navigateToCreateTaskScreen(CommonTaskType.Task, commonTaskId) },
navigateToTask = navController::navigateToTaskScreen
),
navigateToProfile = { userId ->
navController.navigateToProfileScreen(userId)
},
showMessage = showMessage
)
}
@Composable
fun CommonTaskScreenContent(
commonTaskType: CommonTaskType,
toolbarTitle: String,
toolbarSubtitle: String,
commonTask: CommonTaskExtended? = null,
creator: User? = null,
isLoading: Boolean = false,
customFields: List<CustomField> = emptyList(),
attachments: List<Attachment> = emptyList(),
assignees: List<User> = emptyList(),
watchers: List<User> = emptyList(),
isAssignedToMe: Boolean = false,
isWatchedByMe: Boolean = false,
userStories: List<CommonTask> = emptyList(),
tasks: List<CommonTask> = emptyList(),
comments: List<Comment> = emptyList(),
editActions: EditActions = EditActions(),
navigationActions: NavigationActions = NavigationActions(),
navigateToProfile: (userId: Long) -> Unit = {_ ->},
showMessage: (message: Int) -> Unit = {}
) = Box(Modifier.fillMaxSize()) {
var isTaskEditorVisible by remember { mutableStateOf(false) }
var isStatusSelectorVisible by remember { mutableStateOf(false) }
var isTypeSelectorVisible by remember { mutableStateOf(false) }
var isSeveritySelectorVisible by remember { mutableStateOf(false) }
var isPrioritySelectorVisible by remember { mutableStateOf(false) }
var isSprintSelectorVisible by remember { mutableStateOf(false) }
var isAssigneesSelectorVisible by remember { mutableStateOf(false) }
var isWatchersSelectorVisible by remember { mutableStateOf(false) }
var isEpicsSelectorVisible by remember { mutableStateOf(false) }
var isSwimlaneSelectorVisible by remember { mutableStateOf(false) }
var customFieldsValues by remember { mutableStateOf(emptyMap<Long, CustomFieldValue?>()) }
customFieldsValues =
customFields.associate { it.id to (if (it.id in customFieldsValues) customFieldsValues[it.id] else it.value) }
Column(Modifier.fillMaxSize()) {
CommonTaskAppBar(
toolbarTitle = toolbarTitle,
toolbarSubtitle = toolbarSubtitle,
commonTaskType = commonTaskType,
isBlocked = commonTask?.blockedNote != null,
editActions = editActions,
navigationActions = navigationActions,
url = commonTask?.url ?: "",
showTaskEditor = { isTaskEditorVisible = true },
showMessage = showMessage
)
if (isLoading || creator == null || commonTask == null) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
CircularLoader()
}
} else {
val sectionsPadding = 16.dp
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.BottomCenter
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = mainHorizontalScreenPadding)
) {
item {
Spacer(Modifier.height(sectionsPadding / 2))
}
CommonTaskHeader(
commonTask = commonTask,
editActions = editActions,
showStatusSelector = { isStatusSelectorVisible = true },
showSprintSelector = { isSprintSelectorVisible = true },
showTypeSelector = { isTypeSelectorVisible = true },
showSeveritySelector = { isSeveritySelectorVisible = true },
showPrioritySelector = { isPrioritySelectorVisible = true },
showSwimlaneSelector = { isSwimlaneSelectorVisible = true }
)
CommonTaskBelongsTo(
commonTask = commonTask,
navigationActions = navigationActions,
editActions = editActions,
showEpicsSelector = { isEpicsSelectorVisible = true }
)
item {
Spacer(Modifier.height(sectionsPadding))
}
Description(commonTask.description)
item {
Spacer(Modifier.height(sectionsPadding))
}
CommonTaskTags(
commonTask = commonTask,
editActions = editActions
)
item {
Spacer(Modifier.height(sectionsPadding))
}
if (commonTaskType != CommonTaskType.Epic) {
CommonTaskDueDate(
commonTask = commonTask,
editActions = editActions
)
item {
Spacer(Modifier.height(sectionsPadding))
}
}
CommonTaskCreatedBy(
creator = creator,
commonTask = commonTask,
navigateToProfile = navigateToProfile
)
item {
Spacer(Modifier.height(sectionsPadding))
}
CommonTaskAssignees(
assignees = assignees,
isAssignedToMe = isAssignedToMe,
editActions = editActions,
showAssigneesSelector = { isAssigneesSelectorVisible = true },
navigateToProfile = navigateToProfile
)
item {
Spacer(Modifier.height(sectionsPadding))
}
CommonTaskWatchers(
watchers = watchers,
isWatchedByMe = isWatchedByMe,
editActions = editActions,
showWatchersSelector = { isWatchersSelectorVisible = true },
navigateToProfile = navigateToProfile
)
item {
Spacer(Modifier.height(sectionsPadding * 2))
}
if (customFields.isNotEmpty()) {
CommonTaskCustomFields(
customFields = customFields,
customFieldsValues = customFieldsValues,
onValueChange = { itemId, value -> customFieldsValues = customFieldsValues - itemId + Pair(itemId, value) },
editActions = editActions
)
item {
Spacer(Modifier.height(sectionsPadding * 3))
}
}
Attachments(
attachments = attachments,
editAttachments = editActions.editAttachments
)
item {
Spacer(Modifier.height(sectionsPadding))
}
// user stories
if (commonTaskType == CommonTaskType.Epic) {
SimpleTasksListWithTitle(
titleText = R.string.userstories,
bottomPadding = sectionsPadding,
commonTasks = userStories,
navigateToTask = navigationActions.navigateToTask
)
}
// tasks
if (commonTaskType == CommonTaskType.UserStory) {
SimpleTasksListWithTitle(
titleText = R.string.tasks,
bottomPadding = sectionsPadding,
commonTasks = tasks,
navigateToTask = navigationActions.navigateToTask,
navigateToCreateCommonTask = navigationActions.navigateToCreateTask
)
}
item {
Spacer(Modifier.height(sectionsPadding))
}
CommonTaskComments(
comments = comments,
editActions = editActions,
navigateToProfile = navigateToProfile
)
item {
Spacer(
Modifier
.navigationBarsWithImePadding()
.height(72.dp)
)
}
}
CreateCommentBar(editActions.editComments.select)
}
}
}
// Bunch of list selectors
Selectors(
statusEntry = SelectorEntry(
edit = editActions.editStatus,
isVisible = isStatusSelectorVisible,
hide = { isStatusSelectorVisible = false }
),
typeEntry = SelectorEntry(
edit = editActions.editType,
isVisible = isTypeSelectorVisible,
hide = { isTypeSelectorVisible = false }
),
severityEntry = SelectorEntry(
edit = editActions.editSeverity,
isVisible = isSeveritySelectorVisible,
hide = { isSeveritySelectorVisible = false }
),
priorityEntry = SelectorEntry(
edit = editActions.editPriority,
isVisible = isPrioritySelectorVisible,
hide = { isPrioritySelectorVisible = false }
),
sprintEntry = SelectorEntry(
edit = editActions.editSprint,
isVisible = isSprintSelectorVisible,
hide = { isSprintSelectorVisible = false }
),
epicsEntry = SelectorEntry(
edit = editActions.editEpics,
isVisible = isEpicsSelectorVisible,
hide = { isEpicsSelectorVisible = false }
),
assigneesEntry = SelectorEntry(
edit = editActions.editAssignees,
isVisible = isAssigneesSelectorVisible,
hide = { isAssigneesSelectorVisible = false }
),
watchersEntry = SelectorEntry(
edit = editActions.editWatchers,
isVisible = isWatchersSelectorVisible,
hide = { isWatchersSelectorVisible = false }
),
swimlaneEntry = SelectorEntry(
edit = editActions.editSwimlane,
isVisible = isSwimlaneSelectorVisible,
hide = { isSwimlaneSelectorVisible = false }
)
)
// Editor
if (isTaskEditorVisible || editActions.editBasicInfo.isLoading) {
Editor(
toolbarText = stringResource(R.string.edit),
title = commonTask?.title.orEmpty(),
description = commonTask?.description.orEmpty(),
onSaveClick = { title, description ->
isTaskEditorVisible = false
editActions.editBasicInfo.select(Pair(title, description))
},
navigateBack = { isTaskEditorVisible = false }
)
}
if (editActions.run { listOf(editBasicInfo, promoteTask, deleteTask, editBlocked) }.any { it.isLoading }) {
LoadingDialog()
}
}
@Preview(showBackground = true)
@Composable
fun CommonTaskScreenPreview() = TaigaMobileTheme {
CompositionLocalProvider(
LocalFilePicker provides object : FilePicker() {}
) {
CommonTaskScreenContent(
commonTaskType = CommonTaskType.UserStory,
toolbarTitle = "Userstory #99",
toolbarSubtitle = "Project #228",
commonTask = null, // TODO left it null for now since I do not really use this preview
creator = User(
_id = 0L,
fullName = "Full Name",
photo = null,
bigPhoto = null,
username = "username"
),
assignees = List(1) {
User(
_id = 0L,
fullName = "Full Name",
photo = null,
bigPhoto = null,
username = "username"
)
},
watchers = List(2) {
User(
_id = 0L,
fullName = "Full Name",
photo = null,
bigPhoto = null,
username = "username"
)
},
tasks = List(1) {
CommonTask(
id = it.toLong(),
createdDate = LocalDateTime.now(),
title = "Very cool story",
ref = 100,
status = Status(
id = 1,
name = "In progress",
color = "#729fcf",
type = StatusType.Status
),
assignee = null,
projectInfo = Project(0, "", ""),
taskType = CommonTaskType.UserStory,
isClosed = false
)
},
comments = List(1) {
Comment(
id = "",
author = User(
_id = 0L,
fullName = "Full Name",
photo = null,
bigPhoto = null,
username = "username"
),
text = "This is comment text",
postDateTime = LocalDateTime.now(),
deleteDate = null
)
}
)
}
}

@ -0,0 +1,418 @@
package io.eugenethedev.taigamobile.ui.screens.commontask
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.insertHeaderItem
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.*
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.domain.repositories.IUsersRepository
import io.eugenethedev.taigamobile.state.postUpdate
import io.eugenethedev.taigamobile.ui.utils.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.InputStream
import java.time.LocalDate
import javax.inject.Inject
class CommonTaskViewModel(appComponent: AppComponent = TaigaApp.appComponent) : ViewModel() {
@Inject lateinit var session: Session
@Inject lateinit var tasksRepository: ITasksRepository
@Inject lateinit var usersRepository: IUsersRepository
@Inject lateinit var sprintsRepository: ISprintsRepository
companion object {
val SPRINT_HEADER = Sprint(-1, "HEADER", -1, LocalDate.MIN, LocalDate.MIN, 0, false)
val SWIMLANE_HEADER = Swimlane(-1, "HEADER", -1)
}
private var commonTaskId: Long = -1
private lateinit var commonTaskType: CommonTaskType
val commonTask = MutableResultFlow<CommonTaskExtended>()
val creator = MutableResultFlow<User>()
val customFields = MutableResultFlow<CustomFields>()
val attachments = MutableResultFlow<List<Attachment>>()
val assignees = MutableResultFlow<List<User>>()
val watchers = MutableResultFlow<List<User>>()
val userStories = MutableResultFlow<List<CommonTask>>()
val tasks = MutableResultFlow<List<CommonTask>>()
val comments = MutableResultFlow<List<Comment>>()
val team = MutableResultFlow<List<User>>()
val tags = MutableResultFlow<List<Tag>>()
val swimlanes = MutableResultFlow<List<Swimlane>>()
val statuses = MutableResultFlow<Map<StatusType, List<Status>>>()
val isAssignedToMe = assignees.map { session.currentUserId.value in it.data?.map { it.id }.orEmpty() }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
val isWatchedByMe = watchers.map { session.currentUserId.value in it.data?.map { it.id }.orEmpty() }
.stateIn(viewModelScope, SharingStarted.Lazily, false)
val projectName by lazy { session.currentProjectName }
init {
appComponent.inject(this)
}
fun onOpen(commonTaskId: Long, commonTaskType: CommonTaskType) {
this.commonTaskId = commonTaskId
this.commonTaskType = commonTaskType
loadData(isReloading = false)
}
private fun loadData(isReloading: Boolean = true) = viewModelScope.launch {
commonTask.loadOrError(showLoading = !isReloading) {
tasksRepository.getCommonTask(commonTaskId, commonTaskType).also {
suspend fun MutableResultFlow<List<User>>.loadUsersFromIds(ids: List<Long>) =
loadOrError(showLoading = false) {
coroutineScope {
ids.map {
async { usersRepository.getUser(it) }
}.awaitAll()
}
}
val jobsToLoad = arrayOf(
launch {
creator.loadOrError(showLoading = false) { usersRepository.getUser(it.creatorId) }
},
launch {
customFields.loadOrError(showLoading = false) { tasksRepository.getCustomFields(commonTaskId, commonTaskType) }
},
launch {
attachments.loadOrError(showLoading = false) { tasksRepository.getAttachments(commonTaskId, commonTaskType) }
},
launch { assignees.loadUsersFromIds(it.assignedIds) },
launch { watchers.loadUsersFromIds(it.watcherIds) },
launch {
userStories.loadOrError(showLoading = false) { tasksRepository.getEpicUserStories(commonTaskId) }
},
launch {
tasks.loadOrError(showLoading = false) { tasksRepository.getUserStoryTasks(commonTaskId) }
},
launch {
comments.loadOrError(showLoading = false) { tasksRepository.getComments(commonTaskId, commonTaskType) }
},
launch {
tags.loadOrError(showLoading = false) {
tasksRepository.getAllTags(commonTaskType).also { tagsSearched.value = it }
}
}
) + if (!isReloading) {
arrayOf(
launch {
team.loadOrError(showLoading = false) {
usersRepository.getTeam()
.map { it.toUser() }
.also { teamSearched.value = it }
}
},
launch {
swimlanes.loadOrError(showLoading = false) {
listOf(SWIMLANE_HEADER) + tasksRepository.getSwimlanes() // prepend "unclassified"
}
},
launch {
statuses.loadOrError(showLoading = false) {
StatusType.values().filter {
if (commonTaskType != CommonTaskType.Issue) it == StatusType.Status else true
}.associateWith { tasksRepository.getStatusByType(commonTaskType, it) }
}
}
)
} else {
emptyArray()
}
joinAll(*jobsToLoad)
}
}
}
// ================
// Edit task itself
// ================
// Edit task itself (title & description)
val editBasicInfoResult = MutableResultFlow<Unit>()
fun editBasicInfo(title: String, description: String) = viewModelScope.launch {
editBasicInfoResult.loadOrError(R.string.permission_error) {
tasksRepository.editCommonTaskBasicInfo(commonTask.value.data!!, title, description)
loadData().join()
session.taskEdit.postUpdate()
}
}
// Edit status (and also type, severity, priority)
val editStatusResult = MutableResultFlow<StatusType>()
fun editStatus(status: Status) = viewModelScope.launch {
editStatusResult.value = LoadingResult(status.type)
editStatusResult.loadOrError(R.string.permission_error) {
tasksRepository.editStatus(commonTask.value.data!!, status.id, status.type)
loadData().join()
session.taskEdit.postUpdate()
status.type
}
}
// Edit sprint
val sprints by lazy {
Pager(PagingConfig(CommonPagingSource.PAGE_SIZE)) {
CommonPagingSource { sprintsRepository.getSprints(it) }
}.flow.map { it.insertHeaderItem(item = SPRINT_HEADER) } // prepend "Move to backlog"
.asLazyPagingItems(viewModelScope)
}
val editSprintResult = MutableResultFlow<Unit>(NothingResult())
fun editSprint(sprint: Sprint) = viewModelScope.launch {
editSprintResult.loadOrError(R.string.permission_error) {
tasksRepository.editSprint(commonTask.value.data!!, sprint.takeIf { it != SPRINT_HEADER }?.id)
loadData().join()
session.taskEdit.postUpdate()
}
}
// use team for both assignees and watchers
val teamSearched = MutableStateFlow(emptyList<User>())
fun searchTeam(query: String) = viewModelScope.launch {
val q = query.lowercase()
teamSearched.value = team.value.data
.orEmpty()
.filter { q in it.username.lowercase() || q in it.displayName.lowercase() }
}
// Edit assignees
private fun editAssignees(userId: Long, remove: Boolean) = viewModelScope.launch {
assignees.loadOrError(R.string.permission_error) {
teamSearched.value = team.value.data.orEmpty()
tasksRepository.editAssignees(
commonTask.value.data!!,
commonTask.value.data!!.assignedIds.let {
if (remove) it - userId
else it + userId
}
)
loadData().join()
session.taskEdit.postUpdate()
assignees.value.data
}
}
fun addAssignee(userId: Long = session.currentUserId.value) = editAssignees(userId, remove = false)
fun removeAssignee(userId: Long = session.currentUserId.value) = editAssignees(userId, remove = true)
// Edit watchers
private fun editWatchers(userId: Long, remove: Boolean) = viewModelScope.launch {
watchers.loadOrError(R.string.permission_error) {
teamSearched.value = team.value.data.orEmpty()
tasksRepository.editWatchers(
commonTask.value.data!!,
commonTask.value.data?.watcherIds.orEmpty().let {
if (remove) it - userId
else it + userId
}
)
loadData().join()
session.taskEdit.postUpdate()
watchers.value.data
}
}
fun addWatcher(userId: Long = session.currentUserId.value) = editWatchers(userId, remove = false)
fun removeWatcher(userId: Long = session.currentUserId.value) = editWatchers(userId, remove = true)
// Tags
val tagsSearched = MutableStateFlow(emptyList<Tag>())
fun searchTags(query: String) = viewModelScope.launch {
tagsSearched.value = tags.value.data.orEmpty().filter { query.isNotEmpty() && query.lowercase() in it.name }
}
private fun editTag(tag: Tag, remove: Boolean) = viewModelScope.launch {
tags.loadOrError(R.string.permission_error) {
tagsSearched.value = tags.value.data.orEmpty()
tasksRepository.editTags(
commonTask.value.data!!,
commonTask.value.data!!.tags.let { if (remove) it - tag else it + tag },
)
loadData().join()
session.taskEdit.postUpdate()
tags.value.data
}
}
fun addTag(tag: Tag) = editTag(tag, remove = false)
fun deleteTag(tag: Tag) = editTag(tag, remove = true)
// Swimlanes
fun editSwimlane(swimlane: Swimlane) = viewModelScope.launch {
swimlanes.loadOrError(R.string.permission_error) {
tasksRepository.editUserStorySwimlane(commonTask.value.data!!, swimlane.takeIf { it != SWIMLANE_HEADER }?.id)
loadData().join()
session.taskEdit.postUpdate()
swimlanes.value.data
}
}
// Due date
val editDueDateResult = MutableResultFlow<Unit>()
fun editDueDate(date: LocalDate?) = viewModelScope.launch {
editDueDateResult.loadOrError(R.string.permission_error) {
tasksRepository.editDueDate(commonTask.value.data!!, date)
loadData().join()
}
}
// Epic color
val editEpicColorResult = MutableResultFlow<Unit>()
fun editEpicColor(color: String) = viewModelScope.launch {
editEpicColorResult.loadOrError(R.string.permission_error) {
tasksRepository.editEpicColor(commonTask.value.data!!, color)
loadData().join()
session.taskEdit.postUpdate()
}
}
val editBlockedResult = MutableResultFlow<Unit>()
fun editBlocked(blockedNote: String?) = viewModelScope.launch {
editBlockedResult.loadOrError(R.string.permission_error) {
tasksRepository.editBlocked(commonTask.value.data!!, blockedNote)
loadData().join()
session.taskEdit.postUpdate()
}
}
// =============
// Related edits
// =============
// Edit linked epic
private val epicsQuery = MutableStateFlow("")
@OptIn(ExperimentalCoroutinesApi::class)
val epics by lazy {
epicsQuery.flatMapLatest { query ->
Pager(PagingConfig(CommonPagingSource.PAGE_SIZE)) {
CommonPagingSource { tasksRepository.getEpics(it, FiltersData(query = query)) }
}.flow
}.asLazyPagingItems(viewModelScope)
}
fun searchEpics(query: String) {
epicsQuery.value = query
}
val linkToEpicResult = MutableResultFlow<Unit>(NothingResult())
fun linkToEpic(epic: CommonTask) = viewModelScope.launch {
linkToEpicResult.loadOrError(R.string.permission_error) {
tasksRepository.linkToEpic(epic.id, commonTaskId)
loadData().join()
session.taskEdit.postUpdate()
}
}
fun unlinkFromEpic(epic: EpicShortInfo) = viewModelScope.launch {
linkToEpicResult.loadOrError(R.string.permission_error) {
tasksRepository.unlinkFromEpic(epic.id, commonTaskId)
loadData().join()
session.taskEdit.postUpdate()
}
}
// Edit comments
fun createComment(comment: String) = viewModelScope.launch {
comments.loadOrError(R.string.permission_error) {
tasksRepository.createComment(commonTaskId, commonTaskType, comment, commonTask.value.data!!.version)
loadData().join()
comments.value.data
}
}
fun deleteComment(comment: Comment) = viewModelScope.launch {
comments.loadOrError(R.string.permission_error) {
tasksRepository.deleteComment(commonTaskId, commonTaskType, comment.id)
loadData().join()
comments.value.data
}
}
fun deleteAttachment(attachment: Attachment) = viewModelScope.launch {
attachments.loadOrError(R.string.permission_error) {
tasksRepository.deleteAttachment(commonTaskType, attachment.id)
loadData().join()
attachments.value.data
}
}
fun addAttachment(fileName: String, inputStream: InputStream) = viewModelScope.launch {
attachments.loadOrError(R.string.permission_error) {
tasksRepository.addAttachment(commonTaskId, commonTaskType, fileName, inputStream)
loadData().join()
attachments.value.data
}
}
// Delete task
val deleteResult = MutableResultFlow<Unit>()
fun deleteTask() = viewModelScope.launch {
deleteResult.loadOrError(R.string.permission_error) {
tasksRepository.deleteCommonTask(commonTaskType, commonTaskId)
session.taskEdit.postUpdate()
}
}
val promoteResult = MutableResultFlow<CommonTask>()
fun promoteToUserStory() = viewModelScope.launch {
promoteResult.loadOrError(R.string.permission_error, preserveValue = false) {
tasksRepository.promoteCommonTaskToUserStory(commonTaskId, commonTaskType).also {
session.taskEdit.postUpdate()
}
}
}
fun editCustomField(customField: CustomField, value: CustomFieldValue?) = viewModelScope.launch {
customFields.loadOrError(R.string.permission_error) {
tasksRepository.editCustomFields(
commonTaskType = commonTaskType,
commonTaskId = commonTaskId,
fields = customFields.value.data?.fields.orEmpty().map {
it.id to (if (it.id == customField.id) value else it.value)
}.toMap(),
version = customFields.value.data?.version ?: 0
)
loadData().join()
customFields.value.data
}
}
}

@ -0,0 +1,65 @@
package io.eugenethedev.taigamobile.ui.screens.commontask
/**
* Helper structs for CommonTaskScreen
*/
import androidx.paging.compose.LazyPagingItems
import io.eugenethedev.taigamobile.domain.entities.*
import io.eugenethedev.taigamobile.ui.utils.NavigateToTask
import java.io.InputStream
import java.time.LocalDate
/**
* Generic edit action
*/
class EditAction<TItem : Any, TRemove>(
val items: List<TItem> = emptyList(),
val itemsLazy: LazyPagingItems<TItem>? = null,
val searchItems: (query: String) -> Unit = {},
val select: (item: TItem) -> Unit = {},
val isLoading: Boolean = false,
val remove: (item: TRemove) -> Unit = {}
)
/**
* And some type aliases for certain cases
*/
typealias SimpleEditAction<TItem> = EditAction<TItem, TItem>
typealias EmptyEditAction = EditAction<Unit, Unit>
/**
* All edit actions
*/
class EditActions(
val editStatus: SimpleEditAction<Status> = SimpleEditAction(),
val editType: SimpleEditAction<Status> = SimpleEditAction(),
val editSeverity: SimpleEditAction<Status> = SimpleEditAction(),
val editPriority: SimpleEditAction<Status> = SimpleEditAction(),
val editSwimlane: SimpleEditAction<Swimlane> = SimpleEditAction(),
val editSprint: SimpleEditAction<Sprint> = SimpleEditAction(),
val editEpics: EditAction<CommonTask, EpicShortInfo> = EditAction(),
val editAttachments: EditAction<Pair<String, InputStream>, Attachment> = EditAction(),
val editAssignees: SimpleEditAction<User> = SimpleEditAction(),
val editWatchers: SimpleEditAction<User> = SimpleEditAction(),
val editComments: EditAction<String, Comment> = EditAction(),
val editBasicInfo: SimpleEditAction<Pair<String, String>> = SimpleEditAction(),
val editCustomField: SimpleEditAction<Pair<CustomField, CustomFieldValue?>> = SimpleEditAction(),
val editTags: SimpleEditAction<Tag> = SimpleEditAction(),
val editDueDate: EditAction<LocalDate, Unit> = EditAction(),
val editEpicColor: SimpleEditAction<String> = SimpleEditAction(),
val deleteTask: EmptyEditAction = EmptyEditAction(),
val promoteTask: EmptyEditAction = EmptyEditAction(),
val editAssign: EmptyEditAction = EmptyEditAction(),
val editWatch: EmptyEditAction = EmptyEditAction(),
val editBlocked: EditAction<String, Unit> = EditAction()
)
/**
* All navigation actions
*/
class NavigationActions(
val navigateBack: () -> Unit = {},
val navigateToCreateTask: () -> Unit = {},
val navigateToTask: NavigateToTask = { _, _, _ -> },
)

@ -0,0 +1,243 @@
package io.eugenethedev.taigamobile.ui.screens.commontask.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.ui.components.dialogs.ConfirmActionDialog
import io.eugenethedev.taigamobile.ui.components.appbars.AppBarWithBackButton
import io.eugenethedev.taigamobile.ui.components.editors.TextFieldWithHint
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
import io.eugenethedev.taigamobile.ui.screens.commontask.NavigationActions
import io.eugenethedev.taigamobile.ui.theme.dialogTonalElevation
import io.eugenethedev.taigamobile.ui.utils.surfaceColorAtElevation
@Composable
fun CommonTaskAppBar(
toolbarTitle: String,
toolbarSubtitle: String,
commonTaskType: CommonTaskType,
isBlocked: Boolean,
editActions: EditActions,
navigationActions: NavigationActions,
url: String,
showTaskEditor: () -> Unit,
showMessage: (message: Int) -> Unit
) {
var isMenuExpanded by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
AppBarWithBackButton(
title = {
Column(
verticalArrangement = Arrangement.Center
) {
Text(
text = toolbarTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = toolbarSubtitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall
)
}
},
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_task_title),
text = stringResource(R.string.delete_task_text),
onConfirm = {
isDeleteAlertVisible = false
editActions.deleteTask.select(Unit)
},
onDismiss = { isDeleteAlertVisible = false },
iconId = R.drawable.ic_delete
)
}
// promote alert dialog
var isPromoteAlertVisible by remember { mutableStateOf(false) }
if (isPromoteAlertVisible) {
ConfirmActionDialog(
title = stringResource(R.string.promote_title),
text = stringResource(R.string.promote_text),
onConfirm = {
isPromoteAlertVisible = false
editActions.promoteTask.select(Unit)
},
onDismiss = { isPromoteAlertVisible = false },
iconId = R.drawable.ic_arrow_upward
)
}
// block item dialog
var isBlockDialogVisible by remember { mutableStateOf(false) }
if (isBlockDialogVisible) {
BlockDialog(
onConfirm = {
editActions.editBlocked.select(it)
isBlockDialogVisible = false
},
onDismiss = { isBlockDialogVisible = false }
)
}
DropdownMenu(
modifier = Modifier.background(
MaterialTheme.colorScheme.surfaceColorAtElevation(dialogTonalElevation)
),
expanded = isMenuExpanded,
onDismissRequest = { isMenuExpanded = false }
) {
// Copy link
DropdownMenuItem(
onClick = {
isMenuExpanded = false
clipboardManager.setText(
AnnotatedString(url)
)
showMessage(R.string.copy_link_successfully)
},
text = {
Text(
text = stringResource(R.string.copy_link),
style = MaterialTheme.typography.bodyLarge
)
}
)
// edit
DropdownMenuItem(
onClick = {
isMenuExpanded = false
showTaskEditor()
},
text = {
Text(
text = stringResource(R.string.edit),
style = MaterialTheme.typography.bodyLarge
)
}
)
// delete
DropdownMenuItem(
onClick = {
isMenuExpanded = false
isDeleteAlertVisible = true
},
text = {
Text(
text = stringResource(R.string.delete),
style = MaterialTheme.typography.bodyLarge
)
}
)
// promote
if (commonTaskType == CommonTaskType.Task || commonTaskType == CommonTaskType.Issue) {
DropdownMenuItem(
onClick = {
isMenuExpanded = false
isPromoteAlertVisible = true
},
text = {
Text(
text = stringResource(R.string.promote_to_user_story),
style = MaterialTheme.typography.bodyLarge
)
}
)
}
DropdownMenuItem(
onClick = {
isMenuExpanded = false
if (isBlocked) {
editActions.editBlocked.remove(Unit)
} else {
isBlockDialogVisible = true
}
},
text = {
Text(
text = stringResource(if (isBlocked) R.string.unblock else R.string.block),
style = MaterialTheme.typography.bodyLarge
)
}
)
}
}
},
navigateBack = navigationActions.navigateBack
)
}
@Composable
private fun BlockDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit
) {
var reason by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
TextButton(onClick = onDismiss) {
Text(
text = stringResource(R.string.cancel),
style = MaterialTheme.typography.titleMedium
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(reason.text) }) {
Text(
text = stringResource(R.string.ok),
style = MaterialTheme.typography.titleMedium
)
}
},
title = {
Text(
text = stringResource(R.string.block),
style = MaterialTheme.typography.titleLarge
)
},
text = {
TextFieldWithHint(
hintId = R.string.block_reason,
value = reason,
onValueChange = { reason = it },
minHeight = with(LocalDensity.current) { MaterialTheme.typography.bodyLarge.fontSize.toDp() * 4 },
contentAlignment = Alignment.TopStart
)
}
)
}

@ -0,0 +1,85 @@
package io.eugenethedev.taigamobile.ui.screens.commontask.components
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.User
import io.eugenethedev.taigamobile.ui.components.buttons.AddButton
import io.eugenethedev.taigamobile.ui.components.buttons.TextButton
import io.eugenethedev.taigamobile.ui.components.lists.UserItemWithAction
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
@Suppress("FunctionName")
fun LazyListScope.CommonTaskAssignees(
assignees: List<User>,
isAssignedToMe: Boolean,
editActions: EditActions,
showAssigneesSelector: () -> Unit,
navigateToProfile: (userId: Long) -> Unit
) {
item {
// assigned to
Text(
text = stringResource(R.string.assigned_to),
style = MaterialTheme.typography.titleMedium
)
}
itemsIndexed(assignees) { index, item ->
UserItemWithAction(
user = item,
onRemoveClick = { editActions.editAssignees.remove(item) },
onUserItemClick = { navigateToProfile(item.id) }
)
if (index < assignees.lastIndex) {
Spacer(Modifier.height(6.dp))
}
}
// add assignee & loader
item {
if (editActions.editAssignees.isLoading) {
DotsLoader()
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
AddButton(
text = stringResource(R.string.add_assignee),
onClick = { showAssigneesSelector() }
)
Spacer(modifier = Modifier.width(16.dp))
val (@StringRes buttonText: Int, @DrawableRes buttonIcon: Int) = if (isAssignedToMe) {
R.string.unassign to R.drawable.ic_unassigned
} else {
R.string.assign_to_me to R.drawable.ic_assignee_to_me
}
TextButton(
text = stringResource(buttonText),
icon = buttonIcon,
onClick = {
if (isAssignedToMe) {
editActions.editAssign.remove(Unit)
} else {
editActions.editAssign.select(Unit)
}
}
)
}
}
}

@ -0,0 +1,135 @@
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
@Suppress("FunctionName")
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.editEpics.remove(it) }
)
Spacer(Modifier.height(2.dp))
}
item {
if (editActions.editEpics.isLoading) {
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)
)

@ -0,0 +1,104 @@
package io.eugenethedev.taigamobile.ui.screens.commontask.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.Divider
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.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.Comment
import io.eugenethedev.taigamobile.ui.components.dialogs.ConfirmActionDialog
import io.eugenethedev.taigamobile.ui.components.lists.UserItem
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.components.texts.MarkdownText
import io.eugenethedev.taigamobile.ui.components.texts.SectionTitle
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
@Suppress("FunctionName")
fun LazyListScope.CommonTaskComments(
comments: List<Comment>,
editActions: EditActions,
navigateToProfile: (userId: Long) -> Unit
) {
item {
SectionTitle(stringResource(R.string.comments_template).format(comments.size))
}
itemsIndexed(comments) { index, item ->
CommentItem(
comment = item,
onDeleteClick = { editActions.editComments.remove(item) },
navigateToProfile = navigateToProfile
)
if (index < comments.lastIndex) {
Divider(
modifier = Modifier.padding(vertical = 12.dp),
color = MaterialTheme.colorScheme.outline
)
}
}
item {
if (editActions.editComments.isLoading) {
DotsLoader()
}
}
}
@Composable
private fun CommentItem(
comment: Comment,
onDeleteClick: () -> Unit,
navigateToProfile: (userId: Long) -> Unit
) = Column {
var isAlertVisible by remember { mutableStateOf(false) }
if (isAlertVisible) {
ConfirmActionDialog(
title = stringResource(R.string.delete_comment_title),
text = stringResource(R.string.delete_comment_text),
onConfirm = {
isAlertVisible = false
onDeleteClick()
},
onDismiss = { isAlertVisible = false },
iconId = R.drawable.ic_delete
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
UserItem(
user = comment.author,
dateTime = comment.postDateTime,
onUserItemClick = { navigateToProfile(comment.author.id) }
)
if (comment.canDelete) {
IconButton(onClick = { isAlertVisible = true }) {
Icon(
painter = painterResource(R.drawable.ic_delete),
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
}
}
MarkdownText(
text = comment.text,
modifier = Modifier.padding(start = 4.dp)
)
}

@ -0,0 +1,30 @@
package io.eugenethedev.taigamobile.ui.screens.commontask.components
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.res.stringResource
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CommonTaskExtended
import io.eugenethedev.taigamobile.domain.entities.User
import io.eugenethedev.taigamobile.ui.components.lists.UserItem
@Suppress("FunctionName")
fun LazyListScope.CommonTaskCreatedBy(
creator: User,
commonTask: CommonTaskExtended,
navigateToProfile: (userId: Long) -> Unit
) {
item {
Text(
text = stringResource(R.string.created_by),
style = MaterialTheme.typography.titleMedium
)
UserItem(
user = creator,
dateTime = commonTask.createdDateTime,
onUserItemClick = { navigateToProfile(creator.id) }
)
}
}

@ -0,0 +1,53 @@
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
@Suppress("FunctionName")
fun LazyListScope.CommonTaskCustomFields(
customFields: List<CustomField>,
customFieldsValues: Map<Long, CustomFieldValue?>,
onValueChange: (Long, CustomFieldValue?) -> Unit,
editActions: EditActions
) {
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.select(Pair(item, customFieldsValues[item.id])) }
)
if (index < customFields.lastIndex) {
Divider(
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp),
color = MaterialTheme.colorScheme.outline
)
}
}
item {
if (editActions.editCustomField.isLoading) {
Spacer(Modifier.height(8.dp))
DotsLoader()
}
}
}

@ -0,0 +1,80 @@
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.material.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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
import io.eugenethedev.taigamobile.domain.entities.CommonTaskExtended
import io.eugenethedev.taigamobile.domain.entities.DueDateStatus
import io.eugenethedev.taigamobile.ui.components.pickers.DatePicker
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
import io.eugenethedev.taigamobile.ui.theme.*
import io.eugenethedev.taigamobile.ui.utils.surfaceColorAtElevation
@Suppress("FunctionName")
fun LazyListScope.CommonTaskDueDate(
commonTask: CommonTaskExtended,
editActions: EditActions
) {
item {
val background = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
val defaultIconBackground = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(IntrinsicSize.Min)
.background(background, MaterialTheme.shapes.small)
) {
Box(
modifier = Modifier
.fillMaxHeight()
.aspectRatio(1f)
.background(
color = when (commonTask.dueDateStatus) {
DueDateStatus.NotSet, DueDateStatus.NoLongerApplicable, null -> defaultIconBackground
DueDateStatus.Set -> taigaGreenPositive
DueDateStatus.DueSoon -> taigaOrange
DueDateStatus.PastDue -> taigaRed
}.takeUnless { editActions.editDueDate.isLoading } ?: defaultIconBackground,
shape = MaterialTheme.shapes.small
)
.padding(4.dp)
) {
if (editActions.editDueDate.isLoading) {
CircularProgressIndicator(
modifier = Modifier.fillMaxSize().padding(2.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
Icon(
painter = painterResource(R.drawable.ic_clock),
contentDescription = null,
tint = commonTask.dueDate?.let { MaterialTheme.colorScheme.onSurface } ?: MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxSize()
)
}
}
DatePicker(
date = commonTask.dueDate,
onDatePicked = {
editActions.editDueDate.apply {
it?.let { select(it) } ?: remove(Unit)
}
},
hintId = R.string.no_due_date,
modifier = Modifier.padding(6.dp)
)
}
}
}

@ -0,0 +1,168 @@
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.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.SpanStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.google.accompanist.flowlayout.FlowCrossAxisAlignment
import com.google.accompanist.flowlayout.FlowRow
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CommonTaskExtended
import io.eugenethedev.taigamobile.domain.entities.CommonTaskType
import io.eugenethedev.taigamobile.ui.components.badges.ClickableBadge
import io.eugenethedev.taigamobile.ui.components.pickers.ColorPicker
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
import io.eugenethedev.taigamobile.ui.theme.taigaRed
import io.eugenethedev.taigamobile.ui.utils.toColor
import io.eugenethedev.taigamobile.ui.utils.toHex
@Suppress("FunctionName")
fun LazyListScope.CommonTaskHeader(
commonTask: CommonTaskExtended,
editActions: EditActions,
showStatusSelector: () -> Unit,
showSprintSelector: () -> Unit,
showTypeSelector: () -> Unit,
showSeveritySelector: () -> Unit,
showPrioritySelector: () -> Unit,
showSwimlaneSelector: () -> Unit
) {
val badgesPadding = 8.dp
item {
commonTask.blockedNote?.trim()?.let {
Column(
modifier = Modifier.fillMaxWidth()
.background(taigaRed, MaterialTheme.shapes.medium)
.padding(8.dp)
) {
val space = 4.dp
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(R.drawable.ic_lock),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.width(space))
Text(stringResource(R.string.blocked))
}
if (it.isNotEmpty()) {
Spacer(Modifier.width(space))
Text(
text = it,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
)
}
}
Spacer(Modifier.height(badgesPadding))
}
FlowRow(
crossAxisAlignment = FlowCrossAxisAlignment.Center,
crossAxisSpacing = badgesPadding,
mainAxisSpacing = badgesPadding
) {
// epic color
if (commonTask.taskType == CommonTaskType.Epic) {
ColorPicker(
size = 32.dp,
color = commonTask.color.orEmpty().toColor(),
onColorPicked = { editActions.editEpicColor.select(it.toHex()) }
)
}
// status
ClickableBadge(
text = commonTask.status.name,
colorHex = commonTask.status.color,
onClick = { showStatusSelector() },
isLoading = editActions.editStatus.isLoading
)
// sprint
if (commonTask.taskType != CommonTaskType.Epic) {
ClickableBadge(
text = commonTask.sprint?.name ?: stringResource(R.string.no_sprint),
color = commonTask.sprint?.let { MaterialTheme.colorScheme.primary }
?: MaterialTheme.colorScheme.outline,
onClick = { showSprintSelector() },
isLoading = editActions.editSprint.isLoading,
isClickable = commonTask.taskType != CommonTaskType.Task
)
}
// swimlane
if (commonTask.taskType == CommonTaskType.UserStory) {
ClickableBadge(
text = commonTask.swimlane?.name ?: stringResource(R.string.unclassifed),
color = commonTask.swimlane?.let { MaterialTheme.colorScheme.primary }
?: MaterialTheme.colorScheme.outline,
isLoading = editActions.editSwimlane.isLoading,
onClick = { showSwimlaneSelector() }
)
}
if (commonTask.taskType == CommonTaskType.Issue) {
// type
ClickableBadge(
text = commonTask.type!!.name,
colorHex = commonTask.type.color,
onClick = { showTypeSelector() },
isLoading = editActions.editType.isLoading
)
// severity
ClickableBadge(
text = commonTask.severity!!.name,
colorHex = commonTask.severity.color,
onClick = { showSeveritySelector() },
isLoading = editActions.editSeverity.isLoading
)
// priority
ClickableBadge(
text = commonTask.priority!!.name,
colorHex = commonTask.priority.color,
onClick = { showPrioritySelector() },
isLoading = editActions.editPriority.isLoading
)
}
}
// title
Text(
text = commonTask.title,
style = MaterialTheme.typography.headlineSmall.let {
if (commonTask.isClosed) {
it.merge(
SpanStyle(
color = MaterialTheme.colorScheme.outline,
textDecoration = TextDecoration.LineThrough
)
)
} else {
it
}
}
)
Spacer(Modifier.height(4.dp))
}
}

@ -0,0 +1,235 @@
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
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.utils.surfaceColorAtElevation
import io.eugenethedev.taigamobile.ui.utils.textColor
import io.eugenethedev.taigamobile.ui.utils.toColor
import io.eugenethedev.taigamobile.ui.utils.toHex
@Suppress("FunctionName")
fun LazyListScope.CommonTaskTags(
commonTask: CommonTaskExtended,
editActions: EditActions
) {
item {
FlowRow(
crossAxisAlignment = FlowCrossAxisAlignment.Center,
mainAxisSpacing = 8.dp,
crossAxisSpacing = 8.dp
) {
var isAddTagDialogVisible by remember { mutableStateOf(false) }
commonTask.tags.forEach {
TagItem(
tag = it,
onRemoveClick = { editActions.editTags.remove(it) }
)
}
if (editActions.editTags.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(28.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
} else {
AddButton(
text = stringResource(R.string.add_tag),
onClick = { isAddTagDialogVisible = true }
)
}
if (isAddTagDialogVisible) {
AddTagDialog(
tags = editActions.editTags.items,
onInputChange = editActions.editTags.searchItems,
onConfirm = {
editActions.editTags.select(it)
isAddTagDialogVisible = false
},
onDismiss = { isAddTagDialogVisible = false }
)
}
}
}
}
@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 AddTagDialog(
tags: List<Tag>,
onInputChange: (String) -> Unit,
onConfirm: (Tag) -> Unit,
onDismiss: () -> Unit
) {
var name by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
var color by remember { mutableStateOf(ColorPalette.Primary.first()) }
var isDropdownVisible by remember { mutableStateOf(true) }
AlertDialog(
onDismissRequest = onDismiss,
dismissButton = {
TextButton(onClick = onDismiss) {
Text(
text = stringResource(R.string.cancel),
style = MaterialTheme.typography.titleMedium
)
}
},
confirmButton = {
TextButton(
onClick = {
if (name.text.isNotBlank()) {
onConfirm(Tag(name.text, color.toHex()))
}
}
) {
Text(
text = stringResource(R.string.ok),
style = MaterialTheme.typography.titleMedium
)
}
},
title = {
Text(
text = stringResource(R.string.add_tag),
style = MaterialTheme.typography.titleLarge
)
},
text = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Column {
TextFieldWithHint(
hintId = R.string.tag,
value = name,
onValueChange = {
name = it
// if dropdown menu item has been chosen - do not show dropdown again
if (tags.none { it.name == name.text}) {
isDropdownVisible = true
onInputChange(it.text)
}
},
width = 180.dp,
hasBorder = true,
singleLine = true
)
if (isDropdownVisible) {
DropdownMenu(
expanded = tags.isNotEmpty(),
onDismissRequest = { isDropdownVisible = false },
properties = PopupProperties(clippingEnabled = false),
modifier = Modifier
.heightIn(max = 200.dp)
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(dialogTonalElevation))
) {
tags.forEach {
DropdownMenuItem(
onClick = {
name = TextFieldValue(it.name)
color = it.color.toColor()
isDropdownVisible = false
},
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(
Modifier
.size(22.dp)
.background(
color = it.color.toColor(),
shape = MaterialTheme.shapes.extraSmall
)
)
Spacer(Modifier.width(4.dp))
Text(
text = it.name,
style = MaterialTheme.typography.bodyLarge
)
}
}
)
}
}
}
}
Spacer(Modifier.width(8.dp))
ColorPicker(
size = 32.dp,
color = color,
onColorPicked = { color = it }
)
}
}
)
}

@ -0,0 +1,90 @@
package io.eugenethedev.taigamobile.ui.screens.commontask.components
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.User
import io.eugenethedev.taigamobile.ui.components.buttons.AddButton
import io.eugenethedev.taigamobile.ui.components.buttons.TextButton
import io.eugenethedev.taigamobile.ui.components.lists.UserItemWithAction
import io.eugenethedev.taigamobile.ui.components.loaders.DotsLoader
import io.eugenethedev.taigamobile.ui.screens.commontask.EditActions
@Suppress("FunctionName")
fun LazyListScope.CommonTaskWatchers(
watchers: List<User>,
isWatchedByMe: Boolean,
editActions: EditActions,
showWatchersSelector: () -> Unit,
navigateToProfile: (userId: Long) -> Unit
) {
item {
// watchers
Text(
text = stringResource(R.string.watchers),
style = MaterialTheme.typography.titleMedium
)
}
itemsIndexed(watchers) { index, item ->
UserItemWithAction(
user = item,
onRemoveClick = { editActions.editWatchers.remove(item) },
onUserItemClick = { navigateToProfile(item.id) }
)
if (index < watchers.lastIndex) {
Spacer(Modifier.height(6.dp))
}
}
// add watcher & loader
item {
if (editActions.editWatchers.isLoading) {
DotsLoader()
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
AddButton(
text = stringResource(R.string.add_watcher),
onClick = { showWatchersSelector() }
)
Spacer(modifier = Modifier.width(16.dp))
val (@StringRes buttonText: Int, @DrawableRes buttonIcon: Int) = if (isWatchedByMe) {
R.string.unwatch to R.drawable.ic_unwatch
} else {
R.string.watch to R.drawable.ic_watch
}
TextButton(
text = stringResource(buttonText),
icon = buttonIcon,
onClick = {
if (isWatchedByMe) {
editActions.editWatch.remove(Unit)
} else {
editActions.editWatch.select(Unit)
}
}
)
}
}
}

@ -0,0 +1,85 @@
package io.eugenethedev.taigamobile.ui.screens.commontask.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
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.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.google.accompanist.insets.navigationBarsWithImePadding
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.ui.components.editors.TextFieldWithHint
import io.eugenethedev.taigamobile.ui.theme.mainHorizontalScreenPadding
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class)
@Composable
fun CreateCommentBar(
onButtonClick: (String) -> Unit
) = Surface(
modifier = Modifier.fillMaxWidth(),
tonalElevation = 8.dp,
) {
val keyboardController = LocalSoftwareKeyboardController.current
var commentTextValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 8.dp, horizontal = mainHorizontalScreenPadding)
.navigationBarsWithImePadding(),
) {
Box(
modifier = Modifier
.weight(1f)
.padding(end = 4.dp)
.border(
width = 0.5.dp,
color = MaterialTheme.colorScheme.outline,
shape = MaterialTheme.shapes.large
)
.clip(MaterialTheme.shapes.large)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f))
.padding(8.dp),
contentAlignment = Alignment.CenterStart
) {
TextFieldWithHint(
hintId = R.string.comment_hint,
maxLines = 3,
value = commentTextValue,
onValueChange = { commentTextValue = it }
)
}
CompositionLocalProvider(
LocalMinimumTouchTargetEnforcement provides false
) {
IconButton(
onClick = {
commentTextValue.text.trim().takeIf { it.isNotEmpty() }?.let {
onButtonClick(it)
commentTextValue = TextFieldValue()
keyboardController?.hide()
}
},
modifier = Modifier.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
) {
Icon(
painter = painterResource(R.drawable.ic_send),
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}

@ -0,0 +1,613 @@
package io.eugenethedev.taigamobile.ui.screens.commontask.components
import android.content.Intent
import android.net.Uri
import android.util.Patterns
import androidx.annotation.StringRes
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CustomField
import io.eugenethedev.taigamobile.domain.entities.CustomFieldType
import io.eugenethedev.taigamobile.domain.entities.CustomFieldValue
import io.eugenethedev.taigamobile.ui.components.DropdownSelector
import io.eugenethedev.taigamobile.ui.components.editors.TextFieldWithHint
import io.eugenethedev.taigamobile.ui.components.pickers.DatePicker
import io.eugenethedev.taigamobile.ui.components.texts.MarkdownText
import io.eugenethedev.taigamobile.ui.theme.TaigaMobileTheme
import io.eugenethedev.taigamobile.ui.utils.activity
import java.time.LocalDate
import kotlin.math.floor
@Composable
fun CustomField(
customField: CustomField,
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit,
onSaveClick: () -> Unit
) = Column {
Text(
text = customField.name,
style = MaterialTheme.typography.titleMedium
)
customField.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(Modifier.height(4.dp))
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
var showEditButton = false
var buttonsAlignment = Alignment.CenterVertically
var fieldState by remember { mutableStateOf(FieldState.Default) }
val indicationColor = if (value == customField.value) MaterialTheme.colorScheme.outline else MaterialTheme.colorScheme.primary
val borderColor = when (fieldState) {
FieldState.Focused -> MaterialTheme.colorScheme.primary
FieldState.Error -> MaterialTheme.colorScheme.error
FieldState.Default -> indicationColor
}
Row {
Box(
Modifier
.weight(1f)
.border(
width = 1.dp,
color = if (customField.type == CustomFieldType.Checkbox) Color.Transparent else borderColor,
shape = MaterialTheme.shapes.small
)
.clip(MaterialTheme.shapes.extraSmall)
.padding(6.dp)
) {
when (customField.type) {
CustomFieldType.Text -> CustomFieldText(
value = value,
onValueChange = onValueChange,
changeFieldState = { fieldState = it }
)
CustomFieldType.Multiline -> {
buttonsAlignment = Alignment.Top
CustomFieldMultiline(
value = value,
onValueChange = onValueChange,
changeFieldState = { fieldState = it }
)
}
CustomFieldType.RichText -> {
buttonsAlignment = Alignment.Top
showEditButton = true
CustomFieldRichText(
value = value,
onValueChange = onValueChange,
fieldState = fieldState,
changeFieldState = { fieldState = it },
focusRequester = focusRequester
)
}
CustomFieldType.Number -> CustomFieldNumber(
value = value,
onValueChange = onValueChange,
changeFieldState = { fieldState = it }
)
CustomFieldType.Url -> CustomFieldUrl(
value = value,
onValueChange = onValueChange,
changeFieldState = { fieldState = it }
)
CustomFieldType.Date -> CustomFieldDate(
value = value,
onValueChange = onValueChange,
changeFieldState = { fieldState = it }
)
CustomFieldType.Dropdown -> CustomFieldDropdown(
options = customField.options ?: throw IllegalStateException("Dropdown custom field without options"),
borderColor = borderColor,
value = value,
onValueChange = onValueChange,
changeFieldState = { fieldState = it }
)
CustomFieldType.Checkbox -> CustomFieldCheckbox(
value = value,
onValueChange = onValueChange
)
}
}
Row(Modifier.align(buttonsAlignment)) {
if (showEditButton) {
Spacer(Modifier.width(4.dp))
IconButton(
onClick = {
fieldState = FieldState.Focused
},
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
) {
Icon(
painter = painterResource(R.drawable.ic_edit),
contentDescription = null,
tint = MaterialTheme.colorScheme.outline
)
}
} else {
Spacer(Modifier.width(4.dp))
}
IconButton(
onClick = {
if (fieldState != FieldState.Error && value != customField.value) {
focusManager.clearFocus()
onSaveClick()
}
},
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
) {
Icon(
painter = painterResource(R.drawable.ic_save),
contentDescription = null,
tint = indicationColor
)
}
}
}
}
private enum class FieldState {
Focused,
Error,
Default
}
@Composable
private fun TextValue(
@StringRes hintId: Int,
text: TextFieldValue,
onTextChange: (TextFieldValue) -> Unit,
onFocusChange: (Boolean) -> Unit,
focusRequester: FocusRequester = remember { FocusRequester() },
singleLine: Boolean = false,
keyboardType: KeyboardType = KeyboardType.Text,
textColor: Color = MaterialTheme.colorScheme.onSurface
) = TextFieldWithHint(
hintId = hintId,
value = text,
onValueChange = onTextChange,
onFocusChange = onFocusChange,
focusRequester = focusRequester,
singleLine = singleLine,
keyboardType = keyboardType,
textColor = textColor
)
@Composable
private fun CustomFieldText(
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit,
changeFieldState: (FieldState) -> Unit
) {
var text by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(value?.stringValue.orEmpty())) }
TextValue(
hintId = R.string.custom_field_text,
text = text,
onTextChange = {
text = it
onValueChange(CustomFieldValue(it.text))
},
onFocusChange = { changeFieldState(if (it) FieldState.Focused else FieldState.Default) },
singleLine = true
)
}
@Composable
private fun CustomFieldMultiline(
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit,
changeFieldState: (FieldState) -> Unit
) {
var text by remember { mutableStateOf(TextFieldValue(value?.stringValue.orEmpty())) }
TextValue(
hintId = R.string.custom_field_multiline,
text = text,
onTextChange = {
text = it
onValueChange(CustomFieldValue(it.text))
},
onFocusChange = { changeFieldState(if (it) FieldState.Focused else FieldState.Default) },
)
}
@Composable
private fun CustomFieldRichText(
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit,
fieldState: FieldState,
changeFieldState: (FieldState) -> Unit,
focusRequester: FocusRequester
) {
var text by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(value?.stringValue.orEmpty())) }
if (fieldState == FieldState.Focused) {
TextValue(
hintId = R.string.custom_field_rich_text,
text = text,
onTextChange = {
text = it
onValueChange(CustomFieldValue(it.text))
},
onFocusChange = { changeFieldState(if (it) FieldState.Focused else FieldState.Default) },
focusRequester = focusRequester
)
SideEffect {
focusRequester.requestFocus()
}
} else {
MarkdownText(text.text)
}
}
@Composable
private fun CustomFieldNumber(
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit,
changeFieldState: (FieldState) -> Unit
) {
// do not display trailing zeros, like 1.0
fun Double?.prettyDisplay() = this?.let { if (floor(it) != it) toString() else "%.0f".format(it) }.orEmpty()
var text by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(value?.doubleValue.prettyDisplay())) }
TextValue(
hintId = R.string.custom_field_number,
text = text,
onTextChange = {
text = it
if (it.text.isEmpty()) {
onValueChange(null)
changeFieldState(FieldState.Focused)
} else {
it.text.toDoubleOrNull()?.let {
onValueChange(CustomFieldValue(it))
changeFieldState(FieldState.Focused)
} ?: run {
changeFieldState(FieldState.Error)
}
}
},
onFocusChange = {
text = TextFieldValue(value?.doubleValue.prettyDisplay())
changeFieldState(if (it) FieldState.Focused else FieldState.Default)
},
keyboardType = KeyboardType.Number,
singleLine = true
)
}
@Composable
private fun CustomFieldUrl(
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit,
changeFieldState: (FieldState) -> Unit
) {
var text by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue(value?.stringValue.orEmpty())) }
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(Modifier.weight(1f)) {
TextValue(
hintId = R.string.custom_field_url,
text = text,
onTextChange = {
text = it
it.text.takeIf { it.isEmpty() || Patterns.WEB_URL.matcher(it).matches() }
?.let {
changeFieldState(FieldState.Focused)
onValueChange(CustomFieldValue(it))
} ?: run {
changeFieldState(FieldState.Error)
}
},
onFocusChange = {
text = TextFieldValue(value?.stringValue.orEmpty())
changeFieldState(if (it) FieldState.Focused else FieldState.Default)
},
keyboardType = KeyboardType.Uri,
singleLine = true,
textColor = MaterialTheme.colorScheme.primary
)
}
Spacer(Modifier.width(2.dp))
val activity = LocalContext.current.activity
IconButton(
onClick = {
value?.stringValue?.takeIf { it.isNotEmpty() }?.let {
activity.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(it)
)
)
}
},
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
) {
Icon(
painter = painterResource(R.drawable.ic_open),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
}
}
}
@Composable
private fun CustomFieldDate(
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit,
changeFieldState: (FieldState) -> Unit
) {
val date = value?.dateValue
DatePicker(
date = date,
onDatePicked = { onValueChange(it?.let { CustomFieldValue(it) }) },
onOpen = { changeFieldState(FieldState.Focused) },
onClose = { changeFieldState(FieldState.Default) },
modifier = Modifier.fillMaxWidth()
)
}
@Composable
private fun CustomFieldDropdown(
options: List<String>,
borderColor: Color,
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit,
changeFieldState: (FieldState) -> Unit
) {
val option = value?.stringValue.orEmpty()
DropdownSelector(
items = options,
selectedItem = option,
onItemSelected = {
onValueChange(CustomFieldValue(it))
changeFieldState(FieldState.Default)
},
itemContent = {
if (it.isNotEmpty()) {
Text(
text = it,
style = MaterialTheme.typography.bodyLarge
)
} else {
Text(
text = stringResource(R.string.empty),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.outline
)
}
},
selectedItemContent = {
Text(
text = option,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
takeMaxWidth = true,
horizontalArrangement = Arrangement.SpaceBetween,
tint = borderColor,
onExpanded = { changeFieldState(FieldState.Focused) },
onDismissRequest = { changeFieldState(FieldState.Default) }
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CustomFieldCheckbox(
value: CustomFieldValue?,
onValueChange: (CustomFieldValue?) -> Unit
) {
val state = value?.booleanValue ?: false
Checkbox(
checked = state,
onCheckedChange = { onValueChange(CustomFieldValue(it)) }
)
}
@Preview(showBackground = true)
@Composable
fun CustomFieldsPreview() = TaigaMobileTheme {
Column {
var value1 by remember { mutableStateOf<CustomFieldValue?>(CustomFieldValue("Sample value")) }
CustomField(
customField = CustomField(
id = 0L,
type = CustomFieldType.Text,
name = "Sample name",
description = "Description",
value = CustomFieldValue("Sample value"),
),
value = value1,
onValueChange = { value1 = it },
onSaveClick = { }
)
Spacer(Modifier.height(8.dp))
var value2 by remember { mutableStateOf<CustomFieldValue?>(CustomFieldValue("Sample value")) }
CustomField(
customField = CustomField(
id = 0L,
type = CustomFieldType.Multiline,
name = "Sample name",
description = "Description",
value = CustomFieldValue("Sample value"),
),
value = value2,
onValueChange = { value2 = it },
onSaveClick = { }
)
Spacer(Modifier.height(8.dp))
var value3 by remember { mutableStateOf<CustomFieldValue?>(CustomFieldValue("__Sample__ `value`")) }
CustomField(
customField = CustomField(
id = 0L,
type = CustomFieldType.RichText,
name = "Sample name",
description = "Description",
value = CustomFieldValue("__Sample__ `value`"),
),
value = value3,
onValueChange = { value3 = it },
onSaveClick = { }
)
Spacer(Modifier.height(8.dp))
var value4 by remember { mutableStateOf<CustomFieldValue?>(CustomFieldValue(42.0)) }
CustomField(
customField = CustomField(
id = 0L,
type = CustomFieldType.Number,
name = "Sample name",
description = "Description",
value = CustomFieldValue(42.0)
),
value = value4,
onValueChange = { value4 = it },
onSaveClick = { }
)
Spacer(Modifier.height(8.dp))
var value5 by remember { mutableStateOf<CustomFieldValue?>(CustomFieldValue("https://x.com")) }
CustomField(
customField = CustomField(
id = 0L,
type = CustomFieldType.Url,
name = "Sample name",
description = "Description",
value = CustomFieldValue("https://x.com")
),
value = value5,
onValueChange = { value5 = it },
onSaveClick = { }
)
Spacer(Modifier.height(8.dp))
var value6 by remember { mutableStateOf<CustomFieldValue?>(CustomFieldValue(LocalDate.of(1970, 1, 1))) }
CustomField(
customField = CustomField(
id = 0L,
type = CustomFieldType.Date,
name = "Sample name",
description = "Description",
value = CustomFieldValue(LocalDate.of(1970, 1, 1))
),
value = value6,
onValueChange = { value6 = it },
onSaveClick = { }
)
Spacer(Modifier.height(8.dp))
var value7 by remember { mutableStateOf<CustomFieldValue?>(CustomFieldValue("Something 0")) }
CustomField(
customField = CustomField(
id = 0L,
type = CustomFieldType.Dropdown,
name = "Sample name",
description = "Description",
value = CustomFieldValue("Something 0"),
options = listOf("", "Something 0", "Something 1", "Something 2")
),
value = value7,
onValueChange = { value7 = it },
onSaveClick = { }
)
Spacer(Modifier.height(8.dp))
var value8 by remember { mutableStateOf<CustomFieldValue?>(CustomFieldValue(true)) }
CustomField(
customField = CustomField(
id = 0L,
type = CustomFieldType.Checkbox,
name = "Sample name",
description = "Description",
value = CustomFieldValue(true)
),
value = value8,
onValueChange = { value8 = it },
onSaveClick = { }
)
}
}

@ -0,0 +1,300 @@
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.screens.commontask.SimpleEditAction
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.select(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.select(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.select(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.select(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.select(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.select(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.select(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.select(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.select(it)
swimlaneEntry.hide()
}
)
}
}
class SelectorEntry<TItem : Any> (
val edit: EditAction<TItem, *> = SimpleEditAction(),
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
) {
val swimlaneNullable = swimlane.takeIf { it != CommonTaskViewModel.SWIMLANE_HEADER }
Text(
text = swimlaneNullable?.name ?: stringResource(R.string.unclassifed),
style = MaterialTheme.typography.bodyLarge,
color = swimlaneNullable?.let { MaterialTheme.colorScheme.onSurface } ?: MaterialTheme.colorScheme.primary
)
}

@ -0,0 +1,84 @@
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.Editor
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,
showMessage: (message: Int) -> Unit = {},
) {
val viewModel: CreateTaskViewModel = viewModel()
val creationResult by viewModel.creationResult.collectAsState()
creationResult.subscribeOnError(showMessage)
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()) {
Editor(
toolbarText = title,
onSaveClick = createTask,
navigateBack = navigateBack
)
if (isLoading) {
LoadingDialog()
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
fun CreateTaskScreenPreview() = TaigaMobileTheme {
CreateTaskScreenContent(
title = "Create task"
)
}

@ -0,0 +1,42 @@
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()
}
}
}
}

@ -0,0 +1,181 @@
package io.eugenethedev.taigamobile.ui.screens.dashboard
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
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 com.google.accompanist.pager.ExperimentalPagerApi
import io.eugenethedev.taigamobile.R
import io.eugenethedev.taigamobile.domain.entities.CommonTask
import io.eugenethedev.taigamobile.domain.entities.Project
import io.eugenethedev.taigamobile.ui.utils.LoadingResult
import io.eugenethedev.taigamobile.ui.components.containers.HorizontalTabbedPager
import io.eugenethedev.taigamobile.ui.components.containers.Tab
import io.eugenethedev.taigamobile.ui.components.appbars.AppBarWithBackButton
import io.eugenethedev.taigamobile.ui.components.lists.ProjectCard
import io.eugenethedev.taigamobile.ui.components.lists.SimpleTasksListWithTitle
import io.eugenethedev.taigamobile.ui.components.loaders.CircularLoader
import io.eugenethedev.taigamobile.ui.theme.*
import io.eugenethedev.taigamobile.ui.utils.navigateToTaskScreen
import io.eugenethedev.taigamobile.ui.utils.subscribeOnError
@Composable
fun DashboardScreen(
navController: NavController,
showMessage: (message: Int) -> Unit = {},
) {
val viewModel: DashboardViewModel = viewModel()
LaunchedEffect(Unit) {
viewModel.onOpen()
}
val workingOn by viewModel.workingOn.collectAsState()
workingOn.subscribeOnError(showMessage)
val watching by viewModel.watching.collectAsState()
watching.subscribeOnError(showMessage)
val myProjects by viewModel.myProjects.collectAsState()
myProjects.subscribeOnError(showMessage)
val currentProjectId by viewModel.currentProjectId.collectAsState()
DashboardScreenContent(
isLoading = listOf(workingOn, watching, myProjects).any { it is LoadingResult<*> },
workingOn = workingOn.data.orEmpty(),
watching = watching.data.orEmpty(),
myProjects = myProjects.data.orEmpty(),
currentProjectId = currentProjectId,
navigateToTask = {
viewModel.changeCurrentProject(it.projectInfo)
navController.navigateToTaskScreen(it.id, it.taskType, it.ref)
},
changeCurrentProject = viewModel::changeCurrentProject
)
}
@OptIn(ExperimentalPagerApi::class)
@Composable
fun DashboardScreenContent(
isLoading: Boolean = false,
workingOn: List<CommonTask> = emptyList(),
watching: List<CommonTask> = emptyList(),
myProjects: List<Project> = emptyList(),
currentProjectId: Long = 0,
navigateToTask: (CommonTask) -> Unit = { _ -> },
changeCurrentProject: (Project) -> Unit = { _ -> }
) = Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.Start
) {
AppBarWithBackButton(title = { Text(stringResource(R.string.dashboard)) })
if (isLoading) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize()
) {
CircularLoader()
}
} else {
HorizontalTabbedPager(
tabs = Tabs.values(),
modifier = Modifier.fillMaxSize()
) { page ->
when (Tabs.values()[page]) {
Tabs.WorkingOn -> TabContent(
commonTasks = workingOn,
navigateToTask = navigateToTask
)
Tabs.Watching -> TabContent(
commonTasks = watching,
navigateToTask = navigateToTask
)
Tabs.MyProjects -> MyProjects(
myProjects = myProjects,
currentProjectId = currentProjectId,
changeCurrentProject = changeCurrentProject
)
}
}
}
}
private enum class Tabs(@StringRes override val titleId: Int) : Tab {
WorkingOn(R.string.working_on),
Watching(R.string.watching),
MyProjects(R.string.my_projects)
}
@Composable
private fun TabContent(
commonTasks: List<CommonTask>,
navigateToTask: (CommonTask) -> Unit,
) = LazyColumn(Modifier.fillMaxSize()) {
SimpleTasksListWithTitle(
bottomPadding = commonVerticalPadding,
horizontalPadding = mainHorizontalScreenPadding,
showExtendedTaskInfo = true,
commonTasks = commonTasks,
navigateToTask = { id, _, _ -> navigateToTask(commonTasks.find { it.id == id }!!) },
)
}
@Composable
private fun MyProjects(
myProjects: List<Project>,
currentProjectId: Long,
changeCurrentProject: (Project) -> Unit
) = LazyColumn {
items(myProjects) {
ProjectCard(
project = it,
isCurrent = it.id == currentProjectId,
onClick = { changeCurrentProject(it) }
)
Spacer(Modifier.height(12.dp))
}
}
@Preview(showBackground = true)
@Composable
private fun ProjectCardPreview() = TaigaMobileTheme {
ProjectCard(
project = Project(
id = 0,
name = "Name",
slug = "slug",
description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
isPrivate = true
),
isCurrent = true,
onClick = {}
)
}
@Preview(showBackground = true)
@Composable
private fun DashboardPreview() = TaigaMobileTheme {
DashboardScreenContent(
myProjects = List(3) {
Project(
id = it.toLong(),
name = "Name",
slug = "slug",
description = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
isPrivate = true
)
}
)
}

@ -0,0 +1,62 @@
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.entities.Project
import io.eugenethedev.taigamobile.domain.repositories.IProjectsRepository
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 projectsRepository: IProjectsRepository
@Inject lateinit var session: Session
val workingOn = MutableResultFlow<List<CommonTask>>()
val watching = MutableResultFlow<List<CommonTask>>()
val myProjects = MutableResultFlow<List<Project>>()
val currentProjectId by lazy { session.currentProjectId }
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() } },
launch { myProjects.loadOrError(preserveValue = false) { projectsRepository.getMyProjects() } }
)
shouldReload = false
}
fun changeCurrentProject(project: Project) {
project.apply {
session.changeCurrentProject(id, name)
}
}
init {
session.taskEdit.onEach {
workingOn.value = NothingResult()
watching.value = NothingResult()
myProjects.value = NothingResult()
shouldReload = true
}.launchIn(viewModelScope)
}
}

@ -0,0 +1,97 @@
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.ClickableAppBar
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,
showMessage: (message: Int) -> Unit = {},
) {
val viewModel: EpicsViewModel = viewModel()
LaunchedEffect(Unit) {
viewModel.onOpen()
}
val projectName by viewModel.projectName.collectAsState()
val epics = viewModel.epics
epics.subscribeOnError(showMessage)
val filters by viewModel.filters.collectAsState()
filters.subscribeOnError(showMessage)
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
) {
ClickableAppBar(
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"
)
}

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

Loading…
Cancel
Save