diff --git a/app/src/main/java/com/shaarit/core/storage/TimezonePreferences.kt b/app/src/main/java/com/shaarit/core/storage/TimezonePreferences.kt new file mode 100644 index 0000000..f0fe27f --- /dev/null +++ b/app/src/main/java/com/shaarit/core/storage/TimezonePreferences.kt @@ -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 = _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" + ) + } +} diff --git a/app/src/main/java/com/shaarit/data/worker/TodoNotificationReceiver.kt b/app/src/main/java/com/shaarit/data/worker/TodoNotificationReceiver.kt index 28b3e80..5c106b9 100644 --- a/app/src/main/java/com/shaarit/data/worker/TodoNotificationReceiver.kt +++ b/app/src/main/java/com/shaarit/data/worker/TodoNotificationReceiver.kt @@ -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 } diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt index c90226a..88cf5fc 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt index ebe737a..a3caa0c 100644 --- a/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/settings/SettingsViewModel.kt @@ -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()) diff --git a/app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt b/app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt index f33867f..9de0fc7 100644 --- a/app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt +++ b/app/src/main/java/com/shaarit/presentation/todo/TodoScreen.kt @@ -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, + 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, + 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)) } diff --git a/app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt b/app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt index 43d1edd..ceffde7 100644 --- a/app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt +++ b/app/src/main/java/com/shaarit/presentation/todo/TodoViewModel.kt @@ -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 = 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> = 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() diff --git a/version.properties b/version.properties index af6330b..5ccd8b3 100644 --- a/version.properties +++ b/version.properties @@ -1,3 +1,3 @@ -#Fri Feb 13 15:47:32 2026 -VERSION_NAME=2.1.5 -VERSION_CODE=20 \ No newline at end of file +#Fri Feb 13 22:32:44 2026 +VERSION_NAME=2.1.6 +VERSION_CODE=21 \ No newline at end of file