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
targetSdk = 35
versionCode = 1
versionName = "0.0.4"
versionName = "0.0.5"
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
@Serializable
enum class BackendType { LOCAL, CALDAV, TODOIST }
enum class BackendType { LOCAL, CALDAV, TODOIST, BONSAI }
@@ -1,9 +1,7 @@
package com.planify.mobile.ui.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -12,21 +10,17 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.CheckCircle
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.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SegmentedButton
@@ -34,22 +28,18 @@ import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import android.content.Intent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.domain.model.Source
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -57,24 +47,6 @@ fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsState()
val discovery by viewModel.discoveryInProgress.collectAsState()
val exportUri by viewModel.exportUri.collectAsState()
var showAddAccount by remember { mutableStateOf(false) }
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(
modifier = Modifier
@@ -102,43 +74,65 @@ fun SettingsScreen(
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
// ── Synchronisation ─────────────────────────────────────────────────
SectionTitle("Synchronisation")
ListItem(
headlineContent = { Text("Sync automatique") },
trailingContent = {
Switch(
checked = state.syncEnabled,
onCheckedChange = viewModel::setSyncEnabled,
)
},
)
if (state.syncEnabled) {
// ── Bonsai ──────────────────────────────────────────────────────────
SectionTitle("Bonsai")
if (state.isLoggedIn) {
ListItem(
headlineContent = { Text("Intervalle") },
supportingContent = {
val options = listOf(15 to "15 min", 30 to "30 min", 60 to "1 h", 240 to "4 h")
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
options.forEachIndexed { index, (mins, label) ->
SegmentedButton(
selected = state.syncIntervalMinutes == mins,
onClick = { viewModel.setSyncInterval(mins) },
shape = SegmentedButtonDefaults.itemShape(index, options.size),
label = { Text(label) },
)
leadingContent = { Icon(Icons.Outlined.AccountCircle, contentDescription = null) },
headlineContent = { Text("Connecté") },
supportingContent = { Text(state.username) },
trailingContent = {
if (state.syncInProgress) {
CircularProgressIndicator(modifier = Modifier.padding(8.dp))
} else {
IconButton(onClick = viewModel::syncNow) {
Icon(Icons.Outlined.Sync, contentDescription = "Synchroniser")
}
}
},
)
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,
)
}
ListItem(
headlineContent = { Text("Synchroniser maintenant") },
trailingContent = {
IconButton(onClick = viewModel::syncNow) {
Icon(Icons.Outlined.Sync, contentDescription = "Sync")
}
},
)
HorizontalDivider(Modifier.padding(horizontal = 16.dp))
@@ -154,98 +148,68 @@ 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))
}
}
if (showAddAccount) {
AddCalDavAccountDialog(
onDismiss = { showAddAccount = false },
onConfirm = { url, user, pwd ->
viewModel.addCalDavAccount(url, user, pwd)
showAddAccount = false
},
)
}
@Composable
private fun BonsaiLoginForm(
initialUrl: String,
isLoading: Boolean,
error: String?,
onLogin: (url: String, username: String, password: String) -> Unit,
) {
var url by remember { mutableStateOf(initialUrl.ifBlank { BonsaiAuthManager.DEFAULT_API_URL }) }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
editingSource?.let { source ->
EditCalDavAccountDialog(
source = source,
onDismiss = { editingSource = null },
onConfirm = { url, user, pwd ->
viewModel.updateCalDavAccount(source, url, user, pwd)
editingSource = null
},
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
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("Mot de passe") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
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),
)
}
Button(
onClick = { onLogin(url.trim(), username.trim(), password) },
enabled = !isLoading && url.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 8.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
}
Text("Se connecter à Bonsai")
}
}
}
@@ -258,144 +222,3 @@ private fun SectionTitle(text: String) {
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 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 password by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Ajouter un compte CalDAV") },
text = {
Column {
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL du serveur") },
placeholder = { Text("https://example.com/caldav") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("Nom d'utilisateur") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Mot de passe") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
)
}
},
confirmButton = {
Button(
onClick = { onConfirm(url.trim(), username.trim(), password) },
enabled = url.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
) { Text("Connecter") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } },
)
}
@@ -1,183 +1,99 @@
package com.planify.mobile.ui.settings
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.caldav.CalDavCredentialStore
import com.planify.mobile.data.caldav.CalDavDiscovery
import com.planify.mobile.data.caldav.DiscoveryResult
import com.planify.mobile.data.export.ExportManager
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.LoginResult
import com.planify.mobile.data.bonsai.SyncResult
import com.planify.mobile.data.preferences.AppPreferences
import com.planify.mobile.data.preferences.ThemeMode
import com.planify.mobile.data.sync.SyncScheduler
import com.planify.mobile.domain.model.Source
import com.planify.mobile.domain.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 kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
import javax.inject.Inject
data class SettingsUiState(
val themeMode: ThemeMode = ThemeMode.SYSTEM,
val syncEnabled: Boolean = true,
val syncIntervalMinutes: Int = 30,
val notificationsEnabled: Boolean = true,
val caldavSources: List<Source> = emptyList(),
val discoveryInProgress: Boolean = false,
val discoveryError: String? = null,
val isLoggedIn: Boolean = false,
val username: String = "",
val 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
class SettingsViewModel @Inject constructor(
private val prefs: AppPreferences,
private val sourceRepository: SourceRepository,
private val syncScheduler: SyncScheduler,
private val discovery: CalDavDiscovery,
private val credentialStore: CalDavCredentialStore,
private val exportManager: ExportManager,
private val projectRepository: ProjectRepository,
private val taskRepository: TaskRepository,
private val authManager: BonsaiAuthManager,
private val syncManager: BonsaiSyncManager,
) : ViewModel() {
val uiState = combine(
prefs.themeMode,
prefs.syncEnabled,
prefs.syncIntervalMinutes,
prefs.notificationsEnabled,
sourceRepository.getAllSources(),
) { theme, sync, interval, notifs, sources ->
private val _extra = MutableStateFlow(
SettingsUiState(
themeMode = theme,
syncEnabled = sync,
syncIntervalMinutes = interval,
notificationsEnabled = notifs,
caldavSources = sources.filter { it.type == com.planify.mobile.domain.model.SourceType.CALDAV },
isLoggedIn = authManager.isLoggedIn,
username = authManager.getUsername(),
apiUrl = authManager.getApiBaseUrl(),
)
)
val uiState = combine(prefs.themeMode, prefs.notificationsEnabled, _extra) { theme, notifs, extra ->
extra.copy(themeMode = theme, notificationsEnabled = notifs)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SettingsUiState())
private val _discoveryState = MutableStateFlow<Pair<Boolean, String?>>(false to null)
val discoveryInProgress = _discoveryState.asStateFlow()
fun setTheme(mode: ThemeMode) = viewModelScope.launch { prefs.setThemeMode(mode) }
fun setSyncEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setSyncEnabled(enabled)
if (enabled) syncScheduler.schedule(uiState.value.syncIntervalMinutes.toLong())
else syncScheduler.cancel()
}
fun setSyncInterval(minutes: Int) = viewModelScope.launch {
prefs.setSyncInterval(minutes)
if (uiState.value.syncEnabled) syncScheduler.schedule(minutes.toLong())
}
fun setNotificationsEnabled(enabled: Boolean) = viewModelScope.launch {
prefs.setNotificationsEnabled(enabled)
}
fun syncNow() { syncScheduler.syncNow() }
fun addCalDavAccount(baseUrl: String, username: String, password: String) {
_discoveryState.update { true to null }
fun login(apiUrl: String, username: String, password: String) {
_extra.update { it.copy(loginInProgress = true, loginError = null) }
viewModelScope.launch {
when (val result = discovery.discover(baseUrl, username, password)) {
is DiscoveryResult.Success -> {
result.sources.forEach { source ->
credentialStore.savePassword(source.id, password)
sourceRepository.insertSource(source)
when (val result = authManager.login(apiUrl, username, password)) {
is LoginResult.Success -> {
_extra.update {
it.copy(
loginInProgress = false,
isLoggedIn = true,
username = authManager.getUsername(),
apiUrl = authManager.getApiBaseUrl(),
)
}
_discoveryState.update { false to null }
if (uiState.value.syncEnabled) syncScheduler.schedule()
// Sync immediately after login
syncNow()
}
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)
_discoveryState.update { false to result.message }
is LoginResult.Failure -> {
_extra.update { it.copy(loginInProgress = false, loginError = result.message) }
}
}
}
}
fun updateCalDavAccount(source: Source, newUrl: String, newUsername: String, newPassword: String) {
_discoveryState.update { true to null }
val effectivePassword = newPassword.ifBlank { credentialStore.getPassword(source.id) }
fun logout() {
authManager.logout()
_extra.update { it.copy(isLoggedIn = false, username = "", syncSuccess = false) }
}
fun syncNow() {
_extra.update { it.copy(syncInProgress = true, syncError = null, syncSuccess = false) }
viewModelScope.launch {
when (val result = discovery.discover(newUrl, newUsername, effectivePassword)) {
is DiscoveryResult.Success -> {
credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(source.id)
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 }
}
when (val result = syncManager.sync()) {
is SyncResult.Success -> _extra.update { it.copy(syncInProgress = false, syncSuccess = true) }
is SyncResult.NotLoggedIn -> _extra.update { it.copy(syncInProgress = false, syncError = "Non connecté") }
is SyncResult.Failure -> _extra.update { it.copy(syncInProgress = false, syncError = result.message) }
}
}
}
fun removeCalDavAccount(source: Source) = viewModelScope.launch {
credentialStore.deletePassword(source.id)
sourceRepository.deleteSource(source.id)
}
private val _exportUri = MutableStateFlow<Uri?>(null)
val exportUri = _exportUri.asStateFlow()
fun exportJson() = viewModelScope.launch {
val projects = projectRepository.getAllProjects().first()
val tasks = taskRepository.getAllTasks().first()
_exportUri.value = exportManager.exportJson(projects, tasks)
}
fun exportIcal() = viewModelScope.launch {
val tasks = taskRepository.getAllTasks().first()
_exportUri.value = exportManager.exportIcal(tasks)
}
fun clearExportUri() { _exportUri.value = null }
fun clearSyncFeedback() = _extra.update { it.copy(syncSuccess = false, syncError = null) }
}
@@ -2,6 +2,11 @@ package com.planify.mobile.ui.task
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.planify.mobile.data.bonsai.ApiResult
import com.planify.mobile.data.bonsai.BonsaiApiClient
import com.planify.mobile.data.bonsai.BonsaiAuthManager
import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.dto.BonsaIssueRequest
import com.planify.mobile.data.notification.ReminderScheduler
import com.planify.mobile.domain.model.DueDate
import com.planify.mobile.domain.model.Reminder
@@ -32,6 +37,7 @@ data class TaskEditState(
val reminders: List<Reminder> = emptyList(),
val subTasks: List<Task> = emptyList(),
val isSaving: Boolean = false,
val saveError: String? = null,
)
@HiltViewModel
@@ -39,6 +45,8 @@ class TaskEditViewModel @Inject constructor(
private val taskRepository: TaskRepository,
private val reminderRepository: ReminderRepository,
private val reminderScheduler: ReminderScheduler,
private val apiClient: BonsaiApiClient,
private val authManager: BonsaiAuthManager,
) : ViewModel() {
private val _state = MutableStateFlow(TaskEditState())
@@ -50,21 +58,19 @@ class TaskEditViewModel @Inject constructor(
val task = taskRepository.getTaskById(taskId) ?: return@launch
val subTasks = taskRepository.getSubTasks(taskId).first()
val reminders = reminderRepository.getRemindersByTask(taskId).first()
_state.update {
it.copy(
taskId = taskId,
projectId = task.projectId,
sectionId = task.sectionId,
parentId = task.parentId,
content = task.content,
description = task.description,
priority = task.priority,
dueDate = task.dueDate,
labels = task.labels,
reminders = reminders,
subTasks = subTasks,
)
}
_state.value = TaskEditState(
taskId = taskId,
projectId = task.projectId,
sectionId = task.sectionId,
parentId = task.parentId,
content = task.content,
description = task.description,
priority = task.priority,
dueDate = task.dueDate,
labels = task.labels,
reminders = reminders,
subTasks = subTasks,
)
}
} else {
_state.value = TaskEditState(projectId = projectId, sectionId = sectionId, parentId = parentId)
@@ -103,55 +109,85 @@ class TaskEditViewModel @Inject constructor(
fun save(onDone: () -> Unit) {
val st = _state.value
if (st.content.isBlank() || st.projectId.isBlank()) return
_state.update { it.copy(isSaving = true) }
_state.update { it.copy(isSaving = true, saveError = null) }
viewModelScope.launch {
val now = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
val id = st.taskId ?: UUID.randomUUID().toString()
val task = Task(
id = id,
content = st.content,
description = st.description,
projectId = st.projectId,
sectionId = st.sectionId,
parentId = st.parentId,
priority = st.priority,
dueDate = st.dueDate,
labels = st.labels,
addedAt = if (st.taskId == null) now else "",
updatedAt = now,
val request = BonsaIssueRequest(
name = st.content,
priority = BonsaiSyncManager.toBonsaiPriority(st.priority),
status = BonsaiSyncManager.toBonsaiStatus(st.dueDate == null && false),
dueDate = st.dueDate?.date,
description = st.description.ifBlank { null },
)
if (st.taskId == null) taskRepository.insertTask(task)
else taskRepository.updateTask(task)
val projectIdLong = st.projectId.toLongOrNull()
val taskIdLong = st.taskId?.toLongOrNull()
// Sub-tasks: delete removed ones, then upsert remaining
if (st.taskId != null) {
val existingIds = taskRepository.getSubTasks(id).first().map { it.id }.toSet()
val currentIds = st.subTasks.map { it.id }.toSet()
(existingIds - currentIds).forEach { taskRepository.deleteTask(it) }
}
st.subTasks.forEach { sub ->
val actualSub = sub.copy(
parentId = id,
if (authManager.isLoggedIn && projectIdLong != null) {
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(
id = id,
content = st.content,
description = st.description,
projectId = st.projectId,
addedAt = if (sub.addedAt.isBlank()) now else sub.addedAt,
sectionId = st.sectionId,
parentId = st.parentId,
priority = st.priority,
dueDate = st.dueDate,
labels = st.labels,
addedAt = if (st.taskId == null) now else "",
updatedAt = now,
)
if (taskRepository.getTaskById(sub.id) != null) taskRepository.updateTask(actualSub)
else taskRepository.insertTask(actualSub)
if (st.taskId == null) taskRepository.insertTask(task)
else taskRepository.updateTask(task)
saveReminders(id, st, task)
_state.update { it.copy(isSaving = false) }
onDone()
}
}
}
// Reminders: replace all, reschedule
reminderRepository.deleteRemindersByTask(id)
st.reminders.forEach { reminder ->
val actual = reminder.copy(taskId = id)
reminderRepository.insertReminder(actual)
reminderScheduler.schedule(actual, task.content, task.dueDate?.date)
}
_state.update { it.copy(isSaving = false) }
onDone()
private suspend fun saveReminders(taskId: String, st: TaskEditState, task: Task) {
reminderRepository.deleteRemindersByTask(taskId)
st.reminders.forEach { reminder ->
val actual = reminder.copy(taskId = taskId)
reminderRepository.insertReminder(actual)
reminderScheduler.schedule(actual, task.content, task.dueDate?.date)
}
}
}
@@ -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"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" />
<solid android:color="#F0FFF4" />
</shape>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<foreground android:drawable="@drawable/ic_bonsai_foreground" />
</adaptive-icon>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<foreground android:drawable="@drawable/ic_bonsai_foreground" />
</adaptive-icon>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">BonsaiTask</string>
</resources>