feat: Add timezone configuration with user-selectable preferences and notification formatting
- Add TimezonePreferences to SettingsViewModel and TodoNotificationReceiver entry point - Create timezone settings section in SettingsScreen with dropdown selector for common timezones - Apply user timezone to notification date formatting in TodoNotificationReceiver - Refactor TodoScreen FAB into expandable menu with separate AI Brain Dump and manual task creation options - Add animated sub-FABs with labels
This commit is contained in:
parent
f98b730b86
commit
bb6c54e7e5
@ -0,0 +1,65 @@
|
||||
package com.shaarit.core.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.TimeZone
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class TimezonePreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val prefs: SharedPreferences =
|
||||
context.getSharedPreferences("timezone_prefs", Context.MODE_PRIVATE)
|
||||
|
||||
private val _timezoneId = MutableStateFlow(loadTimezoneId())
|
||||
val timezoneId: StateFlow<String> = _timezoneId.asStateFlow()
|
||||
|
||||
private fun loadTimezoneId(): String {
|
||||
return prefs.getString(KEY_TIMEZONE_ID, TimeZone.getDefault().id)
|
||||
?: TimeZone.getDefault().id
|
||||
}
|
||||
|
||||
fun setTimezoneId(id: String) {
|
||||
prefs.edit().putString(KEY_TIMEZONE_ID, id).apply()
|
||||
_timezoneId.value = id
|
||||
}
|
||||
|
||||
fun getTimeZone(): TimeZone {
|
||||
return TimeZone.getTimeZone(_timezoneId.value)
|
||||
}
|
||||
|
||||
fun getZoneId(): java.time.ZoneId {
|
||||
return java.time.ZoneId.of(_timezoneId.value)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_TIMEZONE_ID = "user_timezone_id"
|
||||
|
||||
val COMMON_TIMEZONES = listOf(
|
||||
"America/New_York" to "Est (New York) UTC-5",
|
||||
"America/Chicago" to "Centre (Chicago) UTC-6",
|
||||
"America/Denver" to "Montagne (Denver) UTC-7",
|
||||
"America/Los_Angeles" to "Pacifique (Los Angeles) UTC-8",
|
||||
"America/Toronto" to "Est (Toronto) UTC-5",
|
||||
"America/Montreal" to "Est (Montréal) UTC-5",
|
||||
"America/Vancouver" to "Pacifique (Vancouver) UTC-8",
|
||||
"America/Winnipeg" to "Centre (Winnipeg) UTC-6",
|
||||
"America/Halifax" to "Atlantique (Halifax) UTC-4",
|
||||
"Europe/Paris" to "Europe Centrale (Paris) UTC+1",
|
||||
"Europe/London" to "Royaume-Uni (Londres) UTC+0",
|
||||
"Europe/Berlin" to "Europe Centrale (Berlin) UTC+1",
|
||||
"Europe/Brussels" to "Europe Centrale (Bruxelles) UTC+1",
|
||||
"Europe/Zurich" to "Europe Centrale (Zurich) UTC+1",
|
||||
"Asia/Tokyo" to "Japon (Tokyo) UTC+9",
|
||||
"Asia/Shanghai" to "Chine (Shanghai) UTC+8",
|
||||
"Australia/Sydney" to "Australie (Sydney) UTC+11",
|
||||
"Pacific/Auckland" to "Nouvelle-Zélande (Auckland) UTC+12"
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -21,9 +21,11 @@ import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.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
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
Loading…
x
Reference in New Issue
Block a user