Compare commits

...

35 Commits

Author SHA1 Message Date
Gato cb78d2cbe5 Merge develop
Signed-off-by: Gato <cedric@goutailler-olivier.fr>
2026-06-06 23:15:49 +02:00
Gato 512df6d89e Clean repo 2026-06-06 23:13:37 +02:00
Gato effdc2a1ad fix: démarrage immédiat + session conservée hors-ligne
- AuthViewModel: affiche l'app immédiatement si un token existe, sans
  attendre le refresh réseau — le refresh + sync se font en arrière-plan
- BonsaiAuthManager.refreshIfNeeded: ne déconnecte l'utilisateur que sur
  401/403 (token invalide), pas sur erreur réseau ou timeout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 16:34:36 +02:00
Gato 27a3e569af feat: implement full Bonsai design — bottom nav, green theme, redesigned screens
- Replace drawer + TopAppBar with bottom tab bar (Aujourd'hui / Prévu / Projets / Profil)
- Update theme to forest green (#2F7A4F) + warm cream (#F1ECE0) palette (light & dark)
- TodayScreen: header with date, hero progress ring showing done/total tasks
- ScheduledScreen: horizontal week strip with today highlighted
- ProjectScreen: full-width green banner with done/remaining/progress stats
- ProjectsListScreen: new screen with 2×2 quick tiles + project list with progress rings
- TaskRow: card-style with rounded border, circular checkbox
- Add getDoneTodayCount() to DAO/Repository/ViewModel for progress tracking
- Route.ProjectsList added; start destination changed to Today

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 14:13:36 +02:00
Gato 33f95cc5a5 feat: toggle tâche → statut terminé sur API + bouton déconnexion dans le menu 2026-06-06 11:24:28 +02:00
Gato 6109e4a5df debug: logging HTTP request/response dans BonsaiApiClient 2026-06-06 11:06:24 +02:00
Gato 833a68c06e feat: labels filtrés par projet (releases du projet uniquement) 2026-06-06 10:54:21 +02:00
Gato ba9f379100 fix: projectId extrait des arguments de route (pas du pattern) + affichage erreur API 2026-06-06 10:47:35 +02:00
Gato 8cab357c4c feat: tâche créée immédiatement en local + détail tâche au clic 2026-06-06 10:33:04 +02:00
Gato 38df116328 fix: suppression de la contrainte FK tasks→projects (DB v3)
La contrainte FOREIGN KEY sur tasks.project_id causait des crashs
systématiques dans une app sync-first où les projets et tâches arrivent
de l'API sans garantie d'ordre strict. La cohérence est gérée côté
serveur Bonsai ; inutile de la dupliquer en base locale.
fallbackToDestructiveMigration() recrée la DB proprement au démarrage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 10:08:11 +02:00
Gato 4d59f371ac fix: crash SQLiteConstraintException lors de la sync Bonsai
- BonsaiApiClient : optLong/optString avec fallback pour éviter JSONException
  si l'API omet projectId, type, status ou priority dans la réponse
- BonsaiSyncManager : enveloppe sync() dans runCatching pour ne jamais
  propager d'exception non gérée ; chaque insert de tâche est aussi isolé
  pour que les erreurs individuelles ne bloquent pas toute la sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:54:10 +02:00
Gato 221cf4f80d fix: crash FOREIGN KEY lors de la création de tâche avant sync
Si les projets ne sont pas encore en base Room au moment de l'insertion
(première connexion, sync en cours), vérifie que le projet existe avant
d'insérer la tâche localement. Sinon, déclenche une sync — la tâche est
déjà créée sur l'API et apparaîtra après rafraîchissement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:46:42 +02:00
Gato b268fc13c5 chore: client Keycloak bonsai-android (client public dédié mobile)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:35:50 +02:00
Gato ee67139b04 feat: connexion ROPC — formulaire natif sans redirection Keycloak
Remplace le flux PKCE/Custom Tab par un formulaire username/password
natif qui appelle directement le token endpoint Keycloak (grant_type=password).
Le token et le refresh token sont stockés dans EncryptedSharedPreferences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:30:44 +02:00
Gato d099fc7da7 feat: authentification PKCE Keycloak au démarrage de l'application
Remplace le password grant par Authorization Code + PKCE via Custom Tab.
L'utilisateur est redirigé vers Keycloak à l'ouverture si non connecté,
le token est stocké dans EncryptedSharedPreferences et rafraîchi automatiquement.
Le deep link com.planify.mobile://auth/callback capture le code de retour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:24:58 +02:00
Gato b08ceb5574 feat: adapter le thème Material3 à la palette visuelle Bonsai
Remplace les couleurs dynamiques Android 12+ et le schéma Material3
par défaut par la palette extraite du webapp Bonsai (bleu #2563eb,
fond #f9fafb, surface blanche, contours gris). Ajoute la typographie
complète (13 styles) et renomme les libellés "Planify" en "BonsaiTask".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 09:04:45 +02:00
Gato 1dcfb0f525 fix: icône bonsai agrandie pour remplir la safe zone adaptive icon (v0.0.6)
Reporte les coordonnées SVG dans un viewport 108×108 avec scale ×1.286,
centré dans la safe zone (18–90). Background blanc pur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:55:59 +02:00
Gato 47808b2255 feat: pivot vers Bonsai API — authentification Keycloak + sync issues/milestones
- Renomme l'appli en BonsaiTask
- Remplace CalDAV par l'intégration Bonsai API (REST + JWT Keycloak)
- BonsaiAuthManager : login user/password via password grant Keycloak
- BonsaiApiClient : GET projects/issues(Task)/milestones, POST/PUT/DELETE issues
- BonsaiSyncManager : sync API → Room (issues=tâches, milestones=labels)
- Settings : formulaire de connexion Bonsai remplace la gestion CalDAV
- TaskEditViewModel : création/édition poussée vers l'API Bonsai
- Icône Bonsai (VectorDrawable) + fond vert clair
- BackendType.BONSAI ajouté
- v0.0.5

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:46:35 +02:00
Gato 93a26722d8 fix: connexion Nextcloud en utilisant le chemin CalDAV direct
Pour Nextcloud, bypass la chaîne PROPFIND (principal → calendar-home)
et accède directement à $baseUrl/calendars/$username/ conformément à
la doc officielle Nextcloud. Ajoute les codes HTTP dans les messages
d'erreur de la discovery générique pour faciliter le debug.

v0.0.4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:26:34 +02:00
Gato 0c00d7d5b0 feat: icône Planify (SVG → PNG adaptatif toutes densités) v0.0.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:15:57 +02:00
Gato 98b08f0219 fix: CalDAV Nextcloud — fallback principals/users/ pour calendar-home-set (v0.0.2)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:12:12 +02:00
Gato f038dbe0ee feat: version v0.0.1 affichée en bas du drawer (BuildConfig.VERSION_NAME)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:09:54 +02:00
Gato f5fc51c156 fix: parsing XML CalDAV namespace-aware + fallback principal Nextcloud (principals/users/)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 08:05:28 +02:00
Gato e2085a8dc2 fix: FAB rond (CircleShape) + crash ajout tâche (projet Inbox absent + projectId vide)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 07:59:38 +02:00
Gato 6d5feacf45 fix: ajout du FAB + sur tous les écrans (absent de MainScreen.Scaffold)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 07:54:20 +02:00
Gato 0fd300ffdc fix: champ is_deleted manquant dans TaskEntity, ZoneOffset.UTC, sérialisation des labels, toolchain Kotlin 2.0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 07:45:48 +02:00
Gato 5356e957ba fix: forcer Gradle sur temurin-25 (JBR sans jlink, temurin a jlink + KSP 2.0 supporte JDK 25) 2026-06-06 07:30:13 +02:00
Gato f308a9507d fix: compileSdk/targetSdk 35 + migration kotlinOptions → compilerOptions 2026-06-06 07:29:03 +02:00
Gato 2e59d54de6 feat: sauvegarde du compte CalDAV même si la connexion échoue, avec indicateur visuel 2026-06-06 07:18:34 +02:00
Gato dc6847d205 fix: thème XML android:Theme.Material.Light.NoActionBar (pas de dép externe) 2026-06-06 06:59:52 +02:00
Gato d2c07307c9 fix: thème XML AppCompat au lieu de Material3 (lib View absente des deps) 2026-06-06 06:59:52 +02:00
Gato 5b4265215d Merge pull request 'Milestone/lot 6 polish' (#38) from milestone/lot-6-polish into develop
Reviewed-on: Gato/Planify-mobile#38
2026-06-06 06:56:48 +02:00
Gato a8da951a33 feat: [#30] export et backup des données (JSON et iCalendar)
- ExportManager : génère backup JSON (BackupPayload sérialisable) et .ics via VTodoGenerator
- FileProvider déclaré dans AndroidManifest + res/xml/file_paths.xml (cache/exports/)
- @Serializable ajouté sur Project, Task, BackendType, ViewStyle, SortBy, ItemType
- TaskRepository/Impl/Dao : ajout getAllTasks() pour export global
- SettingsViewModel : exportJson(), exportIcal(), clearExportUri()
- SettingsScreen : section Export & Backup avec partage via Intent.ACTION_SEND

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 06:53:58 +02:00
Gato bf6351fbb5 feat: [#28][#29] écran paramètres (thème, sync, notifs, comptes CalDAV) + thème dynamique Material You piloté par DataStore 2026-06-06 06:48:04 +02:00
Gato a556f4cbdc feat: [#27] sync CalDAV en arrière-plan (WorkManager PeriodicWork, SyncScheduler, reprise au démarrage) 2026-06-06 06:46:28 +02:00
70 changed files with 3174 additions and 792 deletions
+15
View File
@@ -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
+17
View File
@@ -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`
+2
View File
@@ -10,3 +10,5 @@
local.properties local.properties
*.keystore *.keystore
*.jks *.jks
app/build/
java_pid2281.hprof
+12 -9
View File
@@ -1,6 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
@@ -8,14 +9,14 @@ plugins {
android { android {
namespace = "com.planify.mobile" namespace = "com.planify.mobile"
compileSdk = 34 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.planify.mobile" applicationId = "com.planify.mobile"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.1.0" versionName = "0.0.19"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -31,16 +32,15 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions { kotlin {
jvmTarget = "17" compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
} }
buildFeatures { buildFeatures {
compose = true compose = true
} buildConfig = true
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
} }
} }
@@ -83,6 +83,9 @@ dependencies {
// WorkManager // WorkManager
implementation(libs.work.runtime.ktx) implementation(libs.work.runtime.ktx)
// Browser (Custom Tabs pour OAuth)
implementation(libs.androidx.browser)
// Serialization // Serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
@@ -0,0 +1,167 @@
package com.planify.mobile.data.bonsai
import com.planify.mobile.data.bonsai.dto.BonsaiIssueDto
import com.planify.mobile.data.bonsai.dto.BonsaiMilestoneDto
import com.planify.mobile.data.bonsai.dto.BonsaiProjectDto
import com.planify.mobile.data.bonsai.dto.BonsaIssueRequest
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Failure(val message: String, val code: Int = -1) : ApiResult<Nothing>()
}
@Singleton
class BonsaiApiClient @Inject constructor(
private val httpClient: OkHttpClient,
private val auth: BonsaiAuthManager,
) {
private val json = "application/json".toMediaType()
suspend fun getProjects(): ApiResult<List<BonsaiProjectDto>> = get("projects") { arr ->
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BonsaiProjectDto(id = o.getLong("id"), name = o.getString("name"))
}
}
suspend fun getIssues(projectId: Long): ApiResult<List<BonsaiIssueDto>> =
get("projects/$projectId/issues") { arr ->
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BonsaiIssueDto(
id = o.getLong("id"),
projectId = o.optLong("projectId", projectId), // fallback = the project we queried
type = o.getString("type"),
name = o.getString("name"),
status = o.getString("status"),
priority = o.getString("priority"),
assignee = o.optString("assignee").takeIf { it.isNotEmpty() },
dueDate = o.optString("dueDate").takeIf { it.isNotEmpty() },
description = o.optString("description").takeIf { it.isNotEmpty() },
progress = o.optInt("progress", 0),
)
}
}
suspend fun getMilestones(projectId: Long): ApiResult<List<BonsaiMilestoneDto>> =
get("projects/$projectId/milestones") { arr ->
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
val ids = o.optJSONArray("issueIds")?.let { a ->
(0 until a.length()).map { j -> a.getLong(j) }
} ?: emptyList()
BonsaiMilestoneDto(
id = o.getLong("id"),
projectId = o.getLong("projectId"),
name = o.getString("name"),
issueIds = ids,
)
}
}
suspend fun createIssue(projectId: Long, req: BonsaIssueRequest): ApiResult<BonsaiIssueDto> =
post("projects/$projectId/issues", req.toJson()) { o ->
o.toIssueDto(fallbackProjectId = projectId)
}
suspend fun updateIssue(projectId: Long, issueId: Long, req: BonsaIssueRequest): ApiResult<BonsaiIssueDto> =
put("projects/$projectId/issues/$issueId", req.toJson()) { o ->
o.toIssueDto(fallbackProjectId = projectId)
}
suspend fun deleteIssue(projectId: Long, issueId: Long): ApiResult<Unit> = withContext(Dispatchers.IO) {
val authHeader = auth.getAuthHeader() ?: return@withContext ApiResult.Failure("Non connecté")
val url = "${auth.getApiBaseUrl()}/projects/$projectId/issues/$issueId"
val request = Request.Builder()
.url(url)
.delete()
.header("Authorization", authHeader)
.build()
runCatching {
httpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) ApiResult.Success(Unit)
else ApiResult.Failure("HTTP ${response.code}", response.code)
}
}.getOrElse { ApiResult.Failure(it.message ?: "Erreur réseau") }
}
// ── Internals ─────────────────────────────────────────────────────────────
private suspend fun <T> get(path: String, parse: (JSONArray) -> T): ApiResult<T> = withContext(Dispatchers.IO) {
val authHeader = auth.getAuthHeader() ?: return@withContext ApiResult.Failure("Non connecté")
val request = Request.Builder()
.url("${auth.getApiBaseUrl()}/$path")
.get()
.header("Authorization", authHeader)
.header("Accept", "application/json")
.build()
runCatching {
httpClient.newCall(request).execute().use { response ->
val body = response.body?.string() ?: ""
if (!response.isSuccessful) return@withContext ApiResult.Failure("HTTP ${response.code}: $body", response.code)
ApiResult.Success(parse(JSONArray(body)))
}
}.getOrElse { ApiResult.Failure(it.message ?: "Erreur réseau") }
}
private suspend fun <T> post(path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult<T> =
sendWithBody("POST", path, jsonBody, parse)
private suspend fun <T> put(path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult<T> =
sendWithBody("PUT", path, jsonBody, parse)
private suspend fun <T> sendWithBody(method: String, path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult<T> = withContext(Dispatchers.IO) {
val authHeader = auth.getAuthHeader() ?: return@withContext ApiResult.Failure("Non connecté")
val request = Request.Builder()
.url("${auth.getApiBaseUrl()}/$path")
.method(method, jsonBody.toRequestBody(json))
.header("Authorization", authHeader)
.header("Accept", "application/json")
.build()
Log.d("BonsaiApi", "$method ${auth.getApiBaseUrl()}/$path body=$jsonBody")
runCatching {
httpClient.newCall(request).execute().use { response ->
val body = response.body?.string() ?: ""
Log.d("BonsaiApi", "${response.code} body=$body")
if (!response.isSuccessful) return@withContext ApiResult.Failure("HTTP ${response.code}: $body", response.code)
ApiResult.Success(parse(JSONObject(body)))
}
}.getOrElse { e ->
Log.e("BonsaiApi", "Erreur réseau", e)
ApiResult.Failure(e.message ?: "Erreur réseau")
}
}
private fun BonsaIssueRequest.toJson(): String = JSONObject().apply {
put("type", type)
put("name", name)
put("status", status)
put("priority", priority)
dueDate?.let { put("dueDate", it) }
description?.let { put("description", it) }
}.toString()
private fun JSONObject.toIssueDto(fallbackProjectId: Long = 0L) = BonsaiIssueDto(
id = getLong("id"),
projectId = optLong("projectId", fallbackProjectId),
type = optString("type", "Task"),
name = getString("name"),
status = optString("status", "todo"),
priority = optString("priority", "MOYENNE"),
assignee = optString("assignee").takeIf { it.isNotEmpty() },
dueDate = optString("dueDate").takeIf { it.isNotEmpty() },
description = optString("description").takeIf { it.isNotEmpty() },
progress = optInt("progress", 0),
)
}
@@ -0,0 +1,162 @@
package com.planify.mobile.data.bonsai
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
sealed class LoginResult {
object Success : LoginResult()
data class Failure(val message: String) : LoginResult()
}
@Singleton
class BonsaiAuthManager @Inject constructor(
@ApplicationContext private val context: Context,
private val httpClient: OkHttpClient,
) {
private val prefs: SharedPreferences
private val _isAuthenticated: MutableStateFlow<Boolean>
init {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
prefs = EncryptedSharedPreferences.create(
context,
"bonsai_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
_isAuthenticated = MutableStateFlow(prefs.getString(KEY_REFRESH_TOKEN, null) != null)
}
val isAuthenticated: StateFlow<Boolean> = _isAuthenticated
val isLoggedIn: Boolean get() = _isAuthenticated.value
fun getApiBaseUrl(): String = DEFAULT_API_URL
fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: ""
fun getAuthHeader(): String? = prefs.getString(KEY_ACCESS_TOKEN, null)?.let { "Bearer $it" }
suspend fun login(username: String, password: String): LoginResult = withContext(Dispatchers.IO) {
val body = FormBody.Builder()
.add("grant_type", "password")
.add("client_id", CLIENT_ID)
.add("username", username)
.add("password", password)
.build()
val request = Request.Builder()
.url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
.post(body)
.build()
runCatching {
httpClient.newCall(request).execute().use { response ->
val raw = response.body?.string() ?: ""
if (!response.isSuccessful) {
val detail = runCatching {
JSONObject(raw).optString("error_description", raw)
}.getOrDefault(raw)
return@withContext LoginResult.Failure(detail)
}
saveTokens(JSONObject(raw))
LoginResult.Success
}
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
}
suspend fun refreshIfNeeded(): Boolean = withContext(Dispatchers.IO) {
val refreshToken = prefs.getString(KEY_REFRESH_TOKEN, null) ?: return@withContext false
val expiresAt = prefs.getLong(KEY_EXPIRES_AT, 0L)
if (System.currentTimeMillis() < expiresAt - 60_000L) return@withContext true
val body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", CLIENT_ID)
.add("refresh_token", refreshToken)
.build()
val request = Request.Builder()
.url("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token")
.post(body)
.build()
runCatching {
httpClient.newCall(request).execute().use { response ->
when {
response.isSuccessful -> {
val raw = response.body?.string() ?: return@withContext true
saveTokens(JSONObject(raw))
true
}
response.code == 401 || response.code == 403 -> {
// Refresh token genuinely invalid — must re-login.
false
}
else -> {
// Server error or network issue — keep existing token, don't logout.
true
}
}
}
}.getOrDefault(true) // Network exception (offline, timeout) → keep existing token.
}
fun logout() {
prefs.edit()
.remove(KEY_ACCESS_TOKEN)
.remove(KEY_REFRESH_TOKEN)
.remove(KEY_EXPIRES_AT)
.remove(KEY_USERNAME)
.apply()
_isAuthenticated.value = false
}
private fun saveTokens(json: JSONObject) {
val accessToken = json.getString("access_token")
val refreshToken = json.optString("refresh_token", "")
val expiresIn = json.optLong("expires_in", 300L)
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken.ifBlank { null })
.putLong(KEY_EXPIRES_AT, System.currentTimeMillis() + expiresIn * 1000L)
.putString(KEY_USERNAME, extractUsername(accessToken))
.apply()
_isAuthenticated.value = true
}
private fun extractUsername(jwt: String): String = runCatching {
val payload = jwt.split(".").getOrNull(1) ?: return@runCatching ""
val padded = payload + "=".repeat((4 - payload.length % 4) % 4)
val decoded = Base64.getUrlDecoder().decode(padded)
val json = JSONObject(String(decoded))
json.optString("preferred_username", json.optString("sub", ""))
}.getOrDefault("")
companion object {
const val DEFAULT_API_URL = "https://bonsai.goutailler-olivier.com/api"
private const val KEYCLOAK_BASE = "https://auth.goutailler-olivier.com"
private const val REALM = "bonsai"
private const val CLIENT_ID = "bonsai-android"
private const val KEY_ACCESS_TOKEN = "access_token"
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_EXPIRES_AT = "expires_at"
private const val KEY_USERNAME = "username"
}
}
@@ -0,0 +1,141 @@
package com.planify.mobile.data.bonsai
import com.planify.mobile.data.bonsai.dto.BonsaiIssueDto
import com.planify.mobile.data.bonsai.dto.BonsaiMilestoneDto
import com.planify.mobile.domain.model.BackendType
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.Label
import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.LabelRepository
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.TaskRepository
import kotlinx.coroutines.flow.first
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
sealed class SyncResult {
object Success : SyncResult()
object NotLoggedIn : SyncResult()
data class Failure(val message: String) : SyncResult()
}
@Singleton
class BonsaiSyncManager @Inject constructor(
private val apiClient: BonsaiApiClient,
private val authManager: BonsaiAuthManager,
private val projectRepository: ProjectRepository,
private val taskRepository: TaskRepository,
private val labelRepository: LabelRepository,
) {
suspend fun sync(): SyncResult = runCatching { doSync() }
.getOrElse { SyncResult.Failure(it.message ?: "Erreur interne") }
private suspend fun doSync(): SyncResult {
if (!authManager.isLoggedIn) return SyncResult.NotLoggedIn
val projectsResult = apiClient.getProjects()
if (projectsResult is ApiResult.Failure) return SyncResult.Failure(projectsResult.message)
val bonsaiProjects = (projectsResult as ApiResult.Success).data
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
// Sync projects
bonsaiProjects.forEach { dto ->
val id = dto.id.toString()
val project = Project(
id = id,
name = dto.name,
color = "#276749",
backendType = BackendType.BONSAI,
childOrder = dto.id.toInt(),
)
if (projectRepository.getProjectById(id) == null) projectRepository.insertProject(project)
else projectRepository.updateProject(project)
}
// Per-project: issues + milestones
val milestonesByIssueId = mutableMapOf<Long, MutableList<String>>()
val allTaskIssues = mutableListOf<BonsaiIssueDto>()
bonsaiProjects.forEach { project ->
val issuesResult = apiClient.getIssues(project.id)
if (issuesResult is ApiResult.Success) {
val tasks = issuesResult.data.filter { it.type == "Task" }
allTaskIssues.addAll(tasks)
}
val msResult = apiClient.getMilestones(project.id)
if (msResult is ApiResult.Success) {
val milestones = msResult.data
// Sync milestones as labels
milestones.forEach { ms ->
val labelId = "ms_${ms.id}"
val label = Label(
id = labelId,
name = ms.name,
color = "#276749",
backendType = BackendType.BONSAI,
sourceId = project.id.toString(),
)
if (labelRepository.getLabelById(labelId) == null) labelRepository.insertLabel(label)
else labelRepository.updateLabel(label)
ms.issueIds.forEach { issueId ->
milestonesByIssueId.getOrPut(issueId) { mutableListOf() }.add(ms.name)
}
}
}
}
// Sync tasks (issues of type Task)
allTaskIssues.forEach { issue ->
runCatching {
val labels = milestonesByIssueId[issue.id] ?: emptyList()
val task = issue.toTask(labels, now)
val existing = taskRepository.getTaskById(task.id)
if (existing == null) taskRepository.insertTask(task)
else taskRepository.updateTask(task)
}
// Individual task failures are skipped; the next sync will retry.
}
return SyncResult.Success
}
// ── Mapping helpers ───────────────────────────────────────────────────────
private fun BonsaiIssueDto.toTask(milestoneNames: List<String>, now: String) = Task(
id = id.toString(),
content = name,
description = description ?: "",
projectId = projectId.toString(),
priority = mapPriority(priority),
checked = status == "done",
dueDate = dueDate?.let { DueDate(date = it) },
labels = milestoneNames,
addedAt = now,
updatedAt = now,
completedAt = if (status == "done") now else null,
)
companion object {
fun mapPriority(bonsaiPriority: String): Int = when (bonsaiPriority) {
"TRES_HAUTE" -> 1
"HAUTE" -> 2
"MOYENNE" -> 3
else -> 4
}
fun toBonsaiPriority(appPriority: Int): String = when (appPriority) {
1 -> "TRES_HAUTE"
2 -> "HAUTE"
3 -> "MOYENNE"
else -> "BASSE"
}
fun toBonsaiStatus(checked: Boolean): String = if (checked) "done" else "todo"
}
}
@@ -0,0 +1,35 @@
package com.planify.mobile.data.bonsai.dto
data class BonsaiProjectDto(
val id: Long,
val name: String,
)
data class BonsaiIssueDto(
val id: Long,
val projectId: Long,
val type: String,
val name: String,
val status: String,
val priority: String,
val assignee: String? = null,
val dueDate: String? = null,
val description: String? = null,
val progress: Int = 0,
)
data class BonsaIssueRequest(
val type: String = "Task",
val name: String,
val status: String = "todo",
val priority: String = "MOYENNE",
val dueDate: String? = null,
val description: String? = null,
)
data class BonsaiMilestoneDto(
val id: Long,
val projectId: Long,
val name: String,
val issueIds: List<Long> = emptyList(),
)
@@ -4,6 +4,8 @@ import com.planify.mobile.domain.model.CalDavType
import com.planify.mobile.domain.model.Source import com.planify.mobile.domain.model.Source
import com.planify.mobile.domain.model.SourceCalDavData import com.planify.mobile.domain.model.SourceCalDavData
import com.planify.mobile.domain.model.SourceType import com.planify.mobile.domain.model.SourceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory import org.xmlpull.v1.XmlPullParserFactory
import java.io.StringReader import java.io.StringReader
@@ -21,25 +23,146 @@ sealed class DiscoveryResult {
@Singleton @Singleton
class CalDavDiscovery @Inject constructor(private val client: CalDavClient) { class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult { suspend fun discover(baseUrl: String, username: String, password: String): DiscoveryResult = withContext(Dispatchers.IO) {
val credentials = CalDavClient.basicCredentials(username, password) val credentials = CalDavClient.basicCredentials(username, password)
val normalizedBase = baseUrl.trimEnd('/') val normalizedBase = baseUrl.trimEnd('/')
// Step 1: resolve principal URL
val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username)
?: return DiscoveryResult.Failure("Impossible de trouver le principal CalDAV")
// Step 2: find calendar home
val calendarHome = resolveCalendarHome(principalUrl, credentials)
?: return DiscoveryResult.Failure("Impossible de trouver le calendar home")
// Step 3: list VTODO-capable calendars
val calendars = listCalendars(calendarHome, credentials, username, baseUrl)
val caldavType = detectType(normalizedBase) val caldavType = detectType(normalizedBase)
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val sources = calendars.map { cal -> // For Nextcloud, use the known URL structure directly (avoids fragile PROPFIND chain)
if (caldavType == CalDavType.NEXTCLOUD) {
val result = discoverNextcloud(normalizedBase, username, credentials)
if (result != null) return@withContext result
}
// Generic CalDAV discovery via PROPFIND chain
val diag = StringBuilder()
val principalUrl = resolvePrincipalUrl(normalizedBase, credentials, username, diag)
?: return@withContext DiscoveryResult.Failure("Étape 1 échouée : principal introuvable\n$diag")
val calendarHome = resolveCalendarHome(principalUrl, credentials, diag)
?: return@withContext DiscoveryResult.Failure("Étape 2 échouée sur $principalUrl\n$diag")
val calendars = listCalendars(calendarHome, credentials, normalizedBase)
if (calendars.isEmpty()) {
return@withContext DiscoveryResult.Failure("Étape 3 : aucun calendrier VTODO sur $calendarHome\n$diag")
}
DiscoveryResult.Success(buildSources(calendars, calendarHome, username, caldavType, normalizedBase))
}
// ── Nextcloud direct path ─────────────────────────────────────────────────
private fun discoverNextcloud(baseUrl: String, username: String, credentials: String): DiscoveryResult? {
val calendarHome = "$baseUrl/calendars/$username/"
val resp = client.propfind(calendarHome, credentials, "1", calendarListBody())
if (!resp.isSuccess) return null
val calendars = parseCalendarList(resp.body, baseUrl)
if (calendars.isEmpty()) {
// Home exists but no VTODO calendars — still a successful connection
return DiscoveryResult.Success(
listOf(
Source(
id = UUID.randomUUID().toString(),
type = SourceType.CALDAV,
displayName = username,
addedAt = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
updatedAt = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
caldavData = SourceCalDavData(
serverUrl = calendarHome,
username = username,
calendarHomeUrl = calendarHome,
caldavType = CalDavType.NEXTCLOUD,
),
)
)
)
}
return DiscoveryResult.Success(buildSources(calendars, calendarHome, username, CalDavType.NEXTCLOUD, baseUrl))
}
// ── Generic PROPFIND discovery ────────────────────────────────────────────
private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String, diag: StringBuilder): String? {
val origin = baseUrl.substringBefore("://") + "://" + baseUrl.substringAfter("://").substringBefore("/")
val body = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop><current-user-principal/></prop>
</propfind>
""".trimIndent()
for (url in listOf("$origin/.well-known/caldav", baseUrl)) {
val resp = client.propfind(url, credentials, "0", body)
diag.append("PROPFIND $url${resp.code}\n")
if (resp.isSuccess) {
val href = extractHref(resp.body, "current-user-principal")
if (href != null) return resolveUrl(baseUrl, href)
}
}
// Fallbacks
for (candidate in listOf("$baseUrl/principals/users/$username/", "$baseUrl/principals/$username/")) {
val resp = client.propfind(candidate, credentials, "0", body)
diag.append("PROPFIND $candidate${resp.code}\n")
if (resp.isSuccess) return candidate
}
return null
}
private fun resolveCalendarHome(principalUrl: String, credentials: String, diag: StringBuilder): String? {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<prop><c:calendar-home-set/></prop>
</propfind>
""".trimIndent()
val urlsToTry = mutableListOf(principalUrl)
if (principalUrl.contains("/principals/") && !principalUrl.contains("/principals/users/")) {
urlsToTry.add(principalUrl.replaceFirst("/principals/", "/principals/users/"))
}
for (url in urlsToTry) {
val resp = client.propfind(url, credentials, "0", body)
diag.append("PROPFIND calendar-home $url${resp.code}\n")
if (!resp.isSuccess) continue
val href = extractHref(resp.body, "calendar-home-set") ?: continue
return resolveUrl(url, href)
}
return null
}
private fun listCalendars(homeUrl: String, credentials: String, baseUrl: String): List<CalendarInfo> {
val resp = client.propfind(homeUrl, credentials, "1", calendarListBody())
if (!resp.isSuccess) return emptyList()
return parseCalendarList(resp.body, baseUrl)
}
private fun calendarListBody() = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<prop>
<resourcetype/>
<displayname/>
<c:supported-calendar-component-set/>
</prop>
</propfind>
""".trimIndent()
// ── Helpers ───────────────────────────────────────────────────────────────
private data class CalendarInfo(val url: String, val displayName: String)
private fun buildSources(
calendars: List<CalendarInfo>,
calendarHome: String,
username: String,
caldavType: CalDavType,
baseUrl: String,
): List<Source> {
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
return calendars.map { cal ->
Source( Source(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
type = SourceType.CALDAV, type = SourceType.CALDAV,
@@ -54,66 +177,12 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
), ),
) )
} }
return DiscoveryResult.Success(sources)
} }
private fun resolvePrincipalUrl(baseUrl: String, credentials: String, username: String): String? {
val wellKnown = "$baseUrl/.well-known/caldav"
val body = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:">
<prop><current-user-principal/></prop>
</propfind>
""".trimIndent()
for (url in listOf(wellKnown, baseUrl)) {
val resp = client.propfind(url, credentials, "0", body)
if (resp.isSuccess) {
val href = extractHref(resp.body, "current-user-principal")
if (href != null) return resolveUrl(baseUrl, href)
}
}
// Fallback: guess principal path
return "$baseUrl/principals/$username/"
}
private fun resolveCalendarHome(principalUrl: String, credentials: String): String? {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<prop><c:calendar-home-set/></prop>
</propfind>
""".trimIndent()
val resp = client.propfind(principalUrl, credentials, "0", body)
if (!resp.isSuccess) return null
val href = extractHref(resp.body, "calendar-home-set") ?: return null
return resolveUrl(principalUrl, href)
}
private fun listCalendars(homeUrl: String, credentials: String, username: String, baseUrl: String): List<CalendarInfo> {
val body = """
<?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<prop>
<resourcetype/>
<displayname/>
<c:supported-calendar-component-set/>
</prop>
</propfind>
""".trimIndent()
val resp = client.propfind(homeUrl, credentials, "1", body)
if (!resp.isSuccess) return emptyList()
return parseCalendarList(resp.body, baseUrl)
}
private data class CalendarInfo(val url: String, val displayName: String)
private fun parseCalendarList(xml: String, baseUrl: String): List<CalendarInfo> { private fun parseCalendarList(xml: String, baseUrl: String): List<CalendarInfo> {
val results = mutableListOf<CalendarInfo>() val results = mutableListOf<CalendarInfo>()
runCatching { runCatching {
val factory = XmlPullParserFactory.newInstance() val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
val parser = factory.newPullParser() val parser = factory.newPullParser()
parser.setInput(StringReader(xml)) parser.setInput(StringReader(xml))
@@ -133,12 +202,10 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
event == XmlPullParser.START_TAG && name == "prop" -> inProp = true event == XmlPullParser.START_TAG && name == "prop" -> inProp = true
event == XmlPullParser.END_TAG && name == "prop" -> inProp = false event == XmlPullParser.END_TAG && name == "prop" -> inProp = false
event == XmlPullParser.START_TAG && name == "href" && !inProp -> { event == XmlPullParser.START_TAG && name == "href" && !inProp -> {
parser.next() parser.next(); href = parser.text ?: ""
href = parser.text ?: ""
} }
event == XmlPullParser.START_TAG && name == "displayname" -> { event == XmlPullParser.START_TAG && name == "displayname" -> {
parser.next() parser.next(); displayName = parser.text ?: ""
displayName = parser.text ?: ""
} }
event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true event == XmlPullParser.START_TAG && name == "calendar" -> isCalendar = true
event == XmlPullParser.START_TAG && name == "comp" -> { event == XmlPullParser.START_TAG && name == "comp" -> {
@@ -146,8 +213,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
} }
event == XmlPullParser.END_TAG && name == "response" -> { event == XmlPullParser.END_TAG && name == "response" -> {
if (isCalendar && supportsTodo && href.isNotBlank()) { if (isCalendar && supportsTodo && href.isNotBlank()) {
val fullUrl = resolveUrl(baseUrl, href) results.add(CalendarInfo(resolveUrl(baseUrl, href), displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') }))
results.add(CalendarInfo(fullUrl, displayName.ifBlank { href.trimEnd('/').substringAfterLast('/') }))
} }
} }
} }
@@ -158,7 +224,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
} }
private fun extractHref(xml: String, parentTag: String): String? = runCatching { private fun extractHref(xml: String, parentTag: String): String? = runCatching {
val factory = XmlPullParserFactory.newInstance() val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
val parser = factory.newPullParser() val parser = factory.newPullParser()
parser.setInput(StringReader(xml)) parser.setInput(StringReader(xml))
var inTarget = false var inTarget = false
@@ -169,8 +235,7 @@ class CalDavDiscovery @Inject constructor(private val client: CalDavClient) {
event == XmlPullParser.START_TAG && name == parentTag -> inTarget = true event == XmlPullParser.START_TAG && name == parentTag -> inTarget = true
event == XmlPullParser.END_TAG && name == parentTag -> inTarget = false event == XmlPullParser.END_TAG && name == parentTag -> inTarget = false
event == XmlPullParser.START_TAG && name == "href" && inTarget -> { event == XmlPullParser.START_TAG && name == "href" && inTarget -> {
parser.next() parser.next(); return parser.text
return parser.text
} }
} }
event = parser.next() event = parser.next()
@@ -220,7 +220,7 @@ class CalDavSyncManager @Inject constructor(
private fun parseMultiStatus(xml: String, baseUrl: String): List<MultiStatusItem> { private fun parseMultiStatus(xml: String, baseUrl: String): List<MultiStatusItem> {
val results = mutableListOf<MultiStatusItem>() val results = mutableListOf<MultiStatusItem>()
runCatching { runCatching {
val factory = XmlPullParserFactory.newInstance() val factory = XmlPullParserFactory.newInstance().also { it.isNamespaceAware = true }
val parser = factory.newPullParser() val parser = factory.newPullParser()
parser.setInput(StringReader(xml)) parser.setInput(StringReader(xml))
@@ -24,7 +24,7 @@ import com.planify.mobile.data.local.entity.TaskEntity
ReminderEntity::class, ReminderEntity::class,
SourceEntity::class, SourceEntity::class,
], ],
version = 1, version = 3,
exportSchema = true, exportSchema = true,
) )
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -13,6 +13,9 @@ interface LabelDao {
@Query("SELECT * FROM labels WHERE is_deleted = 0 ORDER BY `order` ASC") @Query("SELECT * FROM labels WHERE is_deleted = 0 ORDER BY `order` ASC")
fun getAllLabels(): Flow<List<LabelEntity>> fun getAllLabels(): Flow<List<LabelEntity>>
@Query("SELECT * FROM labels WHERE is_deleted = 0 AND source_id = :sourceId ORDER BY `order` ASC")
fun getLabelsBySourceId(sourceId: String): Flow<List<LabelEntity>>
@Query("SELECT * FROM labels WHERE id = :id") @Query("SELECT * FROM labels WHERE id = :id")
suspend fun getById(id: String): LabelEntity? suspend fun getById(id: String): LabelEntity?
@@ -32,6 +32,9 @@ interface TaskDao {
""") """)
fun getTodayTasks(): Flow<List<TaskEntity>> fun getTodayTasks(): Flow<List<TaskEntity>>
@Query("SELECT COUNT(*) FROM tasks WHERE date(due_date) = date('now') AND checked = 1 AND is_deleted = 0")
fun getDoneTodayCount(): Flow<Int>
@Query(""" @Query("""
SELECT * FROM tasks SELECT * FROM tasks
WHERE date(due_date) < date('now') AND checked = 0 AND is_deleted = 0 WHERE date(due_date) < date('now') AND checked = 0 AND is_deleted = 0
@@ -2,20 +2,11 @@ package com.planify.mobile.data.local.entity
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
@Entity( @Entity(
tableName = "tasks", tableName = "tasks",
foreignKeys = [
ForeignKey(
entity = ProjectEntity::class,
parentColumns = ["id"],
childColumns = ["project_id"],
onDelete = ForeignKey.CASCADE
)
],
indices = [ indices = [
Index("project_id"), Index("project_id"),
Index("section_id"), Index("section_id"),
@@ -45,4 +36,5 @@ data class TaskEntity(
@ColumnInfo(name = "ical_url") val icalUrl: String? = null, @ColumnInfo(name = "ical_url") val icalUrl: String? = null,
val etag: String? = null, val etag: String? = null,
@ColumnInfo(name = "responsible_uid") val responsibleUid: String? = null, @ColumnInfo(name = "responsible_uid") val responsibleUid: String? = null,
@ColumnInfo(name = "is_deleted") val isDeleted: Boolean = false,
) )
@@ -16,6 +16,9 @@ class LabelRepositoryImpl @Inject constructor(
override fun getAllLabels(): Flow<List<Label>> = override fun getAllLabels(): Flow<List<Label>> =
dao.getAllLabels().map { it.map { e -> e.toDomain() } } dao.getAllLabels().map { it.map { e -> e.toDomain() } }
override fun getLabelsByProject(projectId: String): Flow<List<Label>> =
dao.getLabelsBySourceId(projectId).map { it.map { e -> e.toDomain() } }
override suspend fun getLabelById(id: String): Label? = dao.getById(id)?.toDomain() override suspend fun getLabelById(id: String): Label? = dao.getById(id)?.toDomain()
override suspend fun insertLabel(label: Label) = dao.insert(label.toEntity()) override suspend fun insertLabel(label: Label) = dao.insert(label.toEntity())
override suspend fun updateLabel(label: Label) = dao.update(label.toEntity()) override suspend fun updateLabel(label: Label) = dao.update(label.toEntity())
@@ -31,6 +31,8 @@ class TaskRepositoryImpl @Inject constructor(
override fun getTodayTasks(): Flow<List<Task>> = override fun getTodayTasks(): Flow<List<Task>> =
dao.getTodayTasks().map { it.map { e -> e.toDomain() } } dao.getTodayTasks().map { it.map { e -> e.toDomain() } }
override fun getDoneTodayCount() = dao.getDoneTodayCount()
override fun getOverdueTasks(): Flow<List<Task>> = override fun getOverdueTasks(): Flow<List<Task>> =
dao.getOverdueTasks().map { it.map { e -> e.toDomain() } } dao.getOverdueTasks().map { it.map { e -> e.toDomain() } }
@@ -117,7 +119,7 @@ class TaskRepositoryImpl @Inject constructor(
checked = checked, checked = checked,
dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) }, dueDate = dueDate?.let { Json.encodeToString(DueDate.serializer(), it) },
deadlineDate = deadlineDate, deadlineDate = deadlineDate,
labels = Json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.builtins.serializer()), labels), labels = Json.encodeToString(kotlinx.serialization.builtins.ListSerializer(kotlinx.serialization.serializer<String>()), labels),
pinned = pinned, pinned = pinned,
collapsed = collapsed, collapsed = collapsed,
childOrder = childOrder, childOrder = childOrder,
@@ -3,4 +3,4 @@ package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class BackendType { LOCAL, CALDAV, TODOIST } enum class BackendType { LOCAL, CALDAV, TODOIST, BONSAI }
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.Flow
interface LabelRepository { interface LabelRepository {
fun getAllLabels(): Flow<List<Label>> fun getAllLabels(): Flow<List<Label>>
fun getLabelsByProject(projectId: String): Flow<List<Label>>
suspend fun getLabelById(id: String): Label? suspend fun getLabelById(id: String): Label?
suspend fun insertLabel(label: Label) suspend fun insertLabel(label: Label)
suspend fun updateLabel(label: Label) suspend fun updateLabel(label: Label)
@@ -8,6 +8,7 @@ interface TaskRepository {
fun getTasksBySection(sectionId: String): Flow<List<Task>> fun getTasksBySection(sectionId: String): Flow<List<Task>>
fun getInboxTasks(): Flow<List<Task>> fun getInboxTasks(): Flow<List<Task>>
fun getTodayTasks(): Flow<List<Task>> fun getTodayTasks(): Flow<List<Task>>
fun getDoneTodayCount(): Flow<Int>
fun getOverdueTasks(): Flow<List<Task>> fun getOverdueTasks(): Flow<List<Task>>
fun getSubTasks(parentId: String): Flow<List<Task>> fun getSubTasks(parentId: String): Flow<List<Task>>
suspend fun getTaskById(id: String): Task? suspend fun getTaskById(id: String): Task?
@@ -4,18 +4,30 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import com.planify.mobile.ui.auth.AuthStatus
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.auth.LoginScreen
import com.planify.mobile.ui.theme.PlanifyTheme import com.planify.mobile.ui.theme.PlanifyTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val authViewModel: AuthViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
PlanifyTheme { PlanifyTheme {
MainScreen() val status by authViewModel.status.collectAsState()
when (status) {
is AuthStatus.Authenticated -> MainScreen(authViewModel)
else -> LoginScreen(authViewModel)
}
} }
} }
} }
@@ -1,176 +1,158 @@
package com.planify.mobile.ui package com.planify.mobile.ui
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.collectAsState
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.GridView
import androidx.compose.material.icons.outlined.Inbox import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Today import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.DrawerValue import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.NavigationBar
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.navigation.DrawerViewModel import com.planify.mobile.ui.navigation.DrawerViewModel
import com.planify.mobile.ui.navigation.PlanifyNavHost import com.planify.mobile.ui.navigation.PlanifyNavHost
import com.planify.mobile.ui.navigation.Route import com.planify.mobile.ui.navigation.Route
import kotlinx.coroutines.launch import com.planify.mobile.ui.task.TaskEditSheet
private data class BottomTab(
val route: String,
val icon: ImageVector,
val label: String,
)
private val bottomTabs = listOf(
BottomTab(Route.Today.path, Icons.Outlined.Today, "Aujourd'hui"),
BottomTab(Route.Scheduled.path, Icons.Outlined.CalendarMonth, "Prévu"),
BottomTab(Route.ProjectsList.path, Icons.Outlined.GridView, "Projets"),
BottomTab(Route.Settings.path, Icons.Outlined.Person, "Profil"),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) { fun MainScreen(
authViewModel: AuthViewModel,
drawerViewModel: DrawerViewModel = hiltViewModel(),
) {
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val projects by viewModel.projects.collectAsState()
val navBackStack by navController.currentBackStackEntryAsState() val navBackStack by navController.currentBackStackEntryAsState()
val currentRoute = navBackStack?.destination?.route val currentRoute = navBackStack?.destination?.route
val drawerTitles = mapOf( var showCreateTask by remember { mutableStateOf(false) }
Route.Inbox.path to "Inbox", var selectedTask by remember { mutableStateOf<Task?>(null) }
Route.Today.path to "Aujourd'hui",
Route.Scheduled.path to "Planifié",
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"
ModalNavigationDrawer( val projects by drawerViewModel.projects.collectAsState()
drawerState = drawerState, val inboxProjectId = projects.find { it.isInbox }?.id ?: ""
drawerContent = { val createProjectId = if (currentRoute == Route.Project().path)
ModalDrawerSheet { navBackStack?.arguments?.getString("projectId") ?: inboxProjectId
Text( else
text = "Planify", inboxProjectId
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp), val hideBottomBarRoutes = setOf<String>()
) val showBottomBar = currentRoute !in hideBottomBarRoutes
NavigationDrawerItem( val hideFabRoutes = setOf(Route.Settings.path)
icon = { Icon(Icons.Outlined.Inbox, null) }, val showFab = currentRoute !in hideFabRoutes
label = { Text("Inbox") },
selected = currentRoute == Route.Inbox.path, Scaffold(
onClick = { bottomBar = {
navController.navigate(Route.Inbox.path) if (showBottomBar) {
scope.launch { drawerState.close() } NavigationBar(
}, containerColor = MaterialTheme.colorScheme.surface,
) tonalElevation = 0.dp,
NavigationDrawerItem( ) {
icon = { Icon(Icons.Outlined.Today, null) }, bottomTabs.forEach { tab ->
label = { Text("Aujourd'hui") }, val selected = currentRoute == tab.route ||
selected = currentRoute == Route.Today.path, (tab.route == Route.ProjectsList.path && currentRoute == Route.Project().path)
onClick = { NavigationBarItem(
navController.navigate(Route.Today.path) selected = selected,
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.CalendarMonth, null) },
label = { Text("Planifié") },
selected = currentRoute == Route.Scheduled.path,
onClick = {
navController.navigate(Route.Scheduled.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Search, null) },
label = { Text("Recherche") },
selected = currentRoute == Route.Search.path,
onClick = {
navController.navigate(Route.Search.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.FilterList, null) },
label = { Text("Filtres") },
selected = currentRoute == Route.Filter.path,
onClick = {
navController.navigate(Route.Filter.path)
scope.launch { drawerState.close() }
},
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text(
text = "Projets",
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
LazyColumn {
items(projects) { project ->
NavigationDrawerItem(
icon = { Icon(Icons.Default.FolderOpen, null) },
label = { Text(project.name) },
selected = currentRoute == "project/${project.id}",
onClick = { onClick = {
navController.navigate(Route.Project().buildRoute(project.id)) if (currentRoute != tab.route) {
scope.launch { drawerState.close() } 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( selectedTask?.let { task ->
topBar = { TaskEditSheet(
TopAppBar( taskId = task.id,
title = { Text(title) }, projectId = task.projectId,
navigationIcon = { onDismiss = { selectedTask = null },
IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Outlined.Menu, contentDescription = "Menu")
}
},
)
},
) { padding ->
PlanifyNavHost(
navController = navController,
modifier = Modifier.padding(padding),
) )
} }
} }
@@ -0,0 +1,74 @@
package com.planify.mobile.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.LoginResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed class AuthStatus {
object Checking : AuthStatus()
object NotAuthenticated : AuthStatus()
data class Authenticated(val username: String) : AuthStatus()
data class LoginError(val message: String) : AuthStatus()
}
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authManager: BonsaiAuthManager,
private val syncManager: BonsaiSyncManager,
) : ViewModel() {
private val _status = MutableStateFlow<AuthStatus>(AuthStatus.Checking)
val status: StateFlow<AuthStatus> = _status.asStateFlow()
init {
checkAuthOnStartup()
}
private fun checkAuthOnStartup() {
viewModelScope.launch {
if (!authManager.isLoggedIn) {
_status.value = AuthStatus.NotAuthenticated
return@launch
}
// Show the app immediately — don't block on a network call.
_status.value = AuthStatus.Authenticated(authManager.getUsername())
// Refresh token + sync in background without blocking startup.
viewModelScope.launch {
authManager.refreshIfNeeded()
syncManager.sync()
}
}
}
fun login(username: String, password: String) {
_status.value = AuthStatus.Checking
viewModelScope.launch {
when (val result = authManager.login(username, password)) {
is LoginResult.Success -> {
_status.value = AuthStatus.Authenticated(authManager.getUsername())
syncManager.sync()
}
is LoginResult.Failure -> {
_status.value = AuthStatus.LoginError(result.message)
}
}
}
}
fun logout() {
authManager.logout()
_status.value = AuthStatus.NotAuthenticated
}
fun clearError() {
_status.value = AuthStatus.NotAuthenticated
}
}
@@ -0,0 +1,234 @@
package com.planify.mobile.ui.auth
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.planify.mobile.R
private val MintBackground = Color(0xFFF0FDF4)
private val BonsaiGreen = Color(0xFF38A169)
private val FieldBorder = Color(0xFFD1D5DB)
private val LabelColor = Color(0xFF374151)
private val TitleColor = Color(0xFF111827)
@Composable
fun LoginScreen(viewModel: AuthViewModel) {
val status by viewModel.status.collectAsState()
val isLoading = status is AuthStatus.Checking
val errorMessage = (status as? AuthStatus.LoginError)?.message
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
val passwordFocus = remember { FocusRequester() }
Box(
modifier = Modifier
.fillMaxSize()
.background(MintBackground)
.imePadding(),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(16.dp),
shadowElevation = 6.dp,
color = Color.White,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 28.dp)
.verticalScroll(rememberScrollState()),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp),
) {
Image(
painter = painterResource(id = R.drawable.ic_bonsai_foreground),
contentDescription = "Bonsai",
modifier = Modifier.size(72.dp),
)
Spacer(Modifier.height(10.dp))
Text(
text = "Bonsai",
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
color = TitleColor,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Connexion",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = LabelColor,
)
Spacer(Modifier.height(24.dp))
// Username field
Text(
text = "Email ou nom d'utilisateur",
style = MaterialTheme.typography.bodySmall,
color = LabelColor,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { passwordFocus.requestFocus() }
),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = FieldBorder,
focusedBorderColor = BonsaiGreen,
),
shape = RoundedCornerShape(6.dp),
)
Spacer(Modifier.height(12.dp))
// Password field
Text(
text = "Mot de passe",
style = MaterialTheme.typography.bodySmall,
color = LabelColor,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocus),
singleLine = true,
enabled = !isLoading,
visualTransformation = if (passwordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
if (username.isNotBlank() && password.isNotBlank()) {
viewModel.login(username.trim(), password)
}
}
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Outlined.Visibility
else Icons.Outlined.VisibilityOff,
contentDescription = if (passwordVisible) "Masquer" else "Afficher",
tint = Color(0xFF6B7280),
)
}
},
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = FieldBorder,
focusedBorderColor = BonsaiGreen,
),
shape = RoundedCornerShape(6.dp),
)
errorMessage?.let {
Spacer(Modifier.height(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { viewModel.login(username.trim(), password) },
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = BonsaiGreen),
shape = RoundedCornerShape(6.dp),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text(
text = "Se connecter",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
)
}
}
}
}
}
}
@@ -1,18 +1,26 @@
package com.planify.mobile.ui.components package com.planify.mobile.ui.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size
import androidx.compose.material3.Checkbox import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CheckboxDefaults import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -20,7 +28,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -35,53 +45,92 @@ fun TaskRow(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val priorityColor = priorityColor[task.priority] ?: Color.Gray val checkColor = when {
task.checked -> MaterialTheme.colorScheme.primary
task.priority == 1 -> MaterialTheme.colorScheme.secondary
task.priority == 2 -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
task.priority == 3 -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
val textColor by animateColorAsState( val textColor by animateColorAsState(
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) if (task.checked) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface, else MaterialTheme.colorScheme.onSurface,
label = "textColor", label = "textColor",
) )
Row( Surface(
onClick = onClick,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = {}) .padding(horizontal = 16.dp, vertical = 4.dp),
.padding(horizontal = 16.dp, vertical = 8.dp), shape = RoundedCornerShape(18.dp),
verticalAlignment = Alignment.CenterVertically, color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
tonalElevation = 0.dp,
) { ) {
Checkbox( Row(
checked = task.checked, modifier = Modifier.padding(horizontal = 14.dp, vertical = 13.dp),
onCheckedChange = onCheckedChange, verticalAlignment = Alignment.Top,
colors = CheckboxDefaults.colors( horizontalArrangement = Arrangement.spacedBy(12.dp),
checkedColor = priorityColor, ) {
uncheckedColor = priorityColor, CircleCheckbox(
), checked = task.checked,
) color = checkColor,
Spacer(Modifier.width(4.dp)) onClick = { onCheckedChange(!task.checked) },
Column(modifier = Modifier.weight(1f)) { modifier = Modifier.padding(top = 1.dp),
Text(
text = task.content,
style = MaterialTheme.typography.bodyMedium,
color = textColor,
textDecoration = if (task.checked) TextDecoration.LineThrough else null,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
) )
Row( Column(modifier = Modifier.weight(1f)) {
horizontalArrangement = Arrangement.spacedBy(6.dp), Text(
verticalAlignment = Alignment.CenterVertically, text = task.content,
) { style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
if (task.dueDate != null) { color = textColor,
DueDateChip(dateIso = task.dueDate.date) textDecoration = if (task.checked) TextDecoration.LineThrough else null,
} maxLines = 2,
task.labels.take(2).forEach { labelName -> overflow = TextOverflow.Ellipsis,
LabelChip(name = labelName, color = MaterialTheme.colorScheme.primary) )
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) @Preview(showBackground = true)
@Composable @Composable
private fun TaskRowPreview() { private fun TaskRowPreview() {
Surface { Column {
Column { TaskRow(
TaskRow( task = Task(
task = Task( id = "1",
id = "1", content = "Implémenter la navigation principale",
content = "Implémenter la navigation principale", projectId = "p1",
projectId = "p1", priority = 2,
priority = 2, labels = listOf("android", "ui"),
labels = listOf("android", "ui"), ),
), onCheckedChange = {},
onCheckedChange = {}, onClick = {},
onClick = {}, )
) TaskRow(
Spacer(Modifier.height(1.dp)) task = Task(
TaskRow( id = "2",
task = Task( content = "Tâche terminée",
id = "2", projectId = "p1",
content = "Tâche terminée", priority = 4,
projectId = "p1", checked = true,
priority = 4, ),
checked = true, onCheckedChange = {},
), onClick = {},
onCheckedChange = {}, )
onClick = {},
)
}
} }
} }
@@ -2,16 +2,19 @@ package com.planify.mobile.ui.navigation
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.BackendType
import com.planify.mobile.domain.model.Project import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.repository.ProjectRepository import com.planify.mobile.domain.repository.ProjectRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class DrawerViewModel @Inject constructor( class DrawerViewModel @Inject constructor(
projectRepository: ProjectRepository, private val projectRepository: ProjectRepository,
) : ViewModel() { ) : ViewModel() {
val projects = projectRepository.getAllProjects() val projects = projectRepository.getAllProjects()
@@ -19,4 +22,20 @@ class DrawerViewModel @Inject constructor(
val favorites = projectRepository.getFavoriteProjects() val favorites = projectRepository.getFavoriteProjects()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList<Project>()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList<Project>())
init {
viewModelScope.launch {
if (projectRepository.getInboxProject() == null) {
projectRepository.insertProject(
Project(
id = UUID.randomUUID().toString(),
name = "Boîte de réception",
isInbox = true,
backendType = BackendType.LOCAL,
childOrder = -1,
)
)
}
}
}
} }
@@ -7,10 +7,13 @@ import androidx.navigation.NavType
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.navArgument import androidx.navigation.navArgument
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.filter.FilterScreen import com.planify.mobile.ui.filter.FilterScreen
import com.planify.mobile.ui.inbox.InboxScreen import com.planify.mobile.ui.inbox.InboxScreen
import com.planify.mobile.ui.label.LabelScreen import com.planify.mobile.ui.label.LabelScreen
import com.planify.mobile.ui.project.ProjectScreen import com.planify.mobile.ui.project.ProjectScreen
import com.planify.mobile.ui.project.ProjectsListScreen
import com.planify.mobile.ui.scheduled.ScheduledScreen import com.planify.mobile.ui.scheduled.ScheduledScreen
import com.planify.mobile.ui.search.SearchScreen import com.planify.mobile.ui.search.SearchScreen
import com.planify.mobile.ui.settings.SettingsScreen import com.planify.mobile.ui.settings.SettingsScreen
@@ -19,23 +22,45 @@ import com.planify.mobile.ui.today.TodayScreen
@Composable @Composable
fun PlanifyNavHost( fun PlanifyNavHost(
navController: NavHostController, navController: NavHostController,
authViewModel: AuthViewModel,
onTaskClick: (Task) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = Route.Inbox.path, startDestination = Route.Today.path,
modifier = modifier, modifier = modifier,
) { ) {
composable(Route.Inbox.path) { composable(Route.Today.path) {
InboxScreen( TodayScreen(onTaskClick = onTaskClick)
onTaskClick = { /* TODO #11 : ouvrir édition */ } }
composable(Route.Scheduled.path) {
ScheduledScreen(onTaskClick = onTaskClick)
}
composable(Route.ProjectsList.path) {
ProjectsListScreen(
onProjectClick = { projectId ->
navController.navigate(Route.Project().buildRoute(projectId))
},
onInboxClick = { navController.navigate(Route.Inbox.path) },
onTodayClick = {
navController.navigate(Route.Today.path) {
launchSingleTop = true
}
},
onScheduledClick = {
navController.navigate(Route.Scheduled.path) {
launchSingleTop = true
}
},
onLabelsClick = { navController.navigate(Route.Filter.path) },
) )
} }
composable(Route.Today.path) { composable(Route.Inbox.path) {
TodayScreen( InboxScreen(onTaskClick = onTaskClick)
onTaskClick = { /* TODO #11 : ouvrir édition */ }
)
} }
composable( composable(
@@ -45,21 +70,17 @@ fun PlanifyNavHost(
val projectId = backStack.arguments?.getString("projectId") ?: return@composable val projectId = backStack.arguments?.getString("projectId") ?: return@composable
ProjectScreen( ProjectScreen(
projectId = projectId, projectId = projectId,
onTaskClick = { /* TODO: ouvrir édition */ }, onTaskClick = onTaskClick,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
composable(Route.Scheduled.path) {
ScheduledScreen(onTaskClick = { /* TODO: ouvrir édition */ })
}
composable(Route.Search.path) { composable(Route.Search.path) {
SearchScreen(onTaskClick = { /* TODO: ouvrir édition */ }) SearchScreen(onTaskClick = onTaskClick)
} }
composable(Route.Filter.path) { composable(Route.Filter.path) {
FilterScreen(onTaskClick = { /* TODO: ouvrir édition */ }) FilterScreen(onTaskClick = onTaskClick)
} }
composable( composable(
@@ -69,12 +90,12 @@ fun PlanifyNavHost(
val labelId = backStack.arguments?.getString("labelId") ?: return@composable val labelId = backStack.arguments?.getString("labelId") ?: return@composable
LabelScreen( LabelScreen(
labelId = labelId, labelId = labelId,
onTaskClick = { /* TODO: ouvrir édition */ }, onTaskClick = onTaskClick,
) )
} }
composable(Route.Settings.path) { composable(Route.Settings.path) {
SettingsScreen() SettingsScreen(authViewModel = authViewModel)
} }
} }
} }
@@ -6,6 +6,7 @@ sealed class Route(val path: String) {
data object Scheduled : Route("scheduled") data object Scheduled : Route("scheduled")
data object Search : Route("search") data object Search : Route("search")
data object Filter : Route("filter") data object Filter : Route("filter")
data object ProjectsList : Route("projects")
data class Project(val projectId: String = "{projectId}") : data class Project(val projectId: String = "{projectId}") :
Route("project/{projectId}") { Route("project/{projectId}") {
fun buildRoute(id: String) = "project/$id" fun buildRoute(id: String) = "project/$id"
@@ -1,21 +1,33 @@
package com.planify.mobile.ui.project package com.planify.mobile.ui.project
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FolderOpen
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -25,7 +37,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.model.ViewStyle import com.planify.mobile.domain.model.ViewStyle
@@ -49,35 +65,174 @@ fun ProjectScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) } val collapsedSections = remember { mutableStateOf(setOf<String>()) }
if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) { Column(
EmptyState( modifier = modifier
icon = Icons.Outlined.FolderOpen, .fillMaxSize()
title = "Projet vide", .background(MaterialTheme.colorScheme.background),
subtitle = "Créez votre première tâche avec le bouton +", ) {
modifier = modifier, // 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) { if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
ViewStyle.LIST -> ProjectListView( EmptyState(
state = state, icon = Icons.Outlined.FolderOpen,
collapsedSections = collapsedSections.value, title = "Projet vide",
onToggleSection = { key -> subtitle = "Créez votre première tâche avec le bouton +",
collapsedSections.value = collapsedSections.value.let { modifier = Modifier.weight(1f),
if (it.contains(key)) it - key else it + key )
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),
)
}
} }
}, Spacer(Modifier.weight(1f))
onTaskClick = onTaskClick, Box(
onCheckedChange = { task -> viewModel.toggleTask(task) }, modifier = Modifier
onReorder = { viewModel.reorderTasks(it) }, .size(40.dp)
modifier = modifier, .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( Text(
state = state, text = label,
onTaskClick = onTaskClick, style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Medium),
onCheckedChange = { task -> viewModel.toggleTask(task) }, color = Color.White.copy(alpha = 0.82f),
modifier = modifier,
) )
} }
} }
@@ -95,7 +250,11 @@ private fun ProjectListView(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val reorderState = rememberReorderState() val reorderState = rememberReorderState()
LazyColumn(state = listState, modifier = modifier.fillMaxSize()) { LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 96.dp),
) {
state.sections.forEach { group -> state.sections.forEach { group ->
val key = group.section?.id ?: "unsectioned" val key = group.section?.id ?: "unsectioned"
val name = group.section?.name ?: "Sans section" val name = group.section?.name ?: "Sans section"
@@ -7,6 +7,10 @@ import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.model.Section import com.planify.mobile.domain.model.Section
import com.planify.mobile.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.model.ViewStyle import com.planify.mobile.domain.model.ViewStyle
import com.planify.mobile.data.bonsai.BonsaiApiClient
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.dto.BonsaIssueRequest
import com.planify.mobile.domain.repository.ProjectRepository import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.SectionRepository import com.planify.mobile.domain.repository.SectionRepository
import com.planify.mobile.domain.repository.TaskRepository import com.planify.mobile.domain.repository.TaskRepository
@@ -32,6 +36,8 @@ class ProjectViewModel @Inject constructor(
private val taskRepository: TaskRepository, private val taskRepository: TaskRepository,
private val projectRepository: ProjectRepository, private val projectRepository: ProjectRepository,
private val sectionRepository: SectionRepository, private val sectionRepository: SectionRepository,
private val apiClient: BonsaiApiClient,
private val authManager: BonsaiAuthManager,
) : ViewModel() { ) : ViewModel() {
private val projectId: String = checkNotNull(savedStateHandle["projectId"]) private val projectId: String = checkNotNull(savedStateHandle["projectId"])
@@ -66,7 +72,24 @@ class ProjectViewModel @Inject constructor(
) )
fun toggleTask(task: Task) { fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) } val newChecked = !task.checked
viewModelScope.launch {
taskRepository.checkTask(task.id, newChecked)
val projectIdLong = task.projectId.toLongOrNull() ?: return@launch
val taskIdLong = task.id.toLongOrNull() ?: return@launch
if (!authManager.isLoggedIn) return@launch
authManager.refreshIfNeeded()
val request = BonsaIssueRequest(
name = task.content,
priority = BonsaiSyncManager.toBonsaiPriority(task.priority),
status = BonsaiSyncManager.toBonsaiStatus(newChecked),
dueDate = task.dueDate?.date,
description = task.description.ifBlank { null },
)
apiClient.updateIssue(projectIdLong, taskIdLong, request)
}
} }
fun reorderTasks(reordered: List<Task>) { fun reorderTasks(reordered: List<Task>) {
@@ -0,0 +1,378 @@
package com.planify.mobile.ui.project
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.Inbox
import androidx.compose.material.icons.outlined.Label
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
@Composable
fun ProjectsListScreen(
onProjectClick: (String) -> Unit,
onInboxClick: () -> Unit,
onTodayClick: () -> Unit,
onScheduledClick: () -> Unit,
onLabelsClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ProjectsListViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 96.dp),
) {
// Header
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Mon espace",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.4.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Projets",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onBackground,
)
}
Box(
modifier = Modifier
.size(38.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
.background(MaterialTheme.colorScheme.surface, CircleShape)
.clip(CircleShape)
.clickable(onClick = onLabelsClick),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.Search,
contentDescription = "Recherche",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
}
// 2×2 quick-access tiles
item {
val tiles = listOf(
Triple(Icons.Outlined.Inbox, "Boîte de réception",
"${state.inboxProject?.let { "tâches" } ?: "0 tâche"}"),
Triple(Icons.Outlined.Today, "Aujourd'hui",
"${state.todayCount} tâche${if (state.todayCount != 1) "s" else ""}"),
Triple(Icons.Outlined.CalendarMonth, "Prévu",
"${state.scheduledCount} à venir"),
Triple(Icons.Outlined.Label, "Étiquettes", ""),
)
val tileActions = listOf(onInboxClick, onTodayClick, onScheduledClick, onLabelsClick)
val tileColors = listOf(
Color(0xFF5285AE),
MaterialTheme.colorScheme.primary,
Color(0xFF8A6BB0),
Color(0xFFC2683C),
)
androidx.compose.foundation.layout.Box(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
listOf(0..1, 2..3).forEach { range ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
range.forEach { idx ->
val (icon, name, count) = tiles[idx]
QuickTile(
icon = icon,
name = name,
count = count,
iconColor = tileColors[idx],
onClick = tileActions[idx],
modifier = Modifier.weight(1f),
)
}
}
}
}
}
}
// "Mes projets" section header
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, top = 22.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "MES PROJETS",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
if (state.projects.isNotEmpty()) {
Box(
modifier = Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(20.dp),
)
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text = "${state.projects.size}",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
// Project list card
if (state.projects.isNotEmpty()) {
item {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.outlineVariant
),
) {
Column {
state.projects.forEachIndexed { idx, projectStats ->
ProjectListRow(
projectStats = projectStats,
onClick = { onProjectClick(projectStats.project.id) },
showDivider = idx < state.projects.lastIndex,
)
}
}
}
}
} else {
item {
Text(
text = "Aucun projet pour l'instant",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 8.dp),
)
}
}
}
}
@Composable
private fun QuickTile(
icon: ImageVector,
name: String,
count: String,
iconColor: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Surface(
onClick = onClick,
modifier = modifier,
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surface,
border = androidx.compose.foundation.BorderStroke(
1.dp, MaterialTheme.colorScheme.outlineVariant
),
tonalElevation = 0.dp,
) {
Column(
modifier = Modifier.padding(13.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(32.dp)
.background(iconColor, RoundedCornerShape(11.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(17.dp),
)
}
Column {
Text(
text = name,
style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface,
)
if (count.isNotEmpty()) {
Text(
text = count,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun ProjectListRow(
projectStats: ProjectWithStats,
onClick: () -> Unit,
showDivider: Boolean,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
ProjectProgressRing(
progress = projectStats.progress,
size = 38.dp,
strokeWidth = 4.dp,
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = projectStats.project.name,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface,
)
if (projectStats.totalTasks > 0) {
Text(
text = "${projectStats.totalTasks} tâche${if (projectStats.totalTasks != 1) "s" else ""}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (projectStats.totalTasks > 0) {
Text(
text = "${projectStats.doneTasks}/${projectStats.totalTasks}",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (showDivider) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.padding(horizontal = 14.dp)
.background(MaterialTheme.colorScheme.outlineVariant),
)
}
}
}
@Composable
fun ProjectProgressRing(
progress: Float,
size: Dp,
strokeWidth: Dp,
color: Color,
trackColor: Color,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier.size(size)) {
val sw = strokeWidth.toPx()
val diameter = this.size.minDimension - sw
val topLeft = Offset(sw / 2, sw / 2)
val arcSize = Size(diameter, diameter)
drawArc(
color = trackColor,
startAngle = -90f,
sweepAngle = 360f,
useCenter = false,
style = Stroke(width = sw, cap = StrokeCap.Round),
topLeft = topLeft,
size = arcSize,
)
if (progress > 0f) {
drawArc(
color = color,
startAngle = -90f,
sweepAngle = progress * 360f,
useCenter = false,
style = Stroke(width = sw, cap = StrokeCap.Round),
topLeft = topLeft,
size = arcSize,
)
}
}
}
@@ -0,0 +1,63 @@
package com.planify.mobile.ui.project
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
data class ProjectWithStats(
val project: Project,
val totalTasks: Int,
val doneTasks: Int,
) {
val progress: Float get() = if (totalTasks == 0) 0f else doneTasks.toFloat() / totalTasks
}
data class ProjectsListUiState(
val projects: List<ProjectWithStats> = emptyList(),
val inboxProject: Project? = null,
val todayCount: Int = 0,
val scheduledCount: Int = 0,
)
@HiltViewModel
class ProjectsListViewModel @Inject constructor(
private val projectRepository: ProjectRepository,
private val taskRepository: TaskRepository,
) : ViewModel() {
val uiState = combine(
projectRepository.getAllProjects(),
taskRepository.getAllTasks(),
taskRepository.getTodayTasks(),
taskRepository.getScheduledTasks(),
) { projects, allTasks, todayTasks, scheduledTasks ->
val tasksByProject = allTasks.groupBy { it.projectId }
val inboxProject = projects.find { it.isInbox }
val regularProjects = projects.filter { !it.isInbox }
val withStats = regularProjects.map { project ->
val tasks = tasksByProject[project.id] ?: emptyList()
ProjectWithStats(
project = project,
totalTasks = tasks.size,
doneTasks = tasks.count { it.checked },
)
}
ProjectsListUiState(
projects = withStats,
inboxProject = inboxProject,
todayCount = todayTasks.size,
scheduledCount = scheduledTasks.size,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = ProjectsListUiState(),
)
}
@@ -1,9 +1,20 @@
package com.planify.mobile.ui.scheduled package com.planify.mobile.ui.scheduled
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -11,39 +22,55 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow import com.planify.mobile.ui.components.TaskRow
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.TextStyle
import java.util.Locale
@Composable @Composable
fun ScheduledScreen( fun ScheduledScreen(
onTaskClick: (Task) -> Unit, onTaskClick: (Task) -> Unit,
modifier: Modifier = Modifier,
viewModel: ScheduledViewModel = hiltViewModel(), viewModel: ScheduledViewModel = hiltViewModel(),
) { ) {
val groups by viewModel.groups.collectAsState() val groups by viewModel.groups.collectAsState()
if (groups.isEmpty()) { LazyColumn(
EmptyState( modifier = modifier
icon = Icons.Outlined.CalendarMonth, .fillMaxSize()
title = "Aucune tâche planifiée", .background(MaterialTheme.colorScheme.background),
subtitle = "Les tâches avec une date d'échéance apparaîtront ici", contentPadding = PaddingValues(bottom = 96.dp),
) ) {
return // Header
} item { ScheduledHeader() }
LazyColumn(modifier = Modifier.fillMaxSize()) { // Week strip
groups.forEach { group -> item { WeekStrip() }
item(key = group.label) {
Text( if (groups.isEmpty()) {
text = group.label, item {
style = MaterialTheme.typography.titleSmall, EmptyState(
color = MaterialTheme.colorScheme.primary, icon = Icons.Outlined.CalendarMonth,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), 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 -> items(group.tasks, key = { it.id }) { task ->
TaskRow( TaskRow(
task = task, 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 package com.planify.mobile.ui.settings
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -11,12 +10,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -24,52 +21,27 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.domain.model.Source import com.planify.mobile.ui.auth.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
authViewModel: AuthViewModel,
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val discovery by viewModel.discoveryInProgress.collectAsState()
val exportUri by viewModel.exportUri.collectAsState()
var showAddAccount by remember { mutableStateOf(false) }
val context = LocalContext.current
LaunchedEffect(exportUri) {
exportUri?.let { uri ->
val mime = if (uri.path?.endsWith(".ics") == true) "text/calendar" else "application/json"
val intent = Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Exporter"))
viewModel.clearExportUri()
}
}
Column( Column(
modifier = Modifier modifier = Modifier
@@ -77,7 +49,7 @@ fun SettingsScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { ) {
// ── Apparence ─────────────────────────────────────────────────────── // ── Apparence ───────────────────────────────────────────────────────
SectionTitle("Apparence") SectionTitle("Apparence")
ListItem( ListItem(
headlineContent = { Text("Thème") }, headlineContent = { Text("Thème") },
@@ -97,43 +69,65 @@ fun SettingsScreen(
HorizontalDivider(Modifier.padding(horizontal = 16.dp)) HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Synchronisation ───────────────────────────────────────────────── // ── Bonsai ───────────────────────────────────────────────────────────
SectionTitle("Synchronisation") SectionTitle("Bonsai")
ListItem(
headlineContent = { Text("Sync automatique") }, if (state.isLoggedIn) {
trailingContent = {
Switch(
checked = state.syncEnabled,
onCheckedChange = viewModel::setSyncEnabled,
)
},
)
if (state.syncEnabled) {
ListItem( ListItem(
headlineContent = { Text("Intervalle") }, leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
supportingContent = { headlineContent = { Text("Connecté") },
val options = listOf(15 to "15 min", 30 to "30 min", 60 to "1 h", 240 to "4 h") supportingContent = { Text(state.username) },
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { trailingContent = {
options.forEachIndexed { index, (mins, label) -> if (state.syncInProgress) {
SegmentedButton( CircularProgressIndicator(modifier = Modifier.padding(8.dp))
selected = state.syncIntervalMinutes == mins, } else {
onClick = { viewModel.setSyncInterval(mins) }, IconButton(onClick = viewModel::syncNow) {
shape = SegmentedButtonDefaults.itemShape(index, options.size), Icon(Icons.Outlined.Sync, contentDescription = "Synchroniser")
label = { Text(label) },
)
} }
} }
}, },
) )
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)) 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)) Spacer(Modifier.height(32.dp))
} }
if (showAddAccount) {
AddCalDavAccountDialog(
onDismiss = { showAddAccount = false },
onConfirm = { url, user, pwd ->
viewModel.addCalDavAccount(url, user, pwd)
showAddAccount = false
},
)
}
} }
@Composable @Composable
@@ -237,68 +156,3 @@ private fun SectionTitle(text: String) {
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
} }
@Composable
private fun CalDavSourceRow(source: Source, onDelete: () -> Unit) {
ListItem(
leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
headlineContent = { Text(source.displayName) },
supportingContent = { Text(source.caldavData?.serverUrl ?: "") },
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = MaterialTheme.colorScheme.error)
}
},
)
}
@Composable
private fun AddCalDavAccountDialog(
onDismiss: () -> Unit,
onConfirm: (url: String, username: String, password: String) -> Unit,
) {
var url by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Ajouter un compte CalDAV") },
text = {
Column {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL du serveur") },
placeholder = { Text("https://example.com/caldav") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Nom d'utilisateur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Mot de passe") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
}
},
confirmButton = {
Button(
onClick = { onConfirm(url.trim(), username.trim(), password) },
enabled = url.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
) { Text("Connecter") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
)
}
@@ -1,25 +1,16 @@
package com.planify.mobile.ui.settings package com.planify.mobile.ui.settings
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.caldav.CalDavCredentialStore import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.caldav.CalDavDiscovery import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.caldav.DiscoveryResult import com.planify.mobile.data.bonsai.SyncResult
import com.planify.mobile.data.export.ExportManager
import com.planify.mobile.data.preferences.AppPreferences import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.data.sync.SyncScheduler
import com.planify.mobile.domain.model.Source
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.SourceRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -27,101 +18,58 @@ import javax.inject.Inject
data class SettingsUiState( data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM, val themeMode: ThemeMode = ThemeMode.SYSTEM,
val syncEnabled: Boolean = true,
val syncIntervalMinutes: Int = 30,
val notificationsEnabled: Boolean = true, val notificationsEnabled: Boolean = true,
val caldavSources: List<Source> = emptyList(), val isLoggedIn: Boolean = false,
val discoveryInProgress: Boolean = false, val username: String = "",
val discoveryError: String? = null, val syncInProgress: Boolean = false,
val syncError: String? = null,
val syncSuccess: Boolean = false,
) )
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val prefs: AppPreferences, private val prefs: AppPreferences,
private val sourceRepository: SourceRepository, private val authManager: BonsaiAuthManager,
private val syncScheduler: SyncScheduler, private val syncManager: BonsaiSyncManager,
private val discovery: CalDavDiscovery,
private val credentialStore: CalDavCredentialStore,
private val exportManager: ExportManager,
private val projectRepository: ProjectRepository,
private val taskRepository: TaskRepository,
) : ViewModel() { ) : ViewModel() {
private val _extra = MutableStateFlow(
SettingsUiState(
isLoggedIn = authManager.isLoggedIn,
username = authManager.getUsername(),
)
)
val uiState = combine( val uiState = combine(
prefs.themeMode, prefs.themeMode,
prefs.syncEnabled,
prefs.syncIntervalMinutes,
prefs.notificationsEnabled, prefs.notificationsEnabled,
sourceRepository.getAllSources(), authManager.isAuthenticated,
) { theme, sync, interval, notifs, sources -> _extra,
SettingsUiState( ) { theme, notifs, isAuth, extra ->
extra.copy(
themeMode = theme, themeMode = theme,
syncEnabled = sync,
syncIntervalMinutes = interval,
notificationsEnabled = notifs, notificationsEnabled = notifs,
caldavSources = sources.filter { it.type == com.planify.mobile.domain.model.SourceType.CALDAV }, isLoggedIn = isAuth,
username = if (isAuth) authManager.getUsername() else "",
) )
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
private val _discoveryState = MutableStateFlow<Pair<Boolean, String?>>(false to null)
val discoveryInProgress = _discoveryState.asStateFlow()
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setSyncEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setSyncEnabled(enabled)
if (enabled) syncScheduler.schedule(uiState.value.syncIntervalMinutes.toLong())
else syncScheduler.cancel()
}
fun setSyncInterval(minutes: Int) = viewModelScope.launch {
prefs.setSyncInterval(minutes)
if (uiState.value.syncEnabled) syncScheduler.schedule(minutes.toLong())
}
fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch { fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setNotificationsEnabled(enabled) prefs.setNotificationsEnabled(enabled)
} }
fun syncNow() { syncScheduler.syncNow() } fun syncNow() {
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
fun addCalDavAccount(baseUrl: String, username: String, password: String) {
_discoveryState.update { true to null }
viewModelScope.launch { viewModelScope.launch {
when (val result = discovery.discover(baseUrl, username, password)) { when (val result = syncManager.sync()) {
is DiscoveryResult.Success -> { is SyncResult.Success -> _extra.update { it.copy(syncInProgress = false, syncSuccess = true) }
result.sources.forEach { source -> is SyncResult.NotLoggedIn -> _extra.update { it.copy(syncInProgress = false, syncError = "Non connecté") }
credentialStore.savePassword(source.id, password) is SyncResult.Failure -> _extra.update { it.copy(syncInProgress = false, syncError = result.message) }
sourceRepository.insertSource(source)
}
_discoveryState.update { false to null }
if (uiState.value.syncEnabled) syncScheduler.schedule()
}
is DiscoveryResult.Failure -> {
_discoveryState.update { false to result.message }
}
} }
} }
} }
fun removeCalDavAccount(source: Source) = viewModelScope.launch { fun clearSyncFeedback() = _extra.update { it.copy(syncSuccess = false, syncError = null) }
credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(source.id)
}
private val _exportUri = MutableStateFlow<Uri?>(null)
val exportUri = _exportUri.asStateFlow()
fun exportJson() = viewModelScope.launch {
val projects = projectRepository.getAllProjects().first()
val tasks = taskRepository.getAllTasks().first()
_exportUri.value = exportManager.exportJson(projects, tasks)
}
fun exportIcal() = viewModelScope.launch {
val tasks = taskRepository.getAllTasks().first()
_exportUri.value = exportManager.exportIcal(tasks)
}
fun clearExportUri() { _exportUri.value = null }
} }
@@ -34,6 +34,7 @@ import com.planify.mobile.domain.model.DueDate
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -46,7 +47,7 @@ fun DueDatePickerSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val datePickerState = rememberDatePickerState( val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = currentDueDate?.date initialSelectedDateMillis = currentDueDate?.date
?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneId.UTC).toInstant().toEpochMilli() }.getOrNull() } ?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() }.getOrNull() }
) )
var showRecurrence by remember { mutableStateOf(false) } var showRecurrence by remember { mutableStateOf(false) }
@@ -103,7 +104,7 @@ fun DueDatePickerSheet(
TextButton(onClick = { TextButton(onClick = {
val millis = datePickerState.selectedDateMillis val millis = datePickerState.selectedDateMillis
val date = millis?.let { val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString() Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
} ?: return@TextButton } ?: return@TextButton
onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false, onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false,
recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE)) recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE))
@@ -119,7 +120,7 @@ fun DueDatePickerSheet(
showRecurrence = false showRecurrence = false
val millis = datePickerState.selectedDateMillis val millis = datePickerState.selectedDateMillis
val date = millis?.let { val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString() Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
} ?: currentDueDate?.date ?: LocalDate.now().toString() } ?: currentDueDate?.date ?: LocalDate.now().toString()
onConfirm(recDueDate?.copy(date = date)) onConfirm(recDueDate?.copy(date = date))
}, },
@@ -28,31 +28,44 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Label import com.planify.mobile.domain.model.Label
import com.planify.mobile.domain.repository.LabelRepository import com.planify.mobile.domain.repository.LabelRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LabelPickerViewModel @Inject constructor( class LabelPickerViewModel @Inject constructor(
labelRepository: LabelRepository, private val labelRepository: LabelRepository,
) : ViewModel() { ) : ViewModel() {
val labels = labelRepository.getAllLabels() private val _projectId = MutableStateFlow("")
val labels = _projectId
.flatMapLatest { projectId ->
if (projectId.isNotBlank()) labelRepository.getLabelsByProject(projectId)
else labelRepository.getAllLabels()
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setProjectId(projectId: String) { _projectId.value = projectId }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabelPickerSheet( fun LabelPickerSheet(
projectId: String,
selectedLabels: List<String>, selectedLabels: List<String>,
onConfirm: (List<String>) -> Unit, onConfirm: (List<String>) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
viewModel: LabelPickerViewModel = hiltViewModel(), viewModel: LabelPickerViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(projectId) { viewModel.setProjectId(projectId) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val labels by viewModel.labels.collectAsState() val labels by viewModel.labels.collectAsState()
var selected by remember { mutableStateOf(selectedLabels.toSet()) } var selected by remember { mutableStateOf(selectedLabels.toSet()) }
@@ -26,6 +26,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -150,6 +151,15 @@ fun TaskEditSheet(
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
state.saveError?.let { error ->
Text(
text = error,
color = Color.Red,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(vertical = 4.dp),
)
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -184,6 +194,7 @@ fun TaskEditSheet(
} }
if (showLabelPicker) { if (showLabelPicker) {
LabelPickerSheet( LabelPickerSheet(
projectId = projectId,
selectedLabels = state.labels, selectedLabels = state.labels,
onConfirm = { viewModel.setLabels(it); showLabelPicker = false }, onConfirm = { viewModel.setLabels(it); showLabelPicker = false },
onDismiss = { showLabelPicker = false }, onDismiss = { showLabelPicker = false },
@@ -2,6 +2,12 @@ package com.planify.mobile.ui.task
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.ApiResult
import com.planify.mobile.data.bonsai.BonsaiApiClient
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.dto.BonsaIssueRequest
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.data.notification.ReminderScheduler import com.planify.mobile.data.notification.ReminderScheduler
import com.planify.mobile.domain.model.DueDate import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.Reminder import com.planify.mobile.domain.model.Reminder
@@ -32,6 +38,7 @@ data class TaskEditState(
val reminders: List<Reminder> = emptyList(), val reminders: List<Reminder> = emptyList(),
val subTasks: List<Task> = emptyList(), val subTasks: List<Task> = emptyList(),
val isSaving: Boolean = false, val isSaving: Boolean = false,
val saveError: String? = null,
) )
@HiltViewModel @HiltViewModel
@@ -39,6 +46,10 @@ class TaskEditViewModel @Inject constructor(
private val taskRepository: TaskRepository, private val taskRepository: TaskRepository,
private val reminderRepository: ReminderRepository, private val reminderRepository: ReminderRepository,
private val reminderScheduler: ReminderScheduler, private val reminderScheduler: ReminderScheduler,
private val apiClient: BonsaiApiClient,
private val authManager: BonsaiAuthManager,
private val syncManager: BonsaiSyncManager,
private val projectRepository: ProjectRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(TaskEditState()) private val _state = MutableStateFlow(TaskEditState())
@@ -50,24 +61,22 @@ class TaskEditViewModel @Inject constructor(
val task = taskRepository.getTaskById(taskId) ?: return@launch val task = taskRepository.getTaskById(taskId) ?: return@launch
val subTasks = taskRepository.getSubTasks(taskId).first() val subTasks = taskRepository.getSubTasks(taskId).first()
val reminders = reminderRepository.getRemindersByTask(taskId).first() val reminders = reminderRepository.getRemindersByTask(taskId).first()
_state.update { _state.value = TaskEditState(
it.copy( taskId = taskId,
taskId = taskId, projectId = task.projectId,
projectId = task.projectId, sectionId = task.sectionId,
sectionId = task.sectionId, parentId = task.parentId,
parentId = task.parentId, content = task.content,
content = task.content, description = task.description,
description = task.description, priority = task.priority,
priority = task.priority, dueDate = task.dueDate,
dueDate = task.dueDate, labels = task.labels,
labels = task.labels, reminders = reminders,
reminders = reminders, subTasks = subTasks,
subTasks = subTasks, )
)
}
} }
} else { } else {
_state.update { it.copy(projectId = projectId, sectionId = sectionId, parentId = parentId) } _state.value = TaskEditState(projectId = projectId, sectionId = sectionId, parentId = parentId)
} }
} }
@@ -102,56 +111,98 @@ class TaskEditViewModel @Inject constructor(
fun save(onDone: () -> Unit) { fun save(onDone: () -> Unit) {
val st = _state.value val st = _state.value
if (st.content.isBlank()) return if (st.content.isBlank() || st.projectId.isBlank()) return
_state.update { it.copy(isSaving = true) } _state.update { it.copy(isSaving = true, saveError = null) }
viewModelScope.launch { viewModelScope.launch {
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val id = st.taskId ?: UUID.randomUUID().toString()
val task = Task( val request = BonsaIssueRequest(
id = id, name = st.content,
content = st.content, priority = BonsaiSyncManager.toBonsaiPriority(st.priority),
description = st.description, status = BonsaiSyncManager.toBonsaiStatus(st.dueDate == null && false),
projectId = st.projectId, dueDate = st.dueDate?.date,
sectionId = st.sectionId, description = st.description.ifBlank { null },
parentId = st.parentId,
priority = st.priority,
dueDate = st.dueDate,
labels = st.labels,
addedAt = if (st.taskId == null) now else "",
updatedAt = now,
) )
if (st.taskId == null) taskRepository.insertTask(task) val projectIdLong = st.projectId.toLongOrNull()
else taskRepository.updateTask(task) val taskIdLong = st.taskId?.toLongOrNull()
// Sub-tasks: delete removed ones, then upsert remaining if (authManager.isLoggedIn && projectIdLong != null) {
if (st.taskId != null) { // Refresh token if expired before calling API
val existingIds = taskRepository.getSubTasks(id).first().map { it.id }.toSet() val tokenOk = authManager.refreshIfNeeded()
val currentIds = st.subTasks.map { it.id }.toSet() if (!tokenOk) {
(existingIds - currentIds).forEach { taskRepository.deleteTask(it) } _state.update { it.copy(isSaving = false, saveError = "Session expirée, veuillez vous reconnecter") }
} return@launch
st.subTasks.forEach { sub -> }
val actualSub = sub.copy(
parentId = id, 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, 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, updatedAt = now,
) )
if (taskRepository.getTaskById(sub.id) != null) taskRepository.updateTask(actualSub) if (st.taskId == null) taskRepository.insertTask(task)
else taskRepository.insertTask(actualSub) else taskRepository.updateTask(task)
saveReminders(id, st, task)
_state.update { it.copy(isSaving = false) }
onDone()
} }
}
}
// Reminders: replace all, reschedule private suspend fun saveReminders(taskId: String, st: TaskEditState, task: Task) {
reminderRepository.deleteRemindersByTask(id) reminderRepository.deleteRemindersByTask(taskId)
st.reminders.forEach { reminder -> st.reminders.forEach { reminder ->
val actual = reminder.copy(taskId = id) val actual = reminder.copy(taskId = taskId)
reminderRepository.insertReminder(actual) reminderRepository.insertReminder(actual)
reminderScheduler.schedule(actual, task.content, task.dueDate?.date) reminderScheduler.schedule(actual, task.content, task.dueDate?.date)
}
_state.update { it.copy(isSaving = false) }
onDone()
} }
} }
} }
@@ -1,21 +1,116 @@
package com.planify.mobile.ui.theme package com.planify.mobile.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.Color
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
private val LightColorScheme = lightColorScheme() // Bonsai design tokens — warm cream + forest green palette
private val DarkColorScheme = darkColorScheme()
// Light
private val Green_2F7A4F = Color(0xFF2F7A4F)
private val Green_1E5E3C = Color(0xFF1E5E3C)
private val Green_DCEAD8 = Color(0xFFDCEAD8)
private val Green_2C6A45 = Color(0xFF2C6A45)
private val Cream_F1ECE0 = Color(0xFFF1ECE0)
private val Cream_FBF8F1 = Color(0xFFFBF8F1)
private val Cream_EBE5D6 = Color(0xFFEBE5D6)
private val Ink_22291F = Color(0xFF22291F)
private val Ink_6A7163 = Color(0xFF6A7163)
private val Line_E4DCC9 = Color(0xFFE4DCC9)
private val Line_EFE9DB = Color(0xFFEFE9DB)
private val Terra_C2683C = Color(0xFFC2683C)
private val Terra_F2DECE = Color(0xFFF2DECE)
private val Terra_9E5026 = Color(0xFF9E5026)
// Dark
private val DGreen_74C58A = Color(0xFF74C58A)
private val DGreen_4FA268 = Color(0xFF4FA268)
private val DGreen_1F3422 = Color(0xFF1F3422)
private val DGreen_9BD9AC = Color(0xFF9BD9AC)
private val DGreen_08140C = Color(0xFF08140C)
private val DBg_10190F = Color(0xFF10190F)
private val DSurf_18241A = Color(0xFF18241A)
private val DSurf2_1F2E21 = Color(0xFF1F2E21)
private val DInk_EAF0E3 = Color(0xFFEAF0E3)
private val DInk_9DAE9C = Color(0xFF9DAE9C)
private val DLine_27361F = Color(0xFF27361F)
private val DLine_1E2D1A = Color(0xFF1E2D1A)
private val DTerra_E0905E = Color(0xFFE0905E)
private val DTerra_33231A = Color(0xFF33231A)
private val DTerra_E9A579 = Color(0xFFE9A579)
private val BonsaiLightColorScheme = lightColorScheme(
primary = Green_2F7A4F,
onPrimary = Cream_FBF8F1,
primaryContainer = Green_DCEAD8,
onPrimaryContainer = Green_2C6A45,
secondary = Terra_C2683C,
onSecondary = Color.White,
secondaryContainer = Terra_F2DECE,
onSecondaryContainer = Terra_9E5026,
tertiary = Green_1E5E3C,
onTertiary = Cream_FBF8F1,
tertiaryContainer = Green_DCEAD8,
onTertiaryContainer = Green_2C6A45,
error = Terra_C2683C,
onError = Color.White,
errorContainer = Terra_F2DECE,
onErrorContainer = Terra_9E5026,
background = Cream_F1ECE0,
onBackground = Ink_22291F,
surface = Cream_FBF8F1,
onSurface = Ink_22291F,
surfaceVariant = Cream_EBE5D6,
onSurfaceVariant = Ink_6A7163,
outline = Line_E4DCC9,
outlineVariant = Line_EFE9DB,
inverseSurface = Ink_22291F,
inverseOnSurface = Cream_FBF8F1,
inversePrimary = Green_DCEAD8,
)
private val BonsaiDarkColorScheme = darkColorScheme(
primary = DGreen_74C58A,
onPrimary = DGreen_08140C,
primaryContainer = DGreen_1F3422,
onPrimaryContainer = DGreen_9BD9AC,
secondary = DTerra_E0905E,
onSecondary = DGreen_08140C,
secondaryContainer = DTerra_33231A,
onSecondaryContainer = DTerra_E9A579,
tertiary = DGreen_4FA268,
onTertiary = DGreen_08140C,
tertiaryContainer = DGreen_1F3422,
onTertiaryContainer = DGreen_9BD9AC,
error = DTerra_E0905E,
onError = DGreen_08140C,
errorContainer = DTerra_33231A,
onErrorContainer = DTerra_E9A579,
background = DBg_10190F,
onBackground = DInk_EAF0E3,
surface = DSurf_18241A,
onSurface = DInk_EAF0E3,
surfaceVariant = DSurf2_1F2E21,
onSurfaceVariant = DInk_9DAE9C,
outline = DLine_27361F,
outlineVariant = DLine_1E2D1A,
inverseSurface = DInk_EAF0E3,
inverseOnSurface = DBg_10190F,
inversePrimary = Green_2F7A4F,
)
@Composable @Composable
fun PlanifyTheme( fun PlanifyTheme(
@@ -30,17 +125,8 @@ fun PlanifyTheme(
ThemeMode.SYSTEM -> systemDark ThemeMode.SYSTEM -> systemDark
} }
val colorScheme = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
isDark -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = if (isDark) BonsaiDarkColorScheme else BonsaiLightColorScheme,
typography = Typography, typography = Typography,
content = content, content = content,
) )
@@ -6,10 +6,88 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
val Typography = Typography( val Typography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
displayMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp,
),
displaySmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 36.sp,
lineHeight = 44.sp,
),
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp,
) ),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
) )
@@ -1,27 +1,54 @@
package com.planify.mobile.ui.today package com.planify.mobile.ui.today
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Today import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.HorizontalDivider import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.SectionHeader
import com.planify.mobile.ui.components.TaskRow import com.planify.mobile.ui.components.TaskRow
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable @Composable
fun TodayScreen( fun TodayScreen(
@@ -32,65 +59,224 @@ fun TodayScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) } val collapsedSections = remember { mutableStateOf(setOf<String>()) }
if (state.totalCount == 0 && !state.isLoading) { LazyColumn(
EmptyState( modifier = modifier
icon = Icons.Outlined.Today, .fillMaxSize()
title = "Rien pour aujourd'hui", .background(MaterialTheme.colorScheme.background),
subtitle = "Profitez de votre journée !", contentPadding = PaddingValues(bottom = 96.dp),
modifier = modifier, ) {
) item { TodayHeader() }
return
}
LazyColumn(modifier = modifier.fillMaxSize()) { if (state.totalCount > 0) {
if (state.overdueTasks.isNotEmpty()) {
item { item {
SectionHeader( HeroCard(
name = "En retard", doneCount = state.doneCount,
taskCount = state.overdueTasks.size, totalCount = state.totalCount,
collapsed = "overdue" in collapsedSections.value, modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
onToggleCollapse = {
collapsedSections.value = collapsedSections.value.toggle("overdue")
},
onAddTask = {},
) )
} }
if ("overdue" !in collapsedSections.value) { }
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
TaskRow( if (state.totalCount == 0 && !state.isLoading) {
task = task, item {
onCheckedChange = { viewModel.toggleTask(task) }, EmptyState(
onClick = { onTaskClick(task) }, 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) -> state.tasksByProject.forEach { (projectName, tasks) ->
item(key = "header_$projectName") { item(key = "header_$projectName") {
SectionHeader( SectionLabel(name = projectName, count = tasks.size)
name = projectName,
taskCount = tasks.size,
collapsed = projectName in collapsedSections.value,
onToggleCollapse = {
collapsedSections.value = collapsedSections.value.toggle(projectName)
},
onAddTask = {},
)
} }
if (projectName !in collapsedSections.value) { items(tasks, key = { it.id }) { task ->
items(tasks, key = { it.id }) { task -> TaskRow(
TaskRow( task = task,
task = task, onCheckedChange = { viewModel.toggleTask(task) },
onCheckedChange = { viewModel.toggleTask(task) }, onClick = { onTaskClick(task) },
onClick = { onTaskClick(task) }, )
)
}
} }
} }
} }
} }
private fun Set<String>.toggle(key: String) = @Composable
if (contains(key)) minus(key) else plus(key) 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 tasksByProject: Map<String, List<Task>> = emptyMap(),
val overdueTasks: List<Task> = emptyList(), val overdueTasks: List<Task> = emptyList(),
val totalCount: Int = 0, val totalCount: Int = 0,
val doneCount: Int = 0,
val isLoading: Boolean = false, val isLoading: Boolean = false,
) )
@@ -28,15 +29,18 @@ class TodayViewModel @Inject constructor(
val uiState = combine( val uiState = combine(
taskRepository.getTodayTasks(), taskRepository.getTodayTasks(),
taskRepository.getOverdueTasks(), taskRepository.getOverdueTasks(),
taskRepository.getDoneTodayCount(),
projectRepository.getAllProjects(), projectRepository.getAllProjects(),
) { today, overdue, projects -> ) { today, overdue, done, projects ->
val projectMap = projects.associateBy { it.id } val projectMap = projects.associateBy { it.id }
val grouped = today val grouped = today
.groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId } .groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId }
TodayUiState( TodayUiState(
tasksByProject = grouped, tasksByProject = grouped,
overdueTasks = overdue, overdueTasks = overdue,
totalCount = today.size + overdue.size, totalCount = today.size + overdue.size + done,
doneCount = done,
isLoading = false,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Viewport: 108x108
Safe zone: 1890 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">BonsaiTask</string>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<style name="Theme.PlanifyMobile" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+1
View File
@@ -1,6 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.jetbrains.kotlin.android) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
+4
View File
@@ -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
+15 -11
View File
@@ -1,20 +1,22 @@
[versions] [versions]
agp = "8.4.0" agp = "8.7.3"
kotlin = "1.9.24" kotlin = "2.0.21"
ksp = "2.0.21-1.0.28"
coreKtx = "1.13.1" coreKtx = "1.13.1"
lifecycleRuntimeKtx = "2.8.3" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.9.0" activityCompose = "1.9.3"
composeBom = "2024.06.00" composeBom = "2024.12.01"
hilt = "2.51.1" hilt = "2.52"
hiltNavigationCompose = "1.2.0" hiltNavigationCompose = "1.2.0"
navigationCompose = "2.7.7" navigationCompose = "2.8.5"
room = "2.6.1" room = "2.6.1"
coroutines = "1.8.1" coroutines = "1.9.0"
okhttp = "4.12.0" okhttp = "4.12.0"
datastore = "1.1.1" datastore = "1.1.1"
securityCrypto = "1.1.0-alpha06" securityCrypto = "1.1.0-alpha06"
workManager = "2.9.0" workManager = "2.10.0"
serialization = "1.6.3" serialization = "1.7.3"
browser = "1.8.0"
junit = "4.13.2" junit = "4.13.2"
junitExt = "1.2.1" junitExt = "1.2.1"
espressoCore = "3.6.1" espressoCore = "3.6.1"
@@ -49,6 +51,7 @@ datastore-preferences = { group = "androidx.datastore", name = "datastore-prefer
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
junit = { group = "junit", name = "junit", version.ref = "junit" } junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
@@ -56,6 +59,7 @@ androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-co
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version = "1.9.24-1.0.20" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -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
Vendored Executable
+248
View File
@@ -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" "$@"