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:
Bruno Charest 2026-02-13 22:51:41 -05:00
parent f98b730b86
commit bb6c54e7e5
7 changed files with 518 additions and 127 deletions

View File

@ -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"
)
}
}

View File

@ -21,9 +21,11 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import com.shaarit.core.storage.TimezonePreferences
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
class TodoNotificationReceiver : BroadcastReceiver() {
@ -37,12 +39,13 @@ class TodoNotificationReceiver : BroadcastReceiver() {
)
val todoRepository = entryPoint.todoRepository()
val notificationScheduler = entryPoint.notificationScheduler()
val timezonePreferences = entryPoint.timezonePreferences()
val pendingResult = goAsync()
CoroutineScope(Dispatchers.IO).launch {
try {
when (intent.action) {
ACTION_TRIGGER -> showTodoNotification(context, todoId, todoRepository)
ACTION_TRIGGER -> showTodoNotification(context, todoId, todoRepository, timezonePreferences)
ACTION_MARK_DONE -> {
todoRepository.toggleDone(todoId, isDone = true)
notificationScheduler.cancel(todoId)
@ -64,7 +67,8 @@ class TodoNotificationReceiver : BroadcastReceiver() {
private suspend fun showTodoNotification(
context: Context,
todoId: Long,
todoRepository: TodoRepository
todoRepository: TodoRepository,
timezonePreferences: TimezonePreferences
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val granted = ContextCompat.checkSelfPermission(
@ -114,6 +118,7 @@ class TodoNotificationReceiver : BroadcastReceiver() {
val dueText = todo.dueDate?.let {
val sdf = SimpleDateFormat("EEE d MMM · HH:mm", Locale.FRANCE)
sdf.timeZone = timezonePreferences.getTimeZone()
sdf.format(Date(it))
} ?: context.getString(R.string.todo_no_due_date)
@ -154,4 +159,5 @@ class TodoNotificationReceiver : BroadcastReceiver() {
interface TodoNotificationReceiverEntryPoint {
fun todoRepository(): TodoRepository
fun notificationScheduler(): TodoNotificationScheduler
fun timezonePreferences(): TimezonePreferences
}

View File

@ -38,6 +38,7 @@ import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.BiometricAvailability
import com.shaarit.core.storage.LockTimeout
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.core.storage.TimezonePreferences
import com.shaarit.data.export.BookmarkImporter
import com.shaarit.ui.theme.AppTheme
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
item {
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
private fun SecuritySettingsItem(
securityPreferences: SecurityPreferences,

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.core.storage.BiometricAuthManager
import com.shaarit.core.storage.SecurityPreferences
import com.shaarit.core.storage.TimezonePreferences
import com.shaarit.core.storage.TokenManager
import com.shaarit.data.export.BookmarkExporter
import com.shaarit.data.export.BookmarkImporter
@ -35,7 +36,8 @@ class SettingsViewModel @Inject constructor(
private val workManager: WorkManager,
val themePreferences: ThemePreferences,
val securityPreferences: SecurityPreferences,
val biometricAuthManager: BiometricAuthManager
val biometricAuthManager: BiometricAuthManager,
val timezonePreferences: TimezonePreferences
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())

View File

@ -6,6 +6,13 @@ import android.app.TimePickerDialog
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
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.clickable
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.Add
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.Close
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.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -78,6 +87,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.shaarit.R
import com.shaarit.core.storage.TimezonePreferences
import com.shaarit.domain.model.SubTask
import com.shaarit.domain.model.TodoItem
import com.shaarit.ui.components.GlassCard
@ -87,6 +97,7 @@ import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
@ -99,8 +110,10 @@ fun TodoScreen(
val selectedGroup by viewModel.selectedGroup.collectAsState()
val dialogState by viewModel.dialogState.collectAsState()
val editDialogState by viewModel.editDialogState.collectAsState()
val userTz by viewModel.timezonePreferences.timezoneId.collectAsState()
var showBrainDumpDialog by remember { mutableStateOf(false) }
var fabExpanded by remember { mutableStateOf(false) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
@ -162,124 +175,233 @@ fun TodoScreen(
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
showBrainDumpDialog = true
viewModel.clearDialogState()
},
modifier = Modifier.size(74.dp),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
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 ->
Column(
// Dismiss FAB overlay on outside tap
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Group filter chips
if (groupNames.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.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 ->
Column(modifier = Modifier.fillMaxSize()) {
// Group filter chips
if (groupNames.isNotEmpty()) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = selectedGroup == group,
onClick = { viewModel.selectGroup(group) },
label = { Text(group) },
leadingIcon = {
Icon(
Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
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
)
}
}
}
if (filteredTodos.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
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
groupNames.forEach { group ->
FilterChip(
selected = selectedGroup == group,
onClick = { viewModel.selectGroup(group) },
label = { Text(group) },
leadingIcon = {
Icon(
Icons.Default.Folder,
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
if (doneTodos.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(8.dp))
if (filteredTodos.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${stringResourceCompat(R.string.todo_done_section)} (${doneTodos.size})",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold
text = "📝",
fontSize = 48.sp
)
}
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
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResourceCompat(R.string.todo_empty_state),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Appuyez sur + pour créer une tâche",
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(
state = dialogState,
groupNames = groupNames,
timezoneId = userTz,
onDismiss = {
showBrainDumpDialog = false
viewModel.clearDialogState()
@ -306,6 +429,7 @@ fun TodoScreen(
EditTodoDialog(
state = editDialogState,
groupNames = groupNames,
timezoneId = userTz,
onDismiss = viewModel::closeEditDialog,
onContentChanged = viewModel::onEditContentChanged,
onDueDateChanged = viewModel::onEditDueDateChanged,
@ -315,6 +439,9 @@ fun TodoScreen(
onRemoveSubtask = viewModel::removeSubtask,
onToggleSubtask = viewModel::toggleSubtask,
onUpdateSubtaskContent = viewModel::updateSubtaskContent,
onNewTagTextChanged = viewModel::onEditNewTagTextChanged,
onAddTag = viewModel::addTag,
onRemoveTag = viewModel::removeEditTag,
onSave = viewModel::saveEditedTodo
)
}
@ -328,7 +455,8 @@ private fun TodoItemCard(
onDelete: () -> Unit,
onEdit: () -> 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()
@ -385,7 +513,8 @@ private fun TodoItemCard(
DueDateChip(
dueDate = todo.dueDate,
isOverdue = isOverdue,
isDone = todo.isDone
isDone = todo.isDone,
timezoneId = timezoneId
)
// Group chip
@ -463,9 +592,10 @@ private fun TodoItemCard(
private fun DueDateChip(
dueDate: Long?,
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)
val chipColor = when {
@ -506,13 +636,14 @@ private fun DueDateChip(
}
}
// ====== Edit Todo Dialog ======
// ====== Edit / Create Todo Dialog ======
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EditTodoDialog(
state: EditTodoDialogUiState,
groupNames: List<String>,
timezoneId: String = TimeZone.getDefault().id,
onDismiss: () -> Unit,
onContentChanged: (String) -> Unit,
onDueDateChanged: (Long?) -> Unit,
@ -522,9 +653,13 @@ private fun EditTodoDialog(
onRemoveSubtask: (Int) -> Unit,
onToggleSubtask: (Int) -> Unit,
onUpdateSubtaskContent: (Int, String) -> Unit,
onNewTagTextChanged: (String) -> Unit,
onAddTag: () -> Unit,
onRemoveTag: (String) -> Unit,
onSave: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
val dialogTitle = if (state.isCreateMode) " Nouvelle tâche" else "✏️ Modifier la tâche"
AlertDialog(
onDismissRequest = onDismiss,
@ -534,7 +669,7 @@ private fun EditTodoDialog(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("✏️ Modifier la tâche")
Text(dialogTitle)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Fermer")
}
@ -607,13 +742,13 @@ private fun EditTodoDialog(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = state.dueDate?.let(::formatDateTime) ?: "Sans échéance",
text = state.dueDate?.let { formatDateTime(it, timezoneId) } ?: "Sans échéance",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row {
TextButton(onClick = {
pickDateTime(context, state.dueDate) { millis -> onDueDateChanged(millis) }
pickDateTime(context, state.dueDate, timezoneId) { millis -> onDueDateChanged(millis) }
}) {
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()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
@ -696,20 +838,54 @@ private fun EditTodoDialog(
) {
state.tags.forEach { tag ->
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.small,
onClick = { onRemoveTag(tag) }
) {
Text(
text = "#$tag",
style = MaterialTheme.typography.labelSmall,
Row(
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 ->
Text(
text = message,
@ -730,7 +906,7 @@ private fun EditTodoDialog(
}
Icon(Icons.Default.Check, contentDescription = null)
Spacer(modifier = Modifier.width(6.dp))
Text("Enregistrer")
Text(if (state.isCreateMode) "Créer" else "Enregistrer")
}
},
dismissButton = {
@ -748,6 +924,7 @@ private fun EditTodoDialog(
private fun BrainDumpDialog(
state: BrainDumpDialogUiState,
groupNames: List<String>,
timezoneId: String = TimeZone.getDefault().id,
onDismiss: () -> Unit,
onInputChanged: (String) -> Unit,
onVoiceInput: (String) -> Unit,
@ -896,7 +1073,7 @@ private fun BrainDumpDialog(
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = task.dueDate?.let(::formatDateTime)
text = task.dueDate?.let { formatDateTime(it, timezoneId) }
?: stringResourceCompat(R.string.todo_no_due_date),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
@ -907,6 +1084,7 @@ private fun BrainDumpDialog(
pickDateTime(
context = context,
initial = task.dueDate,
timezoneId = timezoneId,
onDateSelected = { millis ->
onTaskDueDateChanged(task.localId, millis)
}
@ -1019,9 +1197,11 @@ private fun BrainDumpShimmer() {
private fun pickDateTime(
context: android.content.Context,
initial: Long?,
timezoneId: String = TimeZone.getDefault().id,
onDateSelected: (Long) -> Unit
) {
val calendar = Calendar.getInstance().apply {
val tz = TimeZone.getTimeZone(timezoneId)
val calendar = Calendar.getInstance(tz).apply {
if (initial != null) {
timeInMillis = initial
}
@ -1033,7 +1213,7 @@ private fun pickDateTime(
TimePickerDialog(
context,
{ _, hourOfDay, minute ->
val selected = Calendar.getInstance().apply {
val selected = Calendar.getInstance(tz).apply {
set(year, month, dayOfMonth, hourOfDay, minute, 0)
set(Calendar.MILLISECOND, 0)
}
@ -1050,8 +1230,9 @@ private fun pickDateTime(
).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)
sdf.timeZone = TimeZone.getTimeZone(timezoneId)
return sdf.format(Date(timestamp))
}

View File

@ -2,6 +2,7 @@ package com.shaarit.presentation.todo
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.shaarit.core.storage.TimezonePreferences
import com.shaarit.data.sync.SyncManager
import com.shaarit.data.worker.TodoNotificationScheduler
import com.shaarit.domain.model.SubTask
@ -40,6 +41,7 @@ data class BrainDumpDialogUiState(
data class EditTodoDialogUiState(
val isVisible: Boolean = false,
val isCreateMode: Boolean = false,
val todoId: Long = 0,
val content: String = "",
val dueDate: Long? = null,
@ -47,6 +49,7 @@ data class EditTodoDialogUiState(
val groupName: String = "",
val subtasks: List<SubTask> = emptyList(),
val newSubtaskText: String = "",
val newTagText: String = "",
val isSaving: Boolean = false,
val errorMessage: String? = null,
val shaarliLinkUrl: String = "",
@ -58,9 +61,13 @@ class TodoViewModel @Inject constructor(
private val todoRepository: TodoRepository,
private val geminiRepository: GeminiRepository,
private val syncManager: SyncManager,
private val notificationScheduler: TodoNotificationScheduler
private val notificationScheduler: TodoNotificationScheduler,
val timezonePreferences: TimezonePreferences
) : ViewModel() {
val isGeminiConfigured: Boolean
get() = geminiRepository.isApiKeyConfigured()
val todos: StateFlow<List<TodoItem>> =
todoRepository
.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) {
viewModelScope.launch {
@ -326,6 +340,27 @@ class TodoViewModel @Inject constructor(
_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() {
val text = _editDialogState.value.newSubtaskText.trim()
if (text.isBlank()) return
@ -371,7 +406,18 @@ class TodoViewModel @Inject constructor(
viewModelScope.launch {
_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(
id = state.todoId,
shaarliLinkUrl = state.shaarliLinkUrl,
@ -383,7 +429,9 @@ class TodoViewModel @Inject constructor(
groupName = state.groupName.takeIf { it.isNotBlank() },
subtasks = state.subtasks.filter { it.content.isNotBlank() }
)
)
}
val result = todoRepository.upsertTodo(todoItem)
if (result.isSuccess) {
syncManager.syncNow()

View File

@ -1,3 +1,3 @@
#Fri Feb 13 15:47:32 2026
VERSION_NAME=2.1.5
VERSION_CODE=20
#Fri Feb 13 22:32:44 2026
VERSION_NAME=2.1.6
VERSION_CODE=21