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:
@@ -3,6 +3,7 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.hilt)
|
alias(libs.plugins.hilt)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -82,6 +83,9 @@ dependencies {
|
|||||||
// WorkManager
|
// WorkManager
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation(libs.work.runtime.ktx)
|
||||||
|
|
||||||
|
// Serialization
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Tests
|
// Tests
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
|||||||
@@ -26,6 +26,18 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.planify.mobile.data.local
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
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.ProjectDao
|
||||||
|
import com.planify.mobile.data.local.dao.ReminderDao
|
||||||
import com.planify.mobile.data.local.dao.SectionDao
|
import com.planify.mobile.data.local.dao.SectionDao
|
||||||
import com.planify.mobile.data.local.dao.TaskDao
|
import com.planify.mobile.data.local.dao.TaskDao
|
||||||
import com.planify.mobile.data.local.entity.LabelEntity
|
import com.planify.mobile.data.local.entity.LabelEntity
|
||||||
@@ -28,4 +30,6 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun taskDao(): TaskDao
|
abstract fun taskDao(): TaskDao
|
||||||
abstract fun projectDao(): ProjectDao
|
abstract fun projectDao(): ProjectDao
|
||||||
abstract fun sectionDao(): SectionDao
|
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 provideTaskDao(db: AppDatabase) = db.taskDao()
|
||||||
@Provides fun provideProjectDao(db: AppDatabase) = db.projectDao()
|
@Provides fun provideProjectDao(db: AppDatabase) = db.projectDao()
|
||||||
@Provides fun provideSectionDao(db: AppDatabase) = db.sectionDao()
|
@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
|
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.ProjectRepositoryImpl
|
||||||
|
import com.planify.mobile.data.repository.ReminderRepositoryImpl
|
||||||
import com.planify.mobile.data.repository.SectionRepositoryImpl
|
import com.planify.mobile.data.repository.SectionRepositoryImpl
|
||||||
import com.planify.mobile.data.repository.TaskRepositoryImpl
|
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.ProjectRepository
|
||||||
|
import com.planify.mobile.domain.repository.ReminderRepository
|
||||||
import com.planify.mobile.domain.repository.SectionRepository
|
import com.planify.mobile.domain.repository.SectionRepository
|
||||||
import com.planify.mobile.domain.repository.TaskRepository
|
import com.planify.mobile.domain.repository.TaskRepository
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
@@ -24,4 +28,10 @@ abstract class RepositoryModule {
|
|||||||
|
|
||||||
@Binds @Singleton
|
@Binds @Singleton
|
||||||
abstract fun bindSectionRepository(impl: SectionRepositoryImpl): SectionRepository
|
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
|
package com.planify.mobile.domain.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
data class DueDate(
|
data class DueDate(
|
||||||
val date: String,
|
val date: String,
|
||||||
val timezone: String? = null,
|
val timezone: String? = null,
|
||||||
@@ -11,6 +14,7 @@ data class DueDate(
|
|||||||
val recurrencyEnd: String? = null,
|
val recurrencyEnd: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
enum class RecurrencyType {
|
enum class RecurrencyType {
|
||||||
NONE, MINUTELY, HOURLY, EVERY_DAY, EVERY_WEEK, EVERY_MONTH, EVERY_YEAR
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ plugins {
|
|||||||
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
alias(libs.plugins.jetbrains.kotlin.android) apply false
|
||||||
alias(libs.plugins.hilt) apply false
|
alias(libs.plugins.hilt) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ okhttp = "4.12.0"
|
|||||||
datastore = "1.1.1"
|
datastore = "1.1.1"
|
||||||
securityCrypto = "1.1.0-alpha06"
|
securityCrypto = "1.1.0-alpha06"
|
||||||
workManager = "2.9.0"
|
workManager = "2.9.0"
|
||||||
|
serialization = "1.6.3"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
junitExt = "1.2.1"
|
junitExt = "1.2.1"
|
||||||
espressoCore = "3.6.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" }
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||||
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
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" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitExt" }
|
||||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
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" }
|
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version = "1.9.24-1.0.20" }
|
ksp = { id = "com.google.devtools.ksp", version = "1.9.24-1.0.20" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
|||||||
Reference in New Issue
Block a user