Merge pull request 'Milestone/lot 3 taches' (#33) from milestone/lot-3-taches into main

Reviewed-on: Gato/Planify-mobile#33
This commit is contained in:
2026-06-06 06:23:22 +02:00
28 changed files with 1676 additions and 0 deletions
+4
View File
@@ -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)
+12
View File
@@ -26,6 +26,18 @@
</intent-filter>
</activity>
<receiver
android:name=".data.notification.ReminderReceiver"
android:exported="false" />
<receiver
android:name=".data.notification.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -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
}
@@ -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<List<LabelEntity>>
@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)
}
@@ -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<List<ReminderEntity>>
@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)
}
@@ -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
}
}
@@ -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"
}
}
@@ -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()
}
@@ -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<List<Label>> =
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,
)
}
@@ -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<List<Reminder>> =
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<DueDate>(it) }.getOrNull() },
minutesOffset = minutesOffset,
)
private fun Reminder.toEntity() = ReminderEntity(
id = id,
taskId = taskId,
type = type.name,
dueDate = dueDate?.let { Json.encodeToString(it) },
minutesOffset = minutesOffset,
)
}
@@ -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()
}
@@ -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
}
@@ -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
}
@@ -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<List<Label>>
suspend fun getLabelById(id: String): Label?
suspend fun insertLabel(label: Label)
suspend fun updateLabel(label: Label)
suspend fun deleteLabel(id: String)
}
@@ -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<List<Reminder>>
suspend fun insertReminder(reminder: Reminder)
suspend fun deleteReminder(reminder: Reminder)
suspend fun deleteRemindersByTask(taskId: String)
}
@@ -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,
)
}
}
@@ -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)
@@ -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()
}
}
}
@@ -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 },
)
}
}
@@ -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<String>,
onConfirm: (List<String>) -> 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() })
}
}
@@ -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)
}
}
}
}
}
@@ -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))
}
}
}
@@ -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<Reminder>,
onUpdate: (List<Reminder>) -> 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)
}
}
}
}
}
@@ -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<Task>,
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")
}
}
}
@@ -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)
@@ -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<String> = emptyList(),
val reminders: List<Reminder> = emptyList(),
val subTasks: List<Task> = 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<String>) = _state.update { it.copy(labels = labels) }
fun setReminders(reminders: List<Reminder>) = _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()
}
}
}
+1
View File
@@ -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
}
+3
View File
@@ -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" }