Compare commits
35 Commits
8fce0f2578
...
main
| 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
|
||||
*.keystore
|
||||
*.jks
|
||||
app/build/
|
||||
java_pid2281.hprof
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
@@ -8,14 +9,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.planify.mobile"
|
||||
compileSdk = 34
|
||||
compileSdk = 35
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.planify.mobile"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
versionName = "0.0.19"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -31,16 +32,15 @@ android {
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ dependencies {
|
||||
// WorkManager
|
||||
implementation(libs.work.runtime.ktx)
|
||||
|
||||
// Browser (Custom Tabs pour OAuth)
|
||||
implementation(libs.androidx.browser)
|
||||
|
||||
// Serialization
|
||||
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.SourceCalDavData
|
||||
import com.planify.mobile.domain.model.SourceType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
import java.io.StringReader
|
||||
@@ -21,25 +23,146 @@ sealed class DiscoveryResult {
|
||||
@Singleton
|
||||
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 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 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(
|
||||
id = UUID.randomUUID().toString(),
|
||||
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> {
|
||||
val results = mutableListOf<CalendarInfo>()
|
||||
runCatching {
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
|
||||
val parser = factory.newPullParser()
|
||||
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.END_TAG && name == "prop" -> inProp = false
|
||||
event == XmlPullParser.START_TAG && name == "href" && !inProp -> {
|
||||
parser.next()
|
||||
href = parser.text ?: ""
|
||||
parser.next(); href = parser.text ?: ""
|
||||
}
|
||||
event == XmlPullParser.START_TAG && name == "displayname" -> {
|
||||
parser.next()
|
||||
displayName = parser.text ?: ""
|
||||
parser.next(); displayName = parser.text ?: ""
|
||||
}
|
||||
event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true
|
||||
event == XmlPullParser.START_TAG && name == "comp" -> {
|
||||
@@ -146,8 +213,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
|
||||
}
|
||||
event == XmlPullParser.END_TAG && name == "response" -> {
|
||||
if (isCalendar && supportsTodo && href.isNotBlank()) {
|
||||
val fullUrl = resolveUrl(baseUrl, href)
|
||||
results.add(CalendarInfo(fullUrl, displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') }))
|
||||
results.add(CalendarInfo(resolveUrl(baseUrl, href), 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 {
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
|
||||
val parser = factory.newPullParser()
|
||||
parser.setInput(StringReader(xml))
|
||||
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.END_TAG && name == parentTag -> inTarget = false
|
||||
event == XmlPullParser.START_TAG && name == "href" && inTarget -> {
|
||||
parser.next()
|
||||
return parser.text
|
||||
parser.next(); return parser.text
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
|
||||
@@ -220,7 +220,7 @@ class CalDavSyncManager @Inject constructor(
|
||||
private fun parseMultiStatus(xml: String, baseUrl: String): List<MultiStatusItem> {
|
||||
val results = mutableListOf<MultiStatusItem>()
|
||||
runCatching {
|
||||
val factory = XmlPullParserFactory.newInstance()
|
||||
val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
|
||||
val parser = factory.newPullParser()
|
||||
parser.setInput(StringReader(xml))
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import com.planify.mobile.data.local.entity.TaskEntity
|
||||
ReminderEntity::class,
|
||||
SourceEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
version = 3,
|
||||
exportSchema = true,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
@@ -13,6 +13,9 @@ interface LabelDao {
|
||||
@Query("SELECT * FROM labels WHERE is_deleted = 0 ORDER BY `order` ASC")
|
||||
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")
|
||||
suspend fun getById(id: String): LabelEntity?
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ interface TaskDao {
|
||||
""")
|
||||
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("""
|
||||
SELECT * FROM tasks
|
||||
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.Entity
|
||||
import androidx.room.ForeignKey
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "tasks",
|
||||
foreignKeys = [
|
||||
ForeignKey(
|
||||
entity = ProjectEntity::class,
|
||||
parentColumns = ["id"],
|
||||
childColumns = ["project_id"],
|
||||
onDelete = ForeignKey.CASCADE
|
||||
)
|
||||
],
|
||||
indices = [
|
||||
Index("project_id"),
|
||||
Index("section_id"),
|
||||
@@ -45,4 +36,5 @@ data class TaskEntity(
|
||||
@ColumnInfo(name = "ical_url") val icalUrl: String? = null,
|
||||
val etag: 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>> =
|
||||
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 insertLabel(label: Label) = dao.insert(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>> =
|
||||
dao.getTodayTasks().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
override fun getDoneTodayCount() = dao.getDoneTodayCount()
|
||||
|
||||
override fun getOverdueTasks(): Flow<List<Task>> =
|
||||
dao.getOverdueTasks().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
@@ -117,7 +119,7 @@ class TaskRepositoryImpl @Inject constructor(
|
||||
checked = checked,
|
||||
dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) },
|
||||
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,
|
||||
collapsed = collapsed,
|
||||
childOrder = childOrder,
|
||||
|
||||
@@ -3,4 +3,4 @@ package com.planify.mobile.domain.model
|
||||
import kotlinx.serialization.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 {
|
||||
fun getAllLabels(): Flow<List<Label>>
|
||||
fun getLabelsByProject(projectId: String): Flow<List<Label>>
|
||||
suspend fun getLabelById(id: String): Label?
|
||||
suspend fun insertLabel(label: Label)
|
||||
suspend fun updateLabel(label: Label)
|
||||
|
||||
@@ -8,6 +8,7 @@ interface TaskRepository {
|
||||
fun getTasksBySection(sectionId: String): Flow<List<Task>>
|
||||
fun getInboxTasks(): Flow<List<Task>>
|
||||
fun getTodayTasks(): Flow<List<Task>>
|
||||
fun getDoneTodayCount(): Flow<Int>
|
||||
fun getOverdueTasks(): Flow<List<Task>>
|
||||
fun getSubTasks(parentId: String): Flow<List<Task>>
|
||||
suspend fun getTaskById(id: String): Task?
|
||||
|
||||
@@ -4,18 +4,30 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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 dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
private val authViewModel: AuthViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
PlanifyTheme {
|
||||
MainScreen()
|
||||
val status by authViewModel.status.collectAsState()
|
||||
when (status) {
|
||||
is AuthStatus.Authenticated -> MainScreen(authViewModel)
|
||||
else -> LoginScreen(authViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +1,158 @@
|
||||
package com.planify.mobile.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.FilterList
|
||||
import androidx.compose.material.icons.outlined.Inbox
|
||||
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.GridView
|
||||
import androidx.compose.material.icons.outlined.Person
|
||||
import androidx.compose.material.icons.outlined.Today
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
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.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
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.PlanifyNavHost
|
||||
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
|
||||
fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) {
|
||||
fun MainScreen(
|
||||
authViewModel: AuthViewModel,
|
||||
drawerViewModel: DrawerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
val projects by viewModel.projects.collectAsState()
|
||||
val navBackStack by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStack?.destination?.route
|
||||
|
||||
val drawerTitles = mapOf(
|
||||
Route.Inbox.path to "Inbox",
|
||||
Route.Today.path to "Aujourd'hui",
|
||||
Route.Scheduled.path to "Planifié",
|
||||
Route.Search.path to "Recherche",
|
||||
Route.Filter.path to "Filtres",
|
||||
Route.Settings.path to "Paramètres",
|
||||
)
|
||||
val title = drawerTitles[currentRoute]
|
||||
?: projects.find { "project/${it.id}" == currentRoute }?.name
|
||||
?: "Planify"
|
||||
var showCreateTask by remember { mutableStateOf(false) }
|
||||
var selectedTask by remember { mutableStateOf<Task?>(null) }
|
||||
|
||||
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}",
|
||||
val projects by drawerViewModel.projects.collectAsState()
|
||||
val inboxProjectId = projects.find { it.isInbox }?.id ?: ""
|
||||
val createProjectId = if (currentRoute == Route.Project().path)
|
||||
navBackStack?.arguments?.getString("projectId") ?: inboxProjectId
|
||||
else
|
||||
inboxProjectId
|
||||
|
||||
val hideBottomBarRoutes = setOf<String>()
|
||||
val showBottomBar = currentRoute !in hideBottomBarRoutes
|
||||
val hideFabRoutes = setOf(Route.Settings.path)
|
||||
val showFab = currentRoute !in hideFabRoutes
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
if (showBottomBar) {
|
||||
NavigationBar(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
bottomTabs.forEach { tab ->
|
||||
val selected = currentRoute == tab.route ||
|
||||
(tab.route == Route.ProjectsList.path && currentRoute == Route.Project().path)
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(Route.Project().buildRoute(project.id))
|
||||
scope.launch { drawerState.close() }
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
},
|
||||
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 ->
|
||||
PlanifyNavHost(
|
||||
navController = navController,
|
||||
authViewModel = authViewModel,
|
||||
onTaskClick = { task -> selectedTask = task },
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
|
||||
if (showCreateTask) {
|
||||
TaskEditSheet(
|
||||
projectId = createProjectId,
|
||||
onDismiss = { showCreateTask = false },
|
||||
)
|
||||
}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Outlined.Menu, contentDescription = "Menu")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
PlanifyNavHost(
|
||||
navController = navController,
|
||||
modifier = Modifier.padding(padding),
|
||||
|
||||
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
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
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.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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
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.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -20,7 +28,9 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -35,53 +45,92 @@ fun TaskRow(
|
||||
onClick: () -> Unit,
|
||||
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(
|
||||
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
if (task.checked) MaterialTheme.colorScheme.onSurfaceVariant
|
||||
else MaterialTheme.colorScheme.onSurface,
|
||||
label = "textColor",
|
||||
)
|
||||
|
||||
Row(
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(onClick = onClick, onLongClick = {})
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = task.checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = priorityColor,
|
||||
uncheckedColor = priorityColor,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = task.content,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = textColor,
|
||||
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 13.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
CircleCheckbox(
|
||||
checked = task.checked,
|
||||
color = checkColor,
|
||||
onClick = { onCheckedChange(!task.checked) },
|
||||
modifier = Modifier.padding(top = 1.dp),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (task.dueDate != null) {
|
||||
DueDateChip(dateIso = task.dueDate.date)
|
||||
}
|
||||
task.labels.take(2).forEach { labelName ->
|
||||
LabelChip(name = labelName, color = MaterialTheme.colorScheme.primary)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = task.content,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = textColor,
|
||||
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val hasMeta = task.dueDate != null || task.labels.isNotEmpty()
|
||||
if (hasMeta) {
|
||||
Spacer(Modifier.height(5.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (task.dueDate != null) {
|
||||
DueDateChip(dateIso = task.dueDate.date)
|
||||
}
|
||||
task.labels.take(2).forEach { labelName ->
|
||||
LabelChip(name = labelName, color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,31 +138,28 @@ fun TaskRow(
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun TaskRowPreview() {
|
||||
Surface {
|
||||
Column {
|
||||
TaskRow(
|
||||
task = Task(
|
||||
id = "1",
|
||||
content = "Implémenter la navigation principale",
|
||||
projectId = "p1",
|
||||
priority = 2,
|
||||
labels = listOf("android", "ui"),
|
||||
),
|
||||
onCheckedChange = {},
|
||||
onClick = {},
|
||||
)
|
||||
Spacer(Modifier.height(1.dp))
|
||||
TaskRow(
|
||||
task = Task(
|
||||
id = "2",
|
||||
content = "Tâche terminée",
|
||||
projectId = "p1",
|
||||
priority = 4,
|
||||
checked = true,
|
||||
),
|
||||
onCheckedChange = {},
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
Column {
|
||||
TaskRow(
|
||||
task = Task(
|
||||
id = "1",
|
||||
content = "Implémenter la navigation principale",
|
||||
projectId = "p1",
|
||||
priority = 2,
|
||||
labels = listOf("android", "ui"),
|
||||
),
|
||||
onCheckedChange = {},
|
||||
onClick = {},
|
||||
)
|
||||
TaskRow(
|
||||
task = Task(
|
||||
id = "2",
|
||||
content = "Tâche terminée",
|
||||
projectId = "p1",
|
||||
priority = 4,
|
||||
checked = true,
|
||||
),
|
||||
onCheckedChange = {},
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ package com.planify.mobile.ui.navigation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.planify.mobile.domain.model.BackendType
|
||||
import com.planify.mobile.domain.model.Project
|
||||
import com.planify.mobile.domain.repository.ProjectRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class DrawerViewModel @Inject constructor(
|
||||
projectRepository: ProjectRepository,
|
||||
private val projectRepository: ProjectRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val projects = projectRepository.getAllProjects()
|
||||
@@ -19,4 +22,20 @@ class DrawerViewModel @Inject constructor(
|
||||
|
||||
val favorites = projectRepository.getFavoriteProjects()
|
||||
.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.composable
|
||||
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.inbox.InboxScreen
|
||||
import com.planify.mobile.ui.label.LabelScreen
|
||||
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.search.SearchScreen
|
||||
import com.planify.mobile.ui.settings.SettingsScreen
|
||||
@@ -19,23 +22,45 @@ import com.planify.mobile.ui.today.TodayScreen
|
||||
@Composable
|
||||
fun PlanifyNavHost(
|
||||
navController: NavHostController,
|
||||
authViewModel: AuthViewModel,
|
||||
onTaskClick: (Task) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Route.Inbox.path,
|
||||
startDestination = Route.Today.path,
|
||||
modifier = modifier,
|
||||
) {
|
||||
composable(Route.Inbox.path) {
|
||||
InboxScreen(
|
||||
onTaskClick = { /* TODO #11 : ouvrir édition */ }
|
||||
composable(Route.Today.path) {
|
||||
TodayScreen(onTaskClick = onTaskClick)
|
||||
}
|
||||
|
||||
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) {
|
||||
TodayScreen(
|
||||
onTaskClick = { /* TODO #11 : ouvrir édition */ }
|
||||
)
|
||||
composable(Route.Inbox.path) {
|
||||
InboxScreen(onTaskClick = onTaskClick)
|
||||
}
|
||||
|
||||
composable(
|
||||
@@ -45,21 +70,17 @@ fun PlanifyNavHost(
|
||||
val projectId = backStack.arguments?.getString("projectId") ?: return@composable
|
||||
ProjectScreen(
|
||||
projectId = projectId,
|
||||
onTaskClick = { /* TODO: ouvrir édition */ },
|
||||
onTaskClick = onTaskClick,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
|
||||
composable(Route.Scheduled.path) {
|
||||
ScheduledScreen(onTaskClick = { /* TODO: ouvrir édition */ })
|
||||
}
|
||||
|
||||
composable(Route.Search.path) {
|
||||
SearchScreen(onTaskClick = { /* TODO: ouvrir édition */ })
|
||||
SearchScreen(onTaskClick = onTaskClick)
|
||||
}
|
||||
|
||||
composable(Route.Filter.path) {
|
||||
FilterScreen(onTaskClick = { /* TODO: ouvrir édition */ })
|
||||
FilterScreen(onTaskClick = onTaskClick)
|
||||
}
|
||||
|
||||
composable(
|
||||
@@ -69,12 +90,12 @@ fun PlanifyNavHost(
|
||||
val labelId = backStack.arguments?.getString("labelId") ?: return@composable
|
||||
LabelScreen(
|
||||
labelId = labelId,
|
||||
onTaskClick = { /* TODO: ouvrir édition */ },
|
||||
onTaskClick = onTaskClick,
|
||||
)
|
||||
}
|
||||
|
||||
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 Search : Route("search")
|
||||
data object Filter : Route("filter")
|
||||
data object ProjectsList : Route("projects")
|
||||
data class Project(val projectId: String = "{projectId}") :
|
||||
Route("project/{projectId}") {
|
||||
fun buildRoute(id: String) = "project/$id"
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
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.PaddingValues
|
||||
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.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
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.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.FolderOpen
|
||||
import androidx.compose.material.icons.outlined.MoreVert
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -25,7 +37,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.domain.model.ViewStyle
|
||||
@@ -49,35 +65,174 @@ fun ProjectScreen(
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
|
||||
|
||||
if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.FolderOpen,
|
||||
title = "Projet vide",
|
||||
subtitle = "Créez votre première tâche avec le bouton +",
|
||||
modifier = modifier,
|
||||
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,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
when (state.viewStyle) {
|
||||
ViewStyle.LIST -> ProjectListView(
|
||||
state = state,
|
||||
collapsedSections = collapsedSections.value,
|
||||
onToggleSection = { key ->
|
||||
collapsedSections.value = collapsedSections.value.let {
|
||||
if (it.contains(key)) it - key else it + key
|
||||
if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.FolderOpen,
|
||||
title = "Projet vide",
|
||||
subtitle = "Créez votre première tâche avec le bouton +",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
when (state.viewStyle) {
|
||||
ViewStyle.LIST -> ProjectListView(
|
||||
state = state,
|
||||
collapsedSections = collapsedSections.value,
|
||||
onToggleSection = { key ->
|
||||
collapsedSections.value = collapsedSections.value.let {
|
||||
if (it.contains(key)) it - key else it + key
|
||||
}
|
||||
},
|
||||
onTaskClick = onTaskClick,
|
||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||
onReorder = { viewModel.reorderTasks(it) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ViewStyle.BOARD -> ProjectBoardView(
|
||||
state = state,
|
||||
onTaskClick = onTaskClick,
|
||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTaskClick = onTaskClick,
|
||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||
onReorder = { viewModel.reorderTasks(it) },
|
||||
modifier = modifier,
|
||||
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,
|
||||
)
|
||||
ViewStyle.BOARD -> ProjectBoardView(
|
||||
state = state,
|
||||
onTaskClick = onTaskClick,
|
||||
onCheckedChange = { task -> viewModel.toggleTask(task) },
|
||||
modifier = modifier,
|
||||
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 reorderState = rememberReorderState()
|
||||
|
||||
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
) {
|
||||
state.sections.forEach { group ->
|
||||
val key = group.section?.id ?: "unsectioned"
|
||||
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.Task
|
||||
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.SectionRepository
|
||||
import com.planify.mobile.domain.repository.TaskRepository
|
||||
@@ -32,6 +36,8 @@ class ProjectViewModel @Inject constructor(
|
||||
private val taskRepository: TaskRepository,
|
||||
private val projectRepository: ProjectRepository,
|
||||
private val sectionRepository: SectionRepository,
|
||||
private val apiClient: BonsaiApiClient,
|
||||
private val authManager: BonsaiAuthManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val projectId: String = checkNotNull(savedStateHandle["projectId"])
|
||||
@@ -66,7 +72,24 @@ class ProjectViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
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>) {
|
||||
|
||||
@@ -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
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.material3.MaterialTheme
|
||||
@@ -11,39 +22,55 @@ 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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.ui.components.EmptyState
|
||||
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
|
||||
fun ScheduledScreen(
|
||||
onTaskClick: (Task) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ScheduledViewModel = hiltViewModel(),
|
||||
) {
|
||||
val groups by viewModel.groups.collectAsState()
|
||||
|
||||
if (groups.isEmpty()) {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.CalendarMonth,
|
||||
title = "Aucune tâche planifiée",
|
||||
subtitle = "Les tâches avec une date d'échéance apparaîtront ici",
|
||||
)
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
) {
|
||||
// Header
|
||||
item { ScheduledHeader() }
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
groups.forEach { group ->
|
||||
item(key = group.label) {
|
||||
Text(
|
||||
text = group.label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
// Week strip
|
||||
item { WeekStrip() }
|
||||
|
||||
if (groups.isEmpty()) {
|
||||
item {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.CalendarMonth,
|
||||
title = "Aucune tâche planifiée",
|
||||
subtitle = "Les tâches avec une date d'échéance apparaîtront ici",
|
||||
)
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
groups.forEach { group ->
|
||||
item(key = "head_${group.label}") {
|
||||
DayGroupHeader(label = group.label, count = group.tasks.size)
|
||||
}
|
||||
items(group.tasks, key = { it.id }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
@@ -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
|
||||
|
||||
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
|
||||
@@ -11,12 +10,10 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.AccountCircle
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.outlined.Delete
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.CheckCircle
|
||||
import androidx.compose.material.icons.outlined.Sync
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -24,52 +21,27 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import android.content.Intent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.data.preferences.ThemeMode
|
||||
import com.planify.mobile.domain.model.Source
|
||||
import com.planify.mobile.ui.auth.AuthViewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
authViewModel: AuthViewModel,
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
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(
|
||||
modifier = Modifier
|
||||
@@ -77,7 +49,7 @@ fun SettingsScreen(
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
// ── Apparence ───────────────────────────────────────────────────────
|
||||
// ── Apparence ────────────────────────────────────────────────────────
|
||||
SectionTitle("Apparence")
|
||||
ListItem(
|
||||
headlineContent = { Text("Thème") },
|
||||
@@ -97,43 +69,65 @@ fun SettingsScreen(
|
||||
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
// ── Synchronisation ─────────────────────────────────────────────────
|
||||
SectionTitle("Synchronisation")
|
||||
ListItem(
|
||||
headlineContent = { Text("Sync automatique") },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = state.syncEnabled,
|
||||
onCheckedChange = viewModel::setSyncEnabled,
|
||||
)
|
||||
},
|
||||
)
|
||||
if (state.syncEnabled) {
|
||||
// ── Bonsai ───────────────────────────────────────────────────────────
|
||||
SectionTitle("Bonsai")
|
||||
|
||||
if (state.isLoggedIn) {
|
||||
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) },
|
||||
)
|
||||
leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
|
||||
headlineContent = { Text("Connecté") },
|
||||
supportingContent = { Text(state.username) },
|
||||
trailingContent = {
|
||||
if (state.syncInProgress) {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
|
||||
} else {
|
||||
IconButton(onClick = viewModel::syncNow) {
|
||||
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") },
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text("Synchroniser maintenant") },
|
||||
trailingContent = {
|
||||
IconButton(onClick = viewModel::syncNow) {
|
||||
Icon(Icons.Outlined.Sync, contentDescription = "Sync")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
if (showAddAccount) {
|
||||
AddCalDavAccountDialog(
|
||||
onDismiss = { showAddAccount = false },
|
||||
onConfirm = { url, user, pwd ->
|
||||
viewModel.addCalDavAccount(url, user, pwd)
|
||||
showAddAccount = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -237,68 +156,3 @@ private fun SectionTitle(text: String) {
|
||||
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
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.planify.mobile.data.caldav.CalDavCredentialStore
|
||||
import com.planify.mobile.data.caldav.CalDavDiscovery
|
||||
import com.planify.mobile.data.caldav.DiscoveryResult
|
||||
import com.planify.mobile.data.export.ExportManager
|
||||
import com.planify.mobile.data.bonsai.BonsaiAuthManager
|
||||
import com.planify.mobile.data.bonsai.BonsaiSyncManager
|
||||
import com.planify.mobile.data.bonsai.SyncResult
|
||||
import com.planify.mobile.data.preferences.AppPreferences
|
||||
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 kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -27,101 +18,58 @@ import javax.inject.Inject
|
||||
|
||||
data class SettingsUiState(
|
||||
val themeMode: ThemeMode = ThemeMode.SYSTEM,
|
||||
val syncEnabled: Boolean = true,
|
||||
val syncIntervalMinutes: Int = 30,
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val caldavSources: List<Source> = emptyList(),
|
||||
val discoveryInProgress: Boolean = false,
|
||||
val discoveryError: String? = null,
|
||||
val isLoggedIn: Boolean = false,
|
||||
val username: String = "",
|
||||
val syncInProgress: Boolean = false,
|
||||
val syncError: String? = null,
|
||||
val syncSuccess: Boolean = false,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val prefs: AppPreferences,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val syncScheduler: SyncScheduler,
|
||||
private val discovery: CalDavDiscovery,
|
||||
private val credentialStore: CalDavCredentialStore,
|
||||
private val exportManager: ExportManager,
|
||||
private val projectRepository: ProjectRepository,
|
||||
private val taskRepository: TaskRepository,
|
||||
private val authManager: BonsaiAuthManager,
|
||||
private val syncManager: BonsaiSyncManager,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _extra = MutableStateFlow(
|
||||
SettingsUiState(
|
||||
isLoggedIn = authManager.isLoggedIn,
|
||||
username = authManager.getUsername(),
|
||||
)
|
||||
)
|
||||
|
||||
val uiState = combine(
|
||||
prefs.themeMode,
|
||||
prefs.syncEnabled,
|
||||
prefs.syncIntervalMinutes,
|
||||
prefs.notificationsEnabled,
|
||||
sourceRepository.getAllSources(),
|
||||
) { theme, sync, interval, notifs, sources ->
|
||||
SettingsUiState(
|
||||
authManager.isAuthenticated,
|
||||
_extra,
|
||||
) { theme, notifs, isAuth, extra ->
|
||||
extra.copy(
|
||||
themeMode = theme,
|
||||
syncEnabled = sync,
|
||||
syncIntervalMinutes = interval,
|
||||
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())
|
||||
|
||||
private val _discoveryState = MutableStateFlow<Pair<Boolean, String?>>(false to null)
|
||||
val discoveryInProgress = _discoveryState.asStateFlow()
|
||||
|
||||
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 {
|
||||
prefs.setNotificationsEnabled(enabled)
|
||||
}
|
||||
|
||||
fun syncNow() { syncScheduler.syncNow() }
|
||||
|
||||
fun addCalDavAccount(baseUrl: String, username: String, password: String) {
|
||||
_discoveryState.update { true to null }
|
||||
fun syncNow() {
|
||||
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
|
||||
viewModelScope.launch {
|
||||
when (val result = discovery.discover(baseUrl, username, password)) {
|
||||
is DiscoveryResult.Success -> {
|
||||
result.sources.forEach { source ->
|
||||
credentialStore.savePassword(source.id, password)
|
||||
sourceRepository.insertSource(source)
|
||||
}
|
||||
_discoveryState.update { false to null }
|
||||
if (uiState.value.syncEnabled) syncScheduler.schedule()
|
||||
}
|
||||
is DiscoveryResult.Failure -> {
|
||||
_discoveryState.update { false to result.message }
|
||||
}
|
||||
when (val result = syncManager.sync()) {
|
||||
is SyncResult.Success -> _extra.update { it.copy(syncInProgress = false, syncSuccess = true) }
|
||||
is SyncResult.NotLoggedIn -> _extra.update { it.copy(syncInProgress = false, syncError = "Non connecté") }
|
||||
is SyncResult.Failure -> _extra.update { it.copy(syncInProgress = false, syncError = result.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCalDavAccount(source: Source) = viewModelScope.launch {
|
||||
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 }
|
||||
fun clearSyncFeedback() = _extra.update { it.copy(syncSuccess = false, syncError = null) }
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import com.planify.mobile.domain.model.DueDate
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -46,7 +47,7 @@ fun DueDatePickerSheet(
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val datePickerState = rememberDatePickerState(
|
||||
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) }
|
||||
|
||||
@@ -103,7 +104,7 @@ fun DueDatePickerSheet(
|
||||
TextButton(onClick = {
|
||||
val millis = datePickerState.selectedDateMillis
|
||||
val date = millis?.let {
|
||||
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString()
|
||||
Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
|
||||
} ?: return@TextButton
|
||||
onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false,
|
||||
recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE))
|
||||
@@ -119,7 +120,7 @@ fun DueDatePickerSheet(
|
||||
showRecurrence = false
|
||||
val millis = datePickerState.selectedDateMillis
|
||||
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()
|
||||
onConfirm(recDueDate?.copy(date = date))
|
||||
},
|
||||
|
||||
@@ -28,31 +28,44 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.planify.mobile.domain.model.Label
|
||||
import com.planify.mobile.domain.repository.LabelRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class LabelPickerViewModel @Inject constructor(
|
||||
labelRepository: LabelRepository,
|
||||
private val labelRepository: LabelRepository,
|
||||
) : 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())
|
||||
|
||||
fun setProjectId(projectId: String) { _projectId.value = projectId }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LabelPickerSheet(
|
||||
projectId: String,
|
||||
selectedLabels: List<String>,
|
||||
onConfirm: (List<String>) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: LabelPickerViewModel = hiltViewModel(),
|
||||
) {
|
||||
LaunchedEffect(projectId) { viewModel.setProjectId(projectId) }
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val labels by viewModel.labels.collectAsState()
|
||||
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.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -150,6 +151,15 @@ fun TaskEditSheet(
|
||||
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
@@ -184,6 +194,7 @@ fun TaskEditSheet(
|
||||
}
|
||||
if (showLabelPicker) {
|
||||
LabelPickerSheet(
|
||||
projectId = projectId,
|
||||
selectedLabels = state.labels,
|
||||
onConfirm = { viewModel.setLabels(it); showLabelPicker = false },
|
||||
onDismiss = { showLabelPicker = false },
|
||||
|
||||
@@ -2,6 +2,12 @@ package com.planify.mobile.ui.task
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.domain.model.DueDate
|
||||
import com.planify.mobile.domain.model.Reminder
|
||||
@@ -32,6 +38,7 @@ data class TaskEditState(
|
||||
val reminders: List<Reminder> = emptyList(),
|
||||
val subTasks: List<Task> = emptyList(),
|
||||
val isSaving: Boolean = false,
|
||||
val saveError: String? = null,
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
@@ -39,6 +46,10 @@ class TaskEditViewModel @Inject constructor(
|
||||
private val taskRepository: TaskRepository,
|
||||
private val reminderRepository: ReminderRepository,
|
||||
private val reminderScheduler: ReminderScheduler,
|
||||
private val apiClient: BonsaiApiClient,
|
||||
private val authManager: BonsaiAuthManager,
|
||||
private val syncManager: BonsaiSyncManager,
|
||||
private val projectRepository: ProjectRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(TaskEditState())
|
||||
@@ -50,24 +61,22 @@ class TaskEditViewModel @Inject constructor(
|
||||
val task = taskRepository.getTaskById(taskId) ?: return@launch
|
||||
val subTasks = taskRepository.getSubTasks(taskId).first()
|
||||
val reminders = reminderRepository.getRemindersByTask(taskId).first()
|
||||
_state.update {
|
||||
it.copy(
|
||||
taskId = taskId,
|
||||
projectId = task.projectId,
|
||||
sectionId = task.sectionId,
|
||||
parentId = task.parentId,
|
||||
content = task.content,
|
||||
description = task.description,
|
||||
priority = task.priority,
|
||||
dueDate = task.dueDate,
|
||||
labels = task.labels,
|
||||
reminders = reminders,
|
||||
subTasks = subTasks,
|
||||
)
|
||||
}
|
||||
_state.value = TaskEditState(
|
||||
taskId = taskId,
|
||||
projectId = task.projectId,
|
||||
sectionId = task.sectionId,
|
||||
parentId = task.parentId,
|
||||
content = task.content,
|
||||
description = task.description,
|
||||
priority = task.priority,
|
||||
dueDate = task.dueDate,
|
||||
labels = task.labels,
|
||||
reminders = reminders,
|
||||
subTasks = subTasks,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(projectId = projectId, sectionId = sectionId, parentId = parentId) }
|
||||
_state.value = TaskEditState(projectId = projectId, sectionId = sectionId, parentId = parentId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,56 +111,98 @@ class TaskEditViewModel @Inject constructor(
|
||||
|
||||
fun save(onDone: () -> Unit) {
|
||||
val st = _state.value
|
||||
if (st.content.isBlank()) return
|
||||
_state.update { it.copy(isSaving = true) }
|
||||
if (st.content.isBlank() || st.projectId.isBlank()) return
|
||||
_state.update { it.copy(isSaving = true, saveError = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||||
val id = st.taskId ?: UUID.randomUUID().toString()
|
||||
|
||||
val task = Task(
|
||||
id = id,
|
||||
content = st.content,
|
||||
description = st.description,
|
||||
projectId = st.projectId,
|
||||
sectionId = st.sectionId,
|
||||
parentId = st.parentId,
|
||||
priority = st.priority,
|
||||
dueDate = st.dueDate,
|
||||
labels = st.labels,
|
||||
addedAt = if (st.taskId == null) now else "",
|
||||
updatedAt = now,
|
||||
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 },
|
||||
)
|
||||
|
||||
if (st.taskId == null) taskRepository.insertTask(task)
|
||||
else taskRepository.updateTask(task)
|
||||
val projectIdLong = st.projectId.toLongOrNull()
|
||||
val taskIdLong = st.taskId?.toLongOrNull()
|
||||
|
||||
// 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,
|
||||
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(
|
||||
id = id,
|
||||
content = st.content,
|
||||
description = st.description,
|
||||
projectId = st.projectId,
|
||||
addedAt = if (sub.addedAt.isBlank()) now else sub.addedAt,
|
||||
sectionId = st.sectionId,
|
||||
parentId = st.parentId,
|
||||
priority = st.priority,
|
||||
dueDate = st.dueDate,
|
||||
labels = st.labels,
|
||||
addedAt = if (st.taskId == null) now else "",
|
||||
updatedAt = now,
|
||||
)
|
||||
if (taskRepository.getTaskById(sub.id) != null) taskRepository.updateTask(actualSub)
|
||||
else taskRepository.insertTask(actualSub)
|
||||
if (st.taskId == null) taskRepository.insertTask(task)
|
||||
else taskRepository.updateTask(task)
|
||||
saveReminders(id, st, task)
|
||||
_state.update { it.copy(isSaving = false) }
|
||||
onDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) }
|
||||
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
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.data.preferences.ThemeMode
|
||||
|
||||
private val LightColorScheme = lightColorScheme()
|
||||
private val DarkColorScheme = darkColorScheme()
|
||||
// Bonsai design tokens — warm cream + forest green palette
|
||||
|
||||
// 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
|
||||
fun PlanifyTheme(
|
||||
@@ -30,17 +125,8 @@ fun PlanifyTheme(
|
||||
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(
|
||||
colorScheme = colorScheme,
|
||||
colorScheme = if (isDark) BonsaiDarkColorScheme else BonsaiLightColorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
|
||||
@@ -6,10 +6,88 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
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(
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.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
|
||||
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.Search
|
||||
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.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.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.planify.mobile.domain.model.Task
|
||||
import com.planify.mobile.ui.components.EmptyState
|
||||
import com.planify.mobile.ui.components.SectionHeader
|
||||
import com.planify.mobile.ui.components.TaskRow
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TodayScreen(
|
||||
@@ -32,65 +59,224 @@ fun TodayScreen(
|
||||
val state by viewModel.uiState.collectAsState()
|
||||
val collapsedSections = remember { mutableStateOf(setOf<String>()) }
|
||||
|
||||
if (state.totalCount == 0 && !state.isLoading) {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.Today,
|
||||
title = "Rien pour aujourd'hui",
|
||||
subtitle = "Profitez de votre journée !",
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentPadding = PaddingValues(bottom = 96.dp),
|
||||
) {
|
||||
item { TodayHeader() }
|
||||
|
||||
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||
if (state.overdueTasks.isNotEmpty()) {
|
||||
if (state.totalCount > 0) {
|
||||
item {
|
||||
SectionHeader(
|
||||
name = "En retard",
|
||||
taskCount = state.overdueTasks.size,
|
||||
collapsed = "overdue" in collapsedSections.value,
|
||||
onToggleCollapse = {
|
||||
collapsedSections.value = collapsedSections.value.toggle("overdue")
|
||||
},
|
||||
onAddTask = {},
|
||||
HeroCard(
|
||||
doneCount = state.doneCount,
|
||||
totalCount = state.totalCount,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
if ("overdue" !in collapsedSections.value) {
|
||||
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { viewModel.toggleTask(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.totalCount == 0 && !state.isLoading) {
|
||||
item {
|
||||
EmptyState(
|
||||
icon = Icons.Outlined.Today,
|
||||
title = "Rien pour aujourd'hui",
|
||||
subtitle = "Profitez de votre journée !",
|
||||
)
|
||||
}
|
||||
return@LazyColumn
|
||||
}
|
||||
|
||||
if (state.overdueTasks.isNotEmpty()) {
|
||||
item { SectionLabel(name = "En retard", count = state.overdueTasks.size) }
|
||||
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { viewModel.toggleTask(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
|
||||
}
|
||||
|
||||
state.tasksByProject.forEach { (projectName, tasks) ->
|
||||
item(key = "header_$projectName") {
|
||||
SectionHeader(
|
||||
name = projectName,
|
||||
taskCount = tasks.size,
|
||||
collapsed = projectName in collapsedSections.value,
|
||||
onToggleCollapse = {
|
||||
collapsedSections.value = collapsedSections.value.toggle(projectName)
|
||||
},
|
||||
onAddTask = {},
|
||||
)
|
||||
SectionLabel(name = projectName, count = tasks.size)
|
||||
}
|
||||
if (projectName !in collapsedSections.value) {
|
||||
items(tasks, key = { it.id }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { viewModel.toggleTask(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
items(tasks, key = { it.id }) { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
onCheckedChange = { viewModel.toggleTask(task) },
|
||||
onClick = { onTaskClick(task) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Set<String>.toggle(key: String) =
|
||||
if (contains(key)) minus(key) else plus(key)
|
||||
@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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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 overdueTasks: List<Task> = emptyList(),
|
||||
val totalCount: Int = 0,
|
||||
val doneCount: Int = 0,
|
||||
val isLoading: Boolean = false,
|
||||
)
|
||||
|
||||
@@ -28,15 +29,18 @@ class TodayViewModel @Inject constructor(
|
||||
val uiState = combine(
|
||||
taskRepository.getTodayTasks(),
|
||||
taskRepository.getOverdueTasks(),
|
||||
taskRepository.getDoneTodayCount(),
|
||||
projectRepository.getAllProjects(),
|
||||
) { today, overdue, projects ->
|
||||
) { today, overdue, done, projects ->
|
||||
val projectMap = projects.associateBy { it.id }
|
||||
val grouped = today
|
||||
.groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId }
|
||||
TodayUiState(
|
||||
tasksByProject = grouped,
|
||||
overdueTasks = overdue,
|
||||
totalCount = today.size + overdue.size,
|
||||
totalCount = today.size + overdue.size + done,
|
||||
doneCount = done,
|
||||
isLoading = false,
|
||||
)
|
||||
}.stateIn(
|
||||
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 {
|
||||
alias(libs.plugins.android.application) 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.ksp) 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]
|
||||
agp = "8.4.0"
|
||||
kotlin = "1.9.24"
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.0.21"
|
||||
ksp = "2.0.21-1.0.28"
|
||||
coreKtx = "1.13.1"
|
||||
lifecycleRuntimeKtx = "2.8.3"
|
||||
activityCompose = "1.9.0"
|
||||
composeBom = "2024.06.00"
|
||||
hilt = "2.51.1"
|
||||
lifecycleRuntimeKtx = "2.8.7"
|
||||
activityCompose = "1.9.3"
|
||||
composeBom = "2024.12.01"
|
||||
hilt = "2.52"
|
||||
hiltNavigationCompose = "1.2.0"
|
||||
navigationCompose = "2.7.7"
|
||||
navigationCompose = "2.8.5"
|
||||
room = "2.6.1"
|
||||
coroutines = "1.8.1"
|
||||
coroutines = "1.9.0"
|
||||
okhttp = "4.12.0"
|
||||
datastore = "1.1.1"
|
||||
securityCrypto = "1.1.0-alpha06"
|
||||
workManager = "2.9.0"
|
||||
serialization = "1.6.3"
|
||||
workManager = "2.10.0"
|
||||
serialization = "1.7.3"
|
||||
browser = "1.8.0"
|
||||
junit = "4.13.2"
|
||||
junitExt = "1.2.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" }
|
||||
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" }
|
||||
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
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" }
|
||||
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" }
|
||||
|
||||
@@ -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" "$@"
|
||||