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>
This commit is contained in:
2026-06-06 08:46:35 +02:00
parent 93a26722d8
commit 47808b2255
14 changed files with 757 additions and 483 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "0.0.4" versionName = "0.0.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -0,0 +1,161 @@
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 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.getLong("projectId"),
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()
}
suspend fun updateIssue(projectId: Long, issueId: Long, req: BonsaIssueRequest): ApiResult<BonsaiIssueDto> =
put("projects/$projectId/issues/$issueId", req.toJson()) { o ->
o.toIssueDto()
}
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()
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(JSONObject(body)))
}
}.getOrElse { ApiResult.Failure(it.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() = BonsaiIssueDto(
id = getLong("id"),
projectId = getLong("projectId"),
type = getString("type"),
name = getString("name"),
status = getString("status"),
priority = getString("priority"),
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,98 @@
package com.planify.mobile.data.bonsai
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
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 by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
"bonsai_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
val isLoggedIn: Boolean get() = prefs.getString(KEY_TOKEN, null) != null
fun getApiBaseUrl(): String = prefs.getString(KEY_API_URL, DEFAULT_API_URL) ?: DEFAULT_API_URL
fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: ""
fun getAuthHeader(): String? = prefs.getString(KEY_TOKEN, null)?.let { "Bearer $it" }
suspend fun login(apiUrl: String, username: String, password: String): LoginResult = withContext(Dispatchers.IO) {
val cleanUrl = apiUrl.trimEnd('/')
val tokenUrl = "$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/token"
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(tokenUrl)
.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("HTTP ${response.code}: $detail")
}
val json = JSONObject(raw)
val token = json.getString("access_token")
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_API_URL, cleanUrl)
.putString(KEY_USERNAME, username)
.apply()
LoginResult.Success
}
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
}
fun logout() {
prefs.edit()
.remove(KEY_TOKEN)
.remove(KEY_USERNAME)
.apply()
}
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-webapp"
private const val KEY_TOKEN = "access_token"
private const val KEY_API_URL = "api_url"
private const val KEY_USERNAME = "username"
}
}
@@ -0,0 +1,135 @@
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 {
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 ->
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)
}
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(),
)
@@ -3,4 +3,4 @@ package com.planify.mobile.domain.model
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
enum class BackendType { LOCAL, CALDAV, TODOIST } enum class BackendType { LOCAL, CALDAV, TODOIST, BONSAI }
@@ -1,9 +1,7 @@
package com.planify.mobile.ui.settings package com.planify.mobile.ui.settings
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
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.height
@@ -12,21 +10,17 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.Sync import androidx.compose.material.icons.outlined.Sync
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButton
@@ -34,22 +28,18 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.content.Intent
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.domain.model.Source
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -57,24 +47,6 @@ fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state by viewModel.uiState.collectAsState() val state by viewModel.uiState.collectAsState()
val discovery by viewModel.discoveryInProgress.collectAsState()
val exportUri by viewModel.exportUri.collectAsState()
var showAddAccount by remember { mutableStateOf(false) }
var editingSource by remember { mutableStateOf<com.planify.mobile.domain.model.Source?>(null) }
val context = LocalContext.current
LaunchedEffect(exportUri) {
exportUri?.let { uri ->
val mime = if (uri.path?.endsWith(".ics") == true) "text/calendar" else "application/json"
val intent = Intent(Intent.ACTION_SEND).apply {
type = mime
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Exporter"))
viewModel.clearExportUri()
}
}
Column( Column(
modifier = Modifier modifier = Modifier
@@ -102,44 +74,66 @@ fun SettingsScreen(
HorizontalDivider(Modifier.padding(horizontal = 16.dp)) HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Synchronisation ───────────────────────────────────────────────── // ── Bonsai ──────────────────────────────────────────────────────────
SectionTitle("Synchronisation") SectionTitle("Bonsai")
if (state.isLoggedIn) {
ListItem( ListItem(
headlineContent = { Text("Sync automatique") }, leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
trailingContent = { headlineContent = { Text("Connecté") },
Switch( supportingContent = { Text(state.username) },
checked = state.syncEnabled,
onCheckedChange = viewModel::setSyncEnabled,
)
},
)
if (state.syncEnabled) {
ListItem(
headlineContent = { Text("Intervalle") },
supportingContent = {
val options = listOf(15 to "15 min", 30 to "30 min", 60 to "1 h", 240 to "4 h")
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, (mins, label) ->
SegmentedButton(
selected = state.syncIntervalMinutes == mins,
onClick = { viewModel.setSyncInterval(mins) },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
label = { Text(label) },
)
}
}
},
)
}
ListItem(
headlineContent = { Text("Synchroniser maintenant") },
trailingContent = { trailingContent = {
if (state.syncInProgress) {
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
} else {
IconButton(onClick = viewModel::syncNow) { IconButton(onClick = viewModel::syncNow) {
Icon(Icons.Outlined.Sync, contentDescription = "Sync") Icon(Icons.Outlined.Sync, contentDescription = "Synchroniser")
}
} }
}, },
) )
state.syncSuccess && run {
ListItem(
leadingContent = {
Icon(
Icons.Outlined.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
},
headlineContent = { Text("Synchronisation réussie", color = MaterialTheme.colorScheme.primary) },
)
true
}
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 = viewModel::logout,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Text("Se déconnecter")
}
} else {
BonsaiLoginForm(
initialUrl = state.apiUrl,
isLoading = state.loginInProgress,
error = state.loginError,
onLogin = viewModel::login,
)
}
HorizontalDivider(Modifier.padding(horizontal = 16.dp)) HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Notifications ──────────────────────────────────────────────────── // ── Notifications ────────────────────────────────────────────────────
@@ -154,220 +148,26 @@ fun SettingsScreen(
}, },
) )
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Comptes CalDAV ────────────────────────────────────────────────────
SectionTitle("Comptes CalDAV")
state.caldavSources.forEach { source ->
CalDavSourceRow(
source = source,
onEdit = { editingSource = source },
onDelete = { viewModel.removeCalDavAccount(source) },
)
}
ListItem(
headlineContent = { Text("Ajouter un compte") },
leadingContent = { Icon(Icons.Outlined.Add, contentDescription = null) },
modifier = Modifier
.fillMaxWidth()
.let { mod ->
mod.then(
Modifier.padding(0.dp).run {
this
}
)
},
trailingContent = null,
)
Button(
onClick = { showAddAccount = true },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Icon(Icons.Outlined.Add, contentDescription = null)
Text("Ajouter un compte CalDAV", modifier = Modifier.padding(start = 8.dp))
}
if (discovery.first) {
ListItem(headlineContent = { Text("Connexion en cours…") })
}
discovery.second?.let { error ->
Text(
text = "Connexion échouée — le compte a été ajouté sans synchronisation : $error",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Export & Backup ──────────────────────────────────────────────────
SectionTitle("Export & Backup")
OutlinedButton(
onClick = viewModel::exportJson,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Icon(Icons.Outlined.Download, contentDescription = null)
Text("Exporter en JSON", modifier = Modifier.padding(start = 8.dp))
}
OutlinedButton(
onClick = viewModel::exportIcal,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Icon(Icons.Outlined.Download, contentDescription = null)
Text("Exporter en iCalendar (.ics)", modifier = Modifier.padding(start = 8.dp))
}
Spacer(Modifier.height(32.dp)) Spacer(Modifier.height(32.dp))
} }
if (showAddAccount) {
AddCalDavAccountDialog(
onDismiss = { showAddAccount = false },
onConfirm = { url, user, pwd ->
viewModel.addCalDavAccount(url, user, pwd)
showAddAccount = false
},
)
}
editingSource?.let { source ->
EditCalDavAccountDialog(
source = source,
onDismiss = { editingSource = null },
onConfirm = { url, user, pwd ->
viewModel.updateCalDavAccount(source, url, user, pwd)
editingSource = null
},
)
}
} }
@Composable @Composable
private fun SectionTitle(text: String) { private fun BonsaiLoginForm(
Text( initialUrl: String,
text = text, isLoading: Boolean,
style = MaterialTheme.typography.labelMedium, error: String?,
color = MaterialTheme.colorScheme.primary, onLogin: (url: String, username: String, password: String) -> Unit,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
@Composable
private fun CalDavSourceRow(source: Source, onEdit: () -> Unit, onDelete: () -> Unit) {
val connectionFailed = source.caldavData?.calendarHomeUrl == null
ListItem(
leadingContent = {
if (connectionFailed) {
Icon(Icons.Outlined.Warning, contentDescription = "Connexion échouée", tint = MaterialTheme.colorScheme.error)
} else {
Icon(Icons.Outlined.AccountCircle, contentDescription = null)
}
},
headlineContent = { Text(source.displayName) },
supportingContent = {
Column {
Text(source.caldavData?.serverUrl ?: "")
if (connectionFailed) {
Text(
text = "Non connecté",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelSmall,
)
}
}
},
trailingContent = {
Row {
IconButton(onClick = onEdit) {
Icon(Icons.Outlined.Edit, contentDescription = "Modifier")
}
Spacer(Modifier.width(4.dp))
IconButton(onClick = onDelete) {
Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = MaterialTheme.colorScheme.error)
}
}
},
)
}
@Composable
private fun EditCalDavAccountDialog(
source: Source,
onDismiss: () -> Unit,
onConfirm: (url: String, username: String, password: String) -> Unit,
) { ) {
var url by remember { mutableStateOf(source.caldavData?.serverUrl ?: "") } var url by remember { mutableStateOf(initialUrl.ifBlank { BonsaiAuthManager.DEFAULT_API_URL }) }
var username by remember { mutableStateOf(source.caldavData?.username ?: "") }
var password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Modifier le compte CalDAV") },
text = {
Column {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL du serveur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Nom d'utilisateur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Nouveau mot de passe") },
placeholder = { Text("Laisser vide pour conserver") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
}
},
confirmButton = {
Button(
onClick = { onConfirm(url.trim(), username.trim(), password) },
enabled = url.isNotBlank() && username.isNotBlank(),
) { Text("Enregistrer") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
)
}
@Composable
private fun AddCalDavAccountDialog(
onDismiss: () -> Unit,
onConfirm: (url: String, username: String, password: String) -> Unit,
) {
var url by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") } var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
AlertDialog( Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
onDismissRequest = onDismiss,
title = { Text("Ajouter un compte CalDAV") },
text = {
Column {
OutlinedTextField( OutlinedTextField(
value = url, value = url,
onValueChange = { url = it }, onValueChange = { url = it },
label = { Text("URL du serveur") }, label = { Text("URL du serveur") },
placeholder = { Text("https://example.com/caldav") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
@@ -386,16 +186,39 @@ private fun AddCalDavAccountDialog(
label = { Text("Mot de passe") }, label = { Text("Mot de passe") },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(), visualTransformation = PasswordVisualTransformation(),
)
Spacer(Modifier.height(12.dp))
error?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 8.dp),
) )
} }
},
confirmButton = {
Button( Button(
onClick = { onConfirm(url.trim(), username.trim(), password) }, onClick = { onLogin(url.trim(), username.trim(), password) },
enabled = url.isNotBlank() && username.isNotBlank() && password.isNotBlank(), enabled = !isLoading && url.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
) { Text("Connecter") } modifier = Modifier.fillMaxWidth(),
}, ) {
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } }, if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 8.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
}
Text("Se connecter à Bonsai")
}
}
}
@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),
) )
} }
@@ -1,183 +1,99 @@
package com.planify.mobile.ui.settings package com.planify.mobile.ui.settings
import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.caldav.CalDavCredentialStore import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.caldav.CalDavDiscovery import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.caldav.DiscoveryResult import com.planify.mobile.data.bonsai.LoginResult
import com.planify.mobile.data.export.ExportManager import com.planify.mobile.data.bonsai.SyncResult
import com.planify.mobile.data.preferences.AppPreferences import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.data.sync.SyncScheduler
import com.planify.mobile.domain.model.Source
import com.planify.mobile.domain.model.SourceCalDavData
import com.planify.mobile.domain.model.SourceType
import com.planify.mobile.domain.repository.ProjectRepository
import com.planify.mobile.domain.repository.SourceRepository
import com.planify.mobile.domain.repository.TaskRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
data class SettingsUiState( data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM, val themeMode: ThemeMode = ThemeMode.SYSTEM,
val syncEnabled: Boolean = true,
val syncIntervalMinutes: Int = 30,
val notificationsEnabled: Boolean = true, val notificationsEnabled: Boolean = true,
val caldavSources: List<Source> = emptyList(), val isLoggedIn: Boolean = false,
val discoveryInProgress: Boolean = false, val username: String = "",
val discoveryError: String? = null, val apiUrl: String = BonsaiAuthManager.DEFAULT_API_URL,
val loginInProgress: Boolean = false,
val loginError: String? = null,
val syncInProgress: Boolean = false,
val syncError: String? = null,
val syncSuccess: Boolean = false,
) )
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val prefs: AppPreferences, private val prefs: AppPreferences,
private val sourceRepository: SourceRepository, private val authManager: BonsaiAuthManager,
private val syncScheduler: SyncScheduler, private val syncManager: BonsaiSyncManager,
private val discovery: CalDavDiscovery,
private val credentialStore: CalDavCredentialStore,
private val exportManager: ExportManager,
private val projectRepository: ProjectRepository,
private val taskRepository: TaskRepository,
) : ViewModel() { ) : ViewModel() {
val uiState = combine( private val _extra = MutableStateFlow(
prefs.themeMode,
prefs.syncEnabled,
prefs.syncIntervalMinutes,
prefs.notificationsEnabled,
sourceRepository.getAllSources(),
) { theme, sync, interval, notifs, sources ->
SettingsUiState( SettingsUiState(
themeMode = theme, isLoggedIn = authManager.isLoggedIn,
syncEnabled = sync, username = authManager.getUsername(),
syncIntervalMinutes = interval, apiUrl = authManager.getApiBaseUrl(),
notificationsEnabled = notifs,
caldavSources = sources.filter { it.type == com.planify.mobile.domain.model.SourceType.CALDAV },
) )
)
val uiState = combine(prefs.themeMode, prefs.notificationsEnabled, _extra) { theme, notifs, extra ->
extra.copy(themeMode = theme, notificationsEnabled = notifs)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
private val _discoveryState = MutableStateFlow<Pair<Boolean, String?>>(false to null)
val discoveryInProgress = _discoveryState.asStateFlow()
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) } fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setSyncEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setSyncEnabled(enabled)
if (enabled) syncScheduler.schedule(uiState.value.syncIntervalMinutes.toLong())
else syncScheduler.cancel()
}
fun setSyncInterval(minutes: Int) = viewModelScope.launch {
prefs.setSyncInterval(minutes)
if (uiState.value.syncEnabled) syncScheduler.schedule(minutes.toLong())
}
fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch { fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setNotificationsEnabled(enabled) prefs.setNotificationsEnabled(enabled)
} }
fun syncNow() { syncScheduler.syncNow() } fun login(apiUrl: String, username: String, password: String) {
_extra.update { it.copy(loginInProgress = true, loginError = null) }
fun addCalDavAccount(baseUrl: String, username: String, password: String) {
_discoveryState.update { true to null }
viewModelScope.launch { viewModelScope.launch {
when (val result = discovery.discover(baseUrl, username, password)) { when (val result = authManager.login(apiUrl, username, password)) {
is DiscoveryResult.Success -> { is LoginResult.Success -> {
result.sources.forEach { source -> _extra.update {
credentialStore.savePassword(source.id, password) it.copy(
sourceRepository.insertSource(source) loginInProgress = false,
} isLoggedIn = true,
_discoveryState.update { false to null } username = authManager.getUsername(),
if (uiState.value.syncEnabled) syncScheduler.schedule() apiUrl = authManager.getApiBaseUrl(),
}
is DiscoveryResult.Failure -> {
// Save the account anyway so the user keeps their credentials
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val sourceId = UUID.randomUUID().toString()
val fallback = Source(
id = sourceId,
type = SourceType.CALDAV,
displayName = username,
addedAt = now,
updatedAt = now,
caldavData = SourceCalDavData(
serverUrl = baseUrl,
username = username,
calendarHomeUrl = null,
),
) )
credentialStore.savePassword(sourceId, password) }
sourceRepository.insertSource(fallback) // Sync immediately after login
_discoveryState.update { false to result.message } syncNow()
}
is LoginResult.Failure -> {
_extra.update { it.copy(loginInProgress = false, loginError = result.message) }
} }
} }
} }
} }
fun updateCalDavAccount(source: Source, newUrl: String, newUsername: String, newPassword: String) { fun logout() {
_discoveryState.update { true to null } authManager.logout()
val effectivePassword = newPassword.ifBlank { credentialStore.getPassword(source.id) } _extra.update { it.copy(isLoggedIn = false, username = "", syncSuccess = false) }
}
fun syncNow() {
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
viewModelScope.launch { viewModelScope.launch {
when (val result = discovery.discover(newUrl, newUsername, effectivePassword)) { when (val result = syncManager.sync()) {
is DiscoveryResult.Success -> { is SyncResult.Success -> _extra.update { it.copy(syncInProgress = false, syncSuccess = true) }
credentialStore.deletePassword(source.id) is SyncResult.NotLoggedIn -> _extra.update { it.copy(syncInProgress = false, syncError = "Non connecté") }
sourceRepository.deleteSource(source.id) is SyncResult.Failure -> _extra.update { it.copy(syncInProgress = false, syncError = result.message) }
result.sources.forEach { s ->
credentialStore.savePassword(s.id, effectivePassword)
sourceRepository.insertSource(s)
}
_discoveryState.update { false to null }
if (uiState.value.syncEnabled) syncScheduler.schedule()
}
is DiscoveryResult.Failure -> {
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val updated = source.copy(
displayName = newUsername,
updatedAt = now,
caldavData = source.caldavData?.copy(
serverUrl = newUrl,
username = newUsername,
calendarHomeUrl = null,
),
)
credentialStore.savePassword(source.id, effectivePassword)
sourceRepository.updateSource(updated)
_discoveryState.update { false to result.message }
}
} }
} }
} }
fun removeCalDavAccount(source: Source) = viewModelScope.launch { fun clearSyncFeedback() = _extra.update { it.copy(syncSuccess = false, syncError = null) }
credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(source.id)
}
private val _exportUri = MutableStateFlow<Uri?>(null)
val exportUri = _exportUri.asStateFlow()
fun exportJson() = viewModelScope.launch {
val projects = projectRepository.getAllProjects().first()
val tasks = taskRepository.getAllTasks().first()
_exportUri.value = exportManager.exportJson(projects, tasks)
}
fun exportIcal() = viewModelScope.launch {
val tasks = taskRepository.getAllTasks().first()
_exportUri.value = exportManager.exportIcal(tasks)
}
fun clearExportUri() { _exportUri.value = null }
} }
@@ -2,6 +2,11 @@ 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.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 +37,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 +45,8 @@ 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,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(TaskEditState()) private val _state = MutableStateFlow(TaskEditState())
@@ -50,8 +58,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,7 +72,6 @@ class TaskEditViewModel @Inject constructor(
subTasks = subTasks, subTasks = subTasks,
) )
} }
}
} else { } else {
_state.value = TaskEditState(projectId = projectId, sectionId = sectionId, parentId = parentId) _state.value = TaskEditState(projectId = projectId, sectionId = sectionId, parentId = parentId)
} }
@@ -103,11 +109,57 @@ class TaskEditViewModel @Inject constructor(
fun save(onDone: () -> Unit) { fun save(onDone: () -> Unit) {
val st = _state.value val st = _state.value
if (st.content.isBlank() || st.projectId.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) {
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,
)
if (st.taskId == null) taskRepository.insertTask(task)
else taskRepository.updateTask(task)
saveReminders(task.id, st, task)
_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 +173,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)
}
}
}
@@ -0,0 +1,67 @@
<?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="48"
android:viewportHeight="56">
<!-- Pot (trapezoid) -->
<path
android:fillColor="#C05621"
android:pathData="M14,56 L16,49 L32,49 L34,56 Z"/>
<!-- Pot rim (rounded rect x=12 y=45 w=24 h=5 rx=2) -->
<path
android:fillColor="#9C4221"
android:pathData="M14,45 L34,45 A2,2 0 0,1 36,47 L36,48 A2,2 0 0,1 34,50 L14,50 A2,2 0 0,1 12,48 L12,47 A2,2 0 0,1 14,45 Z"/>
<!-- Trunk -->
<path
android:fillColor="#00000000"
android:strokeColor="#744210"
android:strokeWidth="4"
android:strokeLineCap="round"
android:pathData="M24,45 C24,39 22,33 20,27 C18,22 19,17 22,13"/>
<!-- Branch right -->
<path
android:fillColor="#00000000"
android:strokeColor="#744210"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:pathData="M21,28 C26,25 31,22 33,18"/>
<!-- Branch left -->
<path
android:fillColor="#00000000"
android:strokeColor="#744210"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M20,35 C15,32 11,28 10,24"/>
<!-- Foliage left (cx=10 cy=21 r=9) -->
<path
android:fillColor="#276749"
android:pathData="M1,21 a9,9 0 1,0 18,0 a9,9 0 1,0 -18,0"/>
<!-- Foliage right (cx=34 cy=16 r=10) -->
<path
android:fillColor="#276749"
android:pathData="M24,16 a10,10 0 1,0 20,0 a10,10 0 1,0 -20,0"/>
<!-- Foliage top center (cx=22 cy=11 r=11) -->
<path
android:fillColor="#2F855A"
android:pathData="M11,11 a11,11 0 1,0 22,0 a11,11 0 1,0 -22,0"/>
<!-- Foliage overlap highlight (cx=26 cy=17 r=8) -->
<path
android:fillColor="#38A169"
android:pathData="M18,17 a8,8 0 1,0 16,0 a8,8 0 1,0 -16,0"/>
<!-- Foliage small accent (cx=18 cy=16 r=6) -->
<path
android:fillColor="#48BB78"
android:pathData="M12,16 a6,6 0 1,0 12,0 a6,6 0 1,0 -12,0"/>
</vector>
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"> <shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" /> <solid android:color="#F0FFF4" />
</shape> </shape>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_bonsai_foreground" />
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" /> <foreground android:drawable="@drawable/ic_bonsai_foreground" />
</adaptive-icon> </adaptive-icon>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">BonsaiTask</string>
</resources>