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:
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">BonsaiTask</string>
|
||||||
|
</resources>
|
||||||
Reference in New Issue
Block a user