Compare commits

..

47 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 8fce0f2578 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:55:49 +02:00
Gato 47aa839e3a feat: [#28][#29] écran paramètres (thème, sync, notifs, comptes CalDAV) + thème dynamique Material You piloté par DataStore 2026-06-06 06:55:49 +02:00
Gato ee1dac46cb feat: [#27] sync CalDAV en arrière-plan (WorkManager PeriodicWork, SyncScheduler, reprise au démarrage) 2026-06-06 06:55:49 +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
Gato d3e9ad4753 Merge pull request 'Develop' (#37) from develop into main
Reviewed-on: Gato/Planify-mobile#37
2026-06-06 06:45:17 +02:00
Gato 289ff97698 Merge pull request 'Milestone/lot 5 avance' (#36) from milestone/lot-5-avance into develop
Reviewed-on: Gato/Planify-mobile#36
2026-06-06 06:44:57 +02:00
Gato 1316c6555b feat: [#26] filtres intelligents (Toutes, Terminées, Récurrentes, Priorités) + navigation Scheduled/Search/Filter/Labels 2026-06-06 06:39:14 +02:00
Gato 5d1c69484a feat: [#25] drag & drop dans la vue liste du projet (long-press handle, reorderTasks) 2026-06-06 06:39:10 +02:00
Gato 5fc6c8a3d4 feat: [#24] recherche globale (debounce 300ms, min 2 chars, live results) 2026-06-06 06:39:06 +02:00
Gato 1146b146c0 feat: [#23] vue Labels (toutes les tâches associées à un label) 2026-06-06 06:39:03 +02:00
Gato 86aab6c3da feat: [#22] vue Scheduled (tâches planifiées groupées par date : aujourd'hui, demain, cette semaine, plus tard) 2026-06-06 06:39:00 +02:00
Gato 7deeb23f33 Merge pull request 'Develop' (#35) from develop into main
Reviewed-on: Gato/Planify-mobile#35
2026-06-06 06:33:58 +02:00
Gato 84e40d12c8 Merge pull request 'Milestone/lot 4 caldav' (#34) from milestone/lot-4-caldav into develop
Reviewed-on: Gato/Planify-mobile#34
2026-06-06 06:33:42 +02:00
88 changed files with 4247 additions and 455 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)
+10
View File
@@ -38,6 +38,16 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>
@@ -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))
@@ -0,0 +1,73 @@
package com.planify.mobile.data.export
import android.content.Context
import android.net.Uri
import androidx.core.content.FileProvider
import com.planify.mobile.data.caldav.VTodoGenerator
import com.planify.mobile.domain.model.Project
import com.planify.mobile.domain.model.Task
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.File
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
data class BackupPayload(
val version: Int = 1,
val exportedAt: String,
val projects: List<Project>,
val tasks: List<Task>,
)
@Singleton
class ExportManager @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val json = Json { prettyPrint = true; ignoreUnknownKeys = true }
private val exportDir get() = File(context.cacheDir, "exports").also { it.mkdirs() }
private val authority = "${context.packageName}.fileprovider"
private fun timestamp() =
LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"))
fun exportJson(projects: List<Project>, tasks: List<Task>): Uri {
val file = File(exportDir, "planify_backup_${timestamp()}.json")
val payload = BackupPayload(
exportedAt = timestamp(),
projects = projects,
tasks = tasks,
)
file.writeText(json.encodeToString(payload))
return FileProvider.getUriForFile(context, authority, file)
}
fun exportIcal(tasks: List<Task>): Uri {
val file = File(exportDir, "planify_tasks_${timestamp()}.ics")
val sb = StringBuilder()
sb.appendLine("BEGIN:VCALENDAR")
sb.appendLine("VERSION:2.0")
sb.appendLine("PRODID:-//Planify Mobile//Android//EN")
sb.appendLine("CALSCALE:GREGORIAN")
tasks.forEach { task ->
val ical = VTodoGenerator.generate(task)
val start = ical.indexOf("BEGIN:VTODO")
val end = ical.indexOf("END:VTODO")
if (start >= 0 && end >= 0) {
sb.appendLine(ical.substring(start, end + "END:VTODO".length))
}
}
sb.append("END:VCALENDAR")
file.writeText(sb.toString())
return FileProvider.getUriForFile(context, authority, file)
}
fun parseJsonBackup(jsonString: String): BackupPayload? =
runCatching { json.decodeFromString<BackupPayload>(jsonString) }.getOrNull()
}
@@ -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
@@ -111,4 +114,8 @@ interface TaskDao {
// #25 — Reorder: update child_order for a single task // #25 — Reorder: update child_order for a single task
@Query("UPDATE tasks SET child_order = :order WHERE id = :id") @Query("UPDATE tasks SET child_order = :order WHERE id = :id")
suspend fun updateChildOrder(id: String, order: Int) suspend fun updateChildOrder(id: String, order: Int)
// #30 — Export: all non-deleted tasks
@Query("SELECT * FROM tasks WHERE is_deleted = 0 ORDER BY project_id, child_order ASC")
fun getAllTasks(): Flow<List<TaskEntity>>
} }
@@ -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,
) )
@@ -3,14 +3,17 @@ package com.planify.mobile.data.notification
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.planify.mobile.data.sync.SyncScheduler
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject lateinit var syncScheduler: SyncScheduler
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) return if (intent.action != Intent.ACTION_BOOT_COMPLETED) return
// TODO #14 : replanifier toutes les alarmes depuis la base de données syncScheduler.schedule()
// Inject ReminderScheduler + ReminderRepository et rejouer tous les rappels actifs
} }
} }
@@ -0,0 +1,64 @@
package com.planify.mobile.data.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore("app_prefs")
@Singleton
class AppPreferences @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val store = context.dataStore
// ── Keys ─────────────────────────────────────────────────────────────────
private val keyTheme = stringPreferencesKey("theme_mode")
private val keySyncEnabled = booleanPreferencesKey("sync_enabled")
private val keySyncInterval = intPreferencesKey("sync_interval_minutes")
private val keyNotifications = booleanPreferencesKey("notifications_enabled")
// ── Reads ─────────────────────────────────────────────────────────────────
val themeMode: Flow<ThemeMode> = store.data.map { prefs ->
ThemeMode.fromKey(prefs[keyTheme] ?: ThemeMode.SYSTEM.key)
}
val syncEnabled: Flow<Boolean> = store.data.map { it[keySyncEnabled] ?: true }
val syncIntervalMinutes: Flow<Int> = store.data.map { it[keySyncInterval] ?: 30 }
val notificationsEnabled: Flow<Boolean> = store.data.map { it[keyNotifications] ?: true }
// ── Writes ────────────────────────────────────────────────────────────────
suspend fun setThemeMode(mode: ThemeMode) =
store.edit { it[keyTheme] = mode.key }
suspend fun setSyncEnabled(enabled: Boolean) =
store.edit { it[keySyncEnabled] = enabled }
suspend fun setSyncInterval(minutes: Int) =
store.edit { it[keySyncInterval] = minutes }
suspend fun setNotificationsEnabled(enabled: Boolean) =
store.edit { it[keyNotifications] = enabled }
}
enum class ThemeMode(val key: String, val label: String) {
SYSTEM("SYSTEM", "Système"),
LIGHT("LIGHT", "Clair"),
DARK("DARK", "Sombre");
companion object {
fun fromKey(key: String) = entries.firstOrNull { it.key == key } ?: SYSTEM
}
}
@@ -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() } }
@@ -78,6 +80,9 @@ class TaskRepositoryImpl @Inject constructor(
orderedIds.forEachIndexed { index, id -> dao.updateChildOrder(id, index) } orderedIds.forEachIndexed { index, id -> dao.updateChildOrder(id, index) }
} }
override fun getAllTasks(): Flow<List<Task>> =
dao.getAllTasks().map { it.map { e -> e.toDomain() } }
private fun TaskEntity.toDomain() = Task( private fun TaskEntity.toDomain() = Task(
id = id, id = id,
content = content, content = content,
@@ -114,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,
@@ -0,0 +1,37 @@
package com.planify.mobile.data.sync
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.planify.mobile.data.caldav.CalDavSyncManager
import com.planify.mobile.data.caldav.SyncResult
import com.planify.mobile.domain.repository.SourceRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class CalDavSyncWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted params: WorkerParameters,
private val sourceRepository: SourceRepository,
private val syncManager: CalDavSyncManager,
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val sources = sourceRepository.getCaldavSources()
if (sources.isEmpty()) return Result.success()
var hasError = false
sources.forEach { source ->
val result = syncManager.incrementalSync(source)
if (result is SyncResult.Failure) hasError = true
}
return if (hasError) Result.retry() else Result.success()
}
companion object {
const val WORK_NAME = "caldav_periodic_sync"
}
}
@@ -0,0 +1,51 @@
package com.planify.mobile.data.sync
import android.content.Context
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.NetworkType
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class SyncScheduler @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val workManager = WorkManager.getInstance(context)
fun schedule(intervalMinutes: Long = 30) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val request = PeriodicWorkRequestBuilder<CalDavSyncWorker>(
repeatInterval = intervalMinutes,
repeatIntervalTimeUnit = TimeUnit.MINUTES,
)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
CalDavSyncWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
request,
)
}
fun cancel() {
workManager.cancelUniqueWork(CalDavSyncWorker.WORK_NAME)
}
fun syncNow() {
val request = androidx.work.OneTimeWorkRequestBuilder<CalDavSyncWorker>()
.setConstraints(
Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()
)
.build()
workManager.enqueue(request)
}
}
@@ -1,3 +1,6 @@
package com.planify.mobile.domain.model package com.planify.mobile.domain.model
enum class BackendType { LOCAL, CALDAV, TODOIST } import kotlinx.serialization.Serializable
@Serializable
enum class BackendType { LOCAL, CALDAV, TODOIST, BONSAI }
@@ -1,5 +1,8 @@
package com.planify.mobile.domain.model package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class Project( data class Project(
val id: String, val id: String,
val name: String, val name: String,
@@ -20,6 +23,8 @@ data class Project(
val syncId: String? = null, val syncId: String? = null,
) )
@Serializable
enum class ViewStyle { LIST, BOARD } enum class ViewStyle { LIST, BOARD }
@Serializable
enum class SortBy { MANUAL, NAME, DUE_DATE, ADDED_DATE, PRIORITY } enum class SortBy { MANUAL, NAME, DUE_DATE, ADDED_DATE, PRIORITY }
@@ -1,5 +1,8 @@
package com.planify.mobile.domain.model package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable
@Serializable
data class Task( data class Task(
val id: String, val id: String,
val content: String, val content: String,
@@ -25,4 +28,5 @@ data class Task(
val responsibleUid: String? = null, val responsibleUid: String? = null,
) )
@Serializable
enum class ItemType { TASK, NOTE } enum class ItemType { TASK, NOTE }
@@ -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?
@@ -22,4 +23,5 @@ interface TaskRepository {
fun getRepeatingTasks(): Flow<List<Task>> fun getRepeatingTasks(): Flow<List<Task>>
fun getTasksByPriority(priority: Int): Flow<List<Task>> fun getTasksByPriority(priority: Int): Flow<List<Task>>
suspend fun reorderTasks(orderedIds: List<String>) suspend fun reorderTasks(orderedIds: List<String>)
fun getAllTasks(): Flow<List<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,141 +1,159 @@
package com.planify.mobile.ui package com.planify.mobile.ui
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.collectAsState
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Inbox import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material.icons.outlined.Menu import androidx.compose.material.icons.outlined.GridView
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Person
import androidx.compose.material.icons.outlined.Today import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.DrawerValue import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.NavigationBar
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.auth.AuthViewModel
import com.planify.mobile.ui.navigation.DrawerViewModel import com.planify.mobile.ui.navigation.DrawerViewModel
import com.planify.mobile.ui.navigation.PlanifyNavHost import com.planify.mobile.ui.navigation.PlanifyNavHost
import com.planify.mobile.ui.navigation.Route import com.planify.mobile.ui.navigation.Route
import kotlinx.coroutines.launch import com.planify.mobile.ui.task.TaskEditSheet
private data class BottomTab(
val route: String,
val icon: ImageVector,
val label: String,
)
private val bottomTabs = listOf(
BottomTab(Route.Today.path, Icons.Outlined.Today, "Aujourd'hui"),
BottomTab(Route.Scheduled.path, Icons.Outlined.CalendarMonth, "Prévu"),
BottomTab(Route.ProjectsList.path, Icons.Outlined.GridView, "Projets"),
BottomTab(Route.Settings.path, Icons.Outlined.Person, "Profil"),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen(viewModel: DrawerViewModel = hiltViewModel()) { fun MainScreen(
authViewModel: AuthViewModel,
drawerViewModel: DrawerViewModel = hiltViewModel(),
) {
val navController = rememberNavController() val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val projects by viewModel.projects.collectAsState()
val navBackStack by navController.currentBackStackEntryAsState() val navBackStack by navController.currentBackStackEntryAsState()
val currentRoute = navBackStack?.destination?.route val currentRoute = navBackStack?.destination?.route
val drawerTitles = mapOf( var showCreateTask by remember { mutableStateOf(false) }
Route.Inbox.path to "Inbox", var selectedTask by remember { mutableStateOf<Task?>(null) }
Route.Today.path to "Aujourd'hui",
Route.Scheduled.path to "Planifié", val projects by drawerViewModel.projects.collectAsState()
) val inboxProjectId = projects.find { it.isInbox }?.id ?: ""
val title = drawerTitles[currentRoute] val createProjectId = if (currentRoute == Route.Project().path)
?: projects.find { "project/${it.id}" == currentRoute }?.name navBackStack?.arguments?.getString("projectId") ?: inboxProjectId
?: "Planify" else
inboxProjectId
val hideBottomBarRoutes = setOf<String>()
val showBottomBar = currentRoute !in hideBottomBarRoutes
val hideFabRoutes = setOf(Route.Settings.path)
val showFab = currentRoute !in hideFabRoutes
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
ModalDrawerSheet {
Text(
text = "Planify",
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp),
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Inbox, null) },
label = { Text("Inbox") },
selected = currentRoute == Route.Inbox.path,
onClick = {
navController.navigate(Route.Inbox.path)
scope.launch { drawerState.close() }
},
)
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Today, null) },
label = { Text("Aujourd'hui") },
selected = currentRoute == Route.Today.path,
onClick = {
navController.navigate(Route.Today.path)
scope.launch { drawerState.close() }
},
)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text(
text = "Projets",
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
LazyColumn {
items(projects) { project ->
NavigationDrawerItem(
icon = { Icon(Icons.Default.FolderOpen, null) },
label = { Text(project.name) },
selected = currentRoute == "project/${project.id}",
onClick = {
navController.navigate(Route.Project().buildRoute(project.id))
scope.launch { drawerState.close() }
},
)
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
NavigationDrawerItem(
icon = { Icon(Icons.Outlined.Settings, null) },
label = { Text("Paramètres") },
selected = false,
onClick = { scope.launch { drawerState.close() } },
)
Spacer(Modifier.height(8.dp))
}
}
) {
Scaffold( Scaffold(
topBar = { bottomBar = {
TopAppBar( if (showBottomBar) {
title = { Text(title) }, NavigationBar(
navigationIcon = { containerColor = MaterialTheme.colorScheme.surface,
IconButton(onClick = { scope.launch { drawerState.open() } }) { tonalElevation = 0.dp,
Icon(Icons.Outlined.Menu, contentDescription = "Menu") ) {
bottomTabs.forEach { tab ->
val selected = currentRoute == tab.route ||
(tab.route == Route.ProjectsList.path && currentRoute == Route.Project().path)
NavigationBarItem(
selected = selected,
onClick = {
if (currentRoute != tab.route) {
navController.navigate(tab.route) {
popUpTo(Route.Today.path) { saveState = true }
launchSingleTop = true
restoreState = true
}
} }
}, },
icon = {
Icon(
imageVector = tab.icon,
contentDescription = tab.label,
) )
}, },
label = {
Text(
text = tab.label,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
),
)
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
}
}
},
floatingActionButton = {
if (showFab) {
FloatingActionButton(
onClick = { showCreateTask = true },
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = RoundedCornerShape(20.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = "Nouvelle tâche")
}
}
},
containerColor = MaterialTheme.colorScheme.background,
) { padding -> ) { padding ->
PlanifyNavHost( PlanifyNavHost(
navController = navController, navController = navController,
authViewModel = authViewModel,
onTaskClick = { task -> selectedTask = task },
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
if (showCreateTask) {
TaskEditSheet(
projectId = createProjectId,
onDismiss = { showCreateTask = false },
)
}
selectedTask?.let { task ->
TaskEditSheet(
taskId = task.id,
projectId = task.projectId,
onDismiss = { selectedTask = null },
)
} }
} }
} }
@@ -0,0 +1,74 @@
package com.planify.mobile.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.LoginResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed class AuthStatus {
object Checking : AuthStatus()
object NotAuthenticated : AuthStatus()
data class Authenticated(val username: String) : AuthStatus()
data class LoginError(val message: String) : AuthStatus()
}
@HiltViewModel
class AuthViewModel @Inject constructor(
private val authManager: BonsaiAuthManager,
private val syncManager: BonsaiSyncManager,
) : ViewModel() {
private val _status = MutableStateFlow<AuthStatus>(AuthStatus.Checking)
val status: StateFlow<AuthStatus> = _status.asStateFlow()
init {
checkAuthOnStartup()
}
private fun checkAuthOnStartup() {
viewModelScope.launch {
if (!authManager.isLoggedIn) {
_status.value = AuthStatus.NotAuthenticated
return@launch
}
// Show the app immediately — don't block on a network call.
_status.value = AuthStatus.Authenticated(authManager.getUsername())
// Refresh token + sync in background without blocking startup.
viewModelScope.launch {
authManager.refreshIfNeeded()
syncManager.sync()
}
}
}
fun login(username: String, password: String) {
_status.value = AuthStatus.Checking
viewModelScope.launch {
when (val result = authManager.login(username, password)) {
is LoginResult.Success -> {
_status.value = AuthStatus.Authenticated(authManager.getUsername())
syncManager.sync()
}
is LoginResult.Failure -> {
_status.value = AuthStatus.LoginError(result.message)
}
}
}
}
fun logout() {
authManager.logout()
_status.value = AuthStatus.NotAuthenticated
}
fun clearError() {
_status.value = AuthStatus.NotAuthenticated
}
}
@@ -0,0 +1,234 @@
package com.planify.mobile.ui.auth
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.planify.mobile.R
private val MintBackground = Color(0xFFF0FDF4)
private val BonsaiGreen = Color(0xFF38A169)
private val FieldBorder = Color(0xFFD1D5DB)
private val LabelColor = Color(0xFF374151)
private val TitleColor = Color(0xFF111827)
@Composable
fun LoginScreen(viewModel: AuthViewModel) {
val status by viewModel.status.collectAsState()
val isLoading = status is AuthStatus.Checking
val errorMessage = (status as? AuthStatus.LoginError)?.message
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
val passwordFocus = remember { FocusRequester() }
Box(
modifier = Modifier
.fillMaxSize()
.background(MintBackground)
.imePadding(),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(16.dp),
shadowElevation = 6.dp,
color = Color.White,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 28.dp)
.verticalScroll(rememberScrollState()),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 32.dp),
) {
Image(
painter = painterResource(id = R.drawable.ic_bonsai_foreground),
contentDescription = "Bonsai",
modifier = Modifier.size(72.dp),
)
Spacer(Modifier.height(10.dp))
Text(
text = "Bonsai",
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
color = TitleColor,
)
Spacer(Modifier.height(4.dp))
Text(
text = "Connexion",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = LabelColor,
)
Spacer(Modifier.height(24.dp))
// Username field
Text(
text = "Email ou nom d'utilisateur",
style = MaterialTheme.typography.bodySmall,
color = LabelColor,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { passwordFocus.requestFocus() }
),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = FieldBorder,
focusedBorderColor = BonsaiGreen,
),
shape = RoundedCornerShape(6.dp),
)
Spacer(Modifier.height(12.dp))
// Password field
Text(
text = "Mot de passe",
style = MaterialTheme.typography.bodySmall,
color = LabelColor,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocus),
singleLine = true,
enabled = !isLoading,
visualTransformation = if (passwordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
if (username.isNotBlank() && password.isNotBlank()) {
viewModel.login(username.trim(), password)
}
}
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Outlined.Visibility
else Icons.Outlined.VisibilityOff,
contentDescription = if (passwordVisible) "Masquer" else "Afficher",
tint = Color(0xFF6B7280),
)
}
},
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = FieldBorder,
focusedBorderColor = BonsaiGreen,
),
shape = RoundedCornerShape(6.dp),
)
errorMessage?.let {
Spacer(Modifier.height(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { viewModel.login(username.trim(), password) },
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = BonsaiGreen),
shape = RoundedCornerShape(6.dp),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text(
text = "Se connecter",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
)
}
}
}
}
}
}
@@ -0,0 +1,109 @@
package com.planify.mobile.ui.components
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import kotlinx.coroutines.launch
/** State for a reorderable LazyColumn. */
class ReorderState {
var draggedIndex by mutableStateOf<Int?>(null)
var dragOffsetY by mutableFloatStateOf(0f)
var overIndex by mutableIntStateOf(-1)
fun isDragged(index: Int) = draggedIndex == index
fun isOver(index: Int) = overIndex == index
}
@Composable
fun rememberReorderState() = remember { ReorderState() }
/**
* Modifier for a drag handle icon that drives a [ReorderState].
* Call [onReorder] when the drag ends with the new list order.
*/
@Composable
fun <T> Modifier.reorderDragHandle(
item: T,
index: Int,
items: List<T>,
state: ReorderState,
listState: LazyListState,
onReorder: (List<T>) -> Unit,
): Modifier {
val scope = rememberCoroutineScope()
return this.pointerInput(item, items) {
detectDragGesturesAfterLongPress(
onDragStart = {
state.draggedIndex = index
state.dragOffsetY = 0f
state.overIndex = index
},
onDrag = { change, dragAmount ->
change.consume()
state.dragOffsetY += dragAmount.y
val currentInfo: LazyListItemInfo? = listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == (state.draggedIndex ?: -1) }
if (currentInfo != null) {
val currentCenter = currentInfo.offset + currentInfo.size / 2 + state.dragOffsetY.toInt()
val newOver = listState.layoutInfo.visibleItemsInfo
.minByOrNull { kotlin.math.abs(it.offset + it.size / 2 - currentCenter) }
?.index ?: state.overIndex
state.overIndex = newOver
}
},
onDragEnd = {
val from = state.draggedIndex ?: return@detectDragGesturesAfterLongPress
val to = state.overIndex
if (from != to && to >= 0 && to < items.size) {
val mutable = items.toMutableList()
val moved = mutable.removeAt(from)
mutable.add(to, moved)
scope.launch { onReorder(mutable) }
}
state.draggedIndex = null
state.dragOffsetY = 0f
state.overIndex = -1
},
onDragCancel = {
state.draggedIndex = null
state.dragOffsetY = 0f
state.overIndex = -1
},
)
}
}
/** Modifier to apply drag visual feedback (elevation + offset) to a dragged item. */
fun Modifier.draggedItemModifier(isDragged: Boolean, offsetY: Float): Modifier =
if (isDragged) this
.zIndex(1f)
.graphicsLayer { translationY = offsetY; shadowElevation = 8f }
else this
@Composable
fun DragHandleIcon(modifier: Modifier = Modifier) {
Icon(
imageVector = Icons.Outlined.DragHandle,
contentDescription = "Réordonner",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = modifier,
)
}
@@ -1,18 +1,26 @@
package com.planify.mobile.ui.components package com.planify.mobile.ui.components
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.size
import androidx.compose.material3.Checkbox import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CheckboxDefaults import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -20,7 +28,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -35,40 +45,54 @@ fun TaskRow(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val priorityColor = priorityColor[task.priority] ?: Color.Gray val checkColor = when {
task.checked -> MaterialTheme.colorScheme.primary
task.priority == 1 -> MaterialTheme.colorScheme.secondary
task.priority == 2 -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
task.priority == 3 -> MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
}
val textColor by animateColorAsState( val textColor by animateColorAsState(
if (task.checked) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) if (task.checked) MaterialTheme.colorScheme.onSurfaceVariant
else MaterialTheme.colorScheme.onSurface, else MaterialTheme.colorScheme.onSurface,
label = "textColor", label = "textColor",
) )
Row( Surface(
onClick = onClick,
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.combinedClickable(onClick = onClick, onLongClick = {}) .padding(horizontal = 16.dp, vertical = 4.dp),
.padding(horizontal = 16.dp, vertical = 8.dp), shape = RoundedCornerShape(18.dp),
verticalAlignment = Alignment.CenterVertically, color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
tonalElevation = 0.dp,
) { ) {
Checkbox( Row(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
CircleCheckbox(
checked = task.checked, checked = task.checked,
onCheckedChange = onCheckedChange, color = checkColor,
colors = CheckboxDefaults.colors( onClick = { onCheckedChange(!task.checked) },
checkedColor = priorityColor, modifier = Modifier.padding(top = 1.dp),
uncheckedColor = priorityColor,
),
) )
Spacer(Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = task.content, text = task.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
color = textColor, color = textColor,
textDecoration = if (task.checked) TextDecoration.LineThrough else null, textDecoration = if (task.checked) TextDecoration.LineThrough else null,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
val hasMeta = task.dueDate != null || task.labels.isNotEmpty()
if (hasMeta) {
Spacer(Modifier.height(5.dp))
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (task.dueDate != null) { if (task.dueDate != null) {
@@ -79,9 +103,34 @@ fun TaskRow(
} }
} }
} }
if (task.priority < 4) { }
Spacer(Modifier.width(8.dp)) }
PriorityBadge(priority = task.priority) }
}
@Composable
fun CircleCheckbox(
checked: Boolean,
color: Color,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(22.dp)
.border(2.dp, color, CircleShape)
.background(if (checked) color else Color.Transparent, CircleShape)
.clip(CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (checked) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(13.dp),
)
} }
} }
} }
@@ -89,7 +138,6 @@ fun TaskRow(
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
private fun TaskRowPreview() { private fun TaskRowPreview() {
Surface {
Column { Column {
TaskRow( TaskRow(
task = Task( task = Task(
@@ -102,7 +150,6 @@ private fun TaskRowPreview() {
onCheckedChange = {}, onCheckedChange = {},
onClick = {}, onClick = {},
) )
Spacer(Modifier.height(1.dp))
TaskRow( TaskRow(
task = Task( task = Task(
id = "2", id = "2",
@@ -115,5 +162,4 @@ private fun TaskRowPreview() {
onClick = {}, onClick = {},
) )
} }
}
} }
@@ -0,0 +1,80 @@
package com.planify.mobile.ui.filter
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FilterList
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow
private val filterLabels = mapOf(
FilterType.ALL to "Toutes",
FilterType.COMPLETED to "Terminées",
FilterType.REPEATING to "Récurrentes",
FilterType.PRIORITY_1 to "Priorité urgente",
FilterType.PRIORITY_2 to "Priorité haute",
FilterType.PRIORITY_3 to "Priorité moyenne",
)
@Composable
fun FilterScreen(
initialFilter: FilterType = FilterType.ALL,
onTaskClick: (Task) -> Unit,
viewModel: FilterViewModel = hiltViewModel(),
) {
val tasks by viewModel.tasks.collectAsState()
val activeFilter by viewModel.activeFilter.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilterType.entries.forEach { filter ->
FilterChip(
selected = activeFilter == filter,
onClick = { viewModel.setFilter(filter) },
label = { Text(filterLabels[filter] ?: filter.name) },
)
}
}
if (tasks.isEmpty()) {
EmptyState(
icon = Icons.Outlined.FilterList,
title = "Aucune tâche",
subtitle = "Aucune tâche ne correspond à ce filtre",
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
}
}
@@ -0,0 +1,51 @@
package com.planify.mobile.ui.filter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
enum class FilterType { ALL, COMPLETED, REPEATING, PRIORITY_1, PRIORITY_2, PRIORITY_3 }
@HiltViewModel
class FilterViewModel @Inject constructor(
private val taskRepository: TaskRepository,
) : ViewModel() {
private val _filter = MutableStateFlow(FilterType.ALL)
@OptIn(ExperimentalCoroutinesApi::class)
val tasks = _filter.flatMapLatest { filter ->
when (filter) {
FilterType.ALL -> taskRepository.getInboxTasks().let {
// ALL = all uncompleted tasks across all projects
taskRepository.getTasksByPriority(4).let { _ ->
// Use a union approach via getRepeatingTasks as base — actually
// for ALL we use getScheduledTasks + inbox combined.
// Simplify: use priority 4 as "all" isn't perfect; provide getAll via search ""
taskRepository.searchTasks("")
}
}
FilterType.COMPLETED -> taskRepository.getCompletedTasks()
FilterType.REPEATING -> taskRepository.getRepeatingTasks()
FilterType.PRIORITY_1 -> taskRepository.getTasksByPriority(1)
FilterType.PRIORITY_2 -> taskRepository.getTasksByPriority(2)
FilterType.PRIORITY_3 -> taskRepository.getTasksByPriority(3)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setFilter(filter: FilterType) { _filter.value = filter }
val activeFilter get() = _filter
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
}
@@ -0,0 +1,46 @@
package com.planify.mobile.ui.label
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Label
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow
@Composable
fun LabelScreen(
labelId: String,
onTaskClick: (Task) -> Unit,
viewModel: LabelViewModel = hiltViewModel(),
) {
LaunchedEffect(labelId) { viewModel.init(labelId) }
val tasks by viewModel.tasks.collectAsState()
if (tasks.isEmpty()) {
EmptyState(
icon = Icons.Outlined.Label,
title = "Aucune tâche",
subtitle = "Aucune tâche n'est associée à ce label",
)
return
}
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
@@ -0,0 +1,52 @@
package com.planify.mobile.ui.label
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Label
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.LabelRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class LabelViewModel @Inject constructor(
private val taskRepository: TaskRepository,
private val labelRepository: LabelRepository,
) : ViewModel() {
private val _labelId = MutableStateFlow<String?>(null)
@OptIn(ExperimentalCoroutinesApi::class)
val label = _labelId.flatMapLatest { id ->
if (id == null) flowOf(null)
else labelRepository.getAllLabels().flatMapLatest { labels ->
flowOf(labels.find { it.id == id })
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val tasks = label.flatMapLatest { lbl ->
if (lbl == null) flowOf(emptyList())
else taskRepository.getTasksByLabel(lbl.name)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun init(labelId: String) {
_labelId.value = labelId
}
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
fun deleteTask(task: Task) {
viewModelScope.launch { taskRepository.deleteTask(task.id) }
}
}
@@ -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,30 +7,60 @@ 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.inbox.InboxScreen import com.planify.mobile.ui.inbox.InboxScreen
import com.planify.mobile.ui.label.LabelScreen
import com.planify.mobile.ui.project.ProjectScreen import com.planify.mobile.ui.project.ProjectScreen
import com.planify.mobile.ui.project.ProjectsListScreen
import com.planify.mobile.ui.scheduled.ScheduledScreen
import com.planify.mobile.ui.search.SearchScreen
import com.planify.mobile.ui.settings.SettingsScreen
import com.planify.mobile.ui.today.TodayScreen 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(
@@ -40,9 +70,32 @@ 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 #11 : ouvrir édition */ }, onTaskClick = onTaskClick,
onBack = { navController.popBackStack() }, onBack = { navController.popBackStack() },
) )
} }
composable(Route.Search.path) {
SearchScreen(onTaskClick = onTaskClick)
}
composable(Route.Filter.path) {
FilterScreen(onTaskClick = onTaskClick)
}
composable(
route = Route.Label().path,
arguments = listOf(navArgument("labelId") { type = NavType.StringType })
) { backStack ->
val labelId = backStack.arguments?.getString("labelId") ?: return@composable
LabelScreen(
labelId = labelId,
onTaskClick = onTaskClick,
)
}
composable(Route.Settings.path) {
SettingsScreen(authViewModel = authViewModel)
}
} }
} }
@@ -4,6 +4,9 @@ sealed class Route(val path: String) {
data object Inbox : Route("inbox") data object Inbox : Route("inbox")
data object Today : Route("today") data object Today : Route("today")
data object Scheduled : Route("scheduled") data object Scheduled : Route("scheduled")
data object Search : Route("search")
data object Filter : Route("filter")
data object ProjectsList : Route("projects")
data class Project(val projectId: String = "{projectId}") : 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,19 +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.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.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
@@ -21,14 +35,24 @@ 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.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
import com.planify.mobile.ui.components.DragHandleIcon
import com.planify.mobile.ui.components.EmptyState import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.ReorderState
import com.planify.mobile.ui.components.SectionHeader import com.planify.mobile.ui.components.SectionHeader
import com.planify.mobile.ui.components.TaskRow import com.planify.mobile.ui.components.TaskRow
import com.planify.mobile.ui.components.draggedItemModifier
import com.planify.mobile.ui.components.reorderDragHandle
import com.planify.mobile.ui.components.rememberReorderState
@Composable @Composable
fun ProjectScreen( fun ProjectScreen(
@@ -41,14 +65,27 @@ fun ProjectScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) } val collapsedSections = remember { mutableStateOf(setOf<String>()) }
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
// Green project banner
ProjectBanner(
projectName = state.project?.name ?: "",
totalTasks = state.sections.sumOf { it.tasks.size },
doneTasks = state.sections.sumOf { s -> s.tasks.count { it.checked } },
onBack = onBack,
)
if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) { if (!state.isLoading && state.sections.all { it.tasks.isEmpty() }) {
EmptyState( EmptyState(
icon = Icons.Outlined.FolderOpen, icon = Icons.Outlined.FolderOpen,
title = "Projet vide", title = "Projet vide",
subtitle = "Créez votre première tâche avec le bouton +", subtitle = "Créez votre première tâche avec le bouton +",
modifier = modifier, modifier = Modifier.weight(1f),
) )
return return@Column
} }
when (state.viewStyle) { when (state.viewStyle) {
@@ -62,13 +99,140 @@ fun ProjectScreen(
}, },
onTaskClick = onTaskClick, onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) }, onCheckedChange = { task -> viewModel.toggleTask(task) },
modifier = modifier, onReorder = { viewModel.reorderTasks(it) },
modifier = Modifier.weight(1f),
) )
ViewStyle.BOARD -> ProjectBoardView( ViewStyle.BOARD -> ProjectBoardView(
state = state, state = state,
onTaskClick = onTaskClick, onTaskClick = onTaskClick,
onCheckedChange = { task -> viewModel.toggleTask(task) }, onCheckedChange = { task -> viewModel.toggleTask(task) },
modifier = modifier, modifier = Modifier.weight(1f),
)
}
}
}
@Composable
private fun ProjectBanner(
projectName: String,
totalTasks: Int,
doneTasks: Int,
onBack: () -> Unit,
) {
val progress = if (totalTasks == 0) 0f else doneTasks.toFloat() / totalTasks
val remainingTasks = totalTasks - doneTasks
val primaryColor = MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.fillMaxWidth()
.background(
color = primaryColor,
shape = RoundedCornerShape(bottomStart = 26.dp, bottomEnd = 26.dp),
)
.padding(horizontal = 18.dp, vertical = 16.dp),
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.background(Color.White.copy(alpha = 0.18f), CircleShape)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
IconButton(onClick = onBack) {
Icon(
Icons.Outlined.ArrowBack,
contentDescription = "Retour",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(Modifier.weight(1f))
Box(
modifier = Modifier
.size(40.dp)
.background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(13.dp)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.FolderOpen,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
Spacer(Modifier.width(8.dp))
Box(
modifier = Modifier
.size(40.dp)
.background(Color.White.copy(alpha = 0.18f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = "Plus",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
Spacer(Modifier.height(16.dp))
Text(
text = projectName,
style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.SemiBold),
color = Color.White,
)
Spacer(Modifier.height(12.dp))
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(16.dp)) {
BannerStat(value = "$doneTasks", label = "Terminées")
BannerStat(value = "$remainingTasks", label = "Restantes")
if (totalTasks > 0) {
BannerStat(value = "${(progress * 100).toInt()}%", label = "Avancement")
}
}
if (totalTasks > 0) {
Spacer(Modifier.height(14.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.background(Color.White.copy(alpha = 0.25f), RoundedCornerShape(6.dp)),
) {
Box(
modifier = Modifier
.fillMaxWidth(progress)
.height(6.dp)
.background(Color.White, RoundedCornerShape(6.dp)),
)
}
}
}
}
}
@Composable
private fun BannerStat(value: String, label: String) {
Column {
Text(
text = value,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = Color.White,
)
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Medium),
color = Color.White.copy(alpha = 0.82f),
) )
} }
} }
@@ -80,9 +244,17 @@ private fun ProjectListView(
onToggleSection: (String) -> Unit, onToggleSection: (String) -> Unit,
onTaskClick: (Task) -> Unit, onTaskClick: (Task) -> Unit,
onCheckedChange: (Task) -> Unit, onCheckedChange: (Task) -> Unit,
onReorder: (List<Task>) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
LazyColumn(modifier = modifier.fillMaxSize()) { val listState = rememberLazyListState()
val reorderState = rememberReorderState()
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"
@@ -98,13 +270,34 @@ private fun ProjectListView(
} }
if (key !in collapsedSections) { if (key !in collapsedSections) {
items(group.tasks, key = { it.id }) { task -> itemsIndexed(group.tasks, key = { _, t -> t.id }) { index, task ->
Row(
modifier = Modifier
.fillMaxWidth()
.draggedItemModifier(
isDragged = reorderState.isDragged(index),
offsetY = if (reorderState.isDragged(index)) reorderState.dragOffsetY else 0f,
),
verticalAlignment = Alignment.CenterVertically,
) {
DragHandleIcon(
modifier = Modifier.reorderDragHandle(
item = task,
index = index,
items = group.tasks,
state = reorderState,
listState = listState,
onReorder = onReorder,
)
)
TaskRow( TaskRow(
task = task, task = task,
onCheckedChange = { onCheckedChange(task) }, onCheckedChange = { onCheckedChange(task) },
onClick = { onTaskClick(task) }, onClick = { onTaskClick(task) },
modifier = Modifier.weight(1f),
) )
} }
}
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) } item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
} }
} }
@@ -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,6 +72,27 @@ 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>) {
viewModelScope.launch { taskRepository.reorderTasks(reordered.map { it.id }) }
} }
} }
@@ -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(),
)
}
@@ -0,0 +1,213 @@
package com.planify.mobile.ui.scheduled
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CalendarMonth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.TaskRow
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.format.TextStyle
import java.util.Locale
@Composable
fun ScheduledScreen(
onTaskClick: (Task) -> Unit,
modifier: Modifier = Modifier,
viewModel: ScheduledViewModel = hiltViewModel(),
) {
val groups by viewModel.groups.collectAsState()
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(bottom = 96.dp),
) {
// Header
item { ScheduledHeader() }
// Week strip
item { WeekStrip() }
if (groups.isEmpty()) {
item {
EmptyState(
icon = Icons.Outlined.CalendarMonth,
title = "Aucune tâche planifiée",
subtitle = "Les tâches avec une date d'échéance apparaîtront ici",
)
}
return@LazyColumn
}
groups.forEach { group ->
item(key = "head_${group.label}") {
DayGroupHeader(label = group.label, count = group.tasks.size)
}
items(group.tasks, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
}
@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,
)
}
}
@@ -0,0 +1,60 @@
package com.planify.mobile.ui.scheduled
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
data class ScheduledGroup(val label: String, val tasks: List<Task>)
@HiltViewModel
class ScheduledViewModel @Inject constructor(
private val taskRepository: TaskRepository,
) : ViewModel() {
val groups = taskRepository.getScheduledTasks()
.map { tasks -> groupByDate(tasks) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private fun groupByDate(tasks: List<Task>): List<ScheduledGroup> {
val today = LocalDate.now()
val tomorrow = today.plusDays(1)
val endOfWeek = today.plusDays(7)
val buckets = linkedMapOf(
"Aujourd'hui" to mutableListOf<Task>(),
"Demain" to mutableListOf(),
"Cette semaine" to mutableListOf(),
"Plus tard" to mutableListOf(),
)
for (task in tasks) {
val date = runCatching { LocalDate.parse(task.dueDate?.date ?: "") }.getOrNull() ?: continue
when {
date == today -> buckets["Aujourd'hui"]!!.add(task)
date == tomorrow -> buckets["Demain"]!!.add(task)
date <= endOfWeek -> buckets["Cette semaine"]!!.add(task)
else -> buckets["Plus tard"]!!.add(task)
}
}
return buckets.entries
.filter { it.value.isNotEmpty() }
.map { ScheduledGroup(it.key, it.value) }
}
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
fun deleteTask(task: Task) {
viewModelScope.launch { taskRepository.deleteTask(task.id) }
}
}
@@ -0,0 +1,85 @@
package com.planify.mobile.ui.search
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.TaskRow
@Composable
fun SearchScreen(
onTaskClick: (Task) -> Unit,
viewModel: SearchViewModel = hiltViewModel(),
) {
val query by viewModel.query.collectAsState()
val results by viewModel.results.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::setQuery,
placeholder = { Text("Rechercher des tâches…") },
leadingIcon = { Icon(Icons.Outlined.Search, contentDescription = null) },
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = { viewModel.setQuery("") }) {
Icon(Icons.Outlined.Close, contentDescription = "Effacer")
}
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
)
when {
query.length < 2 -> Text(
text = "Saisissez au moins 2 caractères",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
results.isEmpty() -> Text(
text = "Aucun résultat pour « $query »",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp),
)
else -> LazyColumn {
item {
Text(
text = "${results.size} résultat(s)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
items(results, key = { it.id }) { task ->
TaskRow(
task = task,
onClick = { onTaskClick(task) },
onCheckedChange = { viewModel.toggleTask(task) },
)
}
}
}
}
}
@@ -0,0 +1,40 @@
package com.planify.mobile.ui.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Task
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SearchViewModel @Inject constructor(
private val taskRepository: TaskRepository,
) : ViewModel() {
val query = MutableStateFlow("")
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
val results = query
.debounce(300)
.flatMapLatest { q ->
if (q.length < 2) flowOf(emptyList())
else taskRepository.searchTasks(q)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setQuery(q: String) { query.value = q }
fun toggleTask(task: Task) {
viewModelScope.launch { taskRepository.checkTask(task.id, !task.checked) }
}
}
@@ -0,0 +1,158 @@
package com.planify.mobile.ui.settings
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.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.ui.auth.AuthViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
authViewModel: AuthViewModel,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
// ── Apparence ────────────────────────────────────────────────────────
SectionTitle("Apparence")
ListItem(
headlineContent = { Text("Thème") },
supportingContent = {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
ThemeMode.entries.forEachIndexed { index, mode ->
SegmentedButton(
selected = state.themeMode == mode,
onClick = { viewModel.setTheme(mode) },
shape = SegmentedButtonDefaults.itemShape(index, ThemeMode.entries.size),
label = { Text(mode.label) },
)
}
}
},
)
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Bonsai ───────────────────────────────────────────────────────────
SectionTitle("Bonsai")
if (state.isLoggedIn) {
ListItem(
leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
headlineContent = { Text("Connecté") },
supportingContent = { Text(state.username) },
trailingContent = {
if (state.syncInProgress) {
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
} else {
IconButton(onClick = viewModel::syncNow) {
Icon(Icons.Outlined.Sync, contentDescription = "Synchroniser")
}
}
},
)
if (state.syncSuccess) {
ListItem(
leadingContent = {
Icon(
Icons.Outlined.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
headlineContent = {
Text("Synchronisation réussie", color = MaterialTheme.colorScheme.primary)
},
)
}
state.syncError?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
OutlinedButton(
onClick = authViewModel::logout,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Text("Se déconnecter")
}
} else {
ListItem(
leadingContent = { Icon(Icons.Outlined.AccountCircle, null) },
headlineContent = { Text("Non connecté") },
supportingContent = { Text("Relancez l'application pour vous connecter") },
)
}
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Notifications ────────────────────────────────────────────────────
SectionTitle("Notifications")
ListItem(
headlineContent = { Text("Rappels activés") },
trailingContent = {
Switch(
checked = state.notificationsEnabled,
onCheckedChange = viewModel::setNotificationsEnabled,
)
},
)
Spacer(Modifier.height(32.dp))
}
}
@Composable
private fun SectionTitle(text: String) {
Text(
text = text,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@@ -0,0 +1,75 @@
package com.planify.mobile.ui.settings
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.SyncResult
import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val notificationsEnabled: Boolean = true,
val isLoggedIn: Boolean = false,
val username: String = "",
val syncInProgress: Boolean = false,
val syncError: String? = null,
val syncSuccess: Boolean = false,
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val prefs: AppPreferences,
private val authManager: BonsaiAuthManager,
private val syncManager: BonsaiSyncManager,
) : ViewModel() {
private val _extra = MutableStateFlow(
SettingsUiState(
isLoggedIn = authManager.isLoggedIn,
username = authManager.getUsername(),
)
)
val uiState = combine(
prefs.themeMode,
prefs.notificationsEnabled,
authManager.isAuthenticated,
_extra,
) { theme, notifs, isAuth, extra ->
extra.copy(
themeMode = theme,
notificationsEnabled = notifs,
isLoggedIn = isAuth,
username = if (isAuth) authManager.getUsername() else "",
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setNotificationsEnabled(enabled)
}
fun syncNow() {
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
viewModelScope.launch {
when (val result = syncManager.sync()) {
is SyncResult.Success -> _extra.update { it.copy(syncInProgress = false, syncSuccess = true) }
is SyncResult.NotLoggedIn -> _extra.update { it.copy(syncInProgress = false, syncError = "Non connecté") }
is SyncResult.Failure -> _extra.update { it.copy(syncInProgress = false, syncError = result.message) }
}
}
}
fun clearSyncFeedback() = _extra.update { it.copy(syncSuccess = false, syncError = null) }
}
@@ -34,6 +34,7 @@ import com.planify.mobile.domain.model.DueDate
import java.time.Instant import java.time.Instant
import java.time.LocalDate import java.time.LocalDate
import java.time.ZoneId import java.time.ZoneId
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -46,7 +47,7 @@ fun DueDatePickerSheet(
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val datePickerState = rememberDatePickerState( val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = currentDueDate?.date initialSelectedDateMillis = currentDueDate?.date
?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneId.UTC).toInstant().toEpochMilli() }.getOrNull() } ?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() }.getOrNull() }
) )
var showRecurrence by remember { mutableStateOf(false) } var showRecurrence by remember { mutableStateOf(false) }
@@ -103,7 +104,7 @@ fun DueDatePickerSheet(
TextButton(onClick = { TextButton(onClick = {
val millis = datePickerState.selectedDateMillis val millis = datePickerState.selectedDateMillis
val date = millis?.let { val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString() Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
} ?: return@TextButton } ?: return@TextButton
onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false, onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false,
recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE)) recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE))
@@ -119,7 +120,7 @@ fun DueDatePickerSheet(
showRecurrence = false showRecurrence = false
val millis = datePickerState.selectedDateMillis val millis = datePickerState.selectedDateMillis
val date = millis?.let { val date = millis?.let {
Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString() Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate().toString()
} ?: currentDueDate?.date ?: LocalDate.now().toString() } ?: currentDueDate?.date ?: LocalDate.now().toString()
onConfirm(recDueDate?.copy(date = date)) onConfirm(recDueDate?.copy(date = date))
}, },
@@ -28,31 +28,44 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.compose.runtime.LaunchedEffect
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.domain.model.Label import com.planify.mobile.domain.model.Label
import com.planify.mobile.domain.repository.LabelRepository import com.planify.mobile.domain.repository.LabelRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LabelPickerViewModel @Inject constructor( class LabelPickerViewModel @Inject constructor(
labelRepository: LabelRepository, private val labelRepository: LabelRepository,
) : ViewModel() { ) : ViewModel() {
val labels = labelRepository.getAllLabels() private val _projectId = MutableStateFlow("")
val labels = _projectId
.flatMapLatest { projectId ->
if (projectId.isNotBlank()) labelRepository.getLabelsByProject(projectId)
else labelRepository.getAllLabels()
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
fun setProjectId(projectId: String) { _projectId.value = projectId }
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LabelPickerSheet( fun LabelPickerSheet(
projectId: String,
selectedLabels: List<String>, selectedLabels: List<String>,
onConfirm: (List<String>) -> Unit, onConfirm: (List<String>) -> Unit,
onDismiss: () -> Unit, onDismiss: () -> Unit,
viewModel: LabelPickerViewModel = hiltViewModel(), viewModel: LabelPickerViewModel = hiltViewModel(),
) { ) {
LaunchedEffect(projectId) { viewModel.setProjectId(projectId) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val labels by viewModel.labels.collectAsState() val labels by viewModel.labels.collectAsState()
var selected by remember { mutableStateOf(selectedLabels.toSet()) } var selected by remember { mutableStateOf(selectedLabels.toSet()) }
@@ -26,6 +26,7 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@@ -150,6 +151,15 @@ fun TaskEditSheet(
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
state.saveError?.let { error ->
Text(
text = error,
color = Color.Red,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(vertical = 4.dp),
)
}
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -184,6 +194,7 @@ fun TaskEditSheet(
} }
if (showLabelPicker) { if (showLabelPicker) {
LabelPickerSheet( LabelPickerSheet(
projectId = projectId,
selectedLabels = state.labels, selectedLabels = state.labels,
onConfirm = { viewModel.setLabels(it); showLabelPicker = false }, onConfirm = { viewModel.setLabels(it); showLabelPicker = false },
onDismiss = { showLabelPicker = false }, onDismiss = { showLabelPicker = false },
@@ -2,6 +2,12 @@ package com.planify.mobile.ui.task
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.ApiResult
import com.planify.mobile.data.bonsai.BonsaiApiClient
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.dto.BonsaIssueRequest
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.data.notification.ReminderScheduler import com.planify.mobile.data.notification.ReminderScheduler
import com.planify.mobile.domain.model.DueDate import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.Reminder import com.planify.mobile.domain.model.Reminder
@@ -32,6 +38,7 @@ data class TaskEditState(
val reminders: List<Reminder> = emptyList(), val reminders: List<Reminder> = emptyList(),
val subTasks: List<Task> = emptyList(), val subTasks: List<Task> = emptyList(),
val isSaving: Boolean = false, val isSaving: Boolean = false,
val saveError: String? = null,
) )
@HiltViewModel @HiltViewModel
@@ -39,6 +46,10 @@ class TaskEditViewModel @Inject constructor(
private val taskRepository: TaskRepository, private val taskRepository: TaskRepository,
private val reminderRepository: ReminderRepository, private val reminderRepository: ReminderRepository,
private val reminderScheduler: ReminderScheduler, private val reminderScheduler: ReminderScheduler,
private val apiClient: BonsaiApiClient,
private val authManager: BonsaiAuthManager,
private val syncManager: BonsaiSyncManager,
private val projectRepository: ProjectRepository,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(TaskEditState()) private val _state = MutableStateFlow(TaskEditState())
@@ -50,8 +61,7 @@ class TaskEditViewModel @Inject constructor(
val task = taskRepository.getTaskById(taskId) ?: return@launch val task = taskRepository.getTaskById(taskId) ?: return@launch
val subTasks = taskRepository.getSubTasks(taskId).first() val subTasks = taskRepository.getSubTasks(taskId).first()
val reminders = reminderRepository.getRemindersByTask(taskId).first() val reminders = reminderRepository.getRemindersByTask(taskId).first()
_state.update { _state.value = TaskEditState(
it.copy(
taskId = taskId, taskId = taskId,
projectId = task.projectId, projectId = task.projectId,
sectionId = task.sectionId, sectionId = task.sectionId,
@@ -65,9 +75,8 @@ class TaskEditViewModel @Inject constructor(
subTasks = subTasks, subTasks = subTasks,
) )
} }
}
} else { } else {
_state.update { it.copy(projectId = projectId, sectionId = sectionId, parentId = parentId) } _state.value = TaskEditState(projectId = projectId, sectionId = sectionId, parentId = parentId)
} }
} }
@@ -102,12 +111,70 @@ class TaskEditViewModel @Inject constructor(
fun save(onDone: () -> Unit) { fun save(onDone: () -> Unit) {
val st = _state.value val st = _state.value
if (st.content.isBlank()) return if (st.content.isBlank() || st.projectId.isBlank()) return
_state.update { it.copy(isSaving = true) } _state.update { it.copy(isSaving = true, saveError = null) }
viewModelScope.launch { viewModelScope.launch {
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val id = st.taskId ?: UUID.randomUUID().toString()
val request = BonsaIssueRequest(
name = st.content,
priority = BonsaiSyncManager.toBonsaiPriority(st.priority),
status = BonsaiSyncManager.toBonsaiStatus(st.dueDate == null && false),
dueDate = st.dueDate?.date,
description = st.description.ifBlank { null },
)
val projectIdLong = st.projectId.toLongOrNull()
val taskIdLong = st.taskId?.toLongOrNull()
if (authManager.isLoggedIn && projectIdLong != null) {
// Refresh token if expired before calling API
val tokenOk = authManager.refreshIfNeeded()
if (!tokenOk) {
_state.update { it.copy(isSaving = false, saveError = "Session expirée, veuillez vous reconnecter") }
return@launch
}
val apiResult = if (taskIdLong != null) {
apiClient.updateIssue(projectIdLong, taskIdLong, request)
} else {
apiClient.createIssue(projectIdLong, request)
}
when (apiResult) {
is ApiResult.Success -> {
val issue = apiResult.data
val task = Task(
id = issue.id.toString(),
content = issue.name,
description = issue.description ?: "",
projectId = issue.projectId.toString(),
priority = BonsaiSyncManager.mapPriority(issue.priority),
checked = issue.status == "done",
dueDate = issue.dueDate?.let { DueDate(date = it) },
labels = st.labels,
addedAt = now,
updatedAt = now,
)
// Always insert/update locally — FK constraint removed, no crash risk.
if (st.taskId == null) taskRepository.insertTask(task)
else taskRepository.updateTask(task)
saveReminders(task.id, st, task)
// If the project isn't in Room yet, sync to pull it.
if (projectRepository.getProjectById(task.projectId) == null) {
viewModelScope.launch { syncManager.sync() }
}
_state.update { it.copy(isSaving = false) }
onDone()
}
is ApiResult.Failure -> {
_state.update { it.copy(isSaving = false, saveError = apiResult.message) }
}
}
} else {
// Local save (not connected to Bonsai)
val id = st.taskId ?: UUID.randomUUID().toString()
val task = Task( val task = Task(
id = id, id = id,
content = st.content, content = st.content,
@@ -121,37 +188,21 @@ class TaskEditViewModel @Inject constructor(
addedAt = if (st.taskId == null) now else "", addedAt = if (st.taskId == null) now else "",
updatedAt = now, updatedAt = now,
) )
if (st.taskId == null) taskRepository.insertTask(task) if (st.taskId == null) taskRepository.insertTask(task)
else taskRepository.updateTask(task) else taskRepository.updateTask(task)
saveReminders(id, st, task)
// Sub-tasks: delete removed ones, then upsert remaining
if (st.taskId != null) {
val existingIds = taskRepository.getSubTasks(id).first().map { it.id }.toSet()
val currentIds = st.subTasks.map { it.id }.toSet()
(existingIds - currentIds).forEach { taskRepository.deleteTask(it) }
}
st.subTasks.forEach { sub ->
val actualSub = sub.copy(
parentId = id,
projectId = st.projectId,
addedAt = if (sub.addedAt.isBlank()) now else sub.addedAt,
updatedAt = now,
)
if (taskRepository.getTaskById(sub.id) != null) taskRepository.updateTask(actualSub)
else taskRepository.insertTask(actualSub)
}
// Reminders: replace all, reschedule
reminderRepository.deleteRemindersByTask(id)
st.reminders.forEach { reminder ->
val actual = reminder.copy(taskId = id)
reminderRepository.insertReminder(actual)
reminderScheduler.schedule(actual, task.content, task.dueDate?.date)
}
_state.update { it.copy(isSaving = false) } _state.update { it.copy(isSaving = false) }
onDone() onDone()
} }
} }
}
private suspend fun saveReminders(taskId: String, st: TaskEditState, task: Task) {
reminderRepository.deleteRemindersByTask(taskId)
st.reminders.forEach { reminder ->
val actual = reminder.copy(taskId = taskId)
reminderRepository.insertReminder(actual)
reminderScheduler.schedule(actual, task.content, task.dueDate?.date)
}
}
} }
@@ -1,36 +1,133 @@
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.ui.platform.LocalContext import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.graphics.Color
import androidx.hilt.navigation.compose.hiltViewModel
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(
darkTheme: Boolean = isSystemInDarkTheme(), viewModel: ThemeViewModel = hiltViewModel(),
dynamicColor: Boolean = true, content: @Composable () -> Unit,
content: @Composable () -> Unit
) { ) {
val colorScheme = when { val themeMode by viewModel.themeMode.collectAsState()
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val systemDark = isSystemInDarkTheme()
val context = LocalContext.current val isDark = when (themeMode) {
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) ThemeMode.DARK -> true
} ThemeMode.LIGHT -> false
darkTheme -> DarkColorScheme ThemeMode.SYSTEM -> systemDark
else -> LightColorScheme
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = if (isDark) BonsaiDarkColorScheme else BonsaiLightColorScheme,
typography = Typography, typography = Typography,
content = content content = content,
) )
} }
@@ -0,0 +1,19 @@
package com.planify.mobile.ui.theme
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class ThemeViewModel @Inject constructor(prefs: AppPreferences) : ViewModel() {
val themeMode = prefs.themeMode.stateIn(
viewModelScope,
SharingStarted.Eagerly,
ThemeMode.SYSTEM,
)
}
@@ -6,10 +6,88 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
val Typography = Typography( val Typography = Typography(
displayLarge = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp,
),
displayMedium = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = 45.sp,
lineHeight = 52.sp,
),
displaySmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 36.sp,
lineHeight = 44.sp,
),
headlineLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp,
),
headlineMedium = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
),
headlineSmall = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
),
titleLarge = TextStyle(
fontWeight = FontWeight.SemiBold,
fontSize = 20.sp,
lineHeight = 28.sp,
),
titleMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp letterSpacing = 0.5.sp,
) ),
bodyMedium = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp,
),
bodySmall = TextStyle(
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp,
),
labelLarge = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp,
),
labelMedium = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
labelSmall = TextStyle(
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp,
),
) )
@@ -1,27 +1,54 @@
package com.planify.mobile.ui.today package com.planify.mobile.ui.today
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Today import androidx.compose.material.icons.outlined.Today
import androidx.compose.material3.HorizontalDivider import androidx.compose.foundation.BorderStroke
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.domain.model.Task import com.planify.mobile.domain.model.Task
import com.planify.mobile.ui.components.EmptyState import com.planify.mobile.ui.components.EmptyState
import com.planify.mobile.ui.components.SectionHeader
import com.planify.mobile.ui.components.TaskRow import com.planify.mobile.ui.components.TaskRow
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale
@Composable @Composable
fun TodayScreen( fun TodayScreen(
@@ -32,30 +59,37 @@ fun TodayScreen(
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val collapsedSections = remember { mutableStateOf(setOf<String>()) } val collapsedSections = remember { mutableStateOf(setOf<String>()) }
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentPadding = PaddingValues(bottom = 96.dp),
) {
item { TodayHeader() }
if (state.totalCount > 0) {
item {
HeroCard(
doneCount = state.doneCount,
totalCount = state.totalCount,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp),
)
}
}
if (state.totalCount == 0 && !state.isLoading) { if (state.totalCount == 0 && !state.isLoading) {
item {
EmptyState( EmptyState(
icon = Icons.Outlined.Today, icon = Icons.Outlined.Today,
title = "Rien pour aujourd'hui", title = "Rien pour aujourd'hui",
subtitle = "Profitez de votre journée !", subtitle = "Profitez de votre journée !",
modifier = modifier,
) )
return }
return@LazyColumn
} }
LazyColumn(modifier = modifier.fillMaxSize()) {
if (state.overdueTasks.isNotEmpty()) { if (state.overdueTasks.isNotEmpty()) {
item { item { SectionLabel(name = "En retard", count = state.overdueTasks.size) }
SectionHeader(
name = "En retard",
taskCount = state.overdueTasks.size,
collapsed = "overdue" in collapsedSections.value,
onToggleCollapse = {
collapsedSections.value = collapsedSections.value.toggle("overdue")
},
onAddTask = {},
)
}
if ("overdue" !in collapsedSections.value) {
items(state.overdueTasks, key = { "overdue_${it.id}" }) { task -> items(state.overdueTasks, key = { "overdue_${it.id}" }) { task ->
TaskRow( TaskRow(
task = task, task = task,
@@ -64,22 +98,11 @@ fun TodayScreen(
) )
} }
} }
item { HorizontalDivider(Modifier.padding(horizontal = 16.dp)) }
}
state.tasksByProject.forEach { (projectName, tasks) -> state.tasksByProject.forEach { (projectName, tasks) ->
item(key = "header_$projectName") { item(key = "header_$projectName") {
SectionHeader( SectionLabel(name = projectName, count = tasks.size)
name = projectName,
taskCount = tasks.size,
collapsed = projectName in collapsedSections.value,
onToggleCollapse = {
collapsedSections.value = collapsedSections.value.toggle(projectName)
},
onAddTask = {},
)
} }
if (projectName !in collapsedSections.value) {
items(tasks, key = { it.id }) { task -> items(tasks, key = { it.id }) { task ->
TaskRow( TaskRow(
task = task, task = task,
@@ -89,8 +112,171 @@ fun TodayScreen(
} }
} }
} }
}
@Composable
private fun TodayHeader() {
val today = LocalDate.now()
val dayFormatter = DateTimeFormatter.ofPattern("EEEE d MMMM", Locale.FRENCH)
val dateStr = today.format(dayFormatter).replaceFirstChar { it.uppercaseChar() }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(34.dp)
.background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(10.dp)),
contentAlignment = Alignment.Center,
) {
Text(text = "🌿", style = MaterialTheme.typography.bodyLarge)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = dateStr,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.4.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "Aujourd'hui",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onBackground,
)
}
Box(
modifier = Modifier
.size(38.dp)
.border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
.background(MaterialTheme.colorScheme.surface, CircleShape)
.clip(CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Outlined.Search,
contentDescription = "Recherche",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
} }
} }
private fun Set<String>.toggle(key: String) = @Composable
if (contains(key)) minus(key) else plus(key) private fun HeroCard(doneCount: Int, totalCount: Int, modifier: Modifier = Modifier) {
val progress = if (totalCount == 0) 0f else doneCount.toFloat() / totalCount
val remaining = totalCount - doneCount
val subtitle = when {
totalCount == 0 -> "Journée libre !"
doneCount == totalCount -> "Toutes les tâches sont faites !"
progress >= 0.5f -> "Tu y es presque !"
doneCount > 0 -> "${doneCount} faite${if (doneCount > 1) "s" else ""}. Encore $remaining pour boucler la journée."
else -> "$remaining tâche${if (remaining > 1) "s" else ""} pour aujourd'hui."
}
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
tonalElevation = 0.dp,
) {
Row(
modifier = Modifier.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
HeroRing(progress = progress, doneCount = doneCount, totalCount = totalCount)
Column {
Text(
text = if (doneCount == totalCount && totalCount > 0)
"Journée bouclée !" else "Ta journée avance bien",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(4.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun HeroRing(progress: Float, doneCount: Int, totalCount: Int) {
val primaryColor = MaterialTheme.colorScheme.primary
val trackColor = MaterialTheme.colorScheme.surfaceVariant
Box(modifier = Modifier.size(74.dp), contentAlignment = Alignment.Center) {
Canvas(modifier = Modifier.size(74.dp)) {
val sw = 7.dp.toPx()
val diameter = this.size.minDimension - sw
val tl = Offset(sw / 2, sw / 2)
val arcSize = Size(diameter, diameter)
drawArc(
color = trackColor, startAngle = -90f, sweepAngle = 360f,
useCenter = false, style = Stroke(width = sw, cap = StrokeCap.Round),
topLeft = tl, size = arcSize,
)
if (progress > 0f) {
drawArc(
color = primaryColor, startAngle = -90f, sweepAngle = progress * 360f,
useCenter = false, style = Stroke(width = sw, cap = StrokeCap.Round),
topLeft = tl, size = arcSize,
)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (totalCount == 0) "0" else "$doneCount/$totalCount",
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = "faites",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun SectionLabel(name: String, count: Int) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 18.dp, end = 18.dp, top = 18.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = name.uppercase(),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.ExtraBold,
letterSpacing = 1.sp,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(20.dp))
.padding(horizontal = 8.dp, vertical = 2.dp),
) {
Text(
text = "$count",
style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -16,6 +16,7 @@ data class TodayUiState(
val tasksByProject: Map<String, List<Task>> = emptyMap(), val tasksByProject: Map<String, List<Task>> = emptyMap(),
val overdueTasks: List<Task> = emptyList(), val overdueTasks: List<Task> = emptyList(),
val totalCount: Int = 0, val totalCount: Int = 0,
val doneCount: Int = 0,
val isLoading: Boolean = false, val isLoading: Boolean = false,
) )
@@ -28,15 +29,18 @@ class TodayViewModel @Inject constructor(
val uiState = combine( val uiState = combine(
taskRepository.getTodayTasks(), taskRepository.getTodayTasks(),
taskRepository.getOverdueTasks(), taskRepository.getOverdueTasks(),
taskRepository.getDoneTodayCount(),
projectRepository.getAllProjects(), projectRepository.getAllProjects(),
) { today, overdue, projects -> ) { today, overdue, done, projects ->
val projectMap = projects.associateBy { it.id } val projectMap = projects.associateBy { it.id }
val grouped = today val grouped = today
.groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId } .groupBy { task -> projectMap[task.projectId]?.name ?: task.projectId }
TodayUiState( TodayUiState(
tasksByProject = grouped, tasksByProject = grouped,
overdueTasks = overdue, overdueTasks = overdue,
totalCount = today.size + overdue.size, totalCount = today.size + overdue.size + done,
doneCount = done,
isLoading = false,
) )
}.stateIn( }.stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Viewport: 108x108
Safe zone: 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>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="exports" path="exports/" />
</paths>
+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" "$@"