Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb78d2cbe5 | |||
| 512df6d89e | |||
| effdc2a1ad | |||
| 27a3e569af | |||
| 33f95cc5a5 | |||
| 6109e4a5df | |||
| 833a68c06e | |||
| ba9f379100 | |||
| 8cab357c4c | |||
| 38df116328 | |||
| 4d59f371ac | |||
| 221cf4f80d | |||
| b268fc13c5 | |||
| ee67139b04 | |||
| d099fc7da7 | |||
| b08ceb5574 | |||
| 1dcfb0f525 | |||
| 47808b2255 | |||
| 93a26722d8 | |||
| 0c00d7d5b0 | |||
| 98b08f0219 | |||
| f038dbe0ee | |||
| f5fc51c156 | |||
| e2085a8dc2 | |||
| 6d5feacf45 | |||
| 0fd300ffdc | |||
| 5356e957ba | |||
| f308a9507d | |||
| 2e59d54de6 | |||
| dc6847d205 | |||
| d2c07307c9 | |||
| 5b4265215d | |||
| a8da951a33 | |||
| bf6351fbb5 | |||
| a556f4cbdc |
@@ -0,0 +1,15 @@
|
|||||||
|
# Règles — Déploiement après commit
|
||||||
|
|
||||||
|
## Comportement attendu
|
||||||
|
Après chaque `git commit` (et `git push`), lancer systématiquement l'installation sur le device physique :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
JAVA_HOME=/home/Gato/.jdks/temurin-25.0.2 PATH="$JAVA_HOME/bin:$PATH" \
|
||||||
|
./gradlew installDebug -Pandroid.injected.deployment.target.serial=89UDU18724006699
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Le serial `89UDU18724006699` est le device Android physique de test (Huawei ANE-LX1, Android 9)
|
||||||
|
- `JAVA_HOME` doit pointer sur Temurin 25 (JBR sans `jlink`)
|
||||||
|
- Si le device est `UNAUTHORIZED` : demander à l'utilisateur d'accepter la dialog ADB sur le téléphone
|
||||||
|
- Si `INSTALL_FAILED_UPDATE_INCOMPATIBLE` : demander à l'utilisateur de désinstaller l'appli manuellement puis relancer
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Règles — Numéro de version
|
||||||
|
|
||||||
|
## Format
|
||||||
|
`0.0.X` — incrémentation du dernier segment à chaque série de modifications.
|
||||||
|
|
||||||
|
Exemples : `0.0.1` → `0.0.2` → `0.0.3` → ... → `0.1.0` (changement majeur)
|
||||||
|
|
||||||
|
## Où modifier
|
||||||
|
`app/build.gradle.kts` → `versionName = "X.X.X"`
|
||||||
|
|
||||||
|
## Comportement attendu
|
||||||
|
- Monter le numéro de version à chaque commit (ou série de commits dans la même session).
|
||||||
|
- La version s'affiche automatiquement en bas du menu latéral via `BuildConfig.VERSION_NAME`.
|
||||||
|
- Ne pas oublier de mettre à jour la version avant le commit final de chaque session.
|
||||||
|
|
||||||
|
## Version actuelle
|
||||||
|
`0.0.1`
|
||||||
@@ -10,3 +10,5 @@
|
|||||||
local.properties
|
local.properties
|
||||||
*.keystore
|
*.keystore
|
||||||
*.jks
|
*.jks
|
||||||
|
app/build/
|
||||||
|
java_pid2281.hprof
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
@@ -8,14 +9,14 @@ plugins {
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.planify.mobile"
|
namespace = "com.planify.mobile"
|
||||||
compileSdk = 34
|
compileSdk = 35
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.planify.mobile"
|
applicationId = "com.planify.mobile"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.0.19"
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,16 +32,15 @@ android {
|
|||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlin {
|
||||||
jvmTarget = "17"
|
compilerOptions {
|
||||||
|
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
buildConfig = true
|
||||||
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = "1.5.14"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +83,9 @@ dependencies {
|
|||||||
// WorkManager
|
// WorkManager
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation(libs.work.runtime.ktx)
|
||||||
|
|
||||||
|
// Browser (Custom Tabs pour OAuth)
|
||||||
|
implementation(libs.androidx.browser)
|
||||||
|
|
||||||
// Serialization
|
// Serialization
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package com.planify.mobile.data.bonsai
|
||||||
|
|
||||||
|
import com.planify.mobile.data.bonsai.dto.BonsaiIssueDto
|
||||||
|
import com.planify.mobile.data.bonsai.dto.BonsaiMilestoneDto
|
||||||
|
import com.planify.mobile.data.bonsai.dto.BonsaiProjectDto
|
||||||
|
import com.planify.mobile.data.bonsai.dto.BonsaIssueRequest
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
sealed class ApiResult<out T> {
|
||||||
|
data class Success<T>(val data: T) : ApiResult<T>()
|
||||||
|
data class Failure(val message: String, val code: Int = -1) : ApiResult<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class BonsaiApiClient @Inject constructor(
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
private val auth: BonsaiAuthManager,
|
||||||
|
) {
|
||||||
|
private val json = "application/json".toMediaType()
|
||||||
|
|
||||||
|
suspend fun getProjects(): ApiResult<List<BonsaiProjectDto>> = get("projects") { arr ->
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BonsaiProjectDto(id = o.getLong("id"), name = o.getString("name"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getIssues(projectId: Long): ApiResult<List<BonsaiIssueDto>> =
|
||||||
|
get("projects/$projectId/issues") { arr ->
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BonsaiIssueDto(
|
||||||
|
id = o.getLong("id"),
|
||||||
|
projectId = o.optLong("projectId", projectId), // fallback = the project we queried
|
||||||
|
type = o.getString("type"),
|
||||||
|
name = o.getString("name"),
|
||||||
|
status = o.getString("status"),
|
||||||
|
priority = o.getString("priority"),
|
||||||
|
assignee = o.optString("assignee").takeIf { it.isNotEmpty() },
|
||||||
|
dueDate = o.optString("dueDate").takeIf { it.isNotEmpty() },
|
||||||
|
description = o.optString("description").takeIf { it.isNotEmpty() },
|
||||||
|
progress = o.optInt("progress", 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMilestones(projectId: Long): ApiResult<List<BonsaiMilestoneDto>> =
|
||||||
|
get("projects/$projectId/milestones") { arr ->
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val ids = o.optJSONArray("issueIds")?.let { a ->
|
||||||
|
(0 until a.length()).map { j -> a.getLong(j) }
|
||||||
|
} ?: emptyList()
|
||||||
|
BonsaiMilestoneDto(
|
||||||
|
id = o.getLong("id"),
|
||||||
|
projectId = o.getLong("projectId"),
|
||||||
|
name = o.getString("name"),
|
||||||
|
issueIds = ids,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun createIssue(projectId: Long, req: BonsaIssueRequest): ApiResult<BonsaiIssueDto> =
|
||||||
|
post("projects/$projectId/issues", req.toJson()) { o ->
|
||||||
|
o.toIssueDto(fallbackProjectId = projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun updateIssue(projectId: Long, issueId: Long, req: BonsaIssueRequest): ApiResult<BonsaiIssueDto> =
|
||||||
|
put("projects/$projectId/issues/$issueId", req.toJson()) { o ->
|
||||||
|
o.toIssueDto(fallbackProjectId = projectId)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteIssue(projectId: Long, issueId: Long): ApiResult<Unit> = withContext(Dispatchers.IO) {
|
||||||
|
val authHeader = auth.getAuthHeader() ?: return@withContext ApiResult.Failure("Non connecté")
|
||||||
|
val url = "${auth.getApiBaseUrl()}/projects/$projectId/issues/$issueId"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.delete()
|
||||||
|
.header("Authorization", authHeader)
|
||||||
|
.build()
|
||||||
|
runCatching {
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (response.isSuccessful) ApiResult.Success(Unit)
|
||||||
|
else ApiResult.Failure("HTTP ${response.code}", response.code)
|
||||||
|
}
|
||||||
|
}.getOrElse { ApiResult.Failure(it.message ?: "Erreur réseau") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun <T> get(path: String, parse: (JSONArray) -> T): ApiResult<T> = withContext(Dispatchers.IO) {
|
||||||
|
val authHeader = auth.getAuthHeader() ?: return@withContext ApiResult.Failure("Non connecté")
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${auth.getApiBaseUrl()}/$path")
|
||||||
|
.get()
|
||||||
|
.header("Authorization", authHeader)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
runCatching {
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string() ?: ""
|
||||||
|
if (!response.isSuccessful) return@withContext ApiResult.Failure("HTTP ${response.code}: $body", response.code)
|
||||||
|
ApiResult.Success(parse(JSONArray(body)))
|
||||||
|
}
|
||||||
|
}.getOrElse { ApiResult.Failure(it.message ?: "Erreur réseau") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> post(path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult<T> =
|
||||||
|
sendWithBody("POST", path, jsonBody, parse)
|
||||||
|
|
||||||
|
private suspend fun <T> put(path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult<T> =
|
||||||
|
sendWithBody("PUT", path, jsonBody, parse)
|
||||||
|
|
||||||
|
private suspend fun <T> sendWithBody(method: String, path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult<T> = withContext(Dispatchers.IO) {
|
||||||
|
val authHeader = auth.getAuthHeader() ?: return@withContext ApiResult.Failure("Non connecté")
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${auth.getApiBaseUrl()}/$path")
|
||||||
|
.method(method, jsonBody.toRequestBody(json))
|
||||||
|
.header("Authorization", authHeader)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.build()
|
||||||
|
Log.d("BonsaiApi", "$method ${auth.getApiBaseUrl()}/$path body=$jsonBody")
|
||||||
|
runCatching {
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string() ?: ""
|
||||||
|
Log.d("BonsaiApi", "→ ${response.code} body=$body")
|
||||||
|
if (!response.isSuccessful) return@withContext ApiResult.Failure("HTTP ${response.code}: $body", response.code)
|
||||||
|
ApiResult.Success(parse(JSONObject(body)))
|
||||||
|
}
|
||||||
|
}.getOrElse { e ->
|
||||||
|
Log.e("BonsaiApi", "Erreur réseau", e)
|
||||||
|
ApiResult.Failure(e.message ?: "Erreur réseau")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun BonsaIssueRequest.toJson(): String = JSONObject().apply {
|
||||||
|
put("type", type)
|
||||||
|
put("name", name)
|
||||||
|
put("status", status)
|
||||||
|
put("priority", priority)
|
||||||
|
dueDate?.let { put("dueDate", it) }
|
||||||
|
description?.let { put("description", it) }
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
private fun JSONObject.toIssueDto(fallbackProjectId: Long = 0L) = BonsaiIssueDto(
|
||||||
|
id = getLong("id"),
|
||||||
|
projectId = optLong("projectId", fallbackProjectId),
|
||||||
|
type = optString("type", "Task"),
|
||||||
|
name = getString("name"),
|
||||||
|
status = optString("status", "todo"),
|
||||||
|
priority = optString("priority", "MOYENNE"),
|
||||||
|
assignee = optString("assignee").takeIf { it.isNotEmpty() },
|
||||||
|
dueDate = optString("dueDate").takeIf { it.isNotEmpty() },
|
||||||
|
description = optString("description").takeIf { it.isNotEmpty() },
|
||||||
|
progress = optInt("progress", 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.planify.mobile.data.bonsai
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
sealed class LoginResult {
|
||||||
|
object Success : LoginResult()
|
||||||
|
data class Failure(val message: String) : LoginResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class BonsaiAuthManager @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val httpClient: OkHttpClient,
|
||||||
|
) {
|
||||||
|
private val prefs: SharedPreferences
|
||||||
|
private val _isAuthenticated: MutableStateFlow<Boolean>
|
||||||
|
|
||||||
|
init {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
prefs = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
"bonsai_credentials",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||||
|
)
|
||||||
|
_isAuthenticated = MutableStateFlow(prefs.getString(KEY_REFRESH_TOKEN, null) != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated
|
||||||
|
val isLoggedIn: Boolean get() = _isAuthenticated.value
|
||||||
|
|
||||||
|
fun getApiBaseUrl(): String = DEFAULT_API_URL
|
||||||
|
fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: ""
|
||||||
|
fun getAuthHeader(): String? = prefs.getString(KEY_ACCESS_TOKEN, null)?.let { "Bearer $it" }
|
||||||
|
|
||||||
|
suspend fun login(username: String, password: String): LoginResult = withContext(Dispatchers.IO) {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("grant_type", "password")
|
||||||
|
.add("client_id", CLIENT_ID)
|
||||||
|
.add("username", username)
|
||||||
|
.add("password", password)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
val raw = response.body?.string() ?: ""
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val detail = runCatching {
|
||||||
|
JSONObject(raw).optString("error_description", raw)
|
||||||
|
}.getOrDefault(raw)
|
||||||
|
return@withContext LoginResult.Failure(detail)
|
||||||
|
}
|
||||||
|
saveTokens(JSONObject(raw))
|
||||||
|
LoginResult.Success
|
||||||
|
}
|
||||||
|
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun refreshIfNeeded(): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) ?: return@withContext false
|
||||||
|
val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L)
|
||||||
|
if (System.currentTimeMillis() < expiresAt - 60_000L) return@withContext true
|
||||||
|
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
.add("grant_type", "refresh_token")
|
||||||
|
.add("client_id", CLIENT_ID)
|
||||||
|
.add("refresh_token", refreshToken)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
runCatching {
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
when {
|
||||||
|
response.isSuccessful -> {
|
||||||
|
val raw = response.body?.string() ?: return@withContext true
|
||||||
|
saveTokens(JSONObject(raw))
|
||||||
|
true
|
||||||
|
}
|
||||||
|
response.code == 401 || response.code == 403 -> {
|
||||||
|
// Refresh token genuinely invalid — must re-login.
|
||||||
|
false
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Server error or network issue — keep existing token, don't logout.
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrDefault(true) // Network exception (offline, timeout) → keep existing token.
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
prefs.edit()
|
||||||
|
.remove(KEY_ACCESS_TOKEN)
|
||||||
|
.remove(KEY_REFRESH_TOKEN)
|
||||||
|
.remove(KEY_EXPIRES_AT)
|
||||||
|
.remove(KEY_USERNAME)
|
||||||
|
.apply()
|
||||||
|
_isAuthenticated.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveTokens(json: JSONObject) {
|
||||||
|
val accessToken = json.getString("access_token")
|
||||||
|
val refreshToken = json.optString("refresh_token", "")
|
||||||
|
val expiresIn = json.optLong("expires_in", 300L)
|
||||||
|
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_ACCESS_TOKEN, accessToken)
|
||||||
|
.putString(KEY_REFRESH_TOKEN, refreshToken.ifBlank { null })
|
||||||
|
.putLong(KEY_EXPIRES_AT, System.currentTimeMillis() + expiresIn * 1000L)
|
||||||
|
.putString(KEY_USERNAME, extractUsername(accessToken))
|
||||||
|
.apply()
|
||||||
|
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractUsername(jwt: String): String = runCatching {
|
||||||
|
val payload = jwt.split(".").getOrNull(1) ?: return@runCatching ""
|
||||||
|
val padded = payload + "=".repeat((4 - payload.length % 4) % 4)
|
||||||
|
val decoded = Base64.getUrlDecoder().decode(padded)
|
||||||
|
val json = JSONObject(String(decoded))
|
||||||
|
json.optString("preferred_username", json.optString("sub", ""))
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_API_URL = "https://bonsai.goutailler-olivier.com/api"
|
||||||
|
private const val KEYCLOAK_BASE = "https://auth.goutailler-olivier.com"
|
||||||
|
private const val REALM = "bonsai"
|
||||||
|
private const val CLIENT_ID = "bonsai-android"
|
||||||
|
private const val KEY_ACCESS_TOKEN = "access_token"
|
||||||
|
private const val KEY_REFRESH_TOKEN = "refresh_token"
|
||||||
|
private const val KEY_EXPIRES_AT = "expires_at"
|
||||||
|
private const val KEY_USERNAME = "username"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package com.planify.mobile.data.bonsai
|
||||||
|
|
||||||
|
import com.planify.mobile.data.bonsai.dto.BonsaiIssueDto
|
||||||
|
import com.planify.mobile.data.bonsai.dto.BonsaiMilestoneDto
|
||||||
|
import com.planify.mobile.domain.model.BackendType
|
||||||
|
import com.planify.mobile.domain.model.DueDate
|
||||||
|
import com.planify.mobile.domain.model.Label
|
||||||
|
import com.planify.mobile.domain.model.Project
|
||||||
|
import com.planify.mobile.domain.model.Task
|
||||||
|
import com.planify.mobile.domain.repository.LabelRepository
|
||||||
|
import com.planify.mobile.domain.repository.ProjectRepository
|
||||||
|
import com.planify.mobile.domain.repository.TaskRepository
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
sealed class SyncResult {
|
||||||
|
object Success : SyncResult()
|
||||||
|
object NotLoggedIn : SyncResult()
|
||||||
|
data class Failure(val message: String) : SyncResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class BonsaiSyncManager @Inject constructor(
|
||||||
|
private val apiClient: BonsaiApiClient,
|
||||||
|
private val authManager: BonsaiAuthManager,
|
||||||
|
private val projectRepository: ProjectRepository,
|
||||||
|
private val taskRepository: TaskRepository,
|
||||||
|
private val labelRepository: LabelRepository,
|
||||||
|
) {
|
||||||
|
suspend fun sync(): SyncResult = runCatching { doSync() }
|
||||||
|
.getOrElse { SyncResult.Failure(it.message ?: "Erreur interne") }
|
||||||
|
|
||||||
|
private suspend fun doSync(): SyncResult {
|
||||||
|
if (!authManager.isLoggedIn) return SyncResult.NotLoggedIn
|
||||||
|
|
||||||
|
val projectsResult = apiClient.getProjects()
|
||||||
|
if (projectsResult is ApiResult.Failure) return SyncResult.Failure(projectsResult.message)
|
||||||
|
val bonsaiProjects = (projectsResult as ApiResult.Success).data
|
||||||
|
|
||||||
|
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
|
||||||
|
// Sync projects
|
||||||
|
bonsaiProjects.forEach { dto ->
|
||||||
|
val id = dto.id.toString()
|
||||||
|
val project = Project(
|
||||||
|
id = id,
|
||||||
|
name = dto.name,
|
||||||
|
color = "#276749",
|
||||||
|
backendType = BackendType.BONSAI,
|
||||||
|
childOrder = dto.id.toInt(),
|
||||||
|
)
|
||||||
|
if (projectRepository.getProjectById(id) == null) projectRepository.insertProject(project)
|
||||||
|
else projectRepository.updateProject(project)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-project: issues + milestones
|
||||||
|
val milestonesByIssueId = mutableMapOf<Long, MutableList<String>>()
|
||||||
|
val allTaskIssues = mutableListOf<BonsaiIssueDto>()
|
||||||
|
|
||||||
|
bonsaiProjects.forEach { project ->
|
||||||
|
val issuesResult = apiClient.getIssues(project.id)
|
||||||
|
if (issuesResult is ApiResult.Success) {
|
||||||
|
val tasks = issuesResult.data.filter { it.type == "Task" }
|
||||||
|
allTaskIssues.addAll(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
val msResult = apiClient.getMilestones(project.id)
|
||||||
|
if (msResult is ApiResult.Success) {
|
||||||
|
val milestones = msResult.data
|
||||||
|
// Sync milestones as labels
|
||||||
|
milestones.forEach { ms ->
|
||||||
|
val labelId = "ms_${ms.id}"
|
||||||
|
val label = Label(
|
||||||
|
id = labelId,
|
||||||
|
name = ms.name,
|
||||||
|
color = "#276749",
|
||||||
|
backendType = BackendType.BONSAI,
|
||||||
|
sourceId = project.id.toString(),
|
||||||
|
)
|
||||||
|
if (labelRepository.getLabelById(labelId) == null) labelRepository.insertLabel(label)
|
||||||
|
else labelRepository.updateLabel(label)
|
||||||
|
|
||||||
|
ms.issueIds.forEach { issueId ->
|
||||||
|
milestonesByIssueId.getOrPut(issueId) { mutableListOf() }.add(ms.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync tasks (issues of type Task)
|
||||||
|
allTaskIssues.forEach { issue ->
|
||||||
|
runCatching {
|
||||||
|
val labels = milestonesByIssueId[issue.id] ?: emptyList()
|
||||||
|
val task = issue.toTask(labels, now)
|
||||||
|
val existing = taskRepository.getTaskById(task.id)
|
||||||
|
if (existing == null) taskRepository.insertTask(task)
|
||||||
|
else taskRepository.updateTask(task)
|
||||||
|
}
|
||||||
|
// Individual task failures are skipped; the next sync will retry.
|
||||||
|
}
|
||||||
|
|
||||||
|
return SyncResult.Success
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mapping helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun BonsaiIssueDto.toTask(milestoneNames: List<String>, now: String) = Task(
|
||||||
|
id = id.toString(),
|
||||||
|
content = name,
|
||||||
|
description = description ?: "",
|
||||||
|
projectId = projectId.toString(),
|
||||||
|
priority = mapPriority(priority),
|
||||||
|
checked = status == "done",
|
||||||
|
dueDate = dueDate?.let { DueDate(date = it) },
|
||||||
|
labels = milestoneNames,
|
||||||
|
addedAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
completedAt = if (status == "done") now else null,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun mapPriority(bonsaiPriority: String): Int = when (bonsaiPriority) {
|
||||||
|
"TRES_HAUTE" -> 1
|
||||||
|
"HAUTE" -> 2
|
||||||
|
"MOYENNE" -> 3
|
||||||
|
else -> 4
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toBonsaiPriority(appPriority: Int): String = when (appPriority) {
|
||||||
|
1 -> "TRES_HAUTE"
|
||||||
|
2 -> "HAUTE"
|
||||||
|
3 -> "MOYENNE"
|
||||||
|
else -> "BASSE"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toBonsaiStatus(checked: Boolean): String = if (checked) "done" else "todo"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.planify.mobile.data.bonsai.dto
|
||||||
|
|
||||||
|
data class BonsaiProjectDto(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BonsaiIssueDto(
|
||||||
|
val id: Long,
|
||||||
|
val projectId: Long,
|
||||||
|
val type: String,
|
||||||
|
val name: String,
|
||||||
|
val status: String,
|
||||||
|
val priority: String,
|
||||||
|
val assignee: String? = null,
|
||||||
|
val dueDate: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val progress: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BonsaIssueRequest(
|
||||||
|
val type: String = "Task",
|
||||||
|
val name: String,
|
||||||
|
val status: String = "todo",
|
||||||
|
val priority: String = "MOYENNE",
|
||||||
|
val dueDate: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BonsaiMilestoneDto(
|
||||||
|
val id: Long,
|
||||||
|
val projectId: Long,
|
||||||
|
val name: String,
|
||||||
|
val issueIds: List<Long> = emptyList(),
|
||||||
|
)
|
||||||
@@ -4,6 +4,8 @@ import com.planify.mobile.domain.model.CalDavType
|
|||||||
import com.planify.mobile.domain.model.Source
|
import com.planify.mobile.domain.model.Source
|
||||||
import com.planify.mobile.domain.model.SourceCalDavData
|
import com.planify.mobile.domain.model.SourceCalDavData
|
||||||
import com.planify.mobile.domain.model.SourceType
|
import com.planify.mobile.domain.model.SourceType
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.xmlpull.v1.XmlPullParser
|
import org.xmlpull.v1.XmlPullParser
|
||||||
import org.xmlpull.v1.XmlPullParserFactory
|
import org.xmlpull.v1.XmlPullParserFactory
|
||||||
import java.io.StringReader
|
import java.io.StringReader
|
||||||
@@ -21,25 +23,146 @@ sealed class DiscoveryResult {
|
|||||||
@Singleton
|
@Singleton
|
||||||
class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
||||||
|
|
||||||
suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult {
|
suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult = withContext(Dispatchers.IO) {
|
||||||
val credentials = CalDavClient.basicCredentials(username, password)
|
val credentials = CalDavClient.basicCredentials(username, password)
|
||||||
val normalizedBase = baseUrl.trimEnd('/')
|
val normalizedBase = baseUrl.trimEnd('/')
|
||||||
|
|
||||||
// Step 1: resolve principal URL
|
|
||||||
val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username)
|
|
||||||
?: return DiscoveryResult.Failure("Impossible de trouver le principal CalDAV")
|
|
||||||
|
|
||||||
// Step 2: find calendar home
|
|
||||||
val calendarHome = resolveCalendarHome(principalUrl, credentials)
|
|
||||||
?: return DiscoveryResult.Failure("Impossible de trouver le calendar home")
|
|
||||||
|
|
||||||
// Step 3: list VTODO-capable calendars
|
|
||||||
val calendars = listCalendars(calendarHome, credentials, username, baseUrl)
|
|
||||||
|
|
||||||
val caldavType = detectType(normalizedBase)
|
val caldavType = detectType(normalizedBase)
|
||||||
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
|
||||||
|
|
||||||
val sources = calendars.map { cal ->
|
// For Nextcloud, use the known URL structure directly (avoids fragile PROPFIND chain)
|
||||||
|
if (caldavType == CalDavType.NEXTCLOUD) {
|
||||||
|
val result = discoverNextcloud(normalizedBase, username, credentials)
|
||||||
|
if (result != null) return@withContext result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic CalDAV discovery via PROPFIND chain
|
||||||
|
val diag = StringBuilder()
|
||||||
|
|
||||||
|
val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username, diag)
|
||||||
|
?: return@withContext DiscoveryResult.Failure("Étape 1 échouée : principal introuvable\n$diag")
|
||||||
|
|
||||||
|
val calendarHome = resolveCalendarHome(principalUrl, credentials, diag)
|
||||||
|
?: return@withContext DiscoveryResult.Failure("Étape 2 échouée sur $principalUrl\n$diag")
|
||||||
|
|
||||||
|
val calendars = listCalendars(calendarHome, credentials, normalizedBase)
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
return@withContext DiscoveryResult.Failure("Étape 3 : aucun calendrier VTODO sur $calendarHome\n$diag")
|
||||||
|
}
|
||||||
|
|
||||||
|
DiscoveryResult.Success(buildSources(calendars, calendarHome, username, caldavType, normalizedBase))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nextcloud direct path ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun discoverNextcloud(baseUrl: String, username: String, credentials: String): DiscoveryResult? {
|
||||||
|
val calendarHome = "$baseUrl/calendars/$username/"
|
||||||
|
val resp = client.propfind(calendarHome, credentials, "1", calendarListBody())
|
||||||
|
if (!resp.isSuccess) return null
|
||||||
|
|
||||||
|
val calendars = parseCalendarList(resp.body, baseUrl)
|
||||||
|
if (calendars.isEmpty()) {
|
||||||
|
// Home exists but no VTODO calendars — still a successful connection
|
||||||
|
return DiscoveryResult.Success(
|
||||||
|
listOf(
|
||||||
|
Source(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
type = SourceType.CALDAV,
|
||||||
|
displayName = username,
|
||||||
|
addedAt = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||||
|
updatedAt = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
|
||||||
|
caldavData = SourceCalDavData(
|
||||||
|
serverUrl = calendarHome,
|
||||||
|
username = username,
|
||||||
|
calendarHomeUrl = calendarHome,
|
||||||
|
caldavType = CalDavType.NEXTCLOUD,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return DiscoveryResult.Success(buildSources(calendars, calendarHome, username, CalDavType.NEXTCLOUD, baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Generic PROPFIND discovery ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String, diag: StringBuilder): String? {
|
||||||
|
val origin = baseUrl.substringBefore("://") + "://" + baseUrl.substringAfter("://").substringBefore("/")
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<propfind xmlns="DAV:">
|
||||||
|
<prop><current-user-principal/></prop>
|
||||||
|
</propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
for (url in listOf("$origin/.well-known/caldav", baseUrl)) {
|
||||||
|
val resp = client.propfind(url, credentials, "0", body)
|
||||||
|
diag.append("PROPFIND $url → ${resp.code}\n")
|
||||||
|
if (resp.isSuccess) {
|
||||||
|
val href = extractHref(resp.body, "current-user-principal")
|
||||||
|
if (href != null) return resolveUrl(baseUrl, href)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallbacks
|
||||||
|
for (candidate in listOf("$baseUrl/principals/users/$username/", "$baseUrl/principals/$username/")) {
|
||||||
|
val resp = client.propfind(candidate, credentials, "0", body)
|
||||||
|
diag.append("PROPFIND $candidate → ${resp.code}\n")
|
||||||
|
if (resp.isSuccess) return candidate
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveCalendarHome(principalUrl: String, credentials: String, diag: StringBuilder): String? {
|
||||||
|
val body = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<prop><c:calendar-home-set/></prop>
|
||||||
|
</propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
val urlsToTry = mutableListOf(principalUrl)
|
||||||
|
if (principalUrl.contains("/principals/") && !principalUrl.contains("/principals/users/")) {
|
||||||
|
urlsToTry.add(principalUrl.replaceFirst("/principals/", "/principals/users/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for (url in urlsToTry) {
|
||||||
|
val resp = client.propfind(url, credentials, "0", body)
|
||||||
|
diag.append("PROPFIND calendar-home $url → ${resp.code}\n")
|
||||||
|
if (!resp.isSuccess) continue
|
||||||
|
val href = extractHref(resp.body, "calendar-home-set") ?: continue
|
||||||
|
return resolveUrl(url, href)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listCalendars(homeUrl: String, credentials: String, baseUrl: String): List<CalendarInfo> {
|
||||||
|
val resp = client.propfind(homeUrl, credentials, "1", calendarListBody())
|
||||||
|
if (!resp.isSuccess) return emptyList()
|
||||||
|
return parseCalendarList(resp.body, baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calendarListBody() = """
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
|
<prop>
|
||||||
|
<resourcetype/>
|
||||||
|
<displayname/>
|
||||||
|
<c:supported-calendar-component-set/>
|
||||||
|
</prop>
|
||||||
|
</propfind>
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private data class CalendarInfo(val url: String, val displayName: String)
|
||||||
|
|
||||||
|
private fun buildSources(
|
||||||
|
calendars: List<CalendarInfo>,
|
||||||
|
calendarHome: String,
|
||||||
|
username: String,
|
||||||
|
caldavType: CalDavType,
|
||||||
|
baseUrl: String,
|
||||||
|
): List<Source> {
|
||||||
|
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
|
return calendars.map { cal ->
|
||||||
Source(
|
Source(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
type = SourceType.CALDAV,
|
type = SourceType.CALDAV,
|
||||||
@@ -54,66 +177,12 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return DiscoveryResult.Success(sources)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String): String? {
|
|
||||||
val wellKnown = "$baseUrl/.well-known/caldav"
|
|
||||||
val body = """
|
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<propfind xmlns="DAV:">
|
|
||||||
<prop><current-user-principal/></prop>
|
|
||||||
</propfind>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
for (url in listOf(wellKnown, baseUrl)) {
|
|
||||||
val resp = client.propfind(url, credentials, "0", body)
|
|
||||||
if (resp.isSuccess) {
|
|
||||||
val href = extractHref(resp.body, "current-user-principal")
|
|
||||||
if (href != null) return resolveUrl(baseUrl, href)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: guess principal path
|
|
||||||
return "$baseUrl/principals/$username/"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveCalendarHome(principalUrl: String, credentials: String): String? {
|
|
||||||
val body = """
|
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<prop><c:calendar-home-set/></prop>
|
|
||||||
</propfind>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val resp = client.propfind(principalUrl, credentials, "0", body)
|
|
||||||
if (!resp.isSuccess) return null
|
|
||||||
val href = extractHref(resp.body, "calendar-home-set") ?: return null
|
|
||||||
return resolveUrl(principalUrl, href)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun listCalendars(homeUrl: String, credentials: String, username: String, baseUrl: String): List<CalendarInfo> {
|
|
||||||
val body = """
|
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
|
||||||
<prop>
|
|
||||||
<resourcetype/>
|
|
||||||
<displayname/>
|
|
||||||
<c:supported-calendar-component-set/>
|
|
||||||
</prop>
|
|
||||||
</propfind>
|
|
||||||
""".trimIndent()
|
|
||||||
|
|
||||||
val resp = client.propfind(homeUrl, credentials, "1", body)
|
|
||||||
if (!resp.isSuccess) return emptyList()
|
|
||||||
return parseCalendarList(resp.body, baseUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class CalendarInfo(val url: String, val displayName: String)
|
|
||||||
|
|
||||||
private fun parseCalendarList(xml: String, baseUrl: String): List<CalendarInfo> {
|
private fun parseCalendarList(xml: String, baseUrl: String): List<CalendarInfo> {
|
||||||
val results = mutableListOf<CalendarInfo>()
|
val results = mutableListOf<CalendarInfo>()
|
||||||
runCatching {
|
runCatching {
|
||||||
val factory = XmlPullParserFactory.newInstance()
|
val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
|
||||||
val parser = factory.newPullParser()
|
val parser = factory.newPullParser()
|
||||||
parser.setInput(StringReader(xml))
|
parser.setInput(StringReader(xml))
|
||||||
|
|
||||||
@@ -133,12 +202,10 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
|||||||
event == XmlPullParser.START_TAG && name == "prop" -> inProp = true
|
event == XmlPullParser.START_TAG && name == "prop" -> inProp = true
|
||||||
event == XmlPullParser.END_TAG && name == "prop" -> inProp = false
|
event == XmlPullParser.END_TAG && name == "prop" -> inProp = false
|
||||||
event == XmlPullParser.START_TAG && name == "href" && !inProp -> {
|
event == XmlPullParser.START_TAG && name == "href" && !inProp -> {
|
||||||
parser.next()
|
parser.next(); href = parser.text ?: ""
|
||||||
href = parser.text ?: ""
|
|
||||||
}
|
}
|
||||||
event == XmlPullParser.START_TAG && name == "displayname" -> {
|
event == XmlPullParser.START_TAG && name == "displayname" -> {
|
||||||
parser.next()
|
parser.next(); displayName = parser.text ?: ""
|
||||||
displayName = parser.text ?: ""
|
|
||||||
}
|
}
|
||||||
event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true
|
event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true
|
||||||
event == XmlPullParser.START_TAG && name == "comp" -> {
|
event == XmlPullParser.START_TAG && name == "comp" -> {
|
||||||
@@ -146,8 +213,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
|||||||
}
|
}
|
||||||
event == XmlPullParser.END_TAG && name == "response" -> {
|
event == XmlPullParser.END_TAG && name == "response" -> {
|
||||||
if (isCalendar && supportsTodo && href.isNotBlank()) {
|
if (isCalendar && supportsTodo && href.isNotBlank()) {
|
||||||
val fullUrl = resolveUrl(baseUrl, href)
|
results.add(CalendarInfo(resolveUrl(baseUrl, href), displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') }))
|
||||||
results.add(CalendarInfo(fullUrl, displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') }))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +224,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun extractHref(xml: String, parentTag: String): String? = runCatching {
|
private fun extractHref(xml: String, parentTag: String): String? = runCatching {
|
||||||
val factory = XmlPullParserFactory.newInstance()
|
val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
|
||||||
val parser = factory.newPullParser()
|
val parser = factory.newPullParser()
|
||||||
parser.setInput(StringReader(xml))
|
parser.setInput(StringReader(xml))
|
||||||
var inTarget = false
|
var inTarget = false
|
||||||
@@ -169,8 +235,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
|||||||
event == XmlPullParser.START_TAG && name == parentTag -> inTarget = true
|
event == XmlPullParser.START_TAG && name == parentTag -> inTarget = true
|
||||||
event == XmlPullParser.END_TAG && name == parentTag -> inTarget = false
|
event == XmlPullParser.END_TAG && name == parentTag -> inTarget = false
|
||||||
event == XmlPullParser.START_TAG && name == "href" && inTarget -> {
|
event == XmlPullParser.START_TAG && name == "href" && inTarget -> {
|
||||||
parser.next()
|
parser.next(); return parser.text
|
||||||
return parser.text
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
event = parser.next()
|
event = parser.next()
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ class CalDavSyncManager @Inject constructor(
|
|||||||
private fun parseMultiStatus(xml: String, baseUrl: String): List<MultiStatusItem> {
|
private fun parseMultiStatus(xml: String, baseUrl: String): List<MultiStatusItem> {
|
||||||
val results = mutableListOf<MultiStatusItem>()
|
val results = mutableListOf<MultiStatusItem>()
|
||||||
runCatching {
|
runCatching {
|
||||||
val factory = XmlPullParserFactory.newInstance()
|
val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
|
||||||
val parser = factory.newPullParser()
|
val parser = factory.newPullParser()
|
||||||
parser.setInput(StringReader(xml))
|
parser.setInput(StringReader(xml))
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import com.planify.mobile.data.local.entity.TaskEntity
|
|||||||
ReminderEntity::class,
|
ReminderEntity::class,
|
||||||
SourceEntity::class,
|
SourceEntity::class,
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 3,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ interface LabelDao {
|
|||||||
@Query("SELECT * FROM labels WHERE is_deleted = 0 ORDER BY `order` ASC")
|
@Query("SELECT * FROM labels WHERE is_deleted = 0 ORDER BY `order` ASC")
|
||||||
fun getAllLabels(): Flow<List<LabelEntity>>
|
fun getAllLabels(): Flow<List<LabelEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM labels WHERE is_deleted = 0 AND source_id = :sourceId ORDER BY `order` ASC")
|
||||||
|
fun getLabelsBySourceId(sourceId: String): Flow<List<LabelEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM labels WHERE id = :id")
|
@Query("SELECT * FROM labels WHERE id = :id")
|
||||||
suspend fun getById(id: String): LabelEntity?
|
suspend fun getById(id: String): LabelEntity?
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ interface TaskDao {
|
|||||||
""")
|
""")
|
||||||
fun getTodayTasks(): Flow<List<TaskEntity>>
|
fun getTodayTasks(): Flow<List<TaskEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM tasks WHERE date(due_date) = date('now') AND checked = 1 AND is_deleted = 0")
|
||||||
|
fun getDoneTodayCount(): Flow<Int>
|
||||||
|
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT * FROM tasks
|
SELECT * FROM tasks
|
||||||
WHERE date(due_date) < date('now') AND checked = 0 AND is_deleted = 0
|
WHERE date(due_date) < date('now') AND checked = 0 AND is_deleted = 0
|
||||||
|
|||||||
@@ -2,20 +2,11 @@ package com.planify.mobile.data.local.entity
|
|||||||
|
|
||||||
import androidx.room.ColumnInfo
|
import androidx.room.ColumnInfo
|
||||||
import androidx.room.Entity
|
import androidx.room.Entity
|
||||||
import androidx.room.ForeignKey
|
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "tasks",
|
tableName = "tasks",
|
||||||
foreignKeys = [
|
|
||||||
ForeignKey(
|
|
||||||
entity = ProjectEntity::class,
|
|
||||||
parentColumns = ["id"],
|
|
||||||
childColumns = ["project_id"],
|
|
||||||
onDelete = ForeignKey.CASCADE
|
|
||||||
)
|
|
||||||
],
|
|
||||||
indices = [
|
indices = [
|
||||||
Index("project_id"),
|
Index("project_id"),
|
||||||
Index("section_id"),
|
Index("section_id"),
|
||||||
@@ -45,4 +36,5 @@ data class TaskEntity(
|
|||||||
@ColumnInfo(name = "ical_url") val icalUrl: String? = null,
|
@ColumnInfo(name = "ical_url") val icalUrl: String? = null,
|
||||||
val etag: String? = null,
|
val etag: String? = null,
|
||||||
@ColumnInfo(name = "responsible_uid") val responsibleUid: String? = null,
|
@ColumnInfo(name = "responsible_uid") val responsibleUid: String? = null,
|
||||||
|
@ColumnInfo(name = "is_deleted") val isDeleted: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class LabelRepositoryImpl @Inject constructor(
|
|||||||
override fun getAllLabels(): Flow<List<Label>> =
|
override fun getAllLabels(): Flow<List<Label>> =
|
||||||
dao.getAllLabels().map { it.map { e -> e.toDomain() } }
|
dao.getAllLabels().map { it.map { e -> e.toDomain() } }
|
||||||
|
|
||||||
|
override fun getLabelsByProject(projectId: String): Flow<List<Label>> =
|
||||||
|
dao.getLabelsBySourceId(projectId).map { it.map { e -> e.toDomain() } }
|
||||||
|
|
||||||
override suspend fun getLabelById(id: String): Label? = dao.getById(id)?.toDomain()
|
override suspend fun getLabelById(id: String): Label? = dao.getById(id)?.toDomain()
|
||||||
override suspend fun insertLabel(label: Label) = dao.insert(label.toEntity())
|
override suspend fun insertLabel(label: Label) = dao.insert(label.toEntity())
|
||||||
override suspend fun updateLabel(label: Label) = dao.update(label.toEntity())
|
override suspend fun updateLabel(label: Label) = dao.update(label.toEntity())
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class TaskRepositoryImpl @Inject constructor(
|
|||||||
override fun getTodayTasks(): Flow<List<Task>> =
|
override fun getTodayTasks(): Flow<List<Task>> =
|
||||||
dao.getTodayTasks().map { it.map { e -> e.toDomain() } }
|
dao.getTodayTasks().map { it.map { e -> e.toDomain() } }
|
||||||
|
|
||||||
|
override fun getDoneTodayCount() = dao.getDoneTodayCount()
|
||||||
|
|
||||||
override fun getOverdueTasks(): Flow<List<Task>> =
|
override fun getOverdueTasks(): Flow<List<Task>> =
|
||||||
dao.getOverdueTasks().map { it.map { e -> e.toDomain() } }
|
dao.getOverdueTasks().map { it.map { e -> e.toDomain() } }
|
||||||
|
|
||||||
@@ -117,7 +119,7 @@ class TaskRepositoryImpl @Inject constructor(
|
|||||||
checked = checked,
|
checked = checked,
|
||||||
dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) },
|
dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) },
|
||||||
deadlineDate = deadlineDate,
|
deadlineDate = deadlineDate,
|
||||||
labels = Json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.builtins.serializer()), labels),
|
labels = Json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.serializer<String>()), labels),
|
||||||
pinned = pinned,
|
pinned = pinned,
|
||||||
collapsed = collapsed,
|
collapsed = collapsed,
|
||||||
childOrder = childOrder,
|
childOrder = childOrder,
|
||||||
|
|||||||
@@ -3,4 +3,4 @@ package com.planify.mobile.domain.model
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class BackendType { LOCAL, CALDAV, TODOIST }
|
enum class BackendType { LOCAL, CALDAV, TODOIST, BONSAI }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
|
|
||||||
interface LabelRepository {
|
interface LabelRepository {
|
||||||
fun getAllLabels(): Flow<List<Label>>
|
fun getAllLabels(): Flow<List<Label>>
|
||||||
|
fun getLabelsByProject(projectId: String): Flow<List<Label>>
|
||||||
suspend fun getLabelById(id: String): Label?
|
suspend fun getLabelById(id: String): Label?
|
||||||
suspend fun insertLabel(label: Label)
|
suspend fun insertLabel(label: Label)
|
||||||
suspend fun updateLabel(label: Label)
|
suspend fun updateLabel(label: Label)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface TaskRepository {
|
|||||||
fun getTasksBySection(sectionId: String): Flow<List<Task>>
|
fun getTasksBySection(sectionId: String): Flow<List<Task>>
|
||||||
fun getInboxTasks(): Flow<List<Task>>
|
fun getInboxTasks(): Flow<List<Task>>
|
||||||
fun getTodayTasks(): Flow<List<Task>>
|
fun getTodayTasks(): Flow<List<Task>>
|
||||||
|
fun getDoneTodayCount(): Flow<Int>
|
||||||
fun getOverdueTasks(): Flow<List<Task>>
|
fun getOverdueTasks(): Flow<List<Task>>
|
||||||
fun getSubTasks(parentId: String): Flow<List<Task>>
|
fun getSubTasks(parentId: String): Flow<List<Task>>
|
||||||
suspend fun getTaskById(id: String): Task?
|
suspend fun getTaskById(id: String): Task?
|
||||||
|
|||||||
@@ -4,18 +4,30 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.viewModels
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import com.planify.mobile.ui.auth.AuthStatus
|
||||||
|
import com.planify.mobile.ui.auth.AuthViewModel
|
||||||
|
import com.planify.mobile.ui.auth.LoginScreen
|
||||||
import com.planify.mobile.ui.theme.PlanifyTheme
|
import com.planify.mobile.ui.theme.PlanifyTheme
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val authViewModel: AuthViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
PlanifyTheme {
|
PlanifyTheme {
|
||||||
MainScreen()
|
val status by authViewModel.status.collectAsState()
|
||||||
|
when (status) {
|
||||||
|
is AuthStatus.Authenticated -> MainScreen(authViewModel)
|
||||||
|
else -> LoginScreen(authViewModel)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,177 +1,159 @@
|
|||||||
package com.planify.mobile.ui
|
package com.planify.mobile.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.FolderOpen
|
import androidx.compose.material.icons.outlined.Add
|
||||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
import androidx.compose.material.icons.outlined.FilterList
|
import androidx.compose.material.icons.outlined.GridView
|
||||||
import androidx.compose.material.icons.outlined.Inbox
|
import androidx.compose.material.icons.outlined.Person
|
||||||
import androidx.compose.material.icons.outlined.Menu
|
|
||||||
import androidx.compose.material.icons.outlined.Search
|
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
|
||||||
import androidx.compose.material.icons.outlined.Today
|
import androidx.compose.material.icons.outlined.Today
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.rememberDrawerState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import com.planify.mobile.domain.model.Task
|
||||||
|
import com.planify.mobile.ui.auth.AuthViewModel
|
||||||
import com.planify.mobile.ui.navigation.DrawerViewModel
|
import com.planify.mobile.ui.navigation.DrawerViewModel
|
||||||
import com.planify.mobile.ui.navigation.PlanifyNavHost
|
import com.planify.mobile.ui.navigation.PlanifyNavHost
|
||||||
import com.planify.mobile.ui.navigation.Route
|
import com.planify.mobile.ui.navigation.Route
|
||||||
import kotlinx.coroutines.launch
|
import com.planify.mobile.ui.task.TaskEditSheet
|
||||||
|
|
||||||
|
private data class BottomTab(
|
||||||
|
val route: String,
|
||||||
|
val icon: ImageVector,
|
||||||
|
val label: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val bottomTabs = listOf(
|
||||||
|
BottomTab(Route.Today.path, Icons.Outlined.Today, "Aujourd'hui"),
|
||||||
|
BottomTab(Route.Scheduled.path, Icons.Outlined.CalendarMonth, "Prévu"),
|
||||||
|
BottomTab(Route.ProjectsList.path, Icons.Outlined.GridView, "Projets"),
|
||||||
|
BottomTab(Route.Settings.path, Icons.Outlined.Person, "Profil"),
|
||||||
|
)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
|
fun MainScreen(
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
drawerViewModel: DrawerViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val projects by viewModel.projects.collectAsState()
|
|
||||||
val navBackStack by navController.currentBackStackEntryAsState()
|
val navBackStack by navController.currentBackStackEntryAsState()
|
||||||
val currentRoute = navBackStack?.destination?.route
|
val currentRoute = navBackStack?.destination?.route
|
||||||
|
|
||||||
val drawerTitles = mapOf(
|
var showCreateTask by remember { mutableStateOf(false) }
|
||||||
Route.Inbox.path to "Inbox",
|
var selectedTask by remember { mutableStateOf<Task?>(null) }
|
||||||
Route.Today.path to "Aujourd'hui",
|
|
||||||
Route.Scheduled.path to "Planifié",
|
val projects by drawerViewModel.projects.collectAsState()
|
||||||
Route.Search.path to "Recherche",
|
val inboxProjectId = projects.find { it.isInbox }?.id ?: ""
|
||||||
Route.Filter.path to "Filtres",
|
val createProjectId = if (currentRoute == Route.Project().path)
|
||||||
Route.Settings.path to "Paramètres",
|
navBackStack?.arguments?.getString("projectId") ?: inboxProjectId
|
||||||
)
|
else
|
||||||
val title = drawerTitles[currentRoute]
|
inboxProjectId
|
||||||
?: projects.find { "project/${it.id}" == currentRoute }?.name
|
|
||||||
?: "Planify"
|
val hideBottomBarRoutes = setOf<String>()
|
||||||
|
val showBottomBar = currentRoute !in hideBottomBarRoutes
|
||||||
|
val hideFabRoutes = setOf(Route.Settings.path)
|
||||||
|
val showFab = currentRoute !in hideFabRoutes
|
||||||
|
|
||||||
ModalNavigationDrawer(
|
|
||||||
drawerState = drawerState,
|
|
||||||
drawerContent = {
|
|
||||||
ModalDrawerSheet {
|
|
||||||
Text(
|
|
||||||
text = "Planify",
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp),
|
|
||||||
)
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Outlined.Inbox, null) },
|
|
||||||
label = { Text("Inbox") },
|
|
||||||
selected = currentRoute == Route.Inbox.path,
|
|
||||||
onClick = {
|
|
||||||
navController.navigate(Route.Inbox.path)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Outlined.Today, null) },
|
|
||||||
label = { Text("Aujourd'hui") },
|
|
||||||
selected = currentRoute == Route.Today.path,
|
|
||||||
onClick = {
|
|
||||||
navController.navigate(Route.Today.path)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Outlined.CalendarMonth, null) },
|
|
||||||
label = { Text("Planifié") },
|
|
||||||
selected = currentRoute == Route.Scheduled.path,
|
|
||||||
onClick = {
|
|
||||||
navController.navigate(Route.Scheduled.path)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Outlined.Search, null) },
|
|
||||||
label = { Text("Recherche") },
|
|
||||||
selected = currentRoute == Route.Search.path,
|
|
||||||
onClick = {
|
|
||||||
navController.navigate(Route.Search.path)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Outlined.FilterList, null) },
|
|
||||||
label = { Text("Filtres") },
|
|
||||||
selected = currentRoute == Route.Filter.path,
|
|
||||||
onClick = {
|
|
||||||
navController.navigate(Route.Filter.path)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Projets",
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
)
|
|
||||||
LazyColumn {
|
|
||||||
items(projects) { project ->
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Default.FolderOpen, null) },
|
|
||||||
label = { Text(project.name) },
|
|
||||||
selected = currentRoute == "project/${project.id}",
|
|
||||||
onClick = {
|
|
||||||
navController.navigate(Route.Project().buildRoute(project.id))
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
|
||||||
NavigationDrawerItem(
|
|
||||||
icon = { Icon(Icons.Outlined.Settings, null) },
|
|
||||||
label = { Text("Paramètres") },
|
|
||||||
selected = currentRoute == Route.Settings.path,
|
|
||||||
onClick = {
|
|
||||||
navController.navigate(Route.Settings.path)
|
|
||||||
scope.launch { drawerState.close() }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
bottomBar = {
|
||||||
TopAppBar(
|
if (showBottomBar) {
|
||||||
title = { Text(title) },
|
NavigationBar(
|
||||||
navigationIcon = {
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
tonalElevation = 0.dp,
|
||||||
Icon(Icons.Outlined.Menu, contentDescription = "Menu")
|
) {
|
||||||
|
bottomTabs.forEach { tab ->
|
||||||
|
val selected = currentRoute == tab.route ||
|
||||||
|
(tab.route == Route.ProjectsList.path && currentRoute == Route.Project().path)
|
||||||
|
NavigationBarItem(
|
||||||
|
selected = selected,
|
||||||
|
onClick = {
|
||||||
|
if (currentRoute != tab.route) {
|
||||||
|
navController.navigate(tab.route) {
|
||||||
|
popUpTo(Route.Today.path) { saveState = true }
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = tab.icon,
|
||||||
|
contentDescription = tab.label,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = tab.label,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = NavigationBarItemDefaults.colors(
|
||||||
|
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||||
|
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||||
|
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (showFab) {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { showCreateTask = true },
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Outlined.Add, contentDescription = "Nouvelle tâche")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
) { padding ->
|
) { padding ->
|
||||||
PlanifyNavHost(
|
PlanifyNavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
|
authViewModel = authViewModel,
|
||||||
|
onTaskClick = { task -> selectedTask = task },
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (showCreateTask) {
|
||||||
|
TaskEditSheet(
|
||||||
|
projectId = createProjectId,
|
||||||
|
onDismiss = { showCreateTask = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedTask?.let { task ->
|
||||||
|
TaskEditSheet(
|
||||||
|
taskId = task.id,
|
||||||
|
projectId = task.projectId,
|
||||||
|
onDismiss = { selectedTask = null },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.planify.mobile.ui.auth
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.planify.mobile.data.bonsai.BonsaiAuthManager
|
||||||
|
import com.planify.mobile.data.bonsai.BonsaiSyncManager
|
||||||
|
import com.planify.mobile.data.bonsai.LoginResult
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed class AuthStatus {
|
||||||
|
object Checking : AuthStatus()
|
||||||
|
object NotAuthenticated : AuthStatus()
|
||||||
|
data class Authenticated(val username: String) : AuthStatus()
|
||||||
|
data class LoginError(val message: String) : AuthStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AuthViewModel @Inject constructor(
|
||||||
|
private val authManager: BonsaiAuthManager,
|
||||||
|
private val syncManager: BonsaiSyncManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _status = MutableStateFlow<AuthStatus>(AuthStatus.Checking)
|
||||||
|
val status: StateFlow<AuthStatus> = _status.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
checkAuthOnStartup()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkAuthOnStartup() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (!authManager.isLoggedIn) {
|
||||||
|
_status.value = AuthStatus.NotAuthenticated
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
// Show the app immediately — don't block on a network call.
|
||||||
|
_status.value = AuthStatus.Authenticated(authManager.getUsername())
|
||||||
|
// Refresh token + sync in background without blocking startup.
|
||||||
|
viewModelScope.launch {
|
||||||
|
authManager.refreshIfNeeded()
|
||||||
|
syncManager.sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login(username: String, password: String) {
|
||||||
|
_status.value = AuthStatus.Checking
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = authManager.login(username, password)) {
|
||||||
|
is LoginResult.Success -> {
|
||||||
|
_status.value = AuthStatus.Authenticated(authManager.getUsername())
|
||||||
|
syncManager.sync()
|
||||||
|
}
|
||||||
|
is LoginResult.Failure -> {
|
||||||
|
_status.value = AuthStatus.LoginError(result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
authManager.logout()
|
||||||
|
_status.value = AuthStatus.NotAuthenticated
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearError() {
|
||||||
|
_status.value = AuthStatus.NotAuthenticated
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.planify.mobile.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Visibility
|
||||||
|
import androidx.compose.material.icons.outlined.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.planify.mobile.R
|
||||||
|
|
||||||
|
private val MintBackground = Color(0xFFF0FDF4)
|
||||||
|
private val BonsaiGreen = Color(0xFF38A169)
|
||||||
|
private val FieldBorder = Color(0xFFD1D5DB)
|
||||||
|
private val LabelColor = Color(0xFF374151)
|
||||||
|
private val TitleColor = Color(0xFF111827)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LoginScreen(viewModel: AuthViewModel) {
|
||||||
|
val status by viewModel.status.collectAsState()
|
||||||
|
val isLoading = status is AuthStatus.Checking
|
||||||
|
val errorMessage = (status as? AuthStatus.LoginError)?.message
|
||||||
|
|
||||||
|
var username by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
var passwordVisible by remember { mutableStateOf(false) }
|
||||||
|
val passwordFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MintBackground)
|
||||||
|
.imePadding(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
shadowElevation = 6.dp,
|
||||||
|
color = Color.White,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 28.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.ic_bonsai_foreground),
|
||||||
|
contentDescription = "Bonsai",
|
||||||
|
modifier = Modifier.size(72.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Bonsai",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
color = TitleColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Connexion",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
color = LabelColor,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Username field
|
||||||
|
Text(
|
||||||
|
text = "Email ou nom d'utilisateur",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LabelColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = username,
|
||||||
|
onValueChange = { username = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isLoading,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Email,
|
||||||
|
imeAction = ImeAction.Next,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onNext = { passwordFocus.requestFocus() }
|
||||||
|
),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
unfocusedBorderColor = FieldBorder,
|
||||||
|
focusedBorderColor = BonsaiGreen,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Password field
|
||||||
|
Text(
|
||||||
|
text = "Mot de passe",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = LabelColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 4.dp),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(passwordFocus),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isLoading,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None
|
||||||
|
else PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(
|
||||||
|
onDone = {
|
||||||
|
if (username.isNotBlank() && password.isNotBlank()) {
|
||||||
|
viewModel.login(username.trim(), password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Outlined.Visibility
|
||||||
|
else Icons.Outlined.VisibilityOff,
|
||||||
|
contentDescription = if (passwordVisible) "Masquer" else "Afficher",
|
||||||
|
tint = Color(0xFF6B7280),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
unfocusedBorderColor = FieldBorder,
|
||||||
|
focusedBorderColor = BonsaiGreen,
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
errorMessage?.let {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { viewModel.login(username.trim(), password) },
|
||||||
|
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(48.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = BonsaiGreen),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
) {
|
||||||
|
if (isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Se connecter",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
package com.planify.mobile.ui.components
|
package com.planify.mobile.ui.components
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.CheckboxDefaults
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -20,7 +28,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -35,40 +45,54 @@ fun TaskRow(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val priorityColor = priorityColor[task.priority] ?: Color.Gray
|
val checkColor = when {
|
||||||
|
task.checked -> MaterialTheme.colorScheme.primary
|
||||||
|
task.priority == 1 -> MaterialTheme.colorScheme.secondary
|
||||||
|
task.priority == 2 -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
|
||||||
|
task.priority == 3 -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
|
||||||
|
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
}
|
||||||
val textColor by animateColorAsState(
|
val textColor by animateColorAsState(
|
||||||
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
if (task.checked) MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
label = "textColor",
|
label = "textColor",
|
||||||
)
|
)
|
||||||
|
|
||||||
Row(
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.combinedClickable(onClick = onClick, onLongClick = {})
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
tonalElevation = 0.dp,
|
||||||
) {
|
) {
|
||||||
Checkbox(
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 14.dp, vertical = 13.dp),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
CircleCheckbox(
|
||||||
checked = task.checked,
|
checked = task.checked,
|
||||||
onCheckedChange = onCheckedChange,
|
color = checkColor,
|
||||||
colors = CheckboxDefaults.colors(
|
onClick = { onCheckedChange(!task.checked) },
|
||||||
checkedColor = priorityColor,
|
modifier = Modifier.padding(top = 1.dp),
|
||||||
uncheckedColor = priorityColor,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.width(4.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = task.content,
|
text = task.content,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
color = textColor,
|
color = textColor,
|
||||||
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
|
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
val hasMeta = task.dueDate != null || task.labels.isNotEmpty()
|
||||||
|
if (hasMeta) {
|
||||||
|
Spacer(Modifier.height(5.dp))
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
if (task.dueDate != null) {
|
if (task.dueDate != null) {
|
||||||
@@ -79,9 +103,34 @@ fun TaskRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (task.priority < 4) {
|
}
|
||||||
Spacer(Modifier.width(8.dp))
|
}
|
||||||
PriorityBadge(priority = task.priority)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CircleCheckbox(
|
||||||
|
checked: Boolean,
|
||||||
|
color: Color,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(22.dp)
|
||||||
|
.border(2.dp, color, CircleShape)
|
||||||
|
.background(if (checked) color else Color.Transparent, CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (checked) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
modifier = Modifier.size(13.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +138,6 @@ fun TaskRow(
|
|||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
private fun TaskRowPreview() {
|
private fun TaskRowPreview() {
|
||||||
Surface {
|
|
||||||
Column {
|
Column {
|
||||||
TaskRow(
|
TaskRow(
|
||||||
task = Task(
|
task = Task(
|
||||||
@@ -102,7 +150,6 @@ private fun TaskRowPreview() {
|
|||||||
onCheckedChange = {},
|
onCheckedChange = {},
|
||||||
onClick = {},
|
onClick = {},
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(1.dp))
|
|
||||||
TaskRow(
|
TaskRow(
|
||||||
task = Task(
|
task = Task(
|
||||||
id = "2",
|
id = "2",
|
||||||
@@ -115,5 +162,4 @@ private fun TaskRowPreview() {
|
|||||||
onClick = {},
|
onClick = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,19 @@ package com.planify.mobile.ui.navigation
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.planify.mobile.domain.model.BackendType
|
||||||
import com.planify.mobile.domain.model.Project
|
import com.planify.mobile.domain.model.Project
|
||||||
import com.planify.mobile.domain.repository.ProjectRepository
|
import com.planify.mobile.domain.repository.ProjectRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DrawerViewModel @Inject constructor(
|
class DrawerViewModel @Inject constructor(
|
||||||
projectRepository: ProjectRepository,
|
private val projectRepository: ProjectRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val projects = projectRepository.getAllProjects()
|
val projects = projectRepository.getAllProjects()
|
||||||
@@ -19,4 +22,20 @@ class DrawerViewModel @Inject constructor(
|
|||||||
|
|
||||||
val favorites = projectRepository.getFavoriteProjects()
|
val favorites = projectRepository.getFavoriteProjects()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList<Project>())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList<Project>())
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
if (projectRepository.getInboxProject() == null) {
|
||||||
|
projectRepository.insertProject(
|
||||||
|
Project(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
name = "Boîte de réception",
|
||||||
|
isInbox = true,
|
||||||
|
backendType = BackendType.LOCAL,
|
||||||
|
childOrder = -1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import androidx.navigation.NavType
|
|||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
|
import com.planify.mobile.domain.model.Task
|
||||||
|
import com.planify.mobile.ui.auth.AuthViewModel
|
||||||
import com.planify.mobile.ui.filter.FilterScreen
|
import com.planify.mobile.ui.filter.FilterScreen
|
||||||
import com.planify.mobile.ui.inbox.InboxScreen
|
import com.planify.mobile.ui.inbox.InboxScreen
|
||||||
import com.planify.mobile.ui.label.LabelScreen
|
import com.planify.mobile.ui.label.LabelScreen
|
||||||
import com.planify.mobile.ui.project.ProjectScreen
|
import com.planify.mobile.ui.project.ProjectScreen
|
||||||
|
import com.planify.mobile.ui.project.ProjectsListScreen
|
||||||
import com.planify.mobile.ui.scheduled.ScheduledScreen
|
import com.planify.mobile.ui.scheduled.ScheduledScreen
|
||||||
import com.planify.mobile.ui.search.SearchScreen
|
import com.planify.mobile.ui.search.SearchScreen
|
||||||
import com.planify.mobile.ui.settings.SettingsScreen
|
import com.planify.mobile.ui.settings.SettingsScreen
|
||||||
@@ -19,23 +22,45 @@ import com.planify.mobile.ui.today.TodayScreen
|
|||||||
@Composable
|
@Composable
|
||||||
fun PlanifyNavHost(
|
fun PlanifyNavHost(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
|
onTaskClick: (Task) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
NavHost(
|
NavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
startDestination = Route.Inbox.path,
|
startDestination = Route.Today.path,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
) {
|
) {
|
||||||
composable(Route.Inbox.path) {
|
composable(Route.Today.path) {
|
||||||
InboxScreen(
|
TodayScreen(onTaskClick = onTaskClick)
|
||||||
onTaskClick = { /* TODO #11 : ouvrir édition */ }
|
}
|
||||||
|
|
||||||
|
composable(Route.Scheduled.path) {
|
||||||
|
ScheduledScreen(onTaskClick = onTaskClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
composable(Route.ProjectsList.path) {
|
||||||
|
ProjectsListScreen(
|
||||||
|
onProjectClick = { projectId ->
|
||||||
|
navController.navigate(Route.Project().buildRoute(projectId))
|
||||||
|
},
|
||||||
|
onInboxClick = { navController.navigate(Route.Inbox.path) },
|
||||||
|
onTodayClick = {
|
||||||
|
navController.navigate(Route.Today.path) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScheduledClick = {
|
||||||
|
navController.navigate(Route.Scheduled.path) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLabelsClick = { navController.navigate(Route.Filter.path) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Route.Today.path) {
|
composable(Route.Inbox.path) {
|
||||||
TodayScreen(
|
InboxScreen(onTaskClick = onTaskClick)
|
||||||
onTaskClick = { /* TODO #11 : ouvrir édition */ }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
@@ -45,21 +70,17 @@ fun PlanifyNavHost(
|
|||||||
val projectId = backStack.arguments?.getString("projectId") ?: return@composable
|
val projectId = backStack.arguments?.getString("projectId") ?: return@composable
|
||||||
ProjectScreen(
|
ProjectScreen(
|
||||||
projectId = projectId,
|
projectId = projectId,
|
||||||
onTaskClick = { /* TODO: ouvrir édition */ },
|
onTaskClick = onTaskClick,
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Route.Scheduled.path) {
|
|
||||||
ScheduledScreen(onTaskClick = { /* TODO: ouvrir édition */ })
|
|
||||||
}
|
|
||||||
|
|
||||||
composable(Route.Search.path) {
|
composable(Route.Search.path) {
|
||||||
SearchScreen(onTaskClick = { /* TODO: ouvrir édition */ })
|
SearchScreen(onTaskClick = onTaskClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Route.Filter.path) {
|
composable(Route.Filter.path) {
|
||||||
FilterScreen(onTaskClick = { /* TODO: ouvrir édition */ })
|
FilterScreen(onTaskClick = onTaskClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(
|
composable(
|
||||||
@@ -69,12 +90,12 @@ fun PlanifyNavHost(
|
|||||||
val labelId = backStack.arguments?.getString("labelId") ?: return@composable
|
val labelId = backStack.arguments?.getString("labelId") ?: return@composable
|
||||||
LabelScreen(
|
LabelScreen(
|
||||||
labelId = labelId,
|
labelId = labelId,
|
||||||
onTaskClick = { /* TODO: ouvrir édition */ },
|
onTaskClick = onTaskClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
composable(Route.Settings.path) {
|
composable(Route.Settings.path) {
|
||||||
SettingsScreen()
|
SettingsScreen(authViewModel = authViewModel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ sealed class Route(val path: String) {
|
|||||||
data object Scheduled : Route("scheduled")
|
data object Scheduled : Route("scheduled")
|
||||||
data object Search : Route("search")
|
data object Search : Route("search")
|
||||||
data object Filter : Route("filter")
|
data object Filter : Route("filter")
|
||||||
|
data object ProjectsList : Route("projects")
|
||||||
data class Project(val projectId: String = "{projectId}") :
|
data class Project(val projectId: String = "{projectId}") :
|
||||||
Route("project/{projectId}") {
|
Route("project/{projectId}") {
|
||||||
fun buildRoute(id: String) = "project/$id"
|
fun buildRoute(id: String) = "project/$id"
|
||||||
|
|||||||
@@ -1,21 +1,33 @@
|
|||||||
package com.planify.mobile.ui.project
|
package com.planify.mobile.ui.project
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.ArrowBack
|
||||||
import androidx.compose.material.icons.outlined.FolderOpen
|
import androidx.compose.material.icons.outlined.FolderOpen
|
||||||
|
import androidx.compose.material.icons.outlined.MoreVert
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -25,7 +37,11 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.planify.mobile.domain.model.Task
|
import com.planify.mobile.domain.model.Task
|
||||||
import com.planify.mobile.domain.model.ViewStyle
|
import com.planify.mobile.domain.model.ViewStyle
|
||||||
@@ -49,14 +65,27 @@ fun ProjectScreen(
|
|||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
|
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
) {
|
||||||
|
// Green project banner
|
||||||
|
ProjectBanner(
|
||||||
|
projectName = state.project?.name ?: "",
|
||||||
|
totalTasks = state.sections.sumOf { it.tasks.size },
|
||||||
|
doneTasks = state.sections.sumOf { s -> s.tasks.count { it.checked } },
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
|
||||||
if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
|
if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = Icons.Outlined.FolderOpen,
|
icon = Icons.Outlined.FolderOpen,
|
||||||
title = "Projet vide",
|
title = "Projet vide",
|
||||||
subtitle = "Créez votre première tâche avec le bouton +",
|
subtitle = "Créez votre première tâche avec le bouton +",
|
||||||
modifier = modifier,
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
return
|
return@Column
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.viewStyle) {
|
when (state.viewStyle) {
|
||||||
@@ -71,13 +100,139 @@ fun ProjectScreen(
|
|||||||
onTaskClick = onTaskClick,
|
onTaskClick = onTaskClick,
|
||||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||||
onReorder = { viewModel.reorderTasks(it) },
|
onReorder = { viewModel.reorderTasks(it) },
|
||||||
modifier = modifier,
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
ViewStyle.BOARD -> ProjectBoardView(
|
ViewStyle.BOARD -> ProjectBoardView(
|
||||||
state = state,
|
state = state,
|
||||||
onTaskClick = onTaskClick,
|
onTaskClick = onTaskClick,
|
||||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||||
modifier = modifier,
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectBanner(
|
||||||
|
projectName: String,
|
||||||
|
totalTasks: Int,
|
||||||
|
doneTasks: Int,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
val progress = if (totalTasks == 0) 0f else doneTasks.toFloat() / totalTasks
|
||||||
|
val remainingTasks = totalTasks - doneTasks
|
||||||
|
val primaryColor = MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(
|
||||||
|
color = primaryColor,
|
||||||
|
shape = RoundedCornerShape(bottomStart = 26.dp, bottomEnd = 26.dp),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 18.dp, vertical = 16.dp),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.background(Color.White.copy(alpha = 0.18f), CircleShape)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.ArrowBack,
|
||||||
|
contentDescription = "Retour",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(13.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.FolderOpen,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.background(Color.White.copy(alpha = 0.18f), CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.MoreVert,
|
||||||
|
contentDescription = "Plus",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = projectName,
|
||||||
|
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp)) {
|
||||||
|
BannerStat(value = "$doneTasks", label = "Terminées")
|
||||||
|
BannerStat(value = "$remainingTasks", label = "Restantes")
|
||||||
|
if (totalTasks > 0) {
|
||||||
|
BannerStat(value = "${(progress * 100).toInt()}%", label = "Avancement")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalTasks > 0) {
|
||||||
|
Spacer(Modifier.height(14.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(6.dp)
|
||||||
|
.background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(6.dp)),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(progress)
|
||||||
|
.height(6.dp)
|
||||||
|
.background(Color.White, RoundedCornerShape(6.dp)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BannerStat(value: String, label: String) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = Color.White,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Medium),
|
||||||
|
color = Color.White.copy(alpha = 0.82f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +250,11 @@ private fun ProjectListView(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val reorderState = rememberReorderState()
|
val reorderState = rememberReorderState()
|
||||||
|
|
||||||
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 96.dp),
|
||||||
|
) {
|
||||||
state.sections.forEach { group ->
|
state.sections.forEach { group ->
|
||||||
val key = group.section?.id ?: "unsectioned"
|
val key = group.section?.id ?: "unsectioned"
|
||||||
val name = group.section?.name ?: "Sans section"
|
val name = group.section?.name ?: "Sans section"
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import com.planify.mobile.domain.model.Project
|
|||||||
import com.planify.mobile.domain.model.Section
|
import com.planify.mobile.domain.model.Section
|
||||||
import com.planify.mobile.domain.model.Task
|
import com.planify.mobile.domain.model.Task
|
||||||
import com.planify.mobile.domain.model.ViewStyle
|
import com.planify.mobile.domain.model.ViewStyle
|
||||||
|
import com.planify.mobile.data.bonsai.BonsaiApiClient
|
||||||
|
import com.planify.mobile.data.bonsai.BonsaiAuthManager
|
||||||
|
import com.planify.mobile.data.bonsai.BonsaiSyncManager
|
||||||
|
import com.planify.mobile.data.bonsai.dto.BonsaIssueRequest
|
||||||
import com.planify.mobile.domain.repository.ProjectRepository
|
import com.planify.mobile.domain.repository.ProjectRepository
|
||||||
import com.planify.mobile.domain.repository.SectionRepository
|
import com.planify.mobile.domain.repository.SectionRepository
|
||||||
import com.planify.mobile.domain.repository.TaskRepository
|
import com.planify.mobile.domain.repository.TaskRepository
|
||||||
@@ -32,6 +36,8 @@ class ProjectViewModel @Inject constructor(
|
|||||||
private val taskRepository: TaskRepository,
|
private val taskRepository: TaskRepository,
|
||||||
private val projectRepository: ProjectRepository,
|
private val projectRepository: ProjectRepository,
|
||||||
private val sectionRepository: SectionRepository,
|
private val sectionRepository: SectionRepository,
|
||||||
|
private val apiClient: BonsaiApiClient,
|
||||||
|
private val authManager: BonsaiAuthManager,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val projectId: String = checkNotNull(savedStateHandle["projectId"])
|
private val projectId: String = checkNotNull(savedStateHandle["projectId"])
|
||||||
@@ -66,7 +72,24 @@ class ProjectViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
|
|
||||||
fun toggleTask(task: Task) {
|
fun toggleTask(task: Task) {
|
||||||
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
|
val newChecked = !task.checked
|
||||||
|
viewModelScope.launch {
|
||||||
|
taskRepository.checkTask(task.id, newChecked)
|
||||||
|
|
||||||
|
val projectIdLong = task.projectId.toLongOrNull() ?: return@launch
|
||||||
|
val taskIdLong = task.id.toLongOrNull() ?: return@launch
|
||||||
|
if (!authManager.isLoggedIn) return@launch
|
||||||
|
authManager.refreshIfNeeded()
|
||||||
|
|
||||||
|
val request = BonsaIssueRequest(
|
||||||
|
name = task.content,
|
||||||
|
priority = BonsaiSyncManager.toBonsaiPriority(task.priority),
|
||||||
|
status = BonsaiSyncManager.toBonsaiStatus(newChecked),
|
||||||
|
dueDate = task.dueDate?.date,
|
||||||
|
description = task.description.ifBlank { null },
|
||||||
|
)
|
||||||
|
apiClient.updateIssue(projectIdLong, taskIdLong, request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reorderTasks(reordered: List<Task>) {
|
fun reorderTasks(reordered: List<Task>) {
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
package com.planify.mobile.ui.project
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
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.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
|
import androidx.compose.material.icons.outlined.Inbox
|
||||||
|
import androidx.compose.material.icons.outlined.Label
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
|
import androidx.compose.material.icons.outlined.Today
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProjectsListScreen(
|
||||||
|
onProjectClick: (String) -> Unit,
|
||||||
|
onInboxClick: () -> Unit,
|
||||||
|
onTodayClick: () -> Unit,
|
||||||
|
onScheduledClick: () -> Unit,
|
||||||
|
onLabelsClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: ProjectsListViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Mon espace",
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
letterSpacing = 1.4.sp,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Projets",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(38.dp)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surface, CircleShape)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.clickable(onClick = onLabelsClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Search,
|
||||||
|
contentDescription = "Recherche",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2×2 quick-access tiles
|
||||||
|
item {
|
||||||
|
val tiles = listOf(
|
||||||
|
Triple(Icons.Outlined.Inbox, "Boîte de réception",
|
||||||
|
"${state.inboxProject?.let { "tâches" } ?: "0 tâche"}"),
|
||||||
|
Triple(Icons.Outlined.Today, "Aujourd'hui",
|
||||||
|
"${state.todayCount} tâche${if (state.todayCount != 1) "s" else ""}"),
|
||||||
|
Triple(Icons.Outlined.CalendarMonth, "Prévu",
|
||||||
|
"${state.scheduledCount} à venir"),
|
||||||
|
Triple(Icons.Outlined.Label, "Étiquettes", ""),
|
||||||
|
)
|
||||||
|
val tileActions = listOf(onInboxClick, onTodayClick, onScheduledClick, onLabelsClick)
|
||||||
|
val tileColors = listOf(
|
||||||
|
Color(0xFF5285AE),
|
||||||
|
MaterialTheme.colorScheme.primary,
|
||||||
|
Color(0xFF8A6BB0),
|
||||||
|
Color(0xFFC2683C),
|
||||||
|
)
|
||||||
|
|
||||||
|
androidx.compose.foundation.layout.Box(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
listOf(0..1, 2..3).forEach { range ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
range.forEach { idx ->
|
||||||
|
val (icon, name, count) = tiles[idx]
|
||||||
|
QuickTile(
|
||||||
|
icon = icon,
|
||||||
|
name = name,
|
||||||
|
count = count,
|
||||||
|
iconColor = tileColors[idx],
|
||||||
|
onClick = tileActions[idx],
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Mes projets" section header
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 18.dp, end = 18.dp, top = 22.dp, bottom = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "MES PROJETS",
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (state.projects.isNotEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
RoundedCornerShape(20.dp),
|
||||||
|
)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${state.projects.size}",
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project list card
|
||||||
|
if (state.projects.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
shape = RoundedCornerShape(20.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
shadowElevation = 0.dp,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(
|
||||||
|
1.dp, MaterialTheme.colorScheme.outlineVariant
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
state.projects.forEachIndexed { idx, projectStats ->
|
||||||
|
ProjectListRow(
|
||||||
|
projectStats = projectStats,
|
||||||
|
onClick = { onProjectClick(projectStats.project.id) },
|
||||||
|
showDivider = idx < state.projects.lastIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Aucun projet pour l'instant",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuickTile(
|
||||||
|
icon: ImageVector,
|
||||||
|
name: String,
|
||||||
|
count: String,
|
||||||
|
iconColor: Color,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border = androidx.compose.foundation.BorderStroke(
|
||||||
|
1.dp, MaterialTheme.colorScheme.outlineVariant
|
||||||
|
),
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(13.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(32.dp)
|
||||||
|
.background(iconColor, RoundedCornerShape(11.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(17.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = name,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
if (count.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = count,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProjectListRow(
|
||||||
|
projectStats: ProjectWithStats,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
showDivider: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 13.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
ProjectProgressRing(
|
||||||
|
progress = projectStats.progress,
|
||||||
|
size = 38.dp,
|
||||||
|
strokeWidth = 4.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
trackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
)
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = projectStats.project.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
if (projectStats.totalTasks > 0) {
|
||||||
|
Text(
|
||||||
|
text = "${projectStats.totalTasks} tâche${if (projectStats.totalTasks != 1) "s" else ""}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (projectStats.totalTasks > 0) {
|
||||||
|
Text(
|
||||||
|
text = "${projectStats.doneTasks}/${projectStats.totalTasks}",
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showDivider) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.padding(horizontal = 14.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.outlineVariant),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProjectProgressRing(
|
||||||
|
progress: Float,
|
||||||
|
size: Dp,
|
||||||
|
strokeWidth: Dp,
|
||||||
|
color: Color,
|
||||||
|
trackColor: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier.size(size)) {
|
||||||
|
val sw = strokeWidth.toPx()
|
||||||
|
val diameter = this.size.minDimension - sw
|
||||||
|
val topLeft = Offset(sw / 2, sw / 2)
|
||||||
|
val arcSize = Size(diameter, diameter)
|
||||||
|
|
||||||
|
drawArc(
|
||||||
|
color = trackColor,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = 360f,
|
||||||
|
useCenter = false,
|
||||||
|
style = Stroke(width = sw, cap = StrokeCap.Round),
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = arcSize,
|
||||||
|
)
|
||||||
|
if (progress > 0f) {
|
||||||
|
drawArc(
|
||||||
|
color = color,
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = progress * 360f,
|
||||||
|
useCenter = false,
|
||||||
|
style = Stroke(width = sw, cap = StrokeCap.Round),
|
||||||
|
topLeft = topLeft,
|
||||||
|
size = arcSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.planify.mobile.ui.project
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.planify.mobile.domain.model.Project
|
||||||
|
import com.planify.mobile.domain.repository.ProjectRepository
|
||||||
|
import com.planify.mobile.domain.repository.TaskRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class ProjectWithStats(
|
||||||
|
val project: Project,
|
||||||
|
val totalTasks: Int,
|
||||||
|
val doneTasks: Int,
|
||||||
|
) {
|
||||||
|
val progress: Float get() = if (totalTasks == 0) 0f else doneTasks.toFloat() / totalTasks
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ProjectsListUiState(
|
||||||
|
val projects: List<ProjectWithStats> = emptyList(),
|
||||||
|
val inboxProject: Project? = null,
|
||||||
|
val todayCount: Int = 0,
|
||||||
|
val scheduledCount: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ProjectsListViewModel @Inject constructor(
|
||||||
|
private val projectRepository: ProjectRepository,
|
||||||
|
private val taskRepository: TaskRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val uiState = combine(
|
||||||
|
projectRepository.getAllProjects(),
|
||||||
|
taskRepository.getAllTasks(),
|
||||||
|
taskRepository.getTodayTasks(),
|
||||||
|
taskRepository.getScheduledTasks(),
|
||||||
|
) { projects, allTasks, todayTasks, scheduledTasks ->
|
||||||
|
val tasksByProject = allTasks.groupBy { it.projectId }
|
||||||
|
val inboxProject = projects.find { it.isInbox }
|
||||||
|
val regularProjects = projects.filter { !it.isInbox }
|
||||||
|
val withStats = regularProjects.map { project ->
|
||||||
|
val tasks = tasksByProject[project.id] ?: emptyList()
|
||||||
|
ProjectWithStats(
|
||||||
|
project = project,
|
||||||
|
totalTasks = tasks.size,
|
||||||
|
doneTasks = tasks.count { it.checked },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ProjectsListUiState(
|
||||||
|
projects = withStats,
|
||||||
|
inboxProject = inboxProject,
|
||||||
|
todayCount = todayTasks.size,
|
||||||
|
scheduledCount = scheduledTasks.size,
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = ProjectsListUiState(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
package com.planify.mobile.ui.scheduled
|
package com.planify.mobile.ui.scheduled
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.CalendarMonth
|
import androidx.compose.material.icons.outlined.CalendarMonth
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -11,38 +22,54 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.planify.mobile.domain.model.Task
|
import com.planify.mobile.domain.model.Task
|
||||||
import com.planify.mobile.ui.components.EmptyState
|
import com.planify.mobile.ui.components.EmptyState
|
||||||
import com.planify.mobile.ui.components.TaskRow
|
import com.planify.mobile.ui.components.TaskRow
|
||||||
|
import java.time.DayOfWeek
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.TextStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ScheduledScreen(
|
fun ScheduledScreen(
|
||||||
onTaskClick: (Task) -> Unit,
|
onTaskClick: (Task) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
viewModel: ScheduledViewModel = hiltViewModel(),
|
viewModel: ScheduledViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val groups by viewModel.groups.collectAsState()
|
val groups by viewModel.groups.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
contentPadding = PaddingValues(bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
item { ScheduledHeader() }
|
||||||
|
|
||||||
|
// Week strip
|
||||||
|
item { WeekStrip() }
|
||||||
|
|
||||||
if (groups.isEmpty()) {
|
if (groups.isEmpty()) {
|
||||||
|
item {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = Icons.Outlined.CalendarMonth,
|
icon = Icons.Outlined.CalendarMonth,
|
||||||
title = "Aucune tâche planifiée",
|
title = "Aucune tâche planifiée",
|
||||||
subtitle = "Les tâches avec une date d'échéance apparaîtront ici",
|
subtitle = "Les tâches avec une date d'échéance apparaîtront ici",
|
||||||
)
|
)
|
||||||
return
|
}
|
||||||
|
return@LazyColumn
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
|
||||||
groups.forEach { group ->
|
groups.forEach { group ->
|
||||||
item(key = group.label) {
|
item(key = "head_${group.label}") {
|
||||||
Text(
|
DayGroupHeader(label = group.label, count = group.tasks.size)
|
||||||
text = group.label,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
items(group.tasks, key = { it.id }) { task ->
|
items(group.tasks, key = { it.id }) { task ->
|
||||||
TaskRow(
|
TaskRow(
|
||||||
@@ -54,3 +81,133 @@ fun ScheduledScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScheduledHeader() {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val monthFormatter = java.time.format.DateTimeFormatter.ofPattern("MMMM yyyy", Locale.FRENCH)
|
||||||
|
val monthStr = today.format(monthFormatter).replaceFirstChar { it.uppercaseChar() }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = monthStr,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
letterSpacing = 1.4.sp,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Prévu",
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekStrip() {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
// Find Monday of the current week
|
||||||
|
val monday = today.with(DayOfWeek.MONDAY)
|
||||||
|
val days = (0..6).map { monday.plusDays(it.toLong()) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
days.forEach { day ->
|
||||||
|
val isToday = day == today
|
||||||
|
WeekDay(
|
||||||
|
dayShort = day.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.FRENCH)
|
||||||
|
.take(3).uppercase(),
|
||||||
|
dayNum = day.dayOfMonth,
|
||||||
|
isToday = isToday,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WeekDay(dayShort: String, dayNum: Int, isToday: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val bgColor = if (isToday) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.surface
|
||||||
|
val textColor = if (isToday) MaterialTheme.colorScheme.onPrimary
|
||||||
|
else MaterialTheme.colorScheme.onSurface
|
||||||
|
val faintColor = if (isToday) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f)
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.border(
|
||||||
|
width = 1.dp,
|
||||||
|
color = if (isToday) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
)
|
||||||
|
.background(bgColor, RoundedCornerShape(16.dp))
|
||||||
|
.padding(vertical = 9.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = dayShort,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
letterSpacing = 0.4.sp,
|
||||||
|
),
|
||||||
|
color = faintColor,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$dayNum",
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = textColor,
|
||||||
|
)
|
||||||
|
// dot placeholder (could indicate tasks on that day)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(5.dp)
|
||||||
|
.background(
|
||||||
|
if (isToday) MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.6f)
|
||||||
|
else MaterialTheme.colorScheme.background,
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DayGroupHeader(label: String, count: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 18.dp, end = 18.dp, top = 16.dp, bottom = 8.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "· $count tâche${if (count > 1) "s" else ""}",
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package com.planify.mobile.ui.settings
|
package com.planify.mobile.ui.settings
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -11,12 +10,10 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.outlined.AccountCircle
|
import androidx.compose.material.icons.outlined.AccountCircle
|
||||||
import androidx.compose.material.icons.outlined.Add
|
import androidx.compose.material.icons.outlined.CheckCircle
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
|
||||||
import androidx.compose.material.icons.outlined.Download
|
|
||||||
import androidx.compose.material.icons.outlined.Sync
|
import androidx.compose.material.icons.outlined.Sync
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -24,52 +21,27 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.planify.mobile.data.preferences.ThemeMode
|
import com.planify.mobile.data.preferences.ThemeMode
|
||||||
import com.planify.mobile.domain.model.Source
|
import com.planify.mobile.ui.auth.AuthViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
|
authViewModel: AuthViewModel,
|
||||||
viewModel: SettingsViewModel = hiltViewModel(),
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val discovery by viewModel.discoveryInProgress.collectAsState()
|
|
||||||
val exportUri by viewModel.exportUri.collectAsState()
|
|
||||||
var showAddAccount by remember { mutableStateOf(false) }
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
LaunchedEffect(exportUri) {
|
|
||||||
exportUri?.let { uri ->
|
|
||||||
val mime = if (uri.path?.endsWith(".ics") == true) "text/calendar" else "application/json"
|
|
||||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
|
||||||
type = mime
|
|
||||||
putExtra(Intent.EXTRA_STREAM, uri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
context.startActivity(Intent.createChooser(intent, "Exporter"))
|
|
||||||
viewModel.clearExportUri()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -77,7 +49,7 @@ fun SettingsScreen(
|
|||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
// ── Apparence ───────────────────────────────────────────────────────
|
// ── Apparence ────────────────────────────────────────────────────────
|
||||||
SectionTitle("Apparence")
|
SectionTitle("Apparence")
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Thème") },
|
headlineContent = { Text("Thème") },
|
||||||
@@ -97,44 +69,66 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
// ── Synchronisation ─────────────────────────────────────────────────
|
// ── Bonsai ───────────────────────────────────────────────────────────
|
||||||
SectionTitle("Synchronisation")
|
SectionTitle("Bonsai")
|
||||||
|
|
||||||
|
if (state.isLoggedIn) {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text("Sync automatique") },
|
leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
|
||||||
trailingContent = {
|
headlineContent = { Text("Connecté") },
|
||||||
Switch(
|
supportingContent = { Text(state.username) },
|
||||||
checked = state.syncEnabled,
|
|
||||||
onCheckedChange = viewModel::setSyncEnabled,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if (state.syncEnabled) {
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Intervalle") },
|
|
||||||
supportingContent = {
|
|
||||||
val options = listOf(15 to "15 min", 30 to "30 min", 60 to "1 h", 240 to "4 h")
|
|
||||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
options.forEachIndexed { index, (mins, label) ->
|
|
||||||
SegmentedButton(
|
|
||||||
selected = state.syncIntervalMinutes == mins,
|
|
||||||
onClick = { viewModel.setSyncInterval(mins) },
|
|
||||||
shape = SegmentedButtonDefaults.itemShape(index, options.size),
|
|
||||||
label = { Text(label) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Synchroniser maintenant") },
|
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
|
if (state.syncInProgress) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
|
||||||
|
} else {
|
||||||
IconButton(onClick = viewModel::syncNow) {
|
IconButton(onClick = viewModel::syncNow) {
|
||||||
Icon(Icons.Outlined.Sync, contentDescription = "Sync")
|
Icon(Icons.Outlined.Sync, contentDescription = "Synchroniser")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (state.syncSuccess) {
|
||||||
|
ListItem(
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = {
|
||||||
|
Text("Synchronisation réussie", color = MaterialTheme.colorScheme.primary)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.syncError?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = authViewModel::logout,
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text("Se déconnecter")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ListItem(
|
||||||
|
leadingContent = { Icon(Icons.Outlined.AccountCircle, null) },
|
||||||
|
headlineContent = { Text("Non connecté") },
|
||||||
|
supportingContent = { Text("Relancez l'application pour vous connecter") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||||
|
|
||||||
// ── Notifications ────────────────────────────────────────────────────
|
// ── Notifications ────────────────────────────────────────────────────
|
||||||
@@ -149,83 +143,8 @@ fun SettingsScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
|
||||||
|
|
||||||
// ── Comptes CalDAV ────────────────────────────────────────────────────
|
|
||||||
SectionTitle("Comptes CalDAV")
|
|
||||||
state.caldavSources.forEach { source ->
|
|
||||||
CalDavSourceRow(source = source, onDelete = { viewModel.removeCalDavAccount(source) })
|
|
||||||
}
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text("Ajouter un compte") },
|
|
||||||
leadingContent = { Icon(Icons.Outlined.Add, contentDescription = null) },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.let { mod ->
|
|
||||||
mod.then(
|
|
||||||
Modifier.padding(0.dp).run {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingContent = null,
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = { showAddAccount = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Outlined.Add, contentDescription = null)
|
|
||||||
Text("Ajouter un compte CalDAV", modifier = Modifier.padding(start = 8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (discovery.first) {
|
|
||||||
ListItem(headlineContent = { Text("Connexion en cours…") })
|
|
||||||
}
|
|
||||||
discovery.second?.let { error ->
|
|
||||||
Text(
|
|
||||||
text = error,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
|
||||||
|
|
||||||
// ── Export & Backup ──────────────────────────────────────────────────
|
|
||||||
SectionTitle("Export & Backup")
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = viewModel::exportJson,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Outlined.Download, contentDescription = null)
|
|
||||||
Text("Exporter en JSON", modifier = Modifier.padding(start = 8.dp))
|
|
||||||
}
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = viewModel::exportIcal,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Icon(Icons.Outlined.Download, contentDescription = null)
|
|
||||||
Text("Exporter en iCalendar (.ics)", modifier = Modifier.padding(start = 8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(32.dp))
|
Spacer(Modifier.height(32.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showAddAccount) {
|
|
||||||
AddCalDavAccountDialog(
|
|
||||||
onDismiss = { showAddAccount = false },
|
|
||||||
onConfirm = { url, user, pwd ->
|
|
||||||
viewModel.addCalDavAccount(url, user, pwd)
|
|
||||||
showAddAccount = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -237,68 +156,3 @@ private fun SectionTitle(text: String) {
|
|||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CalDavSourceRow(source: Source, onDelete: () -> Unit) {
|
|
||||||
ListItem(
|
|
||||||
leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
|
|
||||||
headlineContent = { Text(source.displayName) },
|
|
||||||
supportingContent = { Text(source.caldavData?.serverUrl ?: "") },
|
|
||||||
trailingContent = {
|
|
||||||
IconButton(onClick = onDelete) {
|
|
||||||
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = MaterialTheme.colorScheme.error)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AddCalDavAccountDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (url: String, username: String, password: String) -> Unit,
|
|
||||||
) {
|
|
||||||
var url by remember { mutableStateOf("") }
|
|
||||||
var username by remember { mutableStateOf("") }
|
|
||||||
var password by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("Ajouter un compte CalDAV") },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = url,
|
|
||||||
onValueChange = { url = it },
|
|
||||||
label = { Text("URL du serveur") },
|
|
||||||
placeholder = { Text("https://example.com/caldav") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = username,
|
|
||||||
onValueChange = { username = it },
|
|
||||||
label = { Text("Nom d'utilisateur") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = password,
|
|
||||||
onValueChange = { password = it },
|
|
||||||
label = { Text("Mot de passe") },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = { onConfirm(url.trim(), username.trim(), password) },
|
|
||||||
enabled = url.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
|
|
||||||
) { Text("Connecter") }
|
|
||||||
},
|
|
||||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
package com.planify.mobile.ui.settings
|
package com.planify.mobile.ui.settings
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.planify.mobile.data.caldav.CalDavCredentialStore
|
import com.planify.mobile.data.bonsai.BonsaiAuthManager
|
||||||
import com.planify.mobile.data.caldav.CalDavDiscovery
|
import com.planify.mobile.data.bonsai.BonsaiSyncManager
|
||||||
import com.planify.mobile.data.caldav.DiscoveryResult
|
import com.planify.mobile.data.bonsai.SyncResult
|
||||||
import com.planify.mobile.data.export.ExportManager
|
|
||||||
import com.planify.mobile.data.preferences.AppPreferences
|
import com.planify.mobile.data.preferences.AppPreferences
|
||||||
import com.planify.mobile.data.preferences.ThemeMode
|
import com.planify.mobile.data.preferences.ThemeMode
|
||||||
import com.planify.mobile.data.sync.SyncScheduler
|
|
||||||
import com.planify.mobile.domain.model.Source
|
|
||||||
import com.planify.mobile.domain.repository.ProjectRepository
|
|
||||||
import com.planify.mobile.domain.repository.SourceRepository
|
|
||||||
import com.planify.mobile.domain.repository.TaskRepository
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -27,101 +18,58 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
data class SettingsUiState(
|
data class SettingsUiState(
|
||||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||||
val syncEnabled: Boolean = true,
|
|
||||||
val syncIntervalMinutes: Int = 30,
|
|
||||||
val notificationsEnabled: Boolean = true,
|
val notificationsEnabled: Boolean = true,
|
||||||
val caldavSources: List<Source> = emptyList(),
|
val isLoggedIn: Boolean = false,
|
||||||
val discoveryInProgress: Boolean = false,
|
val username: String = "",
|
||||||
val discoveryError: String? = null,
|
val syncInProgress: Boolean = false,
|
||||||
|
val syncError: String? = null,
|
||||||
|
val syncSuccess: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val prefs: AppPreferences,
|
private val prefs: AppPreferences,
|
||||||
private val sourceRepository: SourceRepository,
|
private val authManager: BonsaiAuthManager,
|
||||||
private val syncScheduler: SyncScheduler,
|
private val syncManager: BonsaiSyncManager,
|
||||||
private val discovery: CalDavDiscovery,
|
|
||||||
private val credentialStore: CalDavCredentialStore,
|
|
||||||
private val exportManager: ExportManager,
|
|
||||||
private val projectRepository: ProjectRepository,
|
|
||||||
private val taskRepository: TaskRepository,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _extra = MutableStateFlow(
|
||||||
|
SettingsUiState(
|
||||||
|
isLoggedIn = authManager.isLoggedIn,
|
||||||
|
username = authManager.getUsername(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
val uiState = combine(
|
val uiState = combine(
|
||||||
prefs.themeMode,
|
prefs.themeMode,
|
||||||
prefs.syncEnabled,
|
|
||||||
prefs.syncIntervalMinutes,
|
|
||||||
prefs.notificationsEnabled,
|
prefs.notificationsEnabled,
|
||||||
sourceRepository.getAllSources(),
|
authManager.isAuthenticated,
|
||||||
) { theme, sync, interval, notifs, sources ->
|
_extra,
|
||||||
SettingsUiState(
|
) { theme, notifs, isAuth, extra ->
|
||||||
|
extra.copy(
|
||||||
themeMode = theme,
|
themeMode = theme,
|
||||||
syncEnabled = sync,
|
|
||||||
syncIntervalMinutes = interval,
|
|
||||||
notificationsEnabled = notifs,
|
notificationsEnabled = notifs,
|
||||||
caldavSources = sources.filter { it.type == com.planify.mobile.domain.model.SourceType.CALDAV },
|
isLoggedIn = isAuth,
|
||||||
|
username = if (isAuth) authManager.getUsername() else "",
|
||||||
)
|
)
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
|
||||||
|
|
||||||
private val _discoveryState = MutableStateFlow<Pair<Boolean, String?>>(false to null)
|
|
||||||
val discoveryInProgress = _discoveryState.asStateFlow()
|
|
||||||
|
|
||||||
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
|
||||||
|
|
||||||
fun setSyncEnabled(enabled: Boolean) = viewModelScope.launch {
|
|
||||||
prefs.setSyncEnabled(enabled)
|
|
||||||
if (enabled) syncScheduler.schedule(uiState.value.syncIntervalMinutes.toLong())
|
|
||||||
else syncScheduler.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSyncInterval(minutes: Int) = viewModelScope.launch {
|
|
||||||
prefs.setSyncInterval(minutes)
|
|
||||||
if (uiState.value.syncEnabled) syncScheduler.schedule(minutes.toLong())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch {
|
fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch {
|
||||||
prefs.setNotificationsEnabled(enabled)
|
prefs.setNotificationsEnabled(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun syncNow() { syncScheduler.syncNow() }
|
fun syncNow() {
|
||||||
|
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
|
||||||
fun addCalDavAccount(baseUrl: String, username: String, password: String) {
|
|
||||||
_discoveryState.update { true to null }
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = discovery.discover(baseUrl, username, password)) {
|
when (val result = syncManager.sync()) {
|
||||||
is DiscoveryResult.Success -> {
|
is SyncResult.Success -> _extra.update { it.copy(syncInProgress = false, syncSuccess = true) }
|
||||||
result.sources.forEach { source ->
|
is SyncResult.NotLoggedIn -> _extra.update { it.copy(syncInProgress = false, syncError = "Non connecté") }
|
||||||
credentialStore.savePassword(source.id, password)
|
is SyncResult.Failure -> _extra.update { it.copy(syncInProgress = false, syncError = result.message) }
|
||||||
sourceRepository.insertSource(source)
|
|
||||||
}
|
|
||||||
_discoveryState.update { false to null }
|
|
||||||
if (uiState.value.syncEnabled) syncScheduler.schedule()
|
|
||||||
}
|
|
||||||
is DiscoveryResult.Failure -> {
|
|
||||||
_discoveryState.update { false to result.message }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeCalDavAccount(source: Source) = viewModelScope.launch {
|
fun clearSyncFeedback() = _extra.update { it.copy(syncSuccess = false, syncError = null) }
|
||||||
credentialStore.deletePassword(source.id)
|
|
||||||
sourceRepository.deleteSource(source.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val _exportUri = MutableStateFlow<Uri?>(null)
|
|
||||||
val exportUri = _exportUri.asStateFlow()
|
|
||||||
|
|
||||||
fun exportJson() = viewModelScope.launch {
|
|
||||||
val projects = projectRepository.getAllProjects().first()
|
|
||||||
val tasks = taskRepository.getAllTasks().first()
|
|
||||||
_exportUri.value = exportManager.exportJson(projects, tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun exportIcal() = viewModelScope.launch {
|
|
||||||
val tasks = taskRepository.getAllTasks().first()
|
|
||||||
_exportUri.value = exportManager.exportIcal(tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearExportUri() { _exportUri.value = null }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import com.planify.mobile.domain.model.DueDate
|
|||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
|
import java.time.ZoneOffset
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -46,7 +47,7 @@ fun DueDatePickerSheet(
|
|||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val datePickerState = rememberDatePickerState(
|
val datePickerState = rememberDatePickerState(
|
||||||
initialSelectedDateMillis = currentDueDate?.date
|
initialSelectedDateMillis = currentDueDate?.date
|
||||||
?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneId.UTC).toInstant().toEpochMilli() }.getOrNull() }
|
?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() }.getOrNull() }
|
||||||
)
|
)
|
||||||
var showRecurrence by remember { mutableStateOf(false) }
|
var showRecurrence by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ fun DueDatePickerSheet(
|
|||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
val millis = datePickerState.selectedDateMillis
|
val millis = datePickerState.selectedDateMillis
|
||||||
val date = millis?.let {
|
val date = millis?.let {
|
||||||
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString()
|
Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
|
||||||
} ?: return@TextButton
|
} ?: return@TextButton
|
||||||
onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false,
|
onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false,
|
||||||
recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE))
|
recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE))
|
||||||
@@ -119,7 +120,7 @@ fun DueDatePickerSheet(
|
|||||||
showRecurrence = false
|
showRecurrence = false
|
||||||
val millis = datePickerState.selectedDateMillis
|
val millis = datePickerState.selectedDateMillis
|
||||||
val date = millis?.let {
|
val date = millis?.let {
|
||||||
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString()
|
Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
|
||||||
} ?: currentDueDate?.date ?: LocalDate.now().toString()
|
} ?: currentDueDate?.date ?: LocalDate.now().toString()
|
||||||
onConfirm(recDueDate?.copy(date = date))
|
onConfirm(recDueDate?.copy(date = date))
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,31 +28,44 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.planify.mobile.domain.model.Label
|
import com.planify.mobile.domain.model.Label
|
||||||
import com.planify.mobile.domain.repository.LabelRepository
|
import com.planify.mobile.domain.repository.LabelRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class LabelPickerViewModel @Inject constructor(
|
class LabelPickerViewModel @Inject constructor(
|
||||||
labelRepository: LabelRepository,
|
private val labelRepository: LabelRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val labels = labelRepository.getAllLabels()
|
private val _projectId = MutableStateFlow("")
|
||||||
|
val labels = _projectId
|
||||||
|
.flatMapLatest { projectId ->
|
||||||
|
if (projectId.isNotBlank()) labelRepository.getLabelsByProject(projectId)
|
||||||
|
else labelRepository.getAllLabels()
|
||||||
|
}
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||||
|
|
||||||
|
fun setProjectId(projectId: String) { _projectId.value = projectId }
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun LabelPickerSheet(
|
fun LabelPickerSheet(
|
||||||
|
projectId: String,
|
||||||
selectedLabels: List<String>,
|
selectedLabels: List<String>,
|
||||||
onConfirm: (List<String>) -> Unit,
|
onConfirm: (List<String>) -> Unit,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
viewModel: LabelPickerViewModel = hiltViewModel(),
|
viewModel: LabelPickerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
LaunchedEffect(projectId) { viewModel.setProjectId(projectId) }
|
||||||
|
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
val labels by viewModel.labels.collectAsState()
|
val labels by viewModel.labels.collectAsState()
|
||||||
var selected by remember { mutableStateOf(selectedLabels.toSet()) }
|
var selected by remember { mutableStateOf(selectedLabels.toSet()) }
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import androidx.compose.material3.ModalBottomSheet
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -150,6 +151,15 @@ fun TaskEditSheet(
|
|||||||
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
state.saveError?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = error,
|
||||||
|
color = Color.Red,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.End,
|
||||||
@@ -184,6 +194,7 @@ fun TaskEditSheet(
|
|||||||
}
|
}
|
||||||
if (showLabelPicker) {
|
if (showLabelPicker) {
|
||||||
LabelPickerSheet(
|
LabelPickerSheet(
|
||||||
|
projectId = projectId,
|
||||||
selectedLabels = state.labels,
|
selectedLabels = state.labels,
|
||||||
onConfirm = { viewModel.setLabels(it); showLabelPicker = false },
|
onConfirm = { viewModel.setLabels(it); showLabelPicker = false },
|
||||||
onDismiss = { showLabelPicker = false },
|
onDismiss = { showLabelPicker = false },
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ package com.planify.mobile.ui.task
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.planify.mobile.data.bonsai.ApiResult
|
||||||
|
import com.planify.mobile.data.bonsai.BonsaiApiClient
|
||||||
|
import com.planify.mobile.data.bonsai.BonsaiAuthManager
|
||||||
|
import com.planify.mobile.data.bonsai.BonsaiSyncManager
|
||||||
|
import com.planify.mobile.data.bonsai.dto.BonsaIssueRequest
|
||||||
|
import com.planify.mobile.domain.repository.ProjectRepository
|
||||||
import com.planify.mobile.data.notification.ReminderScheduler
|
import com.planify.mobile.data.notification.ReminderScheduler
|
||||||
import com.planify.mobile.domain.model.DueDate
|
import com.planify.mobile.domain.model.DueDate
|
||||||
import com.planify.mobile.domain.model.Reminder
|
import com.planify.mobile.domain.model.Reminder
|
||||||
@@ -32,6 +38,7 @@ data class TaskEditState(
|
|||||||
val reminders: List<Reminder> = emptyList(),
|
val reminders: List<Reminder> = emptyList(),
|
||||||
val subTasks: List<Task> = emptyList(),
|
val subTasks: List<Task> = emptyList(),
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
|
val saveError: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -39,6 +46,10 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
private val taskRepository: TaskRepository,
|
private val taskRepository: TaskRepository,
|
||||||
private val reminderRepository: ReminderRepository,
|
private val reminderRepository: ReminderRepository,
|
||||||
private val reminderScheduler: ReminderScheduler,
|
private val reminderScheduler: ReminderScheduler,
|
||||||
|
private val apiClient: BonsaiApiClient,
|
||||||
|
private val authManager: BonsaiAuthManager,
|
||||||
|
private val syncManager: BonsaiSyncManager,
|
||||||
|
private val projectRepository: ProjectRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(TaskEditState())
|
private val _state = MutableStateFlow(TaskEditState())
|
||||||
@@ -50,8 +61,7 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
val task = taskRepository.getTaskById(taskId) ?: return@launch
|
val task = taskRepository.getTaskById(taskId) ?: return@launch
|
||||||
val subTasks = taskRepository.getSubTasks(taskId).first()
|
val subTasks = taskRepository.getSubTasks(taskId).first()
|
||||||
val reminders = reminderRepository.getRemindersByTask(taskId).first()
|
val reminders = reminderRepository.getRemindersByTask(taskId).first()
|
||||||
_state.update {
|
_state.value = TaskEditState(
|
||||||
it.copy(
|
|
||||||
taskId = taskId,
|
taskId = taskId,
|
||||||
projectId = task.projectId,
|
projectId = task.projectId,
|
||||||
sectionId = task.sectionId,
|
sectionId = task.sectionId,
|
||||||
@@ -65,9 +75,8 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
subTasks = subTasks,
|
subTasks = subTasks,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_state.update { it.copy(projectId = projectId, sectionId = sectionId, parentId = parentId) }
|
_state.value = TaskEditState(projectId = projectId, sectionId = sectionId, parentId = parentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,12 +111,70 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun save(onDone: () -> Unit) {
|
fun save(onDone: () -> Unit) {
|
||||||
val st = _state.value
|
val st = _state.value
|
||||||
if (st.content.isBlank()) return
|
if (st.content.isBlank() || st.projectId.isBlank()) return
|
||||||
_state.update { it.copy(isSaving = true) }
|
_state.update { it.copy(isSaving = true, saveError = null) }
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||||
val id = st.taskId ?: UUID.randomUUID().toString()
|
|
||||||
|
|
||||||
|
val request = BonsaIssueRequest(
|
||||||
|
name = st.content,
|
||||||
|
priority = BonsaiSyncManager.toBonsaiPriority(st.priority),
|
||||||
|
status = BonsaiSyncManager.toBonsaiStatus(st.dueDate == null && false),
|
||||||
|
dueDate = st.dueDate?.date,
|
||||||
|
description = st.description.ifBlank { null },
|
||||||
|
)
|
||||||
|
|
||||||
|
val projectIdLong = st.projectId.toLongOrNull()
|
||||||
|
val taskIdLong = st.taskId?.toLongOrNull()
|
||||||
|
|
||||||
|
if (authManager.isLoggedIn && projectIdLong != null) {
|
||||||
|
// Refresh token if expired before calling API
|
||||||
|
val tokenOk = authManager.refreshIfNeeded()
|
||||||
|
if (!tokenOk) {
|
||||||
|
_state.update { it.copy(isSaving = false, saveError = "Session expirée, veuillez vous reconnecter") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val apiResult = if (taskIdLong != null) {
|
||||||
|
apiClient.updateIssue(projectIdLong, taskIdLong, request)
|
||||||
|
} else {
|
||||||
|
apiClient.createIssue(projectIdLong, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (apiResult) {
|
||||||
|
is ApiResult.Success -> {
|
||||||
|
val issue = apiResult.data
|
||||||
|
val task = Task(
|
||||||
|
id = issue.id.toString(),
|
||||||
|
content = issue.name,
|
||||||
|
description = issue.description ?: "",
|
||||||
|
projectId = issue.projectId.toString(),
|
||||||
|
priority = BonsaiSyncManager.mapPriority(issue.priority),
|
||||||
|
checked = issue.status == "done",
|
||||||
|
dueDate = issue.dueDate?.let { DueDate(date = it) },
|
||||||
|
labels = st.labels,
|
||||||
|
addedAt = now,
|
||||||
|
updatedAt = now,
|
||||||
|
)
|
||||||
|
// Always insert/update locally — FK constraint removed, no crash risk.
|
||||||
|
if (st.taskId == null) taskRepository.insertTask(task)
|
||||||
|
else taskRepository.updateTask(task)
|
||||||
|
saveReminders(task.id, st, task)
|
||||||
|
// If the project isn't in Room yet, sync to pull it.
|
||||||
|
if (projectRepository.getProjectById(task.projectId) == null) {
|
||||||
|
viewModelScope.launch { syncManager.sync() }
|
||||||
|
}
|
||||||
|
_state.update { it.copy(isSaving = false) }
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
is ApiResult.Failure -> {
|
||||||
|
_state.update { it.copy(isSaving = false, saveError = apiResult.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Local save (not connected to Bonsai)
|
||||||
|
val id = st.taskId ?: UUID.randomUUID().toString()
|
||||||
val task = Task(
|
val task = Task(
|
||||||
id = id,
|
id = id,
|
||||||
content = st.content,
|
content = st.content,
|
||||||
@@ -121,37 +188,21 @@ class TaskEditViewModel @Inject constructor(
|
|||||||
addedAt = if (st.taskId == null) now else "",
|
addedAt = if (st.taskId == null) now else "",
|
||||||
updatedAt = now,
|
updatedAt = now,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (st.taskId == null) taskRepository.insertTask(task)
|
if (st.taskId == null) taskRepository.insertTask(task)
|
||||||
else taskRepository.updateTask(task)
|
else taskRepository.updateTask(task)
|
||||||
|
saveReminders(id, st, task)
|
||||||
// Sub-tasks: delete removed ones, then upsert remaining
|
|
||||||
if (st.taskId != null) {
|
|
||||||
val existingIds = taskRepository.getSubTasks(id).first().map { it.id }.toSet()
|
|
||||||
val currentIds = st.subTasks.map { it.id }.toSet()
|
|
||||||
(existingIds - currentIds).forEach { taskRepository.deleteTask(it) }
|
|
||||||
}
|
|
||||||
st.subTasks.forEach { sub ->
|
|
||||||
val actualSub = sub.copy(
|
|
||||||
parentId = id,
|
|
||||||
projectId = st.projectId,
|
|
||||||
addedAt = if (sub.addedAt.isBlank()) now else sub.addedAt,
|
|
||||||
updatedAt = now,
|
|
||||||
)
|
|
||||||
if (taskRepository.getTaskById(sub.id) != null) taskRepository.updateTask(actualSub)
|
|
||||||
else taskRepository.insertTask(actualSub)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reminders: replace all, reschedule
|
|
||||||
reminderRepository.deleteRemindersByTask(id)
|
|
||||||
st.reminders.forEach { reminder ->
|
|
||||||
val actual = reminder.copy(taskId = id)
|
|
||||||
reminderRepository.insertReminder(actual)
|
|
||||||
reminderScheduler.schedule(actual, task.content, task.dueDate?.date)
|
|
||||||
}
|
|
||||||
|
|
||||||
_state.update { it.copy(isSaving = false) }
|
_state.update { it.copy(isSaving = false) }
|
||||||
onDone()
|
onDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveReminders(taskId: String, st: TaskEditState, task: Task) {
|
||||||
|
reminderRepository.deleteRemindersByTask(taskId)
|
||||||
|
st.reminders.forEach { reminder ->
|
||||||
|
val actual = reminder.copy(taskId = taskId)
|
||||||
|
reminderRepository.insertReminder(actual)
|
||||||
|
reminderScheduler.schedule(actual, task.content, task.dueDate?.date)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,116 @@
|
|||||||
package com.planify.mobile.ui.theme
|
package com.planify.mobile.ui.theme
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.dynamicDarkColorScheme
|
|
||||||
import androidx.compose.material3.dynamicLightColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.planify.mobile.data.preferences.ThemeMode
|
import com.planify.mobile.data.preferences.ThemeMode
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme()
|
// Bonsai design tokens — warm cream + forest green palette
|
||||||
private val DarkColorScheme = darkColorScheme()
|
|
||||||
|
// Light
|
||||||
|
private val Green_2F7A4F = Color(0xFF2F7A4F)
|
||||||
|
private val Green_1E5E3C = Color(0xFF1E5E3C)
|
||||||
|
private val Green_DCEAD8 = Color(0xFFDCEAD8)
|
||||||
|
private val Green_2C6A45 = Color(0xFF2C6A45)
|
||||||
|
|
||||||
|
private val Cream_F1ECE0 = Color(0xFFF1ECE0)
|
||||||
|
private val Cream_FBF8F1 = Color(0xFFFBF8F1)
|
||||||
|
private val Cream_EBE5D6 = Color(0xFFEBE5D6)
|
||||||
|
|
||||||
|
private val Ink_22291F = Color(0xFF22291F)
|
||||||
|
private val Ink_6A7163 = Color(0xFF6A7163)
|
||||||
|
private val Line_E4DCC9 = Color(0xFFE4DCC9)
|
||||||
|
private val Line_EFE9DB = Color(0xFFEFE9DB)
|
||||||
|
|
||||||
|
private val Terra_C2683C = Color(0xFFC2683C)
|
||||||
|
private val Terra_F2DECE = Color(0xFFF2DECE)
|
||||||
|
private val Terra_9E5026 = Color(0xFF9E5026)
|
||||||
|
|
||||||
|
// Dark
|
||||||
|
private val DGreen_74C58A = Color(0xFF74C58A)
|
||||||
|
private val DGreen_4FA268 = Color(0xFF4FA268)
|
||||||
|
private val DGreen_1F3422 = Color(0xFF1F3422)
|
||||||
|
private val DGreen_9BD9AC = Color(0xFF9BD9AC)
|
||||||
|
private val DGreen_08140C = Color(0xFF08140C)
|
||||||
|
|
||||||
|
private val DBg_10190F = Color(0xFF10190F)
|
||||||
|
private val DSurf_18241A = Color(0xFF18241A)
|
||||||
|
private val DSurf2_1F2E21 = Color(0xFF1F2E21)
|
||||||
|
|
||||||
|
private val DInk_EAF0E3 = Color(0xFFEAF0E3)
|
||||||
|
private val DInk_9DAE9C = Color(0xFF9DAE9C)
|
||||||
|
private val DLine_27361F = Color(0xFF27361F)
|
||||||
|
private val DLine_1E2D1A = Color(0xFF1E2D1A)
|
||||||
|
|
||||||
|
private val DTerra_E0905E = Color(0xFFE0905E)
|
||||||
|
private val DTerra_33231A = Color(0xFF33231A)
|
||||||
|
private val DTerra_E9A579 = Color(0xFFE9A579)
|
||||||
|
|
||||||
|
private val BonsaiLightColorScheme = lightColorScheme(
|
||||||
|
primary = Green_2F7A4F,
|
||||||
|
onPrimary = Cream_FBF8F1,
|
||||||
|
primaryContainer = Green_DCEAD8,
|
||||||
|
onPrimaryContainer = Green_2C6A45,
|
||||||
|
secondary = Terra_C2683C,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = Terra_F2DECE,
|
||||||
|
onSecondaryContainer = Terra_9E5026,
|
||||||
|
tertiary = Green_1E5E3C,
|
||||||
|
onTertiary = Cream_FBF8F1,
|
||||||
|
tertiaryContainer = Green_DCEAD8,
|
||||||
|
onTertiaryContainer = Green_2C6A45,
|
||||||
|
error = Terra_C2683C,
|
||||||
|
onError = Color.White,
|
||||||
|
errorContainer = Terra_F2DECE,
|
||||||
|
onErrorContainer = Terra_9E5026,
|
||||||
|
background = Cream_F1ECE0,
|
||||||
|
onBackground = Ink_22291F,
|
||||||
|
surface = Cream_FBF8F1,
|
||||||
|
onSurface = Ink_22291F,
|
||||||
|
surfaceVariant = Cream_EBE5D6,
|
||||||
|
onSurfaceVariant = Ink_6A7163,
|
||||||
|
outline = Line_E4DCC9,
|
||||||
|
outlineVariant = Line_EFE9DB,
|
||||||
|
inverseSurface = Ink_22291F,
|
||||||
|
inverseOnSurface = Cream_FBF8F1,
|
||||||
|
inversePrimary = Green_DCEAD8,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val BonsaiDarkColorScheme = darkColorScheme(
|
||||||
|
primary = DGreen_74C58A,
|
||||||
|
onPrimary = DGreen_08140C,
|
||||||
|
primaryContainer = DGreen_1F3422,
|
||||||
|
onPrimaryContainer = DGreen_9BD9AC,
|
||||||
|
secondary = DTerra_E0905E,
|
||||||
|
onSecondary = DGreen_08140C,
|
||||||
|
secondaryContainer = DTerra_33231A,
|
||||||
|
onSecondaryContainer = DTerra_E9A579,
|
||||||
|
tertiary = DGreen_4FA268,
|
||||||
|
onTertiary = DGreen_08140C,
|
||||||
|
tertiaryContainer = DGreen_1F3422,
|
||||||
|
onTertiaryContainer = DGreen_9BD9AC,
|
||||||
|
error = DTerra_E0905E,
|
||||||
|
onError = DGreen_08140C,
|
||||||
|
errorContainer = DTerra_33231A,
|
||||||
|
onErrorContainer = DTerra_E9A579,
|
||||||
|
background = DBg_10190F,
|
||||||
|
onBackground = DInk_EAF0E3,
|
||||||
|
surface = DSurf_18241A,
|
||||||
|
onSurface = DInk_EAF0E3,
|
||||||
|
surfaceVariant = DSurf2_1F2E21,
|
||||||
|
onSurfaceVariant = DInk_9DAE9C,
|
||||||
|
outline = DLine_27361F,
|
||||||
|
outlineVariant = DLine_1E2D1A,
|
||||||
|
inverseSurface = DInk_EAF0E3,
|
||||||
|
inverseOnSurface = DBg_10190F,
|
||||||
|
inversePrimary = Green_2F7A4F,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PlanifyTheme(
|
fun PlanifyTheme(
|
||||||
@@ -30,17 +125,8 @@ fun PlanifyTheme(
|
|||||||
ThemeMode.SYSTEM -> systemDark
|
ThemeMode.SYSTEM -> systemDark
|
||||||
}
|
}
|
||||||
|
|
||||||
val colorScheme = when {
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
|
||||||
val context = LocalContext.current
|
|
||||||
if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
|
||||||
}
|
|
||||||
isDark -> DarkColorScheme
|
|
||||||
else -> LightColorScheme
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = if (isDark) BonsaiDarkColorScheme else BonsaiLightColorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,10 +6,88 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
val Typography = Typography(
|
val Typography = Typography(
|
||||||
|
displayLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 57.sp,
|
||||||
|
lineHeight = 64.sp,
|
||||||
|
letterSpacing = (-0.25).sp,
|
||||||
|
),
|
||||||
|
displayMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 45.sp,
|
||||||
|
lineHeight = 52.sp,
|
||||||
|
),
|
||||||
|
displaySmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
lineHeight = 44.sp,
|
||||||
|
),
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 40.sp,
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 36.sp,
|
||||||
|
),
|
||||||
|
headlineSmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
lineHeight = 32.sp,
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 20.sp,
|
||||||
|
lineHeight = 28.sp,
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.15.sp,
|
||||||
|
),
|
||||||
|
titleSmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
bodyLarge = TextStyle(
|
bodyLarge = TextStyle(
|
||||||
fontWeight = FontWeight.Normal,
|
fontWeight = FontWeight.Normal,
|
||||||
fontSize = 16.sp,
|
fontSize = 16.sp,
|
||||||
lineHeight = 24.sp,
|
lineHeight = 24.sp,
|
||||||
letterSpacing = 0.5.sp
|
letterSpacing = 0.5.sp,
|
||||||
)
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.25.sp,
|
||||||
|
),
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.4.sp,
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,27 +1,54 @@
|
|||||||
package com.planify.mobile.ui.today
|
package com.planify.mobile.ui.today
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Search
|
||||||
import androidx.compose.material.icons.outlined.Today
|
import androidx.compose.material.icons.outlined.Today
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.planify.mobile.domain.model.Task
|
import com.planify.mobile.domain.model.Task
|
||||||
import com.planify.mobile.ui.components.EmptyState
|
import com.planify.mobile.ui.components.EmptyState
|
||||||
import com.planify.mobile.ui.components.SectionHeader
|
|
||||||
import com.planify.mobile.ui.components.TaskRow
|
import com.planify.mobile.ui.components.TaskRow
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TodayScreen(
|
fun TodayScreen(
|
||||||
@@ -32,30 +59,37 @@ fun TodayScreen(
|
|||||||
val state by viewModel.uiState.collectAsState()
|
val state by viewModel.uiState.collectAsState()
|
||||||
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
|
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background),
|
||||||
|
contentPadding = PaddingValues(bottom = 96.dp),
|
||||||
|
) {
|
||||||
|
item { TodayHeader() }
|
||||||
|
|
||||||
|
if (state.totalCount > 0) {
|
||||||
|
item {
|
||||||
|
HeroCard(
|
||||||
|
doneCount = state.doneCount,
|
||||||
|
totalCount = state.totalCount,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (state.totalCount == 0 && !state.isLoading) {
|
if (state.totalCount == 0 && !state.isLoading) {
|
||||||
|
item {
|
||||||
EmptyState(
|
EmptyState(
|
||||||
icon = Icons.Outlined.Today,
|
icon = Icons.Outlined.Today,
|
||||||
title = "Rien pour aujourd'hui",
|
title = "Rien pour aujourd'hui",
|
||||||
subtitle = "Profitez de votre journée !",
|
subtitle = "Profitez de votre journée !",
|
||||||
modifier = modifier,
|
|
||||||
)
|
)
|
||||||
return
|
}
|
||||||
|
return@LazyColumn
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
|
||||||
if (state.overdueTasks.isNotEmpty()) {
|
if (state.overdueTasks.isNotEmpty()) {
|
||||||
item {
|
item { SectionLabel(name = "En retard", count = state.overdueTasks.size) }
|
||||||
SectionHeader(
|
|
||||||
name = "En retard",
|
|
||||||
taskCount = state.overdueTasks.size,
|
|
||||||
collapsed = "overdue" in collapsedSections.value,
|
|
||||||
onToggleCollapse = {
|
|
||||||
collapsedSections.value = collapsedSections.value.toggle("overdue")
|
|
||||||
},
|
|
||||||
onAddTask = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if ("overdue" !in collapsedSections.value) {
|
|
||||||
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
|
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
|
||||||
TaskRow(
|
TaskRow(
|
||||||
task = task,
|
task = task,
|
||||||
@@ -64,22 +98,11 @@ fun TodayScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
state.tasksByProject.forEach { (projectName, tasks) ->
|
state.tasksByProject.forEach { (projectName, tasks) ->
|
||||||
item(key = "header_$projectName") {
|
item(key = "header_$projectName") {
|
||||||
SectionHeader(
|
SectionLabel(name = projectName, count = tasks.size)
|
||||||
name = projectName,
|
|
||||||
taskCount = tasks.size,
|
|
||||||
collapsed = projectName in collapsedSections.value,
|
|
||||||
onToggleCollapse = {
|
|
||||||
collapsedSections.value = collapsedSections.value.toggle(projectName)
|
|
||||||
},
|
|
||||||
onAddTask = {},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (projectName !in collapsedSections.value) {
|
|
||||||
items(tasks, key = { it.id }) { task ->
|
items(tasks, key = { it.id }) { task ->
|
||||||
TaskRow(
|
TaskRow(
|
||||||
task = task,
|
task = task,
|
||||||
@@ -89,8 +112,171 @@ fun TodayScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TodayHeader() {
|
||||||
|
val today = LocalDate.now()
|
||||||
|
val dayFormatter = DateTimeFormatter.ofPattern("EEEE d MMMM", Locale.FRENCH)
|
||||||
|
val dateStr = today.format(dayFormatter).replaceFirstChar { it.uppercaseChar() }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(10.dp)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(text = "🌿", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = dateStr,
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
letterSpacing = 1.4.sp,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Aujourd'hui",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(38.dp)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surface, CircleShape)
|
||||||
|
.clip(CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Search,
|
||||||
|
contentDescription = "Recherche",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Set<String>.toggle(key: String) =
|
@Composable
|
||||||
if (contains(key)) minus(key) else plus(key)
|
private fun HeroCard(doneCount: Int, totalCount: Int, modifier: Modifier = Modifier) {
|
||||||
|
val progress = if (totalCount == 0) 0f else doneCount.toFloat() / totalCount
|
||||||
|
val remaining = totalCount - doneCount
|
||||||
|
val subtitle = when {
|
||||||
|
totalCount == 0 -> "Journée libre !"
|
||||||
|
doneCount == totalCount -> "Toutes les tâches sont faites !"
|
||||||
|
progress >= 0.5f -> "Tu y es presque !"
|
||||||
|
doneCount > 0 -> "${doneCount} faite${if (doneCount > 1) "s" else ""}. Encore $remaining pour boucler la journée."
|
||||||
|
else -> "$remaining tâche${if (remaining > 1) "s" else ""} pour aujourd'hui."
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||||
|
tonalElevation = 0.dp,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(18.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
HeroRing(progress = progress, doneCount = doneCount, totalCount = totalCount)
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = if (doneCount == totalCount && totalCount > 0)
|
||||||
|
"Journée bouclée !" else "Ta journée avance bien",
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeroRing(progress: Float, doneCount: Int, totalCount: Int) {
|
||||||
|
val primaryColor = MaterialTheme.colorScheme.primary
|
||||||
|
val trackColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
|
||||||
|
Box(modifier = Modifier.size(74.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Canvas(modifier = Modifier.size(74.dp)) {
|
||||||
|
val sw = 7.dp.toPx()
|
||||||
|
val diameter = this.size.minDimension - sw
|
||||||
|
val tl = Offset(sw / 2, sw / 2)
|
||||||
|
val arcSize = Size(diameter, diameter)
|
||||||
|
drawArc(
|
||||||
|
color = trackColor, startAngle = -90f, sweepAngle = 360f,
|
||||||
|
useCenter = false, style = Stroke(width = sw, cap = StrokeCap.Round),
|
||||||
|
topLeft = tl, size = arcSize,
|
||||||
|
)
|
||||||
|
if (progress > 0f) {
|
||||||
|
drawArc(
|
||||||
|
color = primaryColor, startAngle = -90f, sweepAngle = progress * 360f,
|
||||||
|
useCenter = false, style = Stroke(width = sw, cap = StrokeCap.Round),
|
||||||
|
topLeft = tl, size = arcSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = if (totalCount == 0) "0" else "$doneCount/$totalCount",
|
||||||
|
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "faites",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionLabel(name: String, count: Int) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 18.dp, end = 18.dp, top = 18.dp, bottom = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = name.uppercase(),
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(
|
||||||
|
fontWeight = FontWeight.ExtraBold,
|
||||||
|
letterSpacing = 1.sp,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(20.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$count",
|
||||||
|
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ data class TodayUiState(
|
|||||||
val tasksByProject: Map<String, List<Task>> = emptyMap(),
|
val tasksByProject: Map<String, List<Task>> = emptyMap(),
|
||||||
val overdueTasks: List<Task> = emptyList(),
|
val overdueTasks: List<Task> = emptyList(),
|
||||||
val totalCount: Int = 0,
|
val totalCount: Int = 0,
|
||||||
|
val doneCount: Int = 0,
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,15 +29,18 @@ class TodayViewModel @Inject constructor(
|
|||||||
val uiState = combine(
|
val uiState = combine(
|
||||||
taskRepository.getTodayTasks(),
|
taskRepository.getTodayTasks(),
|
||||||
taskRepository.getOverdueTasks(),
|
taskRepository.getOverdueTasks(),
|
||||||
|
taskRepository.getDoneTodayCount(),
|
||||||
projectRepository.getAllProjects(),
|
projectRepository.getAllProjects(),
|
||||||
) { today, overdue, projects ->
|
) { today, overdue, done, projects ->
|
||||||
val projectMap = projects.associateBy { it.id }
|
val projectMap = projects.associateBy { it.id }
|
||||||
val grouped = today
|
val grouped = today
|
||||||
.groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId }
|
.groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId }
|
||||||
TodayUiState(
|
TodayUiState(
|
||||||
tasksByProject = grouped,
|
tasksByProject = grouped,
|
||||||
overdueTasks = overdue,
|
overdueTasks = overdue,
|
||||||
totalCount = today.size + overdue.size,
|
totalCount = today.size + overdue.size + done,
|
||||||
|
doneCount = done,
|
||||||
|
isLoading = false,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Viewport: 108x108
|
||||||
|
Safe zone: 18–90 on each axis (72×72 px)
|
||||||
|
Bonsai scaled ×1.286 (72/56) from original 48×56 SVG, centered horizontally.
|
||||||
|
Transform: new_x = old_x × 1.286 + 23, new_y = old_y × 1.286 + 18
|
||||||
|
-->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<!-- Pot base (trapezoid) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#C05621"
|
||||||
|
android:pathData="M41,90 L44,81 L64,81 L67,90 Z"/>
|
||||||
|
|
||||||
|
<!-- Pot rim -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#9C4221"
|
||||||
|
android:pathData="M40.5,76 L67.5,76 A2.5,2.5 0 0,1 70,78.5 L70,80 A2.5,2.5 0 0,1 67.5,82.5 L40.5,82.5 A2.5,2.5 0 0,1 38,80 L38,78.5 A2.5,2.5 0 0,1 40.5,76 Z"/>
|
||||||
|
|
||||||
|
<!-- Trunk -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#744210"
|
||||||
|
android:strokeWidth="5"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M54,76 C54,68 51,60 49,53 C46,46 47,40 51,35"/>
|
||||||
|
|
||||||
|
<!-- Branch right -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#744210"
|
||||||
|
android:strokeWidth="3.2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M50,54 C56,50 63,46 65,41"/>
|
||||||
|
|
||||||
|
<!-- Branch left -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#744210"
|
||||||
|
android:strokeWidth="2.6"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M49,63 C42,59 37,54 36,49"/>
|
||||||
|
|
||||||
|
<!-- Foliage left (cx=36, cy=45, r=12) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#276749"
|
||||||
|
android:pathData="M24,45 a12,12 0 1,0 24,0 a12,12 0 1,0 -24,0"/>
|
||||||
|
|
||||||
|
<!-- Foliage right (cx=66, cy=39, r=13) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#276749"
|
||||||
|
android:pathData="M53,39 a13,13 0 1,0 26,0 a13,13 0 1,0 -26,0"/>
|
||||||
|
|
||||||
|
<!-- Foliage top center (cx=51, cy=32, r=14) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#2F855A"
|
||||||
|
android:pathData="M37,32 a14,14 0 1,0 28,0 a14,14 0 1,0 -28,0"/>
|
||||||
|
|
||||||
|
<!-- Foliage overlap highlight (cx=56, cy=40, r=10) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#38A169"
|
||||||
|
android:pathData="M46,40 a10,10 0 1,0 20,0 a10,10 0 1,0 -20,0"/>
|
||||||
|
|
||||||
|
<!-- Foliage small top accent (cx=46, cy=39, r=8) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#48BB78"
|
||||||
|
android:pathData="M38,39 a8,8 0 1,0 16,0 a8,8 0 1,0 -16,0"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#FFFFFF" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M36,30 L72,30 L72,36 L36,36 Z
|
||||||
|
M36,48 L72,48 L72,54 L36,54 Z
|
||||||
|
M36,66 L60,66 L60,72 L36,72 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_bonsai_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_bonsai_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">BonsaiTask</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<style name="Theme.PlanifyMobile" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.compose.compiler) apply false
|
||||||
alias(libs.plugins.hilt) apply false
|
alias(libs.plugins.hilt) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=1024m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.java.home=/home/Gato/.jdks/temurin-25.0.2
|
||||||
@@ -1,20 +1,22 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.4.0"
|
agp = "8.7.3"
|
||||||
kotlin = "1.9.24"
|
kotlin = "2.0.21"
|
||||||
|
ksp = "2.0.21-1.0.28"
|
||||||
coreKtx = "1.13.1"
|
coreKtx = "1.13.1"
|
||||||
lifecycleRuntimeKtx = "2.8.3"
|
lifecycleRuntimeKtx = "2.8.7"
|
||||||
activityCompose = "1.9.0"
|
activityCompose = "1.9.3"
|
||||||
composeBom = "2024.06.00"
|
composeBom = "2024.12.01"
|
||||||
hilt = "2.51.1"
|
hilt = "2.52"
|
||||||
hiltNavigationCompose = "1.2.0"
|
hiltNavigationCompose = "1.2.0"
|
||||||
navigationCompose = "2.7.7"
|
navigationCompose = "2.8.5"
|
||||||
room = "2.6.1"
|
room = "2.6.1"
|
||||||
coroutines = "1.8.1"
|
coroutines = "1.9.0"
|
||||||
okhttp = "4.12.0"
|
okhttp = "4.12.0"
|
||||||
datastore = "1.1.1"
|
datastore = "1.1.1"
|
||||||
securityCrypto = "1.1.0-alpha06"
|
securityCrypto = "1.1.0-alpha06"
|
||||||
workManager = "2.9.0"
|
workManager = "2.10.0"
|
||||||
serialization = "1.6.3"
|
serialization = "1.7.3"
|
||||||
|
browser = "1.8.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitExt = "1.2.1"
|
junitExt = "1.2.1"
|
||||||
espressoCore = "3.6.1"
|
espressoCore = "3.6.1"
|
||||||
@@ -49,6 +51,7 @@ datastore-preferences = { group = "androidx.datastore", name = "datastore-prefer
|
|||||||
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||||
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
||||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
|
||||||
|
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
@@ -56,6 +59,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
|
|||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version = "1.9.24-1.0.20" }
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# https://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.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||