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.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
} }

View File

@ -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,

View File

@ -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())

View File

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

View File

@ -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()

View File

@ -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