feat: Add timezone configuration with user-selectable preferences and notification formatting
- Add TimezonePreferences to SettingsViewModel and TodoNotificationReceiver entry point - Create timezone settings section in SettingsScreen with dropdown selector for common timezones - Apply user timezone to notification date formatting in TodoNotificationReceiver - Refactor TodoScreen FAB into expandable menu with separate AI Brain Dump and manual task creation options - Add animated sub-FABs with labels
This commit is contained in:
parent
f98b730b86
commit
bb6c54e7e5
@ -0,0 +1,65 @@
|
|||||||
|
package com.shaarit.core.storage
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.TimeZone
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class TimezonePreferences @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context
|
||||||
|
) {
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences("timezone_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val _timezoneId = MutableStateFlow(loadTimezoneId())
|
||||||
|
val timezoneId: StateFlow<String> = _timezoneId.asStateFlow()
|
||||||
|
|
||||||
|
private fun loadTimezoneId(): String {
|
||||||
|
return prefs.getString(KEY_TIMEZONE_ID, TimeZone.getDefault().id)
|
||||||
|
?: TimeZone.getDefault().id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTimezoneId(id: String) {
|
||||||
|
prefs.edit().putString(KEY_TIMEZONE_ID, id).apply()
|
||||||
|
_timezoneId.value = id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getTimeZone(): TimeZone {
|
||||||
|
return TimeZone.getTimeZone(_timezoneId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getZoneId(): java.time.ZoneId {
|
||||||
|
return java.time.ZoneId.of(_timezoneId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_TIMEZONE_ID = "user_timezone_id"
|
||||||
|
|
||||||
|
val COMMON_TIMEZONES = listOf(
|
||||||
|
"America/New_York" to "Est (New York) UTC-5",
|
||||||
|
"America/Chicago" to "Centre (Chicago) UTC-6",
|
||||||
|
"America/Denver" to "Montagne (Denver) UTC-7",
|
||||||
|
"America/Los_Angeles" to "Pacifique (Los Angeles) UTC-8",
|
||||||
|
"America/Toronto" to "Est (Toronto) UTC-5",
|
||||||
|
"America/Montreal" to "Est (Montréal) UTC-5",
|
||||||
|
"America/Vancouver" to "Pacifique (Vancouver) UTC-8",
|
||||||
|
"America/Winnipeg" to "Centre (Winnipeg) UTC-6",
|
||||||
|
"America/Halifax" to "Atlantique (Halifax) UTC-4",
|
||||||
|
"Europe/Paris" to "Europe Centrale (Paris) UTC+1",
|
||||||
|
"Europe/London" to "Royaume-Uni (Londres) UTC+0",
|
||||||
|
"Europe/Berlin" to "Europe Centrale (Berlin) UTC+1",
|
||||||
|
"Europe/Brussels" to "Europe Centrale (Bruxelles) UTC+1",
|
||||||
|
"Europe/Zurich" to "Europe Centrale (Zurich) UTC+1",
|
||||||
|
"Asia/Tokyo" to "Japon (Tokyo) UTC+9",
|
||||||
|
"Asia/Shanghai" to "Chine (Shanghai) UTC+8",
|
||||||
|
"Australia/Sydney" to "Australie (Sydney) UTC+11",
|
||||||
|
"Pacific/Auckland" to "Nouvelle-Zélande (Auckland) UTC+12"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -21,9 +21,11 @@ import dagger.hilt.components.SingletonComponent
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import com.shaarit.core.storage.TimezonePreferences
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
class TodoNotificationReceiver : BroadcastReceiver() {
|
class TodoNotificationReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
@ -37,12 +39,13 @@ class TodoNotificationReceiver : BroadcastReceiver() {
|
|||||||
)
|
)
|
||||||
val todoRepository = entryPoint.todoRepository()
|
val todoRepository = entryPoint.todoRepository()
|
||||||
val notificationScheduler = entryPoint.notificationScheduler()
|
val notificationScheduler = entryPoint.notificationScheduler()
|
||||||
|
val timezonePreferences = entryPoint.timezonePreferences()
|
||||||
|
|
||||||
val pendingResult = goAsync()
|
val pendingResult = goAsync()
|
||||||
CoroutineScope(Dispatchers.IO).launch {
|
CoroutineScope(Dispatchers.IO).launch {
|
||||||
try {
|
try {
|
||||||
when (intent.action) {
|
when (intent.action) {
|
||||||
ACTION_TRIGGER -> showTodoNotification(context, todoId, todoRepository)
|
ACTION_TRIGGER -> showTodoNotification(context, todoId, todoRepository, timezonePreferences)
|
||||||
ACTION_MARK_DONE -> {
|
ACTION_MARK_DONE -> {
|
||||||
todoRepository.toggleDone(todoId, isDone = true)
|
todoRepository.toggleDone(todoId, isDone = true)
|
||||||
notificationScheduler.cancel(todoId)
|
notificationScheduler.cancel(todoId)
|
||||||
@ -64,7 +67,8 @@ class TodoNotificationReceiver : BroadcastReceiver() {
|
|||||||
private suspend fun showTodoNotification(
|
private suspend fun showTodoNotification(
|
||||||
context: Context,
|
context: Context,
|
||||||
todoId: Long,
|
todoId: Long,
|
||||||
todoRepository: TodoRepository
|
todoRepository: TodoRepository,
|
||||||
|
timezonePreferences: TimezonePreferences
|
||||||
) {
|
) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
val granted = ContextCompat.checkSelfPermission(
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
@ -114,6 +118,7 @@ class TodoNotificationReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
val dueText = todo.dueDate?.let {
|
val dueText = todo.dueDate?.let {
|
||||||
val sdf = SimpleDateFormat("EEE d MMM · HH:mm", Locale.FRANCE)
|
val sdf = SimpleDateFormat("EEE d MMM · HH:mm", Locale.FRANCE)
|
||||||
|
sdf.timeZone = timezonePreferences.getTimeZone()
|
||||||
sdf.format(Date(it))
|
sdf.format(Date(it))
|
||||||
} ?: context.getString(R.string.todo_no_due_date)
|
} ?: context.getString(R.string.todo_no_due_date)
|
||||||
|
|
||||||
@ -154,4 +159,5 @@ class TodoNotificationReceiver : BroadcastReceiver() {
|
|||||||
interface TodoNotificationReceiverEntryPoint {
|
interface TodoNotificationReceiverEntryPoint {
|
||||||
fun todoRepository(): TodoRepository
|
fun todoRepository(): TodoRepository
|
||||||
fun notificationScheduler(): TodoNotificationScheduler
|
fun notificationScheduler(): TodoNotificationScheduler
|
||||||
|
fun timezonePreferences(): TimezonePreferences
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import com.shaarit.core.storage.BiometricAuthManager
|
|||||||
import com.shaarit.core.storage.BiometricAvailability
|
import com.shaarit.core.storage.BiometricAvailability
|
||||||
import com.shaarit.core.storage.LockTimeout
|
import com.shaarit.core.storage.LockTimeout
|
||||||
import com.shaarit.core.storage.SecurityPreferences
|
import com.shaarit.core.storage.SecurityPreferences
|
||||||
|
import com.shaarit.core.storage.TimezonePreferences
|
||||||
import com.shaarit.data.export.BookmarkImporter
|
import com.shaarit.data.export.BookmarkImporter
|
||||||
import com.shaarit.ui.theme.AppTheme
|
import com.shaarit.ui.theme.AppTheme
|
||||||
import com.shaarit.ui.theme.ThemeMode
|
import com.shaarit.ui.theme.ThemeMode
|
||||||
@ -136,6 +137,18 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Timezone Section
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
SettingsSection(title = "Fuseau horaire")
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
TimezoneSettingsItem(
|
||||||
|
timezonePreferences = viewModel.timezonePreferences
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Security Section
|
// Security Section
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
@ -999,6 +1012,82 @@ private fun ThemePreviewCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimezoneSettingsItem(
|
||||||
|
timezonePreferences: TimezonePreferences
|
||||||
|
) {
|
||||||
|
val currentTimezoneId by timezonePreferences.timezoneId.collectAsState()
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val displayName = TimezonePreferences.COMMON_TIMEZONES
|
||||||
|
.firstOrNull { it.first == currentTimezoneId }?.second
|
||||||
|
?: currentTimezoneId
|
||||||
|
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { showMenu = true },
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Schedule,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "Fuseau horaire",
|
||||||
|
style = MaterialTheme.typography.bodyLarge
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = displayName,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ExpandMore,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMenu,
|
||||||
|
onDismissRequest = { showMenu = false }
|
||||||
|
) {
|
||||||
|
TimezonePreferences.COMMON_TIMEZONES.forEach { (id, label) ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(label) },
|
||||||
|
onClick = {
|
||||||
|
timezonePreferences.setTimezoneId(id)
|
||||||
|
showMenu = false
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
if (id == currentTimezoneId) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SecuritySettingsItem(
|
private fun SecuritySettingsItem(
|
||||||
securityPreferences: SecurityPreferences,
|
securityPreferences: SecurityPreferences,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.shaarit.core.storage.BiometricAuthManager
|
import com.shaarit.core.storage.BiometricAuthManager
|
||||||
import com.shaarit.core.storage.SecurityPreferences
|
import com.shaarit.core.storage.SecurityPreferences
|
||||||
|
import com.shaarit.core.storage.TimezonePreferences
|
||||||
import com.shaarit.core.storage.TokenManager
|
import com.shaarit.core.storage.TokenManager
|
||||||
import com.shaarit.data.export.BookmarkExporter
|
import com.shaarit.data.export.BookmarkExporter
|
||||||
import com.shaarit.data.export.BookmarkImporter
|
import com.shaarit.data.export.BookmarkImporter
|
||||||
@ -35,7 +36,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
private val workManager: WorkManager,
|
private val workManager: WorkManager,
|
||||||
val themePreferences: ThemePreferences,
|
val themePreferences: ThemePreferences,
|
||||||
val securityPreferences: SecurityPreferences,
|
val securityPreferences: SecurityPreferences,
|
||||||
val biometricAuthManager: BiometricAuthManager
|
val biometricAuthManager: BiometricAuthManager,
|
||||||
|
val timezonePreferences: TimezonePreferences
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
|
|||||||
@ -6,6 +6,13 @@ import android.app.TimePickerDialog
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
@ -35,6 +42,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.AccessTime
|
import androidx.compose.material.icons.filled.AccessTime
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.AutoAwesome
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
@ -52,6 +60,7 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SmallFloatingActionButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@ -78,6 +87,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.shaarit.R
|
import com.shaarit.R
|
||||||
|
import com.shaarit.core.storage.TimezonePreferences
|
||||||
import com.shaarit.domain.model.SubTask
|
import com.shaarit.domain.model.SubTask
|
||||||
import com.shaarit.domain.model.TodoItem
|
import com.shaarit.domain.model.TodoItem
|
||||||
import com.shaarit.ui.components.GlassCard
|
import com.shaarit.ui.components.GlassCard
|
||||||
@ -87,6 +97,7 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -99,8 +110,10 @@ fun TodoScreen(
|
|||||||
val selectedGroup by viewModel.selectedGroup.collectAsState()
|
val selectedGroup by viewModel.selectedGroup.collectAsState()
|
||||||
val dialogState by viewModel.dialogState.collectAsState()
|
val dialogState by viewModel.dialogState.collectAsState()
|
||||||
val editDialogState by viewModel.editDialogState.collectAsState()
|
val editDialogState by viewModel.editDialogState.collectAsState()
|
||||||
|
val userTz by viewModel.timezonePreferences.timezoneId.collectAsState()
|
||||||
|
|
||||||
var showBrainDumpDialog by remember { mutableStateOf(false) }
|
var showBrainDumpDialog by remember { mutableStateOf(false) }
|
||||||
|
var fabExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission()
|
contract = ActivityResultContracts.RequestPermission()
|
||||||
@ -162,124 +175,233 @@ fun TodoScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
FloatingActionButton(
|
Column(
|
||||||
onClick = {
|
horizontalAlignment = Alignment.End,
|
||||||
showBrainDumpDialog = true
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
viewModel.clearDialogState()
|
|
||||||
},
|
|
||||||
modifier = Modifier.size(74.dp),
|
|
||||||
containerColor = MaterialTheme.colorScheme.primary,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
|
||||||
) {
|
) {
|
||||||
Text(text = "✨", fontSize = 30.sp)
|
// Sub-FABs (visible when expanded)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = fabExpanded,
|
||||||
|
enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
|
||||||
|
exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 })
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// AI Brain Dump
|
||||||
|
if (viewModel.isGeminiConfigured) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
shadowElevation = 2.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Brain Dump IA",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SmallFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
fabExpanded = false
|
||||||
|
showBrainDumpDialog = true
|
||||||
|
viewModel.clearDialogState()
|
||||||
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.AutoAwesome, contentDescription = "Brain Dump IA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual task creation
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
shadowElevation = 2.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Nouvelle tâche",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SmallFloatingActionButton(
|
||||||
|
onClick = {
|
||||||
|
fabExpanded = false
|
||||||
|
viewModel.openCreateDialog()
|
||||||
|
},
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Nouvelle tâche")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main FAB
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { fabExpanded = !fabExpanded },
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
containerColor = if (fabExpanded) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = if (fabExpanded) MaterialTheme.colorScheme.onError else MaterialTheme.colorScheme.onPrimary
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (fabExpanded) Icons.Default.Close else Icons.Default.Add,
|
||||||
|
contentDescription = if (fabExpanded) "Fermer" else "Ajouter",
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(
|
// Dismiss FAB overlay on outside tap
|
||||||
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.padding(padding)
|
||||||
) {
|
) {
|
||||||
// Group filter chips
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
if (groupNames.isNotEmpty()) {
|
// Group filter chips
|
||||||
Row(
|
if (groupNames.isNotEmpty()) {
|
||||||
modifier = Modifier
|
Row(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.horizontalScroll(rememberScrollState())
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.horizontalScroll(rememberScrollState())
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
FilterChip(
|
) {
|
||||||
selected = selectedGroup == null,
|
|
||||||
onClick = { viewModel.selectGroup(null) },
|
|
||||||
label = { Text("Toutes") },
|
|
||||||
leadingIcon = if (selectedGroup == null) {
|
|
||||||
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
|
||||||
} else null
|
|
||||||
)
|
|
||||||
groupNames.forEach { group ->
|
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selectedGroup == group,
|
selected = selectedGroup == null,
|
||||||
onClick = { viewModel.selectGroup(group) },
|
onClick = { viewModel.selectGroup(null) },
|
||||||
label = { Text(group) },
|
label = { Text("Toutes") },
|
||||||
leadingIcon = {
|
leadingIcon = if (selectedGroup == null) {
|
||||||
Icon(
|
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(16.dp)) }
|
||||||
Icons.Default.Folder,
|
} else null
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
groupNames.forEach { group ->
|
||||||
}
|
FilterChip(
|
||||||
}
|
selected = selectedGroup == group,
|
||||||
|
onClick = { viewModel.selectGroup(group) },
|
||||||
if (filteredTodos.isEmpty()) {
|
label = { Text(group) },
|
||||||
Box(
|
leadingIcon = {
|
||||||
modifier = Modifier.fillMaxSize(),
|
Icon(
|
||||||
contentAlignment = Alignment.Center
|
Icons.Default.Folder,
|
||||||
) {
|
contentDescription = null,
|
||||||
Text(
|
modifier = Modifier.size(16.dp)
|
||||||
text = stringResourceCompat(R.string.todo_empty_state),
|
)
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
if (activeTodos.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = "${stringResourceCompat(R.string.todo_active_section)} (${activeTodos.size})",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items(activeTodos, key = { it.id }) { todo ->
|
|
||||||
TodoItemCard(
|
|
||||||
todo = todo,
|
|
||||||
onToggleDone = { checked -> viewModel.toggleTodo(todo.id, checked) },
|
|
||||||
onDelete = { viewModel.deleteTodo(todo.id) },
|
|
||||||
onEdit = { viewModel.openEditDialog(todo.id) },
|
|
||||||
onSnooze = if (todo.dueDate != null) {
|
|
||||||
{ viewModel.snoozeTodo(todo.id) }
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (doneTodos.isNotEmpty()) {
|
if (filteredTodos.isEmpty()) {
|
||||||
item {
|
Box(
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text(
|
Text(
|
||||||
text = "${stringResourceCompat(R.string.todo_done_section)} (${doneTodos.size})",
|
text = "📝",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
fontSize = 48.sp
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
)
|
||||||
}
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
items(doneTodos, key = { it.id }) { todo ->
|
text = stringResourceCompat(R.string.todo_empty_state),
|
||||||
TodoItemCard(
|
style = MaterialTheme.typography.titleMedium,
|
||||||
todo = todo,
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
onToggleDone = { checked -> viewModel.toggleTodo(todo.id, checked) },
|
)
|
||||||
onDelete = { viewModel.deleteTodo(todo.id) },
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
onEdit = { viewModel.openEditDialog(todo.id) },
|
Text(
|
||||||
onSnooze = null,
|
text = "Appuyez sur + pour créer une tâche",
|
||||||
isDoneSection = true
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
if (activeTodos.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "${stringResourceCompat(R.string.todo_active_section)} (${activeTodos.size})",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(activeTodos, key = { it.id }) { todo ->
|
||||||
|
TodoItemCard(
|
||||||
|
todo = todo,
|
||||||
|
onToggleDone = { checked -> viewModel.toggleTodo(todo.id, checked) },
|
||||||
|
onDelete = { viewModel.deleteTodo(todo.id) },
|
||||||
|
onEdit = { viewModel.openEditDialog(todo.id) },
|
||||||
|
onSnooze = if (todo.dueDate != null) {
|
||||||
|
{ viewModel.snoozeTodo(todo.id) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
timezoneId = userTz
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doneTodos.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "${stringResourceCompat(R.string.todo_done_section)} (${doneTodos.size})",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items(doneTodos, key = { it.id }) { todo ->
|
||||||
|
TodoItemCard(
|
||||||
|
todo = todo,
|
||||||
|
onToggleDone = { checked -> viewModel.toggleTodo(todo.id, checked) },
|
||||||
|
onDelete = { viewModel.deleteTodo(todo.id) },
|
||||||
|
onEdit = { viewModel.openEditDialog(todo.id) },
|
||||||
|
onSnooze = null,
|
||||||
|
isDoneSection = true,
|
||||||
|
timezoneId = userTz
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom spacer so FAB doesn't hide last item
|
||||||
|
item { Spacer(modifier = Modifier.height(80.dp)) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scrim when FAB expanded
|
||||||
|
if (fabExpanded) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.3f))
|
||||||
|
.clickable { fabExpanded = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,6 +409,7 @@ fun TodoScreen(
|
|||||||
BrainDumpDialog(
|
BrainDumpDialog(
|
||||||
state = dialogState,
|
state = dialogState,
|
||||||
groupNames = groupNames,
|
groupNames = groupNames,
|
||||||
|
timezoneId = userTz,
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
showBrainDumpDialog = false
|
showBrainDumpDialog = false
|
||||||
viewModel.clearDialogState()
|
viewModel.clearDialogState()
|
||||||
@ -306,6 +429,7 @@ fun TodoScreen(
|
|||||||
EditTodoDialog(
|
EditTodoDialog(
|
||||||
state = editDialogState,
|
state = editDialogState,
|
||||||
groupNames = groupNames,
|
groupNames = groupNames,
|
||||||
|
timezoneId = userTz,
|
||||||
onDismiss = viewModel::closeEditDialog,
|
onDismiss = viewModel::closeEditDialog,
|
||||||
onContentChanged = viewModel::onEditContentChanged,
|
onContentChanged = viewModel::onEditContentChanged,
|
||||||
onDueDateChanged = viewModel::onEditDueDateChanged,
|
onDueDateChanged = viewModel::onEditDueDateChanged,
|
||||||
@ -315,6 +439,9 @@ fun TodoScreen(
|
|||||||
onRemoveSubtask = viewModel::removeSubtask,
|
onRemoveSubtask = viewModel::removeSubtask,
|
||||||
onToggleSubtask = viewModel::toggleSubtask,
|
onToggleSubtask = viewModel::toggleSubtask,
|
||||||
onUpdateSubtaskContent = viewModel::updateSubtaskContent,
|
onUpdateSubtaskContent = viewModel::updateSubtaskContent,
|
||||||
|
onNewTagTextChanged = viewModel::onEditNewTagTextChanged,
|
||||||
|
onAddTag = viewModel::addTag,
|
||||||
|
onRemoveTag = viewModel::removeEditTag,
|
||||||
onSave = viewModel::saveEditedTodo
|
onSave = viewModel::saveEditedTodo
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -328,7 +455,8 @@ private fun TodoItemCard(
|
|||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onEdit: () -> Unit,
|
onEdit: () -> Unit,
|
||||||
onSnooze: (() -> Unit)?,
|
onSnooze: (() -> Unit)?,
|
||||||
isDoneSection: Boolean = false
|
isDoneSection: Boolean = false,
|
||||||
|
timezoneId: String = TimeZone.getDefault().id
|
||||||
) {
|
) {
|
||||||
val isOverdue = !todo.isDone && todo.dueDate != null && todo.dueDate < System.currentTimeMillis()
|
val isOverdue = !todo.isDone && todo.dueDate != null && todo.dueDate < System.currentTimeMillis()
|
||||||
|
|
||||||
@ -385,7 +513,8 @@ private fun TodoItemCard(
|
|||||||
DueDateChip(
|
DueDateChip(
|
||||||
dueDate = todo.dueDate,
|
dueDate = todo.dueDate,
|
||||||
isOverdue = isOverdue,
|
isOverdue = isOverdue,
|
||||||
isDone = todo.isDone
|
isDone = todo.isDone,
|
||||||
|
timezoneId = timezoneId
|
||||||
)
|
)
|
||||||
|
|
||||||
// Group chip
|
// Group chip
|
||||||
@ -463,9 +592,10 @@ private fun TodoItemCard(
|
|||||||
private fun DueDateChip(
|
private fun DueDateChip(
|
||||||
dueDate: Long?,
|
dueDate: Long?,
|
||||||
isOverdue: Boolean,
|
isOverdue: Boolean,
|
||||||
isDone: Boolean
|
isDone: Boolean,
|
||||||
|
timezoneId: String = TimeZone.getDefault().id
|
||||||
) {
|
) {
|
||||||
val label = dueDate?.let(::formatDateTime)
|
val label = dueDate?.let { formatDateTime(it, timezoneId) }
|
||||||
?: stringResourceCompat(R.string.todo_no_due_date)
|
?: stringResourceCompat(R.string.todo_no_due_date)
|
||||||
|
|
||||||
val chipColor = when {
|
val chipColor = when {
|
||||||
@ -506,13 +636,14 @@ private fun DueDateChip(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== Edit Todo Dialog ======
|
// ====== Edit / Create Todo Dialog ======
|
||||||
|
|
||||||
@OptIn(ExperimentalLayoutApi::class)
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun EditTodoDialog(
|
private fun EditTodoDialog(
|
||||||
state: EditTodoDialogUiState,
|
state: EditTodoDialogUiState,
|
||||||
groupNames: List<String>,
|
groupNames: List<String>,
|
||||||
|
timezoneId: String = TimeZone.getDefault().id,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onContentChanged: (String) -> Unit,
|
onContentChanged: (String) -> Unit,
|
||||||
onDueDateChanged: (Long?) -> Unit,
|
onDueDateChanged: (Long?) -> Unit,
|
||||||
@ -522,9 +653,13 @@ private fun EditTodoDialog(
|
|||||||
onRemoveSubtask: (Int) -> Unit,
|
onRemoveSubtask: (Int) -> Unit,
|
||||||
onToggleSubtask: (Int) -> Unit,
|
onToggleSubtask: (Int) -> Unit,
|
||||||
onUpdateSubtaskContent: (Int, String) -> Unit,
|
onUpdateSubtaskContent: (Int, String) -> Unit,
|
||||||
|
onNewTagTextChanged: (String) -> Unit,
|
||||||
|
onAddTag: () -> Unit,
|
||||||
|
onRemoveTag: (String) -> Unit,
|
||||||
onSave: () -> Unit
|
onSave: () -> Unit
|
||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val dialogTitle = if (state.isCreateMode) "➕ Nouvelle tâche" else "✏️ Modifier la tâche"
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
@ -534,7 +669,7 @@ private fun EditTodoDialog(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Text("✏️ Modifier la tâche")
|
Text(dialogTitle)
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(Icons.Default.Close, contentDescription = "Fermer")
|
Icon(Icons.Default.Close, contentDescription = "Fermer")
|
||||||
}
|
}
|
||||||
@ -607,13 +742,13 @@ private fun EditTodoDialog(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.dueDate?.let(::formatDateTime) ?: "Sans échéance",
|
text = state.dueDate?.let { formatDateTime(it, timezoneId) } ?: "Sans échéance",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Row {
|
Row {
|
||||||
TextButton(onClick = {
|
TextButton(onClick = {
|
||||||
pickDateTime(context, state.dueDate) { millis -> onDueDateChanged(millis) }
|
pickDateTime(context, state.dueDate, timezoneId) { millis -> onDueDateChanged(millis) }
|
||||||
}) {
|
}) {
|
||||||
Text("Date")
|
Text("Date")
|
||||||
}
|
}
|
||||||
@ -688,7 +823,14 @@ private fun EditTodoDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags (read-only display)
|
// Tags section with add capability
|
||||||
|
Text(
|
||||||
|
text = "Tags",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
|
||||||
if (state.tags.isNotEmpty()) {
|
if (state.tags.isNotEmpty()) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
@ -696,20 +838,54 @@ private fun EditTodoDialog(
|
|||||||
) {
|
) {
|
||||||
state.tags.forEach { tag ->
|
state.tags.forEach { tag ->
|
||||||
Surface(
|
Surface(
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small,
|
||||||
|
onClick = { onRemoveTag(tag) }
|
||||||
) {
|
) {
|
||||||
Text(
|
Row(
|
||||||
text = "#$tag",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
verticalAlignment = Alignment.CenterVertically
|
||||||
)
|
) {
|
||||||
|
Text(
|
||||||
|
text = "#$tag",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Supprimer le tag",
|
||||||
|
modifier = Modifier.size(12.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add tag
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.newTagText,
|
||||||
|
onValueChange = onNewTagTextChanged,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
placeholder = { Text("Nouveau tag...") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
imeAction = ImeAction.Done,
|
||||||
|
capitalization = KeyboardCapitalization.None
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { onAddTag() })
|
||||||
|
)
|
||||||
|
IconButton(onClick = onAddTag) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Ajouter tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state.errorMessage?.let { message ->
|
state.errorMessage?.let { message ->
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
@ -730,7 +906,7 @@ private fun EditTodoDialog(
|
|||||||
}
|
}
|
||||||
Icon(Icons.Default.Check, contentDescription = null)
|
Icon(Icons.Default.Check, contentDescription = null)
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
Text("Enregistrer")
|
Text(if (state.isCreateMode) "Créer" else "Enregistrer")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
@ -748,6 +924,7 @@ private fun EditTodoDialog(
|
|||||||
private fun BrainDumpDialog(
|
private fun BrainDumpDialog(
|
||||||
state: BrainDumpDialogUiState,
|
state: BrainDumpDialogUiState,
|
||||||
groupNames: List<String>,
|
groupNames: List<String>,
|
||||||
|
timezoneId: String = TimeZone.getDefault().id,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onInputChanged: (String) -> Unit,
|
onInputChanged: (String) -> Unit,
|
||||||
onVoiceInput: (String) -> Unit,
|
onVoiceInput: (String) -> Unit,
|
||||||
@ -896,7 +1073,7 @@ private fun BrainDumpDialog(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = task.dueDate?.let(::formatDateTime)
|
text = task.dueDate?.let { formatDateTime(it, timezoneId) }
|
||||||
?: stringResourceCompat(R.string.todo_no_due_date),
|
?: stringResourceCompat(R.string.todo_no_due_date),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
@ -907,6 +1084,7 @@ private fun BrainDumpDialog(
|
|||||||
pickDateTime(
|
pickDateTime(
|
||||||
context = context,
|
context = context,
|
||||||
initial = task.dueDate,
|
initial = task.dueDate,
|
||||||
|
timezoneId = timezoneId,
|
||||||
onDateSelected = { millis ->
|
onDateSelected = { millis ->
|
||||||
onTaskDueDateChanged(task.localId, millis)
|
onTaskDueDateChanged(task.localId, millis)
|
||||||
}
|
}
|
||||||
@ -1019,9 +1197,11 @@ private fun BrainDumpShimmer() {
|
|||||||
private fun pickDateTime(
|
private fun pickDateTime(
|
||||||
context: android.content.Context,
|
context: android.content.Context,
|
||||||
initial: Long?,
|
initial: Long?,
|
||||||
|
timezoneId: String = TimeZone.getDefault().id,
|
||||||
onDateSelected: (Long) -> Unit
|
onDateSelected: (Long) -> Unit
|
||||||
) {
|
) {
|
||||||
val calendar = Calendar.getInstance().apply {
|
val tz = TimeZone.getTimeZone(timezoneId)
|
||||||
|
val calendar = Calendar.getInstance(tz).apply {
|
||||||
if (initial != null) {
|
if (initial != null) {
|
||||||
timeInMillis = initial
|
timeInMillis = initial
|
||||||
}
|
}
|
||||||
@ -1033,7 +1213,7 @@ private fun pickDateTime(
|
|||||||
TimePickerDialog(
|
TimePickerDialog(
|
||||||
context,
|
context,
|
||||||
{ _, hourOfDay, minute ->
|
{ _, hourOfDay, minute ->
|
||||||
val selected = Calendar.getInstance().apply {
|
val selected = Calendar.getInstance(tz).apply {
|
||||||
set(year, month, dayOfMonth, hourOfDay, minute, 0)
|
set(year, month, dayOfMonth, hourOfDay, minute, 0)
|
||||||
set(Calendar.MILLISECOND, 0)
|
set(Calendar.MILLISECOND, 0)
|
||||||
}
|
}
|
||||||
@ -1050,8 +1230,9 @@ private fun pickDateTime(
|
|||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDateTime(timestamp: Long): String {
|
private fun formatDateTime(timestamp: Long, timezoneId: String = TimeZone.getDefault().id): String {
|
||||||
val sdf = SimpleDateFormat("EEE d MMM · HH:mm", Locale.FRANCE)
|
val sdf = SimpleDateFormat("EEE d MMM · HH:mm", Locale.FRANCE)
|
||||||
|
sdf.timeZone = TimeZone.getTimeZone(timezoneId)
|
||||||
return sdf.format(Date(timestamp))
|
return sdf.format(Date(timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.shaarit.presentation.todo
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.shaarit.core.storage.TimezonePreferences
|
||||||
import com.shaarit.data.sync.SyncManager
|
import com.shaarit.data.sync.SyncManager
|
||||||
import com.shaarit.data.worker.TodoNotificationScheduler
|
import com.shaarit.data.worker.TodoNotificationScheduler
|
||||||
import com.shaarit.domain.model.SubTask
|
import com.shaarit.domain.model.SubTask
|
||||||
@ -40,6 +41,7 @@ data class BrainDumpDialogUiState(
|
|||||||
|
|
||||||
data class EditTodoDialogUiState(
|
data class EditTodoDialogUiState(
|
||||||
val isVisible: Boolean = false,
|
val isVisible: Boolean = false,
|
||||||
|
val isCreateMode: Boolean = false,
|
||||||
val todoId: Long = 0,
|
val todoId: Long = 0,
|
||||||
val content: String = "",
|
val content: String = "",
|
||||||
val dueDate: Long? = null,
|
val dueDate: Long? = null,
|
||||||
@ -47,6 +49,7 @@ data class EditTodoDialogUiState(
|
|||||||
val groupName: String = "",
|
val groupName: String = "",
|
||||||
val subtasks: List<SubTask> = emptyList(),
|
val subtasks: List<SubTask> = emptyList(),
|
||||||
val newSubtaskText: String = "",
|
val newSubtaskText: String = "",
|
||||||
|
val newTagText: String = "",
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val shaarliLinkUrl: String = "",
|
val shaarliLinkUrl: String = "",
|
||||||
@ -58,9 +61,13 @@ class TodoViewModel @Inject constructor(
|
|||||||
private val todoRepository: TodoRepository,
|
private val todoRepository: TodoRepository,
|
||||||
private val geminiRepository: GeminiRepository,
|
private val geminiRepository: GeminiRepository,
|
||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
private val notificationScheduler: TodoNotificationScheduler
|
private val notificationScheduler: TodoNotificationScheduler,
|
||||||
|
val timezonePreferences: TimezonePreferences
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val isGeminiConfigured: Boolean
|
||||||
|
get() = geminiRepository.isApiKeyConfigured()
|
||||||
|
|
||||||
val todos: StateFlow<List<TodoItem>> =
|
val todos: StateFlow<List<TodoItem>> =
|
||||||
todoRepository
|
todoRepository
|
||||||
.getTodosStream()
|
.getTodosStream()
|
||||||
@ -287,7 +294,14 @@ class TodoViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ====== Edit Todo Dialog ======
|
// ====== Create / Edit Todo Dialog ======
|
||||||
|
|
||||||
|
fun openCreateDialog() {
|
||||||
|
_editDialogState.value = EditTodoDialogUiState(
|
||||||
|
isVisible = true,
|
||||||
|
isCreateMode = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun openEditDialog(todoId: Long) {
|
fun openEditDialog(todoId: Long) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@ -326,6 +340,27 @@ class TodoViewModel @Inject constructor(
|
|||||||
_editDialogState.update { it.copy(newSubtaskText = text) }
|
_editDialogState.update { it.copy(newSubtaskText = text) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onEditNewTagTextChanged(text: String) {
|
||||||
|
_editDialogState.update { it.copy(newTagText = text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addTag() {
|
||||||
|
val tag = _editDialogState.value.newTagText.trim().trimStart('#').lowercase()
|
||||||
|
if (tag.isBlank()) return
|
||||||
|
_editDialogState.update {
|
||||||
|
it.copy(
|
||||||
|
tags = (it.tags + tag).distinct(),
|
||||||
|
newTagText = ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeEditTag(tag: String) {
|
||||||
|
_editDialogState.update {
|
||||||
|
it.copy(tags = it.tags.filterNot { t -> t.equals(tag, ignoreCase = true) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun addSubtask() {
|
fun addSubtask() {
|
||||||
val text = _editDialogState.value.newSubtaskText.trim()
|
val text = _editDialogState.value.newSubtaskText.trim()
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
@ -371,7 +406,18 @@ class TodoViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_editDialogState.update { it.copy(isSaving = true, errorMessage = null) }
|
_editDialogState.update { it.copy(isSaving = true, errorMessage = null) }
|
||||||
|
|
||||||
val result = todoRepository.upsertTodo(
|
val todoItem = if (state.isCreateMode) {
|
||||||
|
TodoItem(
|
||||||
|
shaarliLinkUrl = "",
|
||||||
|
content = content,
|
||||||
|
isDone = false,
|
||||||
|
dueDate = state.dueDate,
|
||||||
|
tags = state.tags,
|
||||||
|
isSynced = false,
|
||||||
|
groupName = state.groupName.takeIf { it.isNotBlank() },
|
||||||
|
subtasks = state.subtasks.filter { it.content.isNotBlank() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
TodoItem(
|
TodoItem(
|
||||||
id = state.todoId,
|
id = state.todoId,
|
||||||
shaarliLinkUrl = state.shaarliLinkUrl,
|
shaarliLinkUrl = state.shaarliLinkUrl,
|
||||||
@ -383,7 +429,9 @@ class TodoViewModel @Inject constructor(
|
|||||||
groupName = state.groupName.takeIf { it.isNotBlank() },
|
groupName = state.groupName.takeIf { it.isNotBlank() },
|
||||||
subtasks = state.subtasks.filter { it.content.isNotBlank() }
|
subtasks = state.subtasks.filter { it.content.isNotBlank() }
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
|
val result = todoRepository.upsertTodo(todoItem)
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
syncManager.syncNow()
|
syncManager.syncNow()
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
#Fri Feb 13 15:47:32 2026
|
#Fri Feb 13 22:32:44 2026
|
||||||
VERSION_NAME=2.1.5
|
VERSION_NAME=2.1.6
|
||||||
VERSION_CODE=20
|
VERSION_CODE=21
|
||||||
Loading…
x
Reference in New Issue
Block a user