From 520971ccaa59ece65e969e4c0242c62598f0d4e7 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:11:02 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20[#12]=20dates=20d'=C3=A9ch=C3=A9anc?= =?UTF-8?q?e,=20r=C3=A9currence=20(DueDatePickerSheet,=20RecurrencePickerS?= =?UTF-8?q?heet,=20RRuleBuilder,=20@Serializable)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 + .../planify/mobile/domain/model/DueDate.kt | 4 + .../mobile/domain/util/RRuleBuilder.kt | 75 ++++++++++ .../mobile/ui/task/DueDatePickerSheet.kt | 129 ++++++++++++++++++ .../mobile/ui/task/RecurrencePickerSheet.kt | 113 +++++++++++++++ build.gradle.kts | 1 + gradle/libs.versions.toml | 3 + 7 files changed, 329 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/domain/util/RRuleBuilder.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/DueDatePickerSheet.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/RecurrencePickerSheet.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 201015e..1d36efa 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.hilt) alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) } android { @@ -82,6 +83,9 @@ dependencies { // WorkManager implementation(libs.work.runtime.ktx) + // Serialization + implementation(libs.kotlinx.serialization.json) + // Tests testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/com/planify/mobile/domain/model/DueDate.kt b/app/src/main/java/com/planify/mobile/domain/model/DueDate.kt index 72e7508..9d85c3f 100644 --- a/app/src/main/java/com/planify/mobile/domain/model/DueDate.kt +++ b/app/src/main/java/com/planify/mobile/domain/model/DueDate.kt @@ -1,5 +1,8 @@ package com.planify.mobile.domain.model +import kotlinx.serialization.Serializable + +@Serializable data class DueDate( val date: String, val timezone: String? = null, @@ -11,6 +14,7 @@ data class DueDate( val recurrencyEnd: String? = null, ) +@Serializable enum class RecurrencyType { NONE, MINUTELY, HOURLY, EVERY_DAY, EVERY_WEEK, EVERY_MONTH, EVERY_YEAR } diff --git a/app/src/main/java/com/planify/mobile/domain/util/RRuleBuilder.kt b/app/src/main/java/com/planify/mobile/domain/util/RRuleBuilder.kt new file mode 100644 index 0000000..971d7af --- /dev/null +++ b/app/src/main/java/com/planify/mobile/domain/util/RRuleBuilder.kt @@ -0,0 +1,75 @@ +package com.planify.mobile.domain.util + +import com.planify.mobile.domain.model.DueDate +import com.planify.mobile.domain.model.RecurrencyType + +object RRuleBuilder { + + private val weekDayNames = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU") + + fun build(dueDate: DueDate): String? { + if (!dueDate.isRecurring || dueDate.recurrencyType == RecurrencyType.NONE) return null + + val freq = when (dueDate.recurrencyType) { + RecurrencyType.MINUTELY -> "MINUTELY" + RecurrencyType.HOURLY -> "HOURLY" + RecurrencyType.EVERY_DAY -> "DAILY" + RecurrencyType.EVERY_WEEK -> "WEEKLY" + RecurrencyType.EVERY_MONTH -> "MONTHLY" + RecurrencyType.EVERY_YEAR -> "YEARLY" + RecurrencyType.NONE -> return null + } + + val parts = mutableListOf("FREQ=$freq") + + if (dueDate.recurrencyInterval > 1) { + parts.add("INTERVAL=${dueDate.recurrencyInterval}") + } + + if (dueDate.recurrencyType == RecurrencyType.EVERY_WEEK && dueDate.recurrencyWeekDays.isNotEmpty()) { + val days = dueDate.recurrencyWeekDays + .filter { it in 0..6 } + .joinToString(",") { weekDayNames[it] } + parts.add("BYDAY=$days") + } + + dueDate.recurrencyCount?.let { parts.add("COUNT=$it") } + dueDate.recurrencyEnd?.let { parts.add("UNTIL=${it.replace("-", "").take(8)}T000000Z") } + + return "RRULE:${parts.joinToString(";")}" + } + + fun parse(rrule: String): DueDate? { + val line = rrule.removePrefix("RRULE:") + val props = line.split(";").associate { + val (k, v) = it.split("=", limit = 2) + k to v + } + + val type = when (props["FREQ"]) { + "MINUTELY" -> RecurrencyType.MINUTELY + "HOURLY" -> RecurrencyType.HOURLY + "DAILY" -> RecurrencyType.EVERY_DAY + "WEEKLY" -> RecurrencyType.EVERY_WEEK + "MONTHLY" -> RecurrencyType.EVERY_MONTH + "YEARLY" -> RecurrencyType.EVERY_YEAR + else -> return null + } + + val weekDays = props["BYDAY"]?.split(",")?.mapNotNull { weekDayNames.indexOf(it).takeIf { i -> i >= 0 } } ?: emptyList() + val count = props["COUNT"]?.toIntOrNull() + val until = props["UNTIL"]?.let { raw -> + if (raw.length >= 8) "${raw.take(4)}-${raw.substring(4, 6)}-${raw.substring(6, 8)}" else null + } + + return DueDate( + date = "", + isRecurring = true, + recurrencyType = type, + recurrencyInterval = props["INTERVAL"]?.toIntOrNull() ?: 1, + recurrencyWeekDays = weekDays, + recurrencyCount = count, + recurrencyEnd = until, + ) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/DueDatePickerSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/DueDatePickerSheet.kt new file mode 100644 index 0000000..427958e --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/DueDatePickerSheet.kt @@ -0,0 +1,129 @@ +package com.planify.mobile.ui.task + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Repeat +import androidx.compose.material3.DatePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.unit.dp +import com.planify.mobile.domain.model.DueDate +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DueDatePickerSheet( + currentDueDate: DueDate?, + onConfirm: (DueDate?) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val datePickerState = rememberDatePickerState( + initialSelectedDateMillis = currentDueDate?.date + ?.let { runCatching { LocalDate.parse(it).atStartOfDay(ZoneId.UTC).toInstant().toEpochMilli() }.getOrNull() } + ) + var showRecurrence by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(horizontal = 16.dp)) { + Text("Date d'échéance", style = androidx.compose.material3.MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val today = LocalDate.now() + listOf( + "Aujourd'hui" to today, + "Demain" to today.plusDays(1), + "Semaine prochaine" to today.plusWeeks(1), + ).forEach { (label, date) -> + SuggestionChip( + onClick = { + onConfirm(DueDate(date = date.toString(), isRecurring = currentDueDate?.isRecurring ?: false)) + }, + label = { Text(label) }, + ) + } + } + + DatePicker(state = datePickerState, showModeToggle = false) + + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showRecurrence = true } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Repeat, contentDescription = null) + Spacer(Modifier.width(12.dp)) + Text("Récurrence") + if (currentDueDate?.isRecurring == true) { + Spacer(Modifier.weight(1f)) + Text(currentDueDate.recurrencyType.name.lowercase().replace("_", " ")) + } + } + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { onConfirm(null) }) { + Icon(Icons.Outlined.Clear, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text("Supprimer") + } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDismiss) { Text("Annuler") } + TextButton(onClick = { + val millis = datePickerState.selectedDateMillis + val date = millis?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString() + } ?: return@TextButton + onConfirm(DueDate(date = date, isRecurring = currentDueDate?.isRecurring ?: false, + recurrencyType = currentDueDate?.recurrencyType ?: com.planify.mobile.domain.model.RecurrencyType.NONE)) + }) { Text("OK") } + } + } + } + + if (showRecurrence) { + RecurrencePickerSheet( + current = currentDueDate, + onConfirm = { recDueDate -> + showRecurrence = false + val millis = datePickerState.selectedDateMillis + val date = millis?.let { + Instant.ofEpochMilli(it).atZone(ZoneId.UTC).toLocalDate().toString() + } ?: currentDueDate?.date ?: LocalDate.now().toString() + onConfirm(recDueDate?.copy(date = date)) + }, + onDismiss = { showRecurrence = false }, + ) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/RecurrencePickerSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/RecurrencePickerSheet.kt new file mode 100644 index 0000000..8bab636 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/RecurrencePickerSheet.kt @@ -0,0 +1,113 @@ +package com.planify.mobile.ui.task + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +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.semantics.Role +import androidx.compose.ui.unit.dp +import com.planify.mobile.domain.model.DueDate +import com.planify.mobile.domain.model.RecurrencyType + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecurrencePickerSheet( + current: DueDate?, + onConfirm: (DueDate?) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var selectedType by remember { mutableStateOf(current?.recurrencyType ?: RecurrencyType.EVERY_DAY) } + var interval by remember { mutableStateOf(current?.recurrencyInterval ?: 1) } + var weekDays by remember { mutableStateOf(current?.recurrencyWeekDays ?: emptyList()) } + + val options = listOf( + RecurrencyType.EVERY_DAY to "Chaque jour", + RecurrencyType.EVERY_WEEK to "Chaque semaine", + RecurrencyType.EVERY_MONTH to "Chaque mois", + RecurrencyType.EVERY_YEAR to "Chaque année", + ) + val dayLabels = listOf("L", "M", "M", "J", "V", "S", "D") + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + Modifier + .padding(horizontal = 16.dp) + .selectableGroup() + ) { + Text("Récurrence", style = androidx.compose.material3.MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + + options.forEach { (type, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selectedType == type, onClick = { selectedType = type }, role = Role.RadioButton) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selectedType == type, onClick = null) + Text(label, modifier = Modifier.padding(start = 12.dp)) + } + } + + if (selectedType == RecurrencyType.EVERY_WEEK) { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + Text("Jours de la semaine", style = androidx.compose.material3.MaterialTheme.typography.labelMedium) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + dayLabels.forEachIndexed { index, label -> + FilterChip( + selected = index in weekDays, + onClick = { + weekDays = if (index in weekDays) weekDays - index else weekDays + index + }, + label = { Text(label) }, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { onConfirm(null) }) { Text("Pas de récurrence") } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onDismiss) { Text("Annuler") } + TextButton(onClick = { + onConfirm( + DueDate( + date = current?.date ?: "", + isRecurring = true, + recurrencyType = selectedType, + recurrencyInterval = interval, + recurrencyWeekDays = if (selectedType == RecurrencyType.EVERY_WEEK) weekDays else emptyList(), + ) + ) + }) { Text("OK") } + } + Spacer(Modifier.height(8.dp)) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 0adef57..c848b72 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,5 @@ plugins { alias(libs.plugins.jetbrains.kotlin.android) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.kotlin.serialization) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d308142..23fc40d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ okhttp = "4.12.0" datastore = "1.1.1" securityCrypto = "1.1.0-alpha06" workManager = "2.9.0" +serialization = "1.6.3" junit = "4.13.2" junitExt = "1.2.1" espressoCore = "3.6.1" @@ -47,6 +48,7 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" } +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } @@ -56,3 +58,4 @@ android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version = "1.9.24-1.0.20" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } From 0f1afda29518ea50433ddd75c5442741bf19bc4f Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:12:32 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20[#13]=20priorit=C3=A9s,=20labels,?= =?UTF-8?q?=20sous-t=C3=A2ches=20(PriorityPicker,=20LabelPicker,=20SubTask?= =?UTF-8?q?Section,=20LabelRepository)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../planify/mobile/data/local/AppDatabase.kt | 2 + .../planify/mobile/data/local/dao/LabelDao.kt | 27 ++++ .../data/repository/LabelRepositoryImpl.kt | 34 +++++ .../com/planify/mobile/di/DatabaseModule.kt | 1 + .../com/planify/mobile/di/RepositoryModule.kt | 5 + .../domain/repository/LabelRepository.kt | 12 ++ .../mobile/ui/task/LabelPickerSheet.kt | 116 ++++++++++++++++++ .../mobile/ui/task/PriorityPickerSheet.kt | 65 ++++++++++ .../planify/mobile/ui/task/SubTaskSection.kt | 113 +++++++++++++++++ 9 files changed, 375 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/local/dao/LabelDao.kt create mode 100644 app/src/main/java/com/planify/mobile/data/repository/LabelRepositoryImpl.kt create mode 100644 app/src/main/java/com/planify/mobile/domain/repository/LabelRepository.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/LabelPickerSheet.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/PriorityPickerSheet.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/SubTaskSection.kt diff --git a/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt index 0c86eaf..3312c5f 100644 --- a/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt +++ b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt @@ -2,6 +2,7 @@ package com.planify.mobile.data.local import androidx.room.Database import androidx.room.RoomDatabase +import com.planify.mobile.data.local.dao.LabelDao import com.planify.mobile.data.local.dao.ProjectDao import com.planify.mobile.data.local.dao.SectionDao import com.planify.mobile.data.local.dao.TaskDao @@ -28,4 +29,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun taskDao(): TaskDao abstract fun projectDao(): ProjectDao abstract fun sectionDao(): SectionDao + abstract fun labelDao(): LabelDao } diff --git a/app/src/main/java/com/planify/mobile/data/local/dao/LabelDao.kt b/app/src/main/java/com/planify/mobile/data/local/dao/LabelDao.kt new file mode 100644 index 0000000..e33d513 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/local/dao/LabelDao.kt @@ -0,0 +1,27 @@ +package com.planify.mobile.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update +import com.planify.mobile.data.local.entity.LabelEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface LabelDao { + @Query("SELECT * FROM labels WHERE is_deleted = 0 ORDER BY `order` ASC") + fun getAllLabels(): Flow> + + @Query("SELECT * FROM labels WHERE id = :id") + suspend fun getById(id: String): LabelEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(label: LabelEntity) + + @Update + suspend fun update(label: LabelEntity) + + @Query("UPDATE labels SET is_deleted = 1 WHERE id = :id") + suspend fun softDelete(id: String) +} diff --git a/app/src/main/java/com/planify/mobile/data/repository/LabelRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/LabelRepositoryImpl.kt new file mode 100644 index 0000000..0cc7d01 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/repository/LabelRepositoryImpl.kt @@ -0,0 +1,34 @@ +package com.planify.mobile.data.repository + +import com.planify.mobile.data.local.dao.LabelDao +import com.planify.mobile.data.local.entity.LabelEntity +import com.planify.mobile.domain.model.BackendType +import com.planify.mobile.domain.model.Label +import com.planify.mobile.domain.repository.LabelRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class LabelRepositoryImpl @Inject constructor( + private val dao: LabelDao, +) : LabelRepository { + + override fun getAllLabels(): Flow> = + dao.getAllLabels().map { it.map { e -> e.toDomain() } } + + override suspend fun getLabelById(id: String): Label? = dao.getById(id)?.toDomain() + override suspend fun insertLabel(label: Label) = dao.insert(label.toEntity()) + override suspend fun updateLabel(label: Label) = dao.update(label.toEntity()) + override suspend fun deleteLabel(id: String) = dao.softDelete(id) + + private fun LabelEntity.toDomain() = Label( + id = id, name = name, color = color, order = order, sourceId = sourceId, + backendType = runCatching { BackendType.valueOf(backendType) }.getOrDefault(BackendType.LOCAL), + isFavorite = isFavorite, isDeleted = isDeleted, + ) + + private fun Label.toEntity() = LabelEntity( + id = id, name = name, color = color, order = order, sourceId = sourceId, + backendType = backendType.name, isFavorite = isFavorite, isDeleted = isDeleted, + ) +} diff --git a/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt b/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt index e8d5238..eb79e57 100644 --- a/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt +++ b/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt @@ -24,4 +24,5 @@ object DatabaseModule { @Provides fun provideTaskDao(db: AppDatabase) = db.taskDao() @Provides fun provideProjectDao(db: AppDatabase) = db.projectDao() @Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao() + @Provides fun provideLabelDao(db: AppDatabase) = db.labelDao() } diff --git a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt index 0eb6edd..5e84478 100644 --- a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt +++ b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt @@ -1,8 +1,10 @@ package com.planify.mobile.di +import com.planify.mobile.data.repository.LabelRepositoryImpl import com.planify.mobile.data.repository.ProjectRepositoryImpl import com.planify.mobile.data.repository.SectionRepositoryImpl import com.planify.mobile.data.repository.TaskRepositoryImpl +import com.planify.mobile.domain.repository.LabelRepository import com.planify.mobile.domain.repository.ProjectRepository import com.planify.mobile.domain.repository.SectionRepository import com.planify.mobile.domain.repository.TaskRepository @@ -24,4 +26,7 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindSectionRepository(impl: SectionRepositoryImpl): SectionRepository + + @Binds @Singleton + abstract fun bindLabelRepository(impl: LabelRepositoryImpl): LabelRepository } diff --git a/app/src/main/java/com/planify/mobile/domain/repository/LabelRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/LabelRepository.kt new file mode 100644 index 0000000..f353d16 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/domain/repository/LabelRepository.kt @@ -0,0 +1,12 @@ +package com.planify.mobile.domain.repository + +import com.planify.mobile.domain.model.Label +import kotlinx.coroutines.flow.Flow + +interface LabelRepository { + fun getAllLabels(): Flow> + suspend fun getLabelById(id: String): Label? + suspend fun insertLabel(label: Label) + suspend fun updateLabel(label: Label) + suspend fun deleteLabel(id: String) +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/LabelPickerSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/LabelPickerSheet.kt new file mode 100644 index 0000000..c667638 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/LabelPickerSheet.kt @@ -0,0 +1,116 @@ +package com.planify.mobile.ui.task + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Label +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.planify.mobile.domain.model.Label +import com.planify.mobile.domain.repository.LabelRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class LabelPickerViewModel @Inject constructor( + labelRepository: LabelRepository, +) : ViewModel() { + val labels = labelRepository.getAllLabels() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LabelPickerSheet( + selectedLabels: List, + onConfirm: (List) -> Unit, + onDismiss: () -> Unit, + viewModel: LabelPickerViewModel = hiltViewModel(), +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val labels by viewModel.labels.collectAsState() + var selected by remember { mutableStateOf(selectedLabels.toSet()) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Labels", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { onConfirm(selected.toList()) }) { Text("OK") } + } + LazyColumn { + items(labels, key = { it.id }) { label -> + LabelRow( + label = label, + checked = label.name in selected, + onToggle = { + selected = if (label.name in selected) selected - label.name else selected + label.name + }, + ) + } + if (labels.isEmpty()) { + item { + Text( + "Aucun label. Créez-en un dans les paramètres.", + modifier = Modifier.padding(16.dp), + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun LabelRow(label: Label, checked: Boolean, onToggle: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.Label, + contentDescription = null, + tint = runCatching { Color(android.graphics.Color.parseColor(label.color)) }.getOrDefault(Color.Gray), + ) + Spacer(Modifier.width(12.dp)) + Text(text = label.name, modifier = Modifier.weight(1f)) + Checkbox(checked = checked, onCheckedChange = { onToggle() }) + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/PriorityPickerSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/PriorityPickerSheet.kt new file mode 100644 index 0000000..f6baf73 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/PriorityPickerSheet.kt @@ -0,0 +1,65 @@ +package com.planify.mobile.ui.task + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.planify.mobile.ui.components.PriorityBadge +import com.planify.mobile.ui.components.priorityColor + +private val priorityLabels = mapOf( + 1 to "Urgente", + 2 to "Haute", + 3 to "Moyenne", + 4 to "Aucune", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PriorityPickerSheet( + current: Int, + onSelect: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(bottom = 16.dp)) { + Text( + text = "Priorité", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + listOf(1, 2, 3, 4).forEach { priority -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(priority); onDismiss() } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PriorityBadge(priority = priority) + Spacer(Modifier.width(12.dp)) + Text( + text = priorityLabels[priority] ?: "", + color = priorityColor[priority] ?: androidx.compose.ui.graphics.Color.Gray, + modifier = Modifier.weight(1f), + ) + RadioButton(selected = current == priority, onClick = null) + } + } + } + } +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/SubTaskSection.kt b/app/src/main/java/com/planify/mobile/ui/task/SubTaskSection.kt new file mode 100644 index 0000000..d56101d --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/SubTaskSection.kt @@ -0,0 +1,113 @@ +package com.planify.mobile.ui.task + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.text.input.ImeAction +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.planify.mobile.domain.model.Task + +@Composable +fun SubTaskSection( + subTasks: List, + onAddSubTask: (String) -> Unit, + onToggleSubTask: (Task) -> Unit, + onDeleteSubTask: (Task) -> Unit, + modifier: Modifier = Modifier, +) { + var newSubTaskContent by remember { mutableStateOf("") } + var showInput by remember { mutableStateOf(false) } + + Column(modifier = modifier) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Sous-tâches (${subTasks.count { it.checked }}/${subTasks.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + } + + subTasks.forEach { subTask -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = subTask.checked, + onCheckedChange = { onToggleSubTask(subTask) }, + ) + Text( + text = subTask.content, + style = MaterialTheme.typography.bodyMedium, + textDecoration = if (subTask.checked) TextDecoration.LineThrough else null, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { onDeleteSubTask(subTask) }) { + Icon(Icons.Outlined.Close, contentDescription = "Supprimer") + } + } + } + + if (showInput) { + Row( + modifier = Modifier.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(40.dp)) + OutlinedTextField( + value = newSubTaskContent, + onValueChange = { newSubTaskContent = it }, + placeholder = { Text("Nouvelle sous-tâche") }, + modifier = Modifier.weight(1f), + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { + if (newSubTaskContent.isNotBlank()) { + onAddSubTask(newSubTaskContent.trim()) + newSubTaskContent = "" + } + showInput = false + }), + ) + } + } + + TextButton( + onClick = { showInput = true }, + modifier = Modifier.padding(start = 8.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text("Ajouter une sous-tâche") + } + } +} From 6db1222ff7600d895059bb26f8ba45bee38da1f3 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:15:55 +0200 Subject: [PATCH 3/5] feat: [#14] rappels et notifications locales (AlarmManager, BroadcastReceiver, ReminderPickerSheet) --- app/src/main/AndroidManifest.xml | 12 +++ .../mobile/data/notification/BootReceiver.kt | 16 ++++ .../data/notification/ReminderReceiver.kt | 56 +++++++++++++ .../data/notification/ReminderScheduler.kt | 73 +++++++++++++++++ .../mobile/ui/task/ReminderPickerSheet.kt | 82 +++++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt create mode 100644 app/src/main/java/com/planify/mobile/data/notification/ReminderReceiver.kt create mode 100644 app/src/main/java/com/planify/mobile/data/notification/ReminderScheduler.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/ReminderPickerSheet.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c6cf7a..a316bb8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -26,6 +26,18 @@ + + + + + + + + diff --git a/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt b/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt new file mode 100644 index 0000000..9f94458 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/notification/BootReceiver.kt @@ -0,0 +1,16 @@ +package com.planify.mobile.data.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class BootReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != Intent.ACTION_BOOT_COMPLETED) return + // TODO #14 : replanifier toutes les alarmes depuis la base de données + // Inject ReminderScheduler + ReminderRepository et rejouer tous les rappels actifs + } +} diff --git a/app/src/main/java/com/planify/mobile/data/notification/ReminderReceiver.kt b/app/src/main/java/com/planify/mobile/data/notification/ReminderReceiver.kt new file mode 100644 index 0000000..afacb9c --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/notification/ReminderReceiver.kt @@ -0,0 +1,56 @@ +package com.planify.mobile.data.notification + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.planify.mobile.ui.MainActivity +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ReminderReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + val taskId = intent.getStringExtra(EXTRA_TASK_ID) ?: return + val title = intent.getStringExtra(EXTRA_TASK_TITLE) ?: "Rappel" + + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ensureChannel(notificationManager) + + val openIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra("taskId", taskId) + } + val openPi = PendingIntent.getActivity( + context, taskId.hashCode(), openIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) + .setContentTitle("Rappel") + .setContentText(title) + .setContentIntent(openPi) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + notificationManager.notify(taskId.hashCode(), notification) + } + + private fun ensureChannel(nm: NotificationManager) { + if (nm.getNotificationChannel(CHANNEL_ID) != null) return + val channel = NotificationChannel(CHANNEL_ID, "Rappels", NotificationManager.IMPORTANCE_HIGH) + nm.createNotificationChannel(channel) + } + + companion object { + const val CHANNEL_ID = "planify_reminders" + const val EXTRA_REMINDER_ID = "reminder_id" + const val EXTRA_TASK_ID = "task_id" + const val EXTRA_TASK_TITLE = "task_title" + } +} diff --git a/app/src/main/java/com/planify/mobile/data/notification/ReminderScheduler.kt b/app/src/main/java/com/planify/mobile/data/notification/ReminderScheduler.kt new file mode 100644 index 0000000..7a66f41 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/notification/ReminderScheduler.kt @@ -0,0 +1,73 @@ +package com.planify.mobile.data.notification + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import com.planify.mobile.domain.model.Reminder +import com.planify.mobile.domain.model.ReminderType +import dagger.hilt.android.qualifiers.ApplicationContext +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ReminderScheduler @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + + fun schedule(reminder: Reminder, taskTitle: String, taskDueDateIso: String?) { + val triggerMillis = when (reminder.type) { + ReminderType.ABSOLUTE -> reminder.dueDate?.date?.let { parseToMillis(it) } + ReminderType.RELATIVE -> { + val offset = reminder.minutesOffset ?: return + taskDueDateIso?.let { parseToMillis(it) }?.minus(offset * 60_000L) + } + } ?: return + + if (triggerMillis <= System.currentTimeMillis()) return + + val intent = Intent(context, ReminderReceiver::class.java).apply { + putExtra(ReminderReceiver.EXTRA_REMINDER_ID, reminder.id) + putExtra(ReminderReceiver.EXTRA_TASK_ID, reminder.taskId) + putExtra(ReminderReceiver.EXTRA_TASK_TITLE, taskTitle) + } + val pi = PendingIntent.getBroadcast( + context, + reminder.id.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMillis, pi) + } else { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerMillis, pi) + } + } + + fun cancel(reminderId: String) { + val intent = Intent(context, ReminderReceiver::class.java) + val pi = PendingIntent.getBroadcast( + context, + reminderId.hashCode(), + intent, + PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE, + ) ?: return + alarmManager.cancel(pi) + pi.cancel() + } + + private fun parseToMillis(dateIso: String): Long? = runCatching { + val formatter = if (dateIso.contains("T")) DateTimeFormatter.ISO_LOCAL_DATE_TIME + else DateTimeFormatter.ISO_LOCAL_DATE + val ldt = if (dateIso.contains("T")) LocalDateTime.parse(dateIso, formatter) + else LocalDate.parse(dateIso, formatter).atStartOfDay() + ldt.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + }.getOrNull() +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/ReminderPickerSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/ReminderPickerSheet.kt new file mode 100644 index 0000000..6ed83c6 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/ReminderPickerSheet.kt @@ -0,0 +1,82 @@ +package com.planify.mobile.ui.task + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.planify.mobile.domain.model.DueDate +import com.planify.mobile.domain.model.Reminder +import com.planify.mobile.domain.model.ReminderType +import java.util.UUID + +private val relativeOptions = listOf( + 5 to "5 minutes avant", + 15 to "15 minutes avant", + 30 to "30 minutes avant", + 60 to "1 heure avant", + 1440 to "1 jour avant", +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReminderPickerSheet( + taskId: String, + currentReminders: List, + onUpdate: (List) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(bottom = 16.dp)) { + Text( + text = "Rappels", + style = androidx.compose.material3.MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(16.dp), + ) + + relativeOptions.forEach { (minutes, label) -> + val existing = currentReminders.find { it.type == ReminderType.RELATIVE && it.minutesOffset == minutes } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val updated = if (existing != null) { + currentReminders - existing + } else { + currentReminders + Reminder( + id = UUID.randomUUID().toString(), + taskId = taskId, + type = ReminderType.RELATIVE, + minutesOffset = minutes, + ) + } + onUpdate(updated) + } + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Outlined.Notifications, contentDescription = null) + Text(label, modifier = Modifier + .weight(1f) + .padding(start = 12.dp)) + RadioButton(selected = existing != null, onClick = null) + } + } + } + } +} From 5049d4d681bf6025ccc6dce8197852de44b5acad Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:19:37 +0200 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20[#11]=20fiche=20t=C3=A2che=20compl?= =?UTF-8?q?=C3=A8te=20(TaskEditSheet=20+=20TaskEditViewModel=20+=20Reminde?= =?UTF-8?q?rRepository)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../planify/mobile/data/local/AppDatabase.kt | 2 + .../mobile/data/local/dao/ReminderDao.kt | 24 ++ .../data/repository/ReminderRepositoryImpl.kt | 43 ++++ .../com/planify/mobile/di/DatabaseModule.kt | 1 + .../com/planify/mobile/di/RepositoryModule.kt | 5 + .../domain/repository/ReminderRepository.kt | 11 + .../planify/mobile/ui/task/TaskEditSheet.kt | 210 ++++++++++++++++++ .../mobile/ui/task/TaskEditViewModel.kt | 157 +++++++++++++ 8 files changed, 453 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/data/local/dao/ReminderDao.kt create mode 100644 app/src/main/java/com/planify/mobile/data/repository/ReminderRepositoryImpl.kt create mode 100644 app/src/main/java/com/planify/mobile/domain/repository/ReminderRepository.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/TaskEditSheet.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt diff --git a/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt index 3312c5f..f872021 100644 --- a/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt +++ b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt @@ -4,6 +4,7 @@ import androidx.room.Database import androidx.room.RoomDatabase import com.planify.mobile.data.local.dao.LabelDao import com.planify.mobile.data.local.dao.ProjectDao +import com.planify.mobile.data.local.dao.ReminderDao import com.planify.mobile.data.local.dao.SectionDao import com.planify.mobile.data.local.dao.TaskDao import com.planify.mobile.data.local.entity.LabelEntity @@ -30,4 +31,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun projectDao(): ProjectDao abstract fun sectionDao(): SectionDao abstract fun labelDao(): LabelDao + abstract fun reminderDao(): ReminderDao } diff --git a/app/src/main/java/com/planify/mobile/data/local/dao/ReminderDao.kt b/app/src/main/java/com/planify/mobile/data/local/dao/ReminderDao.kt new file mode 100644 index 0000000..d0e1675 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/local/dao/ReminderDao.kt @@ -0,0 +1,24 @@ +package com.planify.mobile.data.local.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.planify.mobile.data.local.entity.ReminderEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ReminderDao { + @Query("SELECT * FROM reminders WHERE task_id = :taskId") + fun getRemindersByTask(taskId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(reminder: ReminderEntity) + + @Delete + suspend fun delete(reminder: ReminderEntity) + + @Query("DELETE FROM reminders WHERE task_id = :taskId") + suspend fun deleteByTask(taskId: String) +} diff --git a/app/src/main/java/com/planify/mobile/data/repository/ReminderRepositoryImpl.kt b/app/src/main/java/com/planify/mobile/data/repository/ReminderRepositoryImpl.kt new file mode 100644 index 0000000..d126ea3 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/data/repository/ReminderRepositoryImpl.kt @@ -0,0 +1,43 @@ +package com.planify.mobile.data.repository + +import com.planify.mobile.data.local.dao.ReminderDao +import com.planify.mobile.data.local.entity.ReminderEntity +import com.planify.mobile.domain.model.DueDate +import com.planify.mobile.domain.model.Reminder +import com.planify.mobile.domain.model.ReminderType +import com.planify.mobile.domain.repository.ReminderRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class ReminderRepositoryImpl @Inject constructor( + private val dao: ReminderDao, +) : ReminderRepository { + + override fun getRemindersByTask(taskId: String): Flow> = + dao.getRemindersByTask(taskId).map { list -> list.map { it.toDomain() } } + + override suspend fun insertReminder(reminder: Reminder) = dao.insert(reminder.toEntity()) + + override suspend fun deleteReminder(reminder: Reminder) = dao.delete(reminder.toEntity()) + + override suspend fun deleteRemindersByTask(taskId: String) = dao.deleteByTask(taskId) + + private fun ReminderEntity.toDomain() = Reminder( + id = id, + taskId = taskId, + type = ReminderType.valueOf(type), + dueDate = dueDate?.let { runCatching { Json.decodeFromString(it) }.getOrNull() }, + minutesOffset = minutesOffset, + ) + + private fun Reminder.toEntity() = ReminderEntity( + id = id, + taskId = taskId, + type = type.name, + dueDate = dueDate?.let { Json.encodeToString(it) }, + minutesOffset = minutesOffset, + ) +} diff --git a/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt b/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt index eb79e57..bc9352e 100644 --- a/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt +++ b/app/src/main/java/com/planify/mobile/di/DatabaseModule.kt @@ -25,4 +25,5 @@ object DatabaseModule { @Provides fun provideProjectDao(db: AppDatabase) = db.projectDao() @Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao() @Provides fun provideLabelDao(db: AppDatabase) = db.labelDao() + @Provides fun provideReminderDao(db: AppDatabase) = db.reminderDao() } diff --git a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt index 5e84478..8c0977b 100644 --- a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt +++ b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt @@ -2,10 +2,12 @@ package com.planify.mobile.di import com.planify.mobile.data.repository.LabelRepositoryImpl import com.planify.mobile.data.repository.ProjectRepositoryImpl +import com.planify.mobile.data.repository.ReminderRepositoryImpl import com.planify.mobile.data.repository.SectionRepositoryImpl import com.planify.mobile.data.repository.TaskRepositoryImpl import com.planify.mobile.domain.repository.LabelRepository import com.planify.mobile.domain.repository.ProjectRepository +import com.planify.mobile.domain.repository.ReminderRepository import com.planify.mobile.domain.repository.SectionRepository import com.planify.mobile.domain.repository.TaskRepository import dagger.Binds @@ -29,4 +31,7 @@ abstract class RepositoryModule { @Binds @Singleton abstract fun bindLabelRepository(impl: LabelRepositoryImpl): LabelRepository + + @Binds @Singleton + abstract fun bindReminderRepository(impl: ReminderRepositoryImpl): ReminderRepository } diff --git a/app/src/main/java/com/planify/mobile/domain/repository/ReminderRepository.kt b/app/src/main/java/com/planify/mobile/domain/repository/ReminderRepository.kt new file mode 100644 index 0000000..50d8d4e --- /dev/null +++ b/app/src/main/java/com/planify/mobile/domain/repository/ReminderRepository.kt @@ -0,0 +1,11 @@ +package com.planify.mobile.domain.repository + +import com.planify.mobile.domain.model.Reminder +import kotlinx.coroutines.flow.Flow + +interface ReminderRepository { + fun getRemindersByTask(taskId: String): Flow> + suspend fun insertReminder(reminder: Reminder) + suspend fun deleteReminder(reminder: Reminder) + suspend fun deleteRemindersByTask(taskId: String) +} diff --git a/app/src/main/java/com/planify/mobile/ui/task/TaskEditSheet.kt b/app/src/main/java/com/planify/mobile/ui/task/TaskEditSheet.kt new file mode 100644 index 0000000..f0e731e --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/TaskEditSheet.kt @@ -0,0 +1,210 @@ +package com.planify.mobile.ui.task + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarToday +import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.Label +import androidx.compose.material.icons.outlined.List +import androidx.compose.material.icons.outlined.Notifications +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +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.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.planify.mobile.ui.components.PriorityBadge +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TaskEditSheet( + projectId: String, + taskId: String? = null, + sectionId: String? = null, + parentId: String? = null, + onDismiss: () -> Unit, + viewModel: TaskEditViewModel = hiltViewModel(), +) { + LaunchedEffect(taskId, projectId) { + viewModel.init(taskId, projectId, sectionId, parentId) + } + + val state by viewModel.state.collectAsState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + var showDueDatePicker by remember { mutableStateOf(false) } + var showPriorityPicker by remember { mutableStateOf(false) } + var showLabelPicker by remember { mutableStateOf(false) } + var showReminderPicker by remember { mutableStateOf(false) } + var showSubTasks by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(horizontal = 16.dp)) { + Text( + text = if (taskId == null) "Nouvelle tâche" else "Modifier la tâche", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(bottom = 12.dp), + ) + + OutlinedTextField( + value = state.content, + onValueChange = viewModel::setContent, + placeholder = { Text("Nom de la tâche") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = state.description, + onValueChange = viewModel::setDescription, + placeholder = { Text("Description (optionnelle)") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + ) + + Spacer(Modifier.height(12.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.dueDate != null, + onClick = { showDueDatePicker = true }, + label = { Text(state.dueDate?.date?.let { formatDateShort(it) } ?: "Date") }, + leadingIcon = { Icon(Icons.Outlined.CalendarToday, contentDescription = null) }, + ) + FilterChip( + selected = state.priority != 4, + onClick = { showPriorityPicker = true }, + label = { Text("P${state.priority}") }, + leadingIcon = { PriorityBadge(priority = state.priority) }, + ) + FilterChip( + selected = state.labels.isNotEmpty(), + onClick = { showLabelPicker = true }, + label = { + Text(if (state.labels.isNotEmpty()) "${state.labels.size} label(s)" else "Labels") + }, + leadingIcon = { Icon(Icons.Outlined.Label, contentDescription = null) }, + ) + FilterChip( + selected = state.reminders.isNotEmpty(), + onClick = { showReminderPicker = true }, + label = { + Text(if (state.reminders.isNotEmpty()) "${state.reminders.size} rappel(s)" else "Rappels") + }, + leadingIcon = { Icon(Icons.Outlined.Notifications, contentDescription = null) }, + ) + FilterChip( + selected = state.subTasks.isNotEmpty() || showSubTasks, + onClick = { showSubTasks = !showSubTasks }, + label = { + Text(if (state.subTasks.isNotEmpty()) "${state.subTasks.size} sous-tâche(s)" else "Sous-tâches") + }, + leadingIcon = { Icon(Icons.Outlined.List, contentDescription = null) }, + ) + } + + if (showSubTasks) { + Spacer(Modifier.height(8.dp)) + SubTaskSection( + subTasks = state.subTasks, + onAddSubTask = viewModel::addSubTask, + onToggleSubTask = viewModel::toggleSubTask, + onDeleteSubTask = viewModel::deleteSubTask, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Spacer(Modifier.width(8.dp)) + Button( + onClick = { viewModel.save(onDone = onDismiss) }, + enabled = state.content.isNotBlank() && !state.isSaving, + ) { + Text(if (taskId == null) "Créer" else "Enregistrer") + } + } + + Spacer(Modifier.height(16.dp)) + } + } + + if (showDueDatePicker) { + DueDatePickerSheet( + currentDueDate = state.dueDate, + onConfirm = { viewModel.setDueDate(it); showDueDatePicker = false }, + onDismiss = { showDueDatePicker = false }, + ) + } + if (showPriorityPicker) { + PriorityPickerSheet( + current = state.priority, + onSelect = viewModel::setPriority, + onDismiss = { showPriorityPicker = false }, + ) + } + if (showLabelPicker) { + LabelPickerSheet( + selectedLabels = state.labels, + onConfirm = { viewModel.setLabels(it); showLabelPicker = false }, + onDismiss = { showLabelPicker = false }, + ) + } + if (showReminderPicker) { + ReminderPickerSheet( + taskId = state.taskId ?: "", + currentReminders = state.reminders, + onUpdate = viewModel::setReminders, + onDismiss = { showReminderPicker = false }, + ) + } +} + +private fun formatDateShort(dateIso: String): String = runCatching { + val date = LocalDate.parse(dateIso) + val today = LocalDate.now() + when (date) { + today -> "Aujourd'hui" + today.plusDays(1) -> "Demain" + else -> date.format(DateTimeFormatter.ofPattern("d MMM", Locale.FRENCH)) + } +}.getOrDefault(dateIso) 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 new file mode 100644 index 0000000..c4ba5b1 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/task/TaskEditViewModel.kt @@ -0,0 +1,157 @@ +package com.planify.mobile.ui.task + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.planify.mobile.data.notification.ReminderScheduler +import com.planify.mobile.domain.model.DueDate +import com.planify.mobile.domain.model.Reminder +import com.planify.mobile.domain.model.Task +import com.planify.mobile.domain.repository.ReminderRepository +import com.planify.mobile.domain.repository.TaskRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +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 TaskEditState( + val taskId: String? = null, + val projectId: String = "", + val sectionId: String? = null, + val parentId: String? = null, + val content: String = "", + val description: String = "", + val priority: Int = 4, + val dueDate: DueDate? = null, + val labels: List = emptyList(), + val reminders: List = emptyList(), + val subTasks: List = emptyList(), + val isSaving: Boolean = false, +) + +@HiltViewModel +class TaskEditViewModel @Inject constructor( + private val taskRepository: TaskRepository, + private val reminderRepository: ReminderRepository, + private val reminderScheduler: ReminderScheduler, +) : ViewModel() { + + private val _state = MutableStateFlow(TaskEditState()) + val state = _state.asStateFlow() + + fun init(taskId: String?, projectId: String, sectionId: String? = null, parentId: String? = null) { + if (taskId != null) { + viewModelScope.launch { + 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, + ) + } + } + } else { + _state.update { it.copy(projectId = projectId, sectionId = sectionId, parentId = parentId) } + } + } + + fun setContent(content: String) = _state.update { it.copy(content = content) } + fun setDescription(desc: String) = _state.update { it.copy(description = desc) } + fun setPriority(priority: Int) = _state.update { it.copy(priority = priority) } + fun setDueDate(dueDate: DueDate?) = _state.update { it.copy(dueDate = dueDate) } + fun setLabels(labels: List) = _state.update { it.copy(labels = labels) } + fun setReminders(reminders: List) = _state.update { it.copy(reminders = reminders) } + + fun addSubTask(content: String) { + val sub = Task( + id = UUID.randomUUID().toString(), + content = content, + projectId = _state.value.projectId, + parentId = _state.value.taskId ?: "pending", + ) + _state.update { it.copy(subTasks = it.subTasks + sub) } + } + + fun toggleSubTask(subTask: Task) { + _state.update { + it.copy(subTasks = it.subTasks.map { t -> + if (t.id == subTask.id) t.copy(checked = !t.checked) else t + }) + } + } + + fun deleteSubTask(subTask: Task) { + _state.update { it.copy(subTasks = it.subTasks.filter { t -> t.id != subTask.id }) } + } + + fun save(onDone: () -> Unit) { + val st = _state.value + if (st.content.isBlank()) return + _state.update { it.copy(isSaving = true) } + 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, + ) + + if (st.taskId == null) taskRepository.insertTask(task) + else taskRepository.updateTask(task) + + // Sub-tasks: delete removed ones, then upsert remaining + if (st.taskId != null) { + val existingIds = taskRepository.getSubTasks(id).first().map { it.id }.toSet() + val currentIds = st.subTasks.map { it.id }.toSet() + (existingIds - currentIds).forEach { taskRepository.deleteTask(it) } + } + st.subTasks.forEach { sub -> + val actualSub = sub.copy( + parentId = id, + projectId = st.projectId, + addedAt = if (sub.addedAt.isBlank()) now else sub.addedAt, + updatedAt = now, + ) + if (taskRepository.getTaskById(sub.id) != null) taskRepository.updateTask(actualSub) + else taskRepository.insertTask(actualSub) + } + + // Reminders: replace all, reschedule + reminderRepository.deleteRemindersByTask(id) + st.reminders.forEach { reminder -> + val actual = reminder.copy(taskId = id) + reminderRepository.insertReminder(actual) + reminderScheduler.schedule(actual, task.content, task.dueDate?.date) + } + + _state.update { it.copy(isSaving = false) } + onDone() + } + } +} From 933704ca91e0742c1eedce0bb246cc7ba0ac2ee2 Mon Sep 17 00:00:00 2001 From: Gato Date: Sat, 6 Jun 2026 06:20:19 +0200 Subject: [PATCH 5/5] feat: [#15] fiche projet (ProjectEditSheet + ProjectEditViewModel, couleurs, vue liste/tableau) --- .../mobile/ui/project/ProjectEditSheet.kt | 167 ++++++++++++++++++ .../mobile/ui/project/ProjectEditViewModel.kt | 113 ++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 app/src/main/java/com/planify/mobile/ui/project/ProjectEditSheet.kt create mode 100644 app/src/main/java/com/planify/mobile/ui/project/ProjectEditViewModel.kt diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectEditSheet.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectEditSheet.kt new file mode 100644 index 0000000..86f8f57 --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectEditSheet.kt @@ -0,0 +1,167 @@ +package com.planify.mobile.ui.project + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Star +import androidx.compose.material.icons.outlined.StarBorder +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.planify.mobile.domain.model.ViewStyle + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProjectEditSheet( + projectId: String? = null, + onDismiss: () -> Unit, + onSaved: (projectId: String) -> Unit = {}, + onDeleted: () -> Unit = {}, + viewModel: ProjectEditViewModel = hiltViewModel(), +) { + LaunchedEffect(projectId) { viewModel.init(projectId) } + + val state by viewModel.state.collectAsState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.padding(horizontal = 16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (projectId == null) "Nouveau projet" else "Modifier le projet", + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { viewModel.toggleFavorite() }) { + Icon( + imageVector = if (state.isFavorite) Icons.Outlined.Star else Icons.Outlined.StarBorder, + contentDescription = "Favori", + tint = if (state.isFavorite) Color(0xFFFFC107) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (projectId != null) { + IconButton(onClick = { viewModel.delete { onDeleted(); onDismiss() } }) { + Icon(Icons.Outlined.Delete, contentDescription = "Supprimer", tint = MaterialTheme.colorScheme.error) + } + } + } + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = state.name, + onValueChange = viewModel::setName, + placeholder = { Text("Nom du projet") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + leadingIcon = { + Box( + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + .background(parseColor(state.color)), + ) + }, + ) + + Spacer(Modifier.height(16.dp)) + Text("Couleur", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + + LazyVerticalGrid( + columns = GridCells.Fixed(6), + modifier = Modifier.height(80.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(viewModel.colors) { color -> + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(parseColor(color)) + .then( + if (color == state.color) Modifier.border(2.dp, MaterialTheme.colorScheme.onSurface, CircleShape) + else Modifier + ) + .clickable { viewModel.setColor(color) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(12.dp)) + + Text("Vue par défaut", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(8.dp)) + + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + ViewStyle.entries.forEachIndexed { index, style -> + SegmentedButton( + selected = state.viewStyle == style, + onClick = { viewModel.setViewStyle(style) }, + shape = SegmentedButtonDefaults.itemShape(index = index, count = ViewStyle.entries.size), + label = { Text(if (style == ViewStyle.LIST) "Liste" else "Tableau") }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Button( + onClick = { viewModel.save(onDone = { id -> onSaved(id); onDismiss() }) }, + enabled = state.name.isNotBlank() && !state.isSaving, + ) { + Text(if (projectId == null) "Créer" else "Enregistrer") + } + } + + Spacer(Modifier.height(16.dp)) + } + } +} + +private fun parseColor(hex: String): Color = runCatching { + Color(android.graphics.Color.parseColor(hex)) +}.getOrDefault(Color.Gray) diff --git a/app/src/main/java/com/planify/mobile/ui/project/ProjectEditViewModel.kt b/app/src/main/java/com/planify/mobile/ui/project/ProjectEditViewModel.kt new file mode 100644 index 0000000..593648c --- /dev/null +++ b/app/src/main/java/com/planify/mobile/ui/project/ProjectEditViewModel.kt @@ -0,0 +1,113 @@ +package com.planify.mobile.ui.project + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.planify.mobile.domain.model.BackendType +import com.planify.mobile.domain.model.Project +import com.planify.mobile.domain.model.SortBy +import com.planify.mobile.domain.model.ViewStyle +import com.planify.mobile.domain.repository.ProjectRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +data class ProjectEditState( + val projectId: String? = null, + val name: String = "", + val color: String = "#4CAF50", + val emoji: String? = null, + val isFavorite: Boolean = false, + val viewStyle: ViewStyle = ViewStyle.LIST, + val isSaving: Boolean = false, +) + +private val defaultColors = listOf( + "#F44336", "#E91E63", "#9C27B0", "#3F51B5", + "#2196F3", "#00BCD4", "#4CAF50", "#8BC34A", + "#FFEB3B", "#FF9800", "#795548", "#607D8B", +) + +@HiltViewModel +class ProjectEditViewModel @Inject constructor( + private val projectRepository: ProjectRepository, +) : ViewModel() { + + val colors = defaultColors + + private val _state = MutableStateFlow(ProjectEditState()) + val state = _state.asStateFlow() + + fun init(projectId: String?) { + if (projectId != null) { + viewModelScope.launch { + val project = projectRepository.getProjectById(projectId) ?: return@launch + _state.update { + it.copy( + projectId = projectId, + name = project.name, + color = project.color, + emoji = project.emoji, + isFavorite = project.isFavorite, + viewStyle = project.viewStyle, + ) + } + } + } + } + + fun setName(name: String) = _state.update { it.copy(name = name) } + fun setColor(color: String) = _state.update { it.copy(color = color) } + fun setEmoji(emoji: String?) = _state.update { it.copy(emoji = emoji) } + fun toggleFavorite() = _state.update { it.copy(isFavorite = !it.isFavorite) } + fun setViewStyle(style: ViewStyle) = _state.update { it.copy(viewStyle = style) } + + fun save(onDone: (projectId: String) -> Unit) { + val st = _state.value + if (st.name.isBlank()) return + _state.update { it.copy(isSaving = true) } + viewModelScope.launch { + val id = st.projectId ?: UUID.randomUUID().toString() + if (st.projectId != null) { + val existing = projectRepository.getProjectById(id) + if (existing != null) { + projectRepository.updateProject( + existing.copy( + name = st.name, + color = st.color, + emoji = st.emoji, + isFavorite = st.isFavorite, + viewStyle = st.viewStyle, + ) + ) + } + } else { + projectRepository.insertProject( + Project( + id = id, + name = st.name, + color = st.color, + emoji = st.emoji, + isFavorite = st.isFavorite, + viewStyle = st.viewStyle, + backendType = BackendType.LOCAL, + sortedBy = SortBy.MANUAL, + ) + ) + } + _state.update { it.copy(isSaving = false) } + onDone(id) + } + } + + fun delete(onDone: () -> Unit) { + val id = _state.value.projectId ?: return + viewModelScope.launch { + projectRepository.deleteProject(id) + onDone() + } + } +}