diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c3af3fa..906fe49 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ android { minSdk = 26 targetSdk = 35 versionCode = 1 - versionName = "0.0.4" + versionName = "0.0.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiApiClient.kt b/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiApiClient.kt new file mode 100644 index 0000000..2c3247f --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiApiClient.kt @@ -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 { + data class Success(val data: T) : ApiResult() + data class Failure(val message: String, val code: Int = -1) : ApiResult() +} + +@Singleton +class BonsaiApiClient @Inject constructor( + private val httpClient: OkHttpClient, + private val auth: BonsaiAuthManager, +) { + private val json = "application/json".toMediaType() + + suspend fun getProjects(): ApiResult> = 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> = + 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> = + 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 = + post("projects/$projectId/issues", req.toJson()) { o -> + o.toIssueDto() + } + + suspend fun updateIssue(projectId: Long, issueId: Long, req: BonsaIssueRequest): ApiResult = + put("projects/$projectId/issues/$issueId", req.toJson()) { o -> + o.toIssueDto() + } + + suspend fun deleteIssue(projectId: Long, issueId: Long): ApiResult = 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 get(path: String, parse: (JSONArray) -> T): ApiResult = 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 post(path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult = + sendWithBody("POST", path, jsonBody, parse) + + private suspend fun put(path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult = + sendWithBody("PUT", path, jsonBody, parse) + + private suspend fun sendWithBody(method: String, path: String, jsonBody: String, parse: (JSONObject) -> T): ApiResult = 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), + ) +} diff --git a/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiAuthManager.kt b/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiAuthManager.kt new file mode 100644 index 0000000..9facd89 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiAuthManager.kt @@ -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" + } +} diff --git a/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiSyncManager.kt b/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiSyncManager.kt new file mode 100644 index 0000000..1f72304 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/bonsai/BonsaiSyncManager.kt @@ -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>() + val allTaskIssues = mutableListOf() + + 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, 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" + } +} diff --git a/app/src/main/java/com/planify/mobile/data/bonsai/dto/BonsaiDtos.kt b/app/src/main/java/com/planify/mobile/data/bonsai/dto/BonsaiDtos.kt new file mode 100644 index 0000000..5f99af3 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/bonsai/dto/BonsaiDtos.kt @@ -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 = emptyList(), +) diff --git a/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt b/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt index 6fb7fee..119181b 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/BackendType.kt @@ -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 } diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt index 3d15816..c97e96e 100644 --- a/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsScreen.kt @@ -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(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") } }, - ) -} diff --git a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt index c9d8fbe..61d8ea7 100644 --- a/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/planify/mobile/ui/settings/SettingsViewModel.kt @@ -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 = 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>(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(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) } } diff --git a/app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt b/app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt index 76419ad..f666dd5 100644 --- a/app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt +++ b/app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt @@ -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 = emptyList(), val subTasks: List = 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) } } } diff --git a/app/src/main/res/drawable/ic_bonsai_foreground.xml b/app/src/main/res/drawable/ic_bonsai_foreground.xml new file mode 100644 index 0000000..6560455 --- /dev/null +++ b/app/src/main/res/drawable/ic_bonsai_foreground.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml index ba9d942..6320a90 100644 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 3ba4e35..d779b21 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 3ba4e35..d779b21 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,5 +1,5 @@ - + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6cffe8f --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + BonsaiTask +