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.
|
@ -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>
|
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…
Reference in new issue