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/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/local/AppDatabase.kt b/app/src/main/java/com/planify/mobile/data/local/AppDatabase.kt
index 0c86eaf..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
@@ -2,7 +2,9 @@ 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.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
@@ -28,4 +30,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun taskDao(): TaskDao
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/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/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/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/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/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 e8d5238..bc9352e 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,6 @@ 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()
+ @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 0eb6edd..8c0977b 100644
--- a/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt
+++ b/app/src/main/java/com/planify/mobile/di/RepositoryModule.kt
@@ -1,9 +1,13 @@
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
@@ -24,4 +28,10 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindSectionRepository(impl: SectionRepositoryImpl): SectionRepository
+
+ @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/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/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/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/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/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()
+ }
+ }
+}
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/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/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/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)
+ }
+ }
+ }
+ }
+}
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")
+ }
+ }
+}
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()
+ }
+ }
+}
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" }