From 4ee807702fec3d7ccf11648216e3263afe2c1eae Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 13:32:25 -0400 Subject: [PATCH 01/19] feat(ui): enhance priority visualization and refine home screen layout This update introduces distinct iconography for different task priority levels and reorders category tiles on the Home screen across both Android and iOS. Priority markers now distinguish between "medium" and "high/urgent" tasks using specific icons instead of a single binary flag. - **Priority Visualization**: - Implement `priorityIconFor` (Android) and `priorityIndicatorSymbolName` (iOS) to provide tiered icons: `PriorityHigh`/`exclamationmark.circle.fill` for high/urgent/important levels and `Flag` for medium. - Update task rows in Home, Todo List, Calendar, and Completed screens to display these specific priority indicators. - Expand color logic to consistently handle "urgent" and "important" priority strings. - Refactor task metadata layout to group list and priority icons within horizontal containers (`Row`/`HStack`) with standard spacing. - **Home Screen Refinement**: - Reorder category tiles to prioritize "Scheduled" and "Priority" on the top row, moving "Overdue" to the second row alongside "All". - Standardize tile colors and watermark icons across platforms for better visual consistency. - **Technical Refactoring**: - Replace boolean `isHighPriority` checks with optional icon lookups to support multiple priority states. - Ensure case-insensitive and trimmed string comparison for priority parsing on both platforms. - Update `buildScheduledSections` in `TodoListScreen` to handle priority and list modes with explicit logic branches. --- .../feature/calendar/CalendarScreen.kt | 27 ++++--- .../feature/completed/CompletedScreen.kt | 17 +++-- .../tday/compose/feature/home/HomeScreen.kt | 73 +++++++++++++------ .../compose/feature/todos/TodoListScreen.kt | 40 +++++----- .../Feature/Calendar/CalendarScreen.swift | 12 +-- .../Feature/Completed/CompletedScreen.swift | 8 +- .../Tday/Feature/Home/HomeScreen.swift | 42 +++++++---- .../Tday/Feature/Todos/TodoListScreen.swift | 34 +++++++-- 8 files changed, 160 insertions(+), 93 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 7e6620df..55b06404 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -1989,9 +1989,9 @@ private fun CalendarTaskDragPreview( modifier = Modifier.size(18.dp), ) } - if (isHighPriority(todo.priority)) { + priorityIconFor(todo.priority)?.let { priorityIcon -> Icon( - imageVector = Icons.Rounded.Flag, + imageVector = priorityIcon, contentDescription = null, tint = priorityColor(todo.priority), modifier = Modifier.size(18.dp), @@ -2041,7 +2041,8 @@ private fun CalendarTodoRow( .format(todo.due) val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val showListIndicator = listMeta != null - val showPriorityFlag = isHighPriority(todo.priority) + val priorityIcon = priorityIconFor(todo.priority) + val showPriorityIcon = priorityIcon != null val listIndicatorColor = listAccentColor(listMeta?.color) val rowShape = RoundedCornerShape(16.dp) val foregroundColor = colorScheme.background @@ -2238,7 +2239,7 @@ private fun CalendarTodoRow( style = MaterialTheme.typography.bodySmall, ) } - if (showListIndicator || showPriorityFlag) { + if (showListIndicator || showPriorityIcon) { Row( modifier = Modifier.padding(end = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -2252,9 +2253,9 @@ private fun CalendarTodoRow( modifier = Modifier.size(18.dp), ) } - if (showPriorityFlag) { + if (priorityIcon != null) { Icon( - imageVector = Icons.Rounded.Flag, + imageVector = priorityIcon, contentDescription = stringResource(R.string.label_priority_task), tint = priorityColor(todo.priority), modifier = Modifier.size(18.dp), @@ -2295,7 +2296,8 @@ private fun CalendarCompletedTodoRow( ?: item.listColor?.let(::listAccentColor) ?: colorScheme.onSurfaceVariant.copy(alpha = 0.86f) val showListIndicator = !item.listName.isNullOrBlank() || listMeta != null - val showPriorityFlag = isHighPriority(item.priority) + val priorityIcon = priorityIconFor(item.priority) + val showPriorityIcon = priorityIcon != null val rowShape = RoundedCornerShape(16.dp) Column( @@ -2368,7 +2370,7 @@ private fun CalendarCompletedTodoRow( style = MaterialTheme.typography.bodySmall, ) } - if (showPriorityFlag) { + if (showPriorityIcon) { Row( modifier = Modifier.padding(end = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -2383,7 +2385,7 @@ private fun CalendarCompletedTodoRow( ) } Icon( - imageVector = Icons.Rounded.Flag, + imageVector = priorityIcon ?: Icons.Rounded.Flag, contentDescription = stringResource(R.string.label_priority_task), tint = priorityColor(item.priority), modifier = Modifier.size(18.dp), @@ -2559,10 +2561,11 @@ private fun priorityColor(priority: String): Color { } } -private fun isHighPriority(priority: String): Boolean { +private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { - "medium", "high", "urgent", "important" -> true - else -> false + "medium" -> Icons.Rounded.Flag + "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + else -> null } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt index 71bb5c6b..ce735795 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.material.icons.rounded.Inbox import androidx.compose.material.icons.rounded.LocalBar import androidx.compose.material.icons.rounded.LocalHospital import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.PriorityHigh import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material.icons.rounded.Restaurant import androidx.compose.material.icons.rounded.Schedule @@ -602,7 +603,8 @@ private fun CompletedSwipeRow( ?: item.listColor?.let(::listAccentColor) ?: colorScheme.onSurfaceVariant.copy(alpha = 0.86f) val showListIndicator = !item.listName.isNullOrBlank() || listMeta != null - val showPriorityFlag = isHighPriority(item.priority) + val priorityIcon = priorityIconFor(item.priority) + val showPriorityIcon = priorityIcon != null val rowShape = RoundedCornerShape(16.dp) val foregroundColor = colorScheme.background @@ -775,7 +777,7 @@ private fun CompletedSwipeRow( } } - if (showPriorityFlag) { + if (showPriorityIcon) { Row( modifier = Modifier.padding(end = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -790,7 +792,7 @@ private fun CompletedSwipeRow( ) } Icon( - imageVector = Icons.Rounded.Flag, + imageVector = priorityIcon ?: Icons.Rounded.Flag, contentDescription = stringResource(R.string.label_priority_task), tint = priorityColor(item.priority), modifier = Modifier.size(18.dp), @@ -881,10 +883,11 @@ private fun priorityColor(priority: String): Color { } } -private fun isHighPriority(priority: String): Boolean { - return when (priority.trim().lowercase()) { - "medium", "high", "urgent", "important" -> true - else -> false +private fun priorityIconFor(priority: String): ImageVector? { + return when (priority.trim().lowercase(Locale.getDefault())) { + "medium" -> Icons.Rounded.Flag + "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + else -> null } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index c39c1455..401f808d 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -1704,6 +1704,7 @@ private fun HomeTodayTaskRow( val rowShape = RoundedCornerShape(16.dp) val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val listIndicatorColor = listColorAccent(listMeta?.color) + val priorityIcon = priorityIconFor(todo.priority) val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) val subtitleColor = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy( @@ -1902,16 +1903,29 @@ private fun HomeTodayTaskRow( ) } - if (listMeta != null) { - Icon( - imageVector = listIconForKey(listMeta.iconKey), - contentDescription = null, - tint = listIndicatorColor, - modifier = Modifier - .size(18.dp) - .padding(end = 0.dp), - ) - Spacer(Modifier.width(12.dp)) + if (listMeta != null || priorityIcon != null) { + Row( + modifier = Modifier.padding(end = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (listMeta != null) { + Icon( + imageVector = listIconForKey(listMeta.iconKey), + contentDescription = null, + tint = listIndicatorColor, + modifier = Modifier.size(18.dp), + ) + } + if (priorityIcon != null) { + Icon( + imageVector = priorityIcon, + contentDescription = stringResource(R.string.label_priority_task), + tint = priorityColor(todo.priority), + modifier = Modifier.size(18.dp), + ) + } + } } } } @@ -1939,15 +1953,6 @@ private fun CategoryGrid( Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - CategoryCard( - modifier = Modifier.weight(1f), - color = Color(0xFFDA7661), - icon = Icons.Rounded.ErrorOutline, - backgroundWatermark = Icons.Rounded.ErrorOutline, - title = stringResource(R.string.home_category_overdue), - count = overdueCount, - onClick = onOpenOverdue, - ) CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFFDDB37D), @@ -1957,8 +1962,6 @@ private fun CategoryGrid( count = scheduledCount, onClick = onOpenScheduled, ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFFD48A8C), @@ -1968,6 +1971,17 @@ private fun CategoryGrid( count = priorityCount, onClick = onOpenPriority, ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + CategoryCard( + modifier = Modifier.weight(1f), + color = Color(0xFFDA7661), + icon = Icons.Rounded.ErrorOutline, + backgroundWatermark = Icons.Rounded.ErrorOutline, + title = stringResource(R.string.home_category_overdue), + count = overdueCount, + onClick = onOpenOverdue, + ) CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFF4E4E50), @@ -2390,6 +2404,23 @@ private fun performGentleHaptic(view: android.view.View) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) } +@Composable +private fun priorityColor(priority: String): Color { + return when (priority.lowercase(Locale.getDefault())) { + "high", "urgent", "important" -> Color(0xFFE56A6A) + "medium" -> Color(0xFFE3B368) + else -> Color(0xFF6FBF86) + } +} + +private fun priorityIconFor(priority: String): ImageVector? { + return when (priority.trim().lowercase(Locale.getDefault())) { + "medium" -> Icons.Rounded.Flag + "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + else -> null + } +} + private val LIST_COLOR_OPTIONS = listOf( ListColorOption("RED", Color(0xFFE65E52)), ListColorOption("ORANGE", Color(0xFFF29F38)), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index aced6ef1..d20e6208 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -1952,9 +1952,9 @@ private fun TimelineTaskDragPreview( modifier = Modifier.size(18.dp), ) } - if (isHighPriority(todo.priority)) { + priorityIconFor(todo.priority)?.let { priorityIcon -> Icon( - imageVector = Icons.Rounded.Flag, + imageVector = priorityIcon, contentDescription = null, tint = priorityColor(todo.priority), modifier = Modifier.size(18.dp), @@ -2158,7 +2158,15 @@ private fun buildTimelineSections( includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) - TodoListMode.PRIORITY, TodoListMode.LIST -> buildScheduledSections( + TodoListMode.PRIORITY -> buildScheduledSections( + items = items, + zoneId = zoneId, + futureOnly = false, + placesEarlierBeforeToday = true, + includeEmptyEarlierTarget = includeEmptyEarlierTarget, + ) + + TodoListMode.LIST -> buildScheduledSections( items = items, zoneId = zoneId, futureOnly = false, @@ -2754,15 +2762,8 @@ private fun SwipeTaskRow( TodoListMode.LIST, -> false } - val showPriorityFlag = when (mode) { - TodoListMode.TODAY, - TodoListMode.OVERDUE, - TodoListMode.SCHEDULED, - TodoListMode.PRIORITY, - TodoListMode.LIST, - TodoListMode.ALL, - -> isHighPriority(todo.priority) - } + val priorityIcon = priorityIconFor(todo.priority) + val showPriorityIcon = priorityIcon != null val listIndicatorColor = listAccentColor(listMeta?.color) LaunchedEffect(flashHighlight) { if (!flashHighlight) return@LaunchedEffect @@ -3011,7 +3012,7 @@ private fun SwipeTaskRow( } } } - if (showListIndicator || showPriorityFlag) { + if (showListIndicator || showPriorityIcon) { Row( modifier = Modifier.padding(start = 8.dp, end = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -3025,9 +3026,9 @@ private fun SwipeTaskRow( modifier = Modifier.size(18.dp), ) } - if (showPriorityFlag) { + if (priorityIcon != null) { Icon( - imageVector = Icons.Rounded.Flag, + imageVector = priorityIcon, contentDescription = stringResource(R.string.label_priority_task), tint = priorityColor(todo.priority), modifier = Modifier.size(18.dp), @@ -3226,10 +3227,11 @@ private fun priorityColor(priority: String): Color { } } -private fun isHighPriority(priority: String): Boolean { - return when (priority.trim().lowercase()) { - "medium", "high", "urgent", "important" -> true - else -> false +private fun priorityIconFor(priority: String): ImageVector? { + return when (priority.trim().lowercase(Locale.getDefault())) { + "medium" -> Icons.Rounded.Flag + "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + else -> null } } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 16e5328f..198b1dd6 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -2451,8 +2451,8 @@ private struct CalendarTaskDragPreview: View { Spacer(minLength: 0) - if todo.priority.lowercased() == "high" { - Image(systemName: "flag.fill") + if let priorityIcon = priorityIndicatorSymbolName(todo.priority) { + Image(systemName: priorityIcon) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(priorityColor(todo.priority)) } @@ -2480,7 +2480,7 @@ private struct CalendarPendingTaskRow: View { @Environment(\.tdayColors) private var colors var body: some View { - let showPriorityFlag = todo.priority.lowercased() == "high" + let priorityIcon = priorityIndicatorSymbolName(todo.priority) VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { @@ -2511,15 +2511,15 @@ private struct CalendarPendingTaskRow: View { Spacer(minLength: 0) - if list != nil || showPriorityFlag { + if list != nil || priorityIcon != nil { HStack(spacing: 8) { if let list { Image(systemName: calendarListSymbolName(for: list.iconKey)) .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) .foregroundStyle(calendarListAccentColor(for: list.color)) } - if showPriorityFlag { - Image(systemName: "flag.fill") + if let priorityIcon { + Image(systemName: priorityIcon) .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) .foregroundStyle(priorityColor(todo.priority)) } diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 5629014a..24b0f35e 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -300,7 +300,7 @@ private struct CompletedTimelineRow: View { let completedDate = item.completedAt ?? item.due let completedTimeText = completedDate.formatted(date: .omitted, time: .shortened) let showListIndicator = item.listName?.isEmpty == false - let showPriorityFlag = item.priority.lowercased() == "high" + let priorityIcon = priorityIndicatorSymbolName(item.priority) VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { @@ -344,15 +344,15 @@ private struct CompletedTimelineRow: View { Spacer(minLength: 0) - if showListIndicator || showPriorityFlag { + if showListIndicator || priorityIcon != nil { HStack(spacing: 8) { if showListIndicator { Image(systemName: "tray.fill") .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) .foregroundStyle(todoListAccentColor(for: item.listColor)) } - if showPriorityFlag { - Image(systemName: "flag.fill") + if let priorityIcon { + Image(systemName: priorityIcon) .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) .foregroundStyle(priorityColor(item.priority)) } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index e8a4836e..4a0bf56f 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -572,6 +572,7 @@ private struct HomeTodayTaskRow: View { todo.listId.flatMap { id in lists.first { $0.id == id } } } + private var priorityIcon: String? { priorityIndicatorSymbolName(todo.priority) } private var isOverdue: Bool { !todo.completed && todo.due < Date() } private var dueText: String { todo.due.formatted(date: .omitted, time: .shortened) } private var subtitleText: String { isOverdue ? "Overdue, \(dueText)" : "Due \(dueText)" } @@ -682,11 +683,20 @@ private struct HomeTodayTaskRow: View { Spacer(minLength: 0) - if let listMeta { - Image(systemName: homeListSymbolName(for: listMeta.iconKey)) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(homeListAccentColor(for: listMeta.color)) - .padding(.trailing, 8) + if listMeta != nil || priorityIcon != nil { + HStack(spacing: 8) { + if let listMeta { + Image(systemName: homeListSymbolName(for: listMeta.iconKey)) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(homeListAccentColor(for: listMeta.color)) + } + if let priorityIcon { + Image(systemName: priorityIcon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(priorityColor(todo.priority)) + } + } + .padding(.trailing, 8) } } .padding(.vertical, 10) @@ -868,15 +878,6 @@ private struct HomeCategoryBoard: View { var body: some View { VStack(spacing: HomeMetrics.tileGap) { HStack(spacing: HomeMetrics.tileGap) { - HomeCategoryTile( - color: Color(hex: 0xDA7661), - icon: "exclamationmark.circle", - watermark: "exclamationmark.circle", - title: "Overdue", - count: overdueCount, - action: onOpenOverdue - ) - HomeCategoryTile( color: Color(hex: 0xDDB37D), icon: "clock", @@ -885,9 +886,7 @@ private struct HomeCategoryBoard: View { count: scheduledCount, action: onOpenScheduled ) - } - HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0xD48A8C), icon: "flag.fill", @@ -896,6 +895,17 @@ private struct HomeCategoryBoard: View { count: priorityCount, action: onOpenPriority ) + } + + HStack(spacing: HomeMetrics.tileGap) { + HomeCategoryTile( + color: Color(hex: 0xDA7661), + icon: "exclamationmark.circle", + watermark: "exclamationmark.circle", + title: "Overdue", + count: overdueCount, + action: onOpenOverdue + ) HomeCategoryTile( color: Color(hex: 0x4E4E50), diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index ed4bdcef..31985bd3 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -1009,7 +1009,7 @@ struct TodoListScreen: View { viewModel.lists.first(where: { $0.id == listId }) } let showListIndicator = listMeta != nil && viewModel.mode != .list - let showPriorityFlag = todo.priority.lowercased() == "high" + let priorityIcon = priorityIndicatorSymbolName(todo.priority) let subtitleText = minimalTimelineSubtitle(for: todo, in: section) let isOverdueTask = !todo.completed && todo.due < Date() let subtitleColor = isOverdueTask ? colors.error : colors.onSurfaceVariant.opacity(0.8) @@ -1046,15 +1046,15 @@ struct TodoListScreen: View { Spacer(minLength: 0) - if showListIndicator || showPriorityFlag { + if showListIndicator || priorityIcon != nil { HStack(spacing: 8) { if let listMeta, showListIndicator { Image(systemName: todoListSymbolName(for: listMeta.iconKey)) .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) .foregroundStyle(todoListAccentColor(for: listMeta.color)) } - if showPriorityFlag { - Image(systemName: "flag.fill") + if let priorityIcon { + Image(systemName: priorityIcon) .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) .foregroundStyle(priorityColor(todo.priority)) } @@ -1735,8 +1735,8 @@ private struct TodoDragPreview: View { Spacer(minLength: 0) - if todo.priority.lowercased() == "high" { - Image(systemName: "flag.fill") + if let priorityIcon = priorityIndicatorSymbolName(todo.priority) { + Image(systemName: priorityIcon) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(priorityColor(todo.priority)) } @@ -2397,7 +2397,14 @@ private func buildSections( placesEarlierBeforeToday: true, includeEmptyEarlierTarget: includeEmptyEarlierTarget ) - case .priority, .list: + case .priority: + return buildFutureTimelineSections( + items: items, + calendar: calendar, + placesEarlierBeforeToday: true, + includeEmptyEarlierTarget: includeEmptyEarlierTarget + ) + case .list: return buildFutureTimelineSections( items: items, calendar: calendar, @@ -2578,7 +2585,7 @@ private func monthIndex(for date: Date, calendar: Calendar) -> Int { func priorityColor(_ priority: String) -> Color { switch priority.lowercased() { - case "high": + case "high", "urgent", "important": return .red case "medium": return .orange @@ -2587,6 +2594,17 @@ func priorityColor(_ priority: String) -> Color { } } +func priorityIndicatorSymbolName(_ priority: String) -> String? { + switch priority.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "medium": + return "flag.fill" + case "high", "urgent", "important": + return "exclamationmark.circle.fill" + default: + return nil + } +} + private func emptyTimelineMessage(for mode: TodoListMode) -> String { switch mode { case .today: From 1883db1ccf2ce1beb592d9423f9dda01205319cf Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 13:44:06 -0400 Subject: [PATCH 02/19] Fix iOS task sheet date picker taps --- .../Tday/UI/Component/CreateTaskSheet.swift | 170 ++++++++++++++---- 1 file changed, 131 insertions(+), 39 deletions(-) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 008bd411..427bf560 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -148,7 +148,11 @@ struct CreateTaskSheet: View { CreateTaskSheetSectionTitle(text: "Schedule") CreateTaskSheetGroupCard { - CreateTaskSheetDueRow(dueDate: $dueDate) + CreateTaskSheetDueRow( + dueDate: $dueDate, + onDateTap: { activeSelector = .date }, + onTimeTap: { activeSelector = .time } + ) } CreateTaskSheetSectionTitle(text: "Details") @@ -330,9 +334,19 @@ struct CreateTaskSheet: View { activeSelector = nil } } + + case .date: + CreateTaskSheetDateSelectorContent(dueDate: $dueDate) { + activeSelector = nil + } + + case .time: + CreateTaskSheetTimeSelectorContent(dueDate: $dueDate) { + activeSelector = nil + } } } - .padding(.horizontal, 54) + .padding(.horizontal, selector.horizontalPadding) } } } @@ -341,6 +355,8 @@ private enum CreateTaskSheetSelector: String, Identifiable { case list case priority case recurrence + case date + case time var id: String { rawValue } @@ -352,6 +368,19 @@ private enum CreateTaskSheetSelector: String, Identifiable { return "Priority" case .recurrence: return "Repeat" + case .date: + return "Due date" + case .time: + return "Due time" + } + } + + var horizontalPadding: CGFloat { + switch self { + case .date, .time: + return 24 + case .list, .priority, .recurrence: + return 54 } } } @@ -456,6 +485,8 @@ private struct CreateTaskSheetGroupCard: View { private struct CreateTaskSheetDueRow: View { @Binding var dueDate: Date + let onDateTap: () -> Void + let onTimeTap: () -> Void @Environment(\.tdayColors) private var colors @@ -465,7 +496,11 @@ private struct CreateTaskSheetDueRow: View { Spacer(minLength: 6) - CreateTaskSheetDateTimeControl(dueDate: $dueDate) + CreateTaskSheetDateTimeControl( + dueDate: $dueDate, + onDateTap: onDateTap, + onTimeTap: onTimeTap + ) } .padding(.horizontal, 16) .padding(.vertical, 14) @@ -488,6 +523,8 @@ private struct CreateTaskSheetDueRow: View { private struct CreateTaskSheetDateTimeControl: View { @Binding var dueDate: Date + let onDateTap: () -> Void + let onTimeTap: () -> Void @Environment(\.tdayColors) private var colors @@ -500,54 +537,109 @@ private struct CreateTaskSheetDateTimeControl: View { } var body: some View { - ZStack { - HStack(spacing: 0) { + HStack(spacing: 0) { + Button(action: onDateTap) { Text(dateText) - .frame(maxWidth: .infinity) + .frame(width: 113, height: 38) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Due date") + .accessibilityValue(dateText) - Rectangle() - .fill(colors.onSurfaceVariant.opacity(0.2)) - .frame(width: 1, height: 22) + Rectangle() + .fill(colors.onSurfaceVariant.opacity(0.2)) + .frame(width: 1, height: 22) + Button(action: onTimeTap) { Text(timeText) - .frame(maxWidth: .infinity) - } - .font(.tdayRounded(size: 13, weight: .heavy)) - .foregroundStyle(colors.onSurfaceVariant) - .lineLimit(1) - .minimumScaleFactor(0.74) - .padding(.horizontal, 10) - .frame(width: 206, height: 38) - .overlay { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(colors.onSurfaceVariant.opacity(0.24), lineWidth: 1) - } - .background( - colors.bottomSheetControlSurface.opacity(0.32), - in: RoundedRectangle(cornerRadius: 16, style: .continuous) - ) - - HStack(spacing: 0) { - DatePicker("", selection: $dueDate, displayedComponents: .date) - .labelsHidden() - .datePickerStyle(.compact) - .tint(colors.onSurfaceVariant) - .frame(width: 114, height: 38) - .opacity(0.02) - - DatePicker("", selection: $dueDate, displayedComponents: .hourAndMinute) - .labelsHidden() - .datePickerStyle(.compact) - .tint(colors.onSurfaceVariant) .frame(width: 92, height: 38) - .opacity(0.02) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityLabel("Due time") + .accessibilityValue(timeText) } + .font(.tdayRounded(size: 13, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.74) .frame(width: 206, height: 38) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(colors.onSurfaceVariant.opacity(0.24), lineWidth: 1) + } + .background( + colors.bottomSheetControlSurface.opacity(0.32), + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } +private struct CreateTaskSheetDateSelectorContent: View { + @Binding var dueDate: Date + let onDone: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + VStack(spacing: 12) { + DatePicker("", selection: $dueDate, displayedComponents: .date) + .datePickerStyle(.graphical) + .labelsHidden() + .tint(colors.primary) + .padding(.horizontal, 12) + + CreateTaskSheetSelectorDoneButton(action: onDone) + } + } +} + +private struct CreateTaskSheetTimeSelectorContent: View { + @Binding var dueDate: Date + let onDone: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + VStack(spacing: 12) { + DatePicker("", selection: $dueDate, displayedComponents: .hourAndMinute) + .datePickerStyle(.wheel) + .labelsHidden() + .tint(colors.primary) + .frame(height: 154) + .clipped() + .padding(.horizontal, 12) + + CreateTaskSheetSelectorDoneButton(action: onDone) + } + } +} + +private struct CreateTaskSheetSelectorDoneButton: View { + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: action) { + Text("Done") + .font(.tdayRounded(size: 17, weight: .heavy)) + .foregroundStyle(colors.primary) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .background( + colors.bottomSheetControlSurface.opacity(0.45), + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) + } + .buttonStyle(.plain) + .padding(.horizontal, 16) + .padding(.bottom, 4) + } +} + private struct CreateTaskSheetSelectorTriggerRow: View { let iconName: String let title: String From 813f654ee7cee2b57e83fe59e38b9f5a99c264c9 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 14:17:17 -0400 Subject: [PATCH 03/19] style: update color palette and refine list accent colors across platforms Synchronize the UI color scheme by updating hex values for home categories and list accents. This change standardizes color keys across Android and iOS, shifts the default list color, and adjusts the visual weight of list containers. - **Color Palette Refresh**: - Update hex codes for home categories (Scheduled, Priority, Overdue, All, Completed, and Calendar) with a refined color set. - Update the global list accent colors with new hex values across all feature screens (Home, Todos, Completed, Calendar) and task creation components. - **List Color Logic**: - Change the default list color key from `BLUE` to `PINK`. - Implement color normalization to map legacy or alternative keys like `GREEN` to `LIME` and `GRAY` to `SLATE`. - Reorder and update list color option definitions for consistency between Android and iOS. - **UI & Presentation**: - Adjust list container color blending weight (lerp/blending amount) to `0.66` to enhance list item background prominence. - Ensure fallback colors for list accents match the new `PINK` default. --- .../feature/calendar/CalendarScreen.kt | 32 +++---- .../feature/completed/CompletedScreen.kt | 32 +++---- .../tday/compose/feature/home/HomeScreen.kt | 56 ++++++------ .../compose/feature/todos/TodoListScreen.kt | 64 +++++++------- .../ui/component/CreateTaskBottomSheet.kt | 30 +++---- .../Feature/Calendar/CalendarScreen.swift | 50 +++++------ .../Tday/Feature/Home/HomeScreen.swift | 58 ++++++++----- .../Tday/Feature/Todos/TodoListScreen.swift | 87 +++++++++++++------ .../Tday/UI/Component/CreateTaskSheet.swift | 50 +++++------ 9 files changed, 256 insertions(+), 203 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 55b06404..98dc1923 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -2576,22 +2576,22 @@ private fun CompletedItem.resolveListSummary(lists: List): ListSumm private fun listAccentColor(colorKey: String?): Color { return when (colorKey) { - "RED" -> Color(0xFFE65E52) - "ORANGE" -> Color(0xFFF29F38) - "YELLOW" -> Color(0xFFF3D04A) - "LIME" -> Color(0xFF8ACF56) - "BLUE" -> Color(0xFF5C9FE7) - "PURPLE" -> Color(0xFF8D6CE2) - "PINK" -> Color(0xFFDF6DAA) - "TEAL" -> Color(0xFF4EB5B0) - "CORAL" -> Color(0xFFE3876D) - "GOLD" -> Color(0xFFCFAB57) - "DEEP_BLUE" -> Color(0xFF4B73D6) - "ROSE" -> Color(0xFFD9799A) - "LIGHT_RED" -> Color(0xFFE48888) - "BRICK" -> Color(0xFFB86A5C) - "SLATE" -> Color(0xFF7B8593) - else -> Color(0xFF5C9FE7) + "PINK" -> Color(0xFFC987A5) + "GOLD" -> Color(0xFFC7AA63) + "DEEP_BLUE" -> Color(0xFF6F86C6) + "CORAL" -> Color(0xFFD39A82) + "TEAL" -> Color(0xFF67AAA7) + "SLATE", "GRAY" -> Color(0xFF7F8996) + "BLUE" -> Color(0xFF6F9FCE) + "PURPLE" -> Color(0xFF9A86CF) + "ROSE" -> Color(0xFFC98299) + "LIGHT_RED" -> Color(0xFFD58D8D) + "BRICK" -> Color(0xFFAD786E) + "YELLOW" -> Color(0xFFCFB866) + "LIME", "GREEN" -> Color(0xFF8DBB73) + "ORANGE" -> Color(0xFFD69B63) + "RED" -> Color(0xFFD97873) + else -> Color(0xFFC987A5) } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt index ce735795..ab1c8a4a 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt @@ -921,22 +921,22 @@ private fun CompletedItem.toEditableTodo(lists: List): TodoItem { private fun listAccentColor(colorKey: String?): Color { return when (colorKey?.trim()?.uppercase(Locale.getDefault())) { - "RED" -> Color(0xFFE65E52) - "ORANGE" -> Color(0xFFF29F38) - "YELLOW" -> Color(0xFFF3D04A) - "LIME" -> Color(0xFF8ACF56) - "BLUE" -> Color(0xFF5C9FE7) - "PURPLE" -> Color(0xFF8D6CE2) - "PINK" -> Color(0xFFDF6DAA) - "TEAL" -> Color(0xFF4EB5B0) - "CORAL" -> Color(0xFFE3876D) - "GOLD" -> Color(0xFFCFAB57) - "DEEP_BLUE" -> Color(0xFF4B73D6) - "ROSE" -> Color(0xFFD9799A) - "LIGHT_RED" -> Color(0xFFE48888) - "BRICK" -> Color(0xFFB86A5C) - "SLATE" -> Color(0xFF7B8593) - else -> Color(0xFF6EA8E1) + "PINK" -> Color(0xFFC987A5) + "GOLD" -> Color(0xFFC7AA63) + "DEEP_BLUE" -> Color(0xFF6F86C6) + "CORAL" -> Color(0xFFD39A82) + "TEAL" -> Color(0xFF67AAA7) + "SLATE", "GRAY" -> Color(0xFF7F8996) + "BLUE" -> Color(0xFF6F9FCE) + "PURPLE" -> Color(0xFF9A86CF) + "ROSE" -> Color(0xFFC98299) + "LIGHT_RED" -> Color(0xFFD58D8D) + "BRICK" -> Color(0xFFAD786E) + "YELLOW" -> Color(0xFFCFB866) + "LIME", "GREEN" -> Color(0xFF8DBB73) + "ORANGE" -> Color(0xFFD69B63) + "RED" -> Color(0xFFD97873) + else -> Color(0xFFC987A5) } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 401f808d..160b849d 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -1955,7 +1955,7 @@ private fun CategoryGrid( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), - color = Color(0xFFDDB37D), + color = Color(0xFFD98F4B), icon = Icons.Rounded.Schedule, backgroundWatermark = Icons.Rounded.Schedule, title = stringResource(R.string.home_category_scheduled), @@ -1964,7 +1964,7 @@ private fun CategoryGrid( ) CategoryCard( modifier = Modifier.weight(1f), - color = Color(0xFFD48A8C), + color = Color(0xFFC97880), icon = Icons.Rounded.Flag, backgroundWatermark = Icons.Rounded.Flag, title = stringResource(R.string.home_category_priority), @@ -1975,7 +1975,7 @@ private fun CategoryGrid( Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), - color = Color(0xFFDA7661), + color = Color(0xFFE06F66), icon = Icons.Rounded.ErrorOutline, backgroundWatermark = Icons.Rounded.ErrorOutline, title = stringResource(R.string.home_category_overdue), @@ -1984,7 +1984,7 @@ private fun CategoryGrid( ) CategoryCard( modifier = Modifier.weight(1f), - color = Color(0xFF4E4E50), + color = Color(0xFF68717A), icon = Icons.Rounded.Inbox, backgroundWatermark = Icons.Rounded.Inbox, title = stringResource(R.string.home_category_all), @@ -2016,11 +2016,11 @@ private fun CategoryGrid( } private fun completedTileColor(colorScheme: ColorScheme): Color { - return Color(0xFFA8C8B2) + return Color(0xFF719F84) } private fun calendarTileColor(colorScheme: ColorScheme): Color { - return Color(0xFFC3B4DF) + return Color(0xFF9A89D2) } @Composable @@ -2268,7 +2268,7 @@ private fun ListRow( ) val accent = listColorAccent(colorKey) val icon = listIconForKey(iconKey) - val containerColor = lerp(colorScheme.surfaceVariant, accent, 0.38f) + val containerColor = lerp(colorScheme.surfaceVariant, accent, HOME_LIST_CONTAINER_COLOR_WEIGHT) val displayName = capitalizeFirstListLetter(name) Card( @@ -2392,8 +2392,9 @@ private data class ListIconOption( val icon: ImageVector, ) -private const val DEFAULT_LIST_COLOR = "BLUE" +private const val DEFAULT_LIST_COLOR = "PINK" private const val DEFAULT_LIST_ICON_KEY = "inbox" +private const val HOME_LIST_CONTAINER_COLOR_WEIGHT = 0.66f private const val CREATE_LIST_SHEET_MAX_HEIGHT_FRACTION = 0.80f private const val CREATE_LIST_SHEET_NORMAL_HEIGHT_FRACTION = 0.70f private const val CREATE_LIST_SHEET_KEYBOARD_HEIGHT_FRACTION = 0.80f @@ -2422,21 +2423,21 @@ private fun priorityIconFor(priority: String): ImageVector? { } private val LIST_COLOR_OPTIONS = listOf( - ListColorOption("RED", Color(0xFFE65E52)), - ListColorOption("ORANGE", Color(0xFFF29F38)), - ListColorOption("YELLOW", Color(0xFFF3D04A)), - ListColorOption("LIME", Color(0xFF8ACF56)), - ListColorOption("BLUE", Color(0xFF5C9FE7)), - ListColorOption("PURPLE", Color(0xFF8D6CE2)), - ListColorOption("PINK", Color(0xFFDF6DAA)), - ListColorOption("TEAL", Color(0xFF4EB5B0)), - ListColorOption("CORAL", Color(0xFFE3876D)), - ListColorOption("GOLD", Color(0xFFCFAB57)), - ListColorOption("DEEP_BLUE", Color(0xFF4B73D6)), - ListColorOption("ROSE", Color(0xFFD9799A)), - ListColorOption("LIGHT_RED", Color(0xFFE48888)), - ListColorOption("BRICK", Color(0xFFB86A5C)), - ListColorOption("SLATE", Color(0xFF7B8593)), + ListColorOption("PINK", Color(0xFFC987A5)), + ListColorOption("GOLD", Color(0xFFC7AA63)), + ListColorOption("DEEP_BLUE", Color(0xFF6F86C6)), + ListColorOption("CORAL", Color(0xFFD39A82)), + ListColorOption("TEAL", Color(0xFF67AAA7)), + ListColorOption("SLATE", Color(0xFF7F8996)), + ListColorOption("BLUE", Color(0xFF6F9FCE)), + ListColorOption("PURPLE", Color(0xFF9A86CF)), + ListColorOption("ROSE", Color(0xFFC98299)), + ListColorOption("LIGHT_RED", Color(0xFFD58D8D)), + ListColorOption("BRICK", Color(0xFFAD786E)), + ListColorOption("YELLOW", Color(0xFFCFB866)), + ListColorOption("LIME", Color(0xFF8DBB73)), + ListColorOption("ORANGE", Color(0xFFD69B63)), + ListColorOption("RED", Color(0xFFD97873)), ) private val LIST_ICON_OPTIONS = listOf( @@ -2511,8 +2512,13 @@ private val LIST_ICON_OPTIONS = listOf( ) private fun listColorAccent(colorKey: String?): Color { - return LIST_COLOR_OPTIONS.firstOrNull { it.key == colorKey }?.color - ?: Color(0xFFE9A03B) + val normalizedKey = when (colorKey) { + "GREEN" -> "LIME" + "GRAY" -> "SLATE" + else -> colorKey + } + return LIST_COLOR_OPTIONS.firstOrNull { it.key == normalizedKey }?.color + ?: Color(0xFFC987A5) } private fun listIconForKey(iconKey: String?): ImageVector { diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index d20e6208..5adf7fcf 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -560,9 +560,7 @@ fun TodoListScreen( } else if (selectedList != null) { listSettingsTargetId = selectedList.id listSettingsName = selectedList.name - listSettingsColor = selectedList.color - ?.takeIf { isSupportedListColor(it) } - ?: DEFAULT_LIST_COLOR_KEY + listSettingsColor = normalizedListColorKey(selectedList.color) listSettingsIconKey = selectedList.iconKey ?.takeIf { isSupportedListIconKey(it) } ?: DEFAULT_LIST_ICON_KEY @@ -3261,22 +3259,22 @@ private fun modeAccentColor( private fun listAccentColor(colorKey: String?): Color { return when (colorKey) { - "RED" -> Color(0xFFE65E52) - "ORANGE" -> Color(0xFFF29F38) - "YELLOW" -> Color(0xFFF3D04A) - "LIME" -> Color(0xFF8ACF56) - "BLUE" -> Color(0xFF5C9FE7) - "PURPLE" -> Color(0xFF8D6CE2) - "PINK" -> Color(0xFFDF6DAA) - "TEAL" -> Color(0xFF4EB5B0) - "CORAL" -> Color(0xFFE3876D) - "GOLD" -> Color(0xFFCFAB57) - "DEEP_BLUE" -> Color(0xFF4B73D6) - "ROSE" -> Color(0xFFD9799A) - "LIGHT_RED" -> Color(0xFFE48888) - "BRICK" -> Color(0xFFB86A5C) - "SLATE" -> Color(0xFF7B8593) - else -> Color(0xFF5C9FE7) + "PINK" -> Color(0xFFC987A5) + "GOLD" -> Color(0xFFC7AA63) + "DEEP_BLUE" -> Color(0xFF6F86C6) + "CORAL" -> Color(0xFFD39A82) + "TEAL" -> Color(0xFF67AAA7) + "SLATE", "GRAY" -> Color(0xFF7F8996) + "BLUE" -> Color(0xFF6F9FCE) + "PURPLE" -> Color(0xFF9A86CF) + "ROSE" -> Color(0xFFC98299) + "LIGHT_RED" -> Color(0xFFD58D8D) + "BRICK" -> Color(0xFFAD786E) + "YELLOW" -> Color(0xFFCFB866) + "LIME", "GREEN" -> Color(0xFF8DBB73) + "ORANGE" -> Color(0xFFD69B63) + "RED" -> Color(0xFFD97873) + else -> Color(0xFFC987A5) } } @@ -3288,6 +3286,14 @@ private fun isSupportedListColor(colorKey: String): Boolean { return LIST_SETTINGS_COLOR_KEYS.contains(colorKey) } +private fun normalizedListColorKey(colorKey: String?): String { + return when (colorKey) { + "GREEN" -> "LIME" + "GRAY" -> "SLATE" + else -> colorKey?.takeIf { isSupportedListColor(it) } ?: DEFAULT_LIST_COLOR_KEY + } +} + private fun isSupportedListIconKey(iconKey: String): Boolean { return LIST_SETTINGS_ICON_OPTIONS.any { it.key == iconKey } } @@ -3297,25 +3303,25 @@ private data class ListSettingsIconOption( val icon: ImageVector, ) -private const val DEFAULT_LIST_COLOR_KEY = "BLUE" +private const val DEFAULT_LIST_COLOR_KEY = "PINK" private const val DEFAULT_LIST_ICON_KEY = "inbox" private val LIST_SETTINGS_COLOR_KEYS = listOf( - "RED", - "ORANGE", - "YELLOW", - "LIME", - "BLUE", - "PURPLE", "PINK", - "TEAL", - "CORAL", "GOLD", "DEEP_BLUE", + "CORAL", + "TEAL", + "SLATE", + "BLUE", + "PURPLE", "ROSE", "LIGHT_RED", "BRICK", - "SLATE", + "YELLOW", + "LIME", + "ORANGE", + "RED", ) private val LIST_SETTINGS_ICON_OPTIONS = listOf( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt index 9055aedf..37a2a896 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt @@ -967,21 +967,21 @@ private fun CenteredSelectorRow( private fun listColorSwatchForSelector(raw: String?, fallback: Color): Color { if (raw.isNullOrBlank()) return fallback return when (raw.trim().uppercase()) { - "RED" -> Color(0xFFE65E52) - "ORANGE" -> Color(0xFFF29F38) - "YELLOW" -> Color(0xFFF3D04A) - "LIME" -> Color(0xFF8ACF56) - "BLUE" -> Color(0xFF5C9FE7) - "PURPLE" -> Color(0xFF8D6CE2) - "PINK" -> Color(0xFFDF6DAA) - "TEAL" -> Color(0xFF4EB5B0) - "CORAL" -> Color(0xFFE3876D) - "GOLD" -> Color(0xFFCFAB57) - "DEEP_BLUE" -> Color(0xFF4B73D6) - "ROSE" -> Color(0xFFD9799A) - "LIGHT_RED" -> Color(0xFFE48888) - "BRICK" -> Color(0xFFB86A5C) - "SLATE" -> Color(0xFF7B8593) + "PINK" -> Color(0xFFC987A5) + "GOLD" -> Color(0xFFC7AA63) + "DEEP_BLUE" -> Color(0xFF6F86C6) + "CORAL" -> Color(0xFFD39A82) + "TEAL" -> Color(0xFF67AAA7) + "SLATE", "GRAY" -> Color(0xFF7F8996) + "BLUE" -> Color(0xFF6F9FCE) + "PURPLE" -> Color(0xFF9A86CF) + "ROSE" -> Color(0xFFC98299) + "LIGHT_RED" -> Color(0xFFD58D8D) + "BRICK" -> Color(0xFFAD786E) + "YELLOW" -> Color(0xFFCFB866) + "LIME", "GREEN" -> Color(0xFF8DBB73) + "ORANGE" -> Color(0xFFD69B63) + "RED" -> Color(0xFFD97873) else -> runCatching { Color(AndroidColor.parseColor(raw)) } .getOrDefault(fallback) } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 198b1dd6..ee22342d 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -2537,38 +2537,38 @@ private struct CalendarPendingTaskRow: View { private func calendarListAccentColor(for key: String?) -> Color { switch key { - case "RED": - return calendarHexColor(0xE65E52) - case "ORANGE": - return calendarHexColor(0xF29F38) - case "YELLOW": - return calendarHexColor(0xF3D04A) - case "LIME": - return calendarHexColor(0x8ACF56) - case "BLUE": - return calendarHexColor(0x5C9FE7) - case "PURPLE": - return calendarHexColor(0x8D6CE2) case "PINK": - return calendarHexColor(0xDF6DAA) - case "TEAL": - return calendarHexColor(0x4EB5B0) - case "CORAL": - return calendarHexColor(0xE3876D) + return calendarHexColor(0xC987A5) case "GOLD": - return calendarHexColor(0xCFAB57) + return calendarHexColor(0xC7AA63) case "DEEP_BLUE": - return calendarHexColor(0x4B73D6) + return calendarHexColor(0x6F86C6) + case "CORAL": + return calendarHexColor(0xD39A82) + case "TEAL": + return calendarHexColor(0x67AAA7) + case "SLATE", "GRAY": + return calendarHexColor(0x7F8996) + case "BLUE": + return calendarHexColor(0x6F9FCE) + case "PURPLE": + return calendarHexColor(0x9A86CF) case "ROSE": - return calendarHexColor(0xD9799A) + return calendarHexColor(0xC98299) case "LIGHT_RED": - return calendarHexColor(0xE48888) + return calendarHexColor(0xD58D8D) case "BRICK": - return calendarHexColor(0xB86A5C) - case "SLATE": - return calendarHexColor(0x7B8593) + return calendarHexColor(0xAD786E) + case "YELLOW": + return calendarHexColor(0xCFB866) + case "LIME", "GREEN": + return calendarHexColor(0x8DBB73) + case "ORANGE": + return calendarHexColor(0xD69B63) + case "RED": + return calendarHexColor(0xD97873) default: - return calendarHexColor(0x5C9FE7) + return calendarHexColor(0xC987A5) } } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 4a0bf56f..92f6b62b 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -12,6 +12,7 @@ private enum HomeMetrics { static let tileInnerPadding: CGFloat = 12 static let todayCardHeight: CGFloat = 70 static let listRowHeight: CGFloat = 70 + static let listContainerColorWeight: CGFloat = 0.66 static let tileWatermarkSize: CGFloat = 116 static let tileWatermarkTrailingInset: CGFloat = 22 } @@ -879,7 +880,7 @@ private struct HomeCategoryBoard: View { VStack(spacing: HomeMetrics.tileGap) { HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( - color: Color(hex: 0xDDB37D), + color: Color(hex: 0xD98F4B), icon: "clock", watermark: "clock", title: "Scheduled", @@ -888,7 +889,7 @@ private struct HomeCategoryBoard: View { ) HomeCategoryTile( - color: Color(hex: 0xD48A8C), + color: Color(hex: 0xC97880), icon: "flag.fill", watermark: "flag.fill", title: "Priority", @@ -899,7 +900,7 @@ private struct HomeCategoryBoard: View { HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( - color: Color(hex: 0xDA7661), + color: Color(hex: 0xE06F66), icon: "exclamationmark.circle", watermark: "exclamationmark.circle", title: "Overdue", @@ -908,7 +909,7 @@ private struct HomeCategoryBoard: View { ) HomeCategoryTile( - color: Color(hex: 0x4E4E50), + color: Color(hex: 0x68717A), icon: "tray.fill", watermark: "tray.fill", title: "All", @@ -919,7 +920,7 @@ private struct HomeCategoryBoard: View { HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( - color: Color(hex: 0xA8C8B2), + color: Color(hex: 0x719F84), icon: "checkmark", watermark: "checkmark", title: "Completed", @@ -928,7 +929,7 @@ private struct HomeCategoryBoard: View { ) HomeCategoryTile( - color: Color(hex: 0xC3B4DF), + color: Color(hex: 0x9A89D2), icon: "calendar", watermark: nil, title: "Calendar", @@ -1092,7 +1093,7 @@ private struct HomeListRow: View { } private var containerColor: Color { - colors.surfaceVariant.blended(with: accent, amount: colors.isDark ? 0.24 : 0.38) + colors.surfaceVariant.blended(with: accent, amount: HomeMetrics.listContainerColorWeight) } var body: some View { @@ -1400,7 +1401,7 @@ private struct CreateListSheet: View { @FocusState private var nameFieldFocused: Bool @State private var name = "" - @State private var color = "BLUE" + @State private var color = "PINK" @State private var iconKey = "inbox" @State private var headerHeight: CGFloat = 84 @State private var contentHeight: CGFloat = CreateListSheetMetrics.initialCompactHeight - 84 @@ -1715,21 +1716,21 @@ private struct CreateListSheetCard: View { } private let homeListColorOptions: [HomeListColorOption] = [ - HomeListColorOption(key: "RED", color: Color(hex: 0xE65E52)), - HomeListColorOption(key: "ORANGE", color: Color(hex: 0xF29F38)), - HomeListColorOption(key: "YELLOW", color: Color(hex: 0xF3D04A)), - HomeListColorOption(key: "LIME", color: Color(hex: 0x8ACF56)), - HomeListColorOption(key: "BLUE", color: Color(hex: 0x5C9FE7)), - HomeListColorOption(key: "PURPLE", color: Color(hex: 0x8D6CE2)), - HomeListColorOption(key: "PINK", color: Color(hex: 0xDF6DAA)), - HomeListColorOption(key: "TEAL", color: Color(hex: 0x4EB5B0)), - HomeListColorOption(key: "CORAL", color: Color(hex: 0xE3876D)), - HomeListColorOption(key: "GOLD", color: Color(hex: 0xCFAB57)), - HomeListColorOption(key: "DEEP_BLUE", color: Color(hex: 0x4B73D6)), - HomeListColorOption(key: "ROSE", color: Color(hex: 0xD9799A)), - HomeListColorOption(key: "LIGHT_RED", color: Color(hex: 0xE48888)), - HomeListColorOption(key: "BRICK", color: Color(hex: 0xB86A5C)), - HomeListColorOption(key: "SLATE", color: Color(hex: 0x7B8593)), + HomeListColorOption(key: "PINK", color: Color(hex: 0xC987A5)), + HomeListColorOption(key: "GOLD", color: Color(hex: 0xC7AA63)), + HomeListColorOption(key: "DEEP_BLUE", color: Color(hex: 0x6F86C6)), + HomeListColorOption(key: "CORAL", color: Color(hex: 0xD39A82)), + HomeListColorOption(key: "TEAL", color: Color(hex: 0x67AAA7)), + HomeListColorOption(key: "SLATE", color: Color(hex: 0x7F8996)), + HomeListColorOption(key: "BLUE", color: Color(hex: 0x6F9FCE)), + HomeListColorOption(key: "PURPLE", color: Color(hex: 0x9A86CF)), + HomeListColorOption(key: "ROSE", color: Color(hex: 0xC98299)), + HomeListColorOption(key: "LIGHT_RED", color: Color(hex: 0xD58D8D)), + HomeListColorOption(key: "BRICK", color: Color(hex: 0xAD786E)), + HomeListColorOption(key: "YELLOW", color: Color(hex: 0xCFB866)), + HomeListColorOption(key: "LIME", color: Color(hex: 0x8DBB73)), + HomeListColorOption(key: "ORANGE", color: Color(hex: 0xD69B63)), + HomeListColorOption(key: "RED", color: Color(hex: 0xD97873)), ] private let homeListIconOptions: [HomeListIconOption] = [ @@ -1804,7 +1805,16 @@ private let homeListIconOptions: [HomeListIconOption] = [ ] private func homeListAccentColor(for key: String?) -> Color { - homeListColorOptions.first(where: { $0.key == key })?.color ?? Color(hex: 0xE9A03B) + let normalizedKey: String? + switch key { + case "GREEN": + normalizedKey = "LIME" + case "GRAY": + normalizedKey = "SLATE" + default: + normalizedKey = key + } + return homeListColorOptions.first(where: { $0.key == normalizedKey })?.color ?? Color(hex: 0xC987A5) } private func homeListSymbolName(for key: String?) -> String { diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 31985bd3..ccd41a8a 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -2038,6 +2038,24 @@ private struct TimelineSectionHeaderButtonStyle: ButtonStyle { } } +private let todoListSettingsColorKeys = [ + "PINK", + "GOLD", + "DEEP_BLUE", + "CORAL", + "TEAL", + "SLATE", + "BLUE", + "PURPLE", + "ROSE", + "LIGHT_RED", + "BRICK", + "YELLOW", + "LIME", + "ORANGE", + "RED", +] + private struct ListSettingsSheet: View { let list: ListSummary? let onSubmit: (String, String?, String?) -> Void @@ -2045,10 +2063,10 @@ private struct ListSettingsSheet: View { @Environment(\.tdayColors) private var tdayColors @State private var name = "" - @State private var color = "BLUE" + @State private var color = "PINK" @State private var iconKey = "inbox" - private let colors = ["BLUE", "GREEN", "ORANGE", "PINK", "PURPLE", "GRAY"] + private let colors = todoListSettingsColorKeys private let icons = ["inbox", "briefcase", "calendar", "list.bullet", "star", "heart"] private var canSave: Bool { !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty @@ -2087,7 +2105,7 @@ private struct ListSettingsSheet: View { .toolbar(.hidden, for: .navigationBar) .task { name = list?.name ?? "" - color = list?.color ?? "BLUE" + color = normalizedTodoListColorKey(list?.color) iconKey = list?.iconKey ?? "inbox" } } @@ -2658,38 +2676,51 @@ private func todoModeAccentColor(_ mode: TodoListMode, listColorKey: String?) -> func todoListAccentColor(for key: String?) -> Color { switch key { - case "RED": - return todoHexColor(0xE65E52) - case "ORANGE": - return todoHexColor(0xF29F38) - case "YELLOW": - return todoHexColor(0xF3D04A) - case "LIME": - return todoHexColor(0x8ACF56) - case "BLUE": - return todoHexColor(0x5C9FE7) - case "PURPLE": - return todoHexColor(0x8D6CE2) case "PINK": - return todoHexColor(0xDF6DAA) - case "TEAL": - return todoHexColor(0x4EB5B0) - case "CORAL": - return todoHexColor(0xE3876D) + return todoHexColor(0xC987A5) case "GOLD": - return todoHexColor(0xCFAB57) + return todoHexColor(0xC7AA63) case "DEEP_BLUE": - return todoHexColor(0x4B73D6) + return todoHexColor(0x6F86C6) + case "CORAL": + return todoHexColor(0xD39A82) + case "TEAL": + return todoHexColor(0x67AAA7) + case "SLATE", "GRAY": + return todoHexColor(0x7F8996) + case "BLUE": + return todoHexColor(0x6F9FCE) + case "PURPLE": + return todoHexColor(0x9A86CF) case "ROSE": - return todoHexColor(0xD9799A) + return todoHexColor(0xC98299) case "LIGHT_RED": - return todoHexColor(0xE48888) + return todoHexColor(0xD58D8D) case "BRICK": - return todoHexColor(0xB86A5C) - case "SLATE": - return todoHexColor(0x7B8593) + return todoHexColor(0xAD786E) + case "YELLOW": + return todoHexColor(0xCFB866) + case "LIME", "GREEN": + return todoHexColor(0x8DBB73) + case "ORANGE": + return todoHexColor(0xD69B63) + case "RED": + return todoHexColor(0xD97873) default: - return todoHexColor(0x5C9FE7) + return todoHexColor(0xC987A5) + } +} + +private func normalizedTodoListColorKey(_ key: String?) -> String { + switch key { + case "GREEN": + return "LIME" + case "GRAY": + return "SLATE" + case let value? where todoListSettingsColorKeys.contains(value): + return value + default: + return "PINK" } } diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 427bf560..da702fe1 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -849,38 +849,38 @@ private struct CreateTaskSheetHeaderButton: View { private func createTaskSheetListSwatchColor(_ raw: String?) -> Color { switch raw?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { - case "RED": - return createTaskSheetHexColor(0xE65E52) - case "ORANGE": - return createTaskSheetHexColor(0xF29F38) - case "YELLOW": - return createTaskSheetHexColor(0xF3D04A) - case "LIME": - return createTaskSheetHexColor(0x8ACF56) - case "BLUE": - return createTaskSheetHexColor(0x5C9FE7) - case "PURPLE": - return createTaskSheetHexColor(0x8D6CE2) case "PINK": - return createTaskSheetHexColor(0xDF6DAA) - case "TEAL": - return createTaskSheetHexColor(0x4EB5B0) - case "CORAL": - return createTaskSheetHexColor(0xE3876D) + return createTaskSheetHexColor(0xC987A5) case "GOLD": - return createTaskSheetHexColor(0xCFAB57) + return createTaskSheetHexColor(0xC7AA63) case "DEEP_BLUE": - return createTaskSheetHexColor(0x4B73D6) + return createTaskSheetHexColor(0x6F86C6) + case "CORAL": + return createTaskSheetHexColor(0xD39A82) + case "TEAL": + return createTaskSheetHexColor(0x67AAA7) + case "SLATE", "GRAY": + return createTaskSheetHexColor(0x7F8996) + case "BLUE": + return createTaskSheetHexColor(0x6F9FCE) + case "PURPLE": + return createTaskSheetHexColor(0x9A86CF) case "ROSE": - return createTaskSheetHexColor(0xD9799A) + return createTaskSheetHexColor(0xC98299) case "LIGHT_RED": - return createTaskSheetHexColor(0xE48888) + return createTaskSheetHexColor(0xD58D8D) case "BRICK": - return createTaskSheetHexColor(0xB86A5C) - case "SLATE": - return createTaskSheetHexColor(0x7B8593) + return createTaskSheetHexColor(0xAD786E) + case "YELLOW": + return createTaskSheetHexColor(0xCFB866) + case "LIME", "GREEN": + return createTaskSheetHexColor(0x8DBB73) + case "ORANGE": + return createTaskSheetHexColor(0xD69B63) + case "RED": + return createTaskSheetHexColor(0xD97873) default: - return createTaskSheetHexColor(0x5C9FE7) + return createTaskSheetHexColor(0xC987A5) } } From 1d39e53410ea029fcca7d7f580a79b1492b46225 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 14:27:54 -0400 Subject: [PATCH 04/19] feat(ux): improve drag-and-drop validation for task rescheduling Refine drag-and-drop logic in Todo and Calendar screens to prevent invalid drops on the current due date and improve visual feedback across Android and iOS. - **Validation & Logic**: - Implement `canDropTodoInTimelineSection` and `calendarTaskAlreadyDueOnDate` to filter out drop targets that match a task's existing due date. - Update drop target detection to ignore ineligible sections/dates during active drags. - Ensure both in-app drags and system-level drag-and-drop events (from external sources) respect these validation rules. - **UI/UX Enhancements**: - Disable drop target highlights and placeholders when hovering over the task's original section or date. - Android: Refactor `TodoListScreen` and `CalendarScreen` to use unified drop target filtering in `updateActiveTimelineDropTarget` and `activeCalendarDropDate`. - iOS: Update `TodoTimelineSection` and `CalendarScreen` to use the new `canMoveTodo` and `canMove` validation blocks. - Fix an issue where empty timeline sections were unnecessarily forced to a minimum height during certain drag states. - **State Management**: - Centralize drag session state in `TodoTaskDragSession` and `CalendarTaskDragSession` on iOS to ensure consistent validation across different view modifiers. - Refined `TodoListViewModel` integration to only trigger rescheduling moves when a valid target date is confirmed. --- .../feature/calendar/CalendarScreen.kt | 52 ++++++-- .../compose/feature/todos/TodoListScreen.kt | 68 ++++++---- .../Feature/Calendar/CalendarScreen.swift | 67 ++++++++-- .../Tday/Feature/Todos/TodoListScreen.swift | 119 ++++++++++++++---- 4 files changed, 236 insertions(+), 70 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 98dc1923..3a990b23 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -263,6 +263,12 @@ private data class CalendarDateDropTargetBounds( val bounds: Rect, ) +private fun calendarTaskAlreadyDueOnDate( + todo: TodoItem, + date: LocalDate, + zoneId: ZoneId = ZoneId.systemDefault(), +): Boolean = LocalDate.ofInstant(todo.due, zoneId) == date + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun CalendarScreen( @@ -436,8 +442,7 @@ fun CalendarScreen( activeCalendarDrag = null activeDropDateIso = null calendarDropTargetBounds.clear() - val currentDate = LocalDate.ofInstant(todo.due, zoneId) - if (currentDate == targetDate) return + if (calendarTaskAlreadyDueOnDate(todo, targetDate, zoneId)) return ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) if (todo.isRecurring) { pendingRescheduleDrop = CalendarTaskRescheduleDrop(todo = todo, targetDate = targetDate) @@ -447,22 +452,29 @@ fun CalendarScreen( } } - fun activeCalendarDropDate(position: Offset): LocalDate? { + fun activeCalendarDropDate(position: Offset, todo: TodoItem?): LocalDate? { return calendarDropTargetBounds.values .asSequence() .filter { target -> target.bounds.contains(position) } + .filter { target -> + todo == null || !calendarTaskAlreadyDueOnDate(todo, target.date, zoneId) + } .minByOrNull { target -> target.bounds.width * target.bounds.height } ?.date } fun updateActiveCalendarDropTarget(position: Offset) { - activeDropDateIso = activeCalendarDropDate(position)?.toString() + val todo = activeCalendarDrag?.todo ?: draggedCalendarTodo + activeDropDateIso = activeCalendarDropDate(position, todo)?.toString() } fun finishCalendarDrag(position: Offset?) { val drag = activeCalendarDrag - val targetDate = position?.let(::activeCalendarDropDate) + val targetDate = position?.let { activeCalendarDropDate(it, drag?.todo) } ?: activeDropDate + ?.takeUnless { target -> + drag?.todo?.let { todo -> calendarTaskAlreadyDueOnDate(todo, target, zoneId) } == true + } activeCalendarDrag = null draggedCalendarTodoId = null activeDropDateIso = null @@ -994,14 +1006,18 @@ private fun CalendarWeekCard( val isToday = day == today val taskCount = tasksByDate[day]?.size ?: 0 val isEnabled = canSelectDate(day) + val dropEligibleDraggedTodo = draggedTodo?.takeIf { todo -> + isEnabled && !calendarTaskAlreadyDueOnDate(todo, day) + } CalendarWeekDayCell( date = day, taskCount = taskCount, isSelected = isSelected, isToday = isToday, isEnabled = isEnabled, - isDropTarget = activeDropDate == day, - draggedTodo = draggedTodo.takeIf { isEnabled }, + isDropTarget = activeDropDate == day && + (draggedTodo == null || dropEligibleDraggedTodo != null), + draggedTodo = dropEligibleDraggedTodo, dropTargets = dropTargets, onClick = { onSelectDate(day) }, onDropDateChanged = onDropDateChanged, @@ -1141,11 +1157,17 @@ private fun Modifier.calendarDateDropTarget( return dragAndDropTarget( shouldStartDragAndDrop = { event -> - event.mimeTypes().any { mimeType -> mimeType.startsWith("text/") } + event.mimeTypes().any { mimeType -> mimeType.startsWith("text/") } && + (draggedTodo?.let { todo -> !calendarTaskAlreadyDueOnDate(todo, date) } != false) }, target = object : DragAndDropTarget { override fun onEntered(event: DragAndDropEvent) { - onDropDateChanged(date) + val todo = draggedTodo ?: event.todoIdText()?.let(resolveTodo) + if (todo == null || !calendarTaskAlreadyDueOnDate(todo, date)) { + onDropDateChanged(date) + } else { + onDropDateChanged(null) + } } override fun onExited(event: DragAndDropEvent) { @@ -1154,6 +1176,10 @@ private fun Modifier.calendarDateDropTarget( override fun onDrop(event: DragAndDropEvent): Boolean { val todo = draggedTodo ?: event.todoIdText()?.let(resolveTodo) ?: return false + if (calendarTaskAlreadyDueOnDate(todo, date)) { + onDropDateChanged(null) + return false + } onDropDateChanged(null) onMoveTaskToDate(todo, date) return true @@ -1709,14 +1735,18 @@ private fun CalendarMonthCard( week.forEach { cell -> val taskCount = tasksByDate[cell.date]?.size ?: 0 val isEnabled = canSelectDate(cell.date) + val dropEligibleDraggedTodo = draggedTodo?.takeIf { todo -> + isEnabled && !calendarTaskAlreadyDueOnDate(todo, cell.date) + } CalendarDayCell( cell = cell, taskCount = taskCount, isSelected = cell.date == selectedDate, isToday = cell.date == today, isEnabled = isEnabled, - isDropTarget = activeDropDate == cell.date, - draggedTodo = draggedTodo.takeIf { isEnabled }, + isDropTarget = activeDropDate == cell.date && + (draggedTodo == null || dropEligibleDraggedTodo != null), + draggedTodo = dropEligibleDraggedTodo, dropTargets = dropTargets, onClick = { onSelectDate(cell.date) }, onDropDateChanged = onDropDateChanged, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 5adf7fcf..e7dd7813 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -270,11 +270,10 @@ fun TodoListScreen( uiState.items.isEmpty() var draggedScheduledTodoId by rememberSaveable(uiState.mode) { mutableStateOf(null) } val canRescheduleTasks = uiState.mode.supportsTaskReschedule() - val timelineSections = remember(uiState.mode, uiState.items, draggedScheduledTodoId) { + val timelineSections = remember(uiState.mode, uiState.items) { buildTimelineSections( mode = uiState.mode, items = uiState.items, - includeEmptyEarlierTarget = canRescheduleTasks && draggedScheduledTodoId != null, ) } var timelineAnimationsReady by remember(uiState.mode, uiState.listId) { @@ -503,27 +502,51 @@ fun TodoListScreen( } } - fun updateActiveTimelineDropTarget(position: Offset) { - activeDropSectionKey = timelineDropTargetBounds.values + fun timelineSectionForKey(key: String): TodoSection? = + timelineSections.firstOrNull { section -> section.key == key } + + fun originSectionKeyFor(todo: TodoItem): String? { + timelineSections.firstOrNull { section -> + section.items.any { item -> item.id == todo.id } + }?.let { section -> + return section.key + } + return timelineSections.firstOrNull { section -> + section.items.any { item -> item.canonicalId == todo.canonicalId } + }?.key + } + + fun canDropTodoInTimelineSection(todo: TodoItem, section: TodoSection): Boolean { + val targetDate = section.targetDate ?: return false + if (originSectionKeyFor(todo) == section.key) return false + return LocalDate.ofInstant(todo.due, zoneId) != targetDate + } + + fun timelineDropSectionKeyAt(position: Offset, todo: TodoItem): String? { + return timelineDropTargetBounds.values .asSequence() .filter { target -> target.bounds.contains(position) } + .mapNotNull { target -> + val section = timelineSectionForKey(target.sectionKey) ?: return@mapNotNull null + if (canDropTodoInTimelineSection(todo, section)) target else null + } .minByOrNull { target -> target.bounds.height } ?.sectionKey } + fun updateActiveTimelineDropTarget(position: Offset) { + val todo = activeTimelineDrag?.todo ?: draggedScheduledTodo + activeDropSectionKey = todo?.let { timelineDropSectionKeyAt(position, it) } + } + fun finishTimelineDrag(position: Offset?) { val drag = activeTimelineDrag val targetKey = position - ?.let { dropPosition -> - timelineDropTargetBounds.values - .asSequence() - .filter { target -> target.bounds.contains(dropPosition) } - .minByOrNull { target -> target.bounds.height } - ?.sectionKey - } + ?.let { dropPosition -> drag?.let { timelineDropSectionKeyAt(dropPosition, it.todo) } } ?: activeDropSectionKey val targetDate = targetKey - ?.let { key -> timelineSections.firstOrNull { section -> section.key == key } } + ?.let(::timelineSectionForKey) + ?.takeIf { section -> drag?.let { canDropTodoInTimelineSection(it.todo, section) } == true } ?.targetDate activeTimelineDrag = null draggedScheduledTodoId = null @@ -674,6 +697,9 @@ fun TodoListScreen( } else { null } + val isDropEligibleSection = sectionDraggedTodo?.let { todo -> + canDropTodoInTimelineSection(todo, section) + } == true item( key = "timeline-header-${section.key}", @@ -693,24 +719,18 @@ fun TodoListScreen( TimelineSectionHeader( modifier = headerModifier .fillMaxWidth() - .heightIn( - min = if (canRescheduleTasks && draggedScheduledTodoId != null) { - if (usesTodayStyle) 44.dp else 56.dp - } else { - 1.dp - }, - ) + .heightIn(min = 1.dp) .timelineInAppDropTarget( targetId = "header-${section.key}", section = section, - enabled = canRescheduleTasks && draggedScheduledTodoId != null, + enabled = isDropEligibleSection, dropTargets = timelineDropTargetBounds, ) .padding(top = if (sectionIndex == 0) 0.dp else 8.dp), section = section, useMinimalStyle = usesTodayStyle, isCollapsed = isCollapsed, - isDropTarget = isActiveDropSection, + isDropTarget = isActiveDropSection && isDropEligibleSection, bottomSpacing = if (isCollapsed) { timelineItemSpacing } else { @@ -739,7 +759,7 @@ fun TodoListScreen( ) } - if (canRescheduleTasks && isActiveDropSection && section.targetDate != null) { + if (canRescheduleTasks && isActiveDropSection && isDropEligibleSection && section.targetDate != null) { item( key = "timeline-drop-placeholder-${section.key}", contentType = "timeline-drop-placeholder", @@ -766,7 +786,7 @@ fun TodoListScreen( .timelineInAppDropTarget( targetId = "placeholder-${section.key}", section = section, - enabled = true, + enabled = isDropEligibleSection, dropTargets = timelineDropTargetBounds, ) .padding( @@ -813,7 +833,7 @@ fun TodoListScreen( .timelineInAppDropTarget( targetId = "row-${section.key}-${todo.id}", section = section, - enabled = canRescheduleTasks && draggedScheduledTodoId != null, + enabled = isDropEligibleSection, dropTargets = timelineDropTargetBounds, ) .padding( diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index ee22342d..a363e378 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -30,6 +30,10 @@ private struct CalendarDateDropTargetFramePreferenceKey: PreferenceKey { } } +private func calendarTaskAlreadyDueOnDate(_ todo: TodoItem, _ date: Date) -> Bool { + Calendar.current.isDate(todo.due, inSameDayAs: date) +} + private enum CalendarTitleHandoff { static let collapseDistance: CGFloat = 180 static let expandedTitleHeight: CGFloat = 56 @@ -540,7 +544,7 @@ struct CalendarScreen: View { return } CalendarTaskDragSession.shared.handledDropSignature = dropSignature - guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDay) else { + guard !calendarTaskAlreadyDueOnDate(todo, targetDay) else { return } @@ -574,11 +578,14 @@ struct CalendarScreen: View { private func updateInAppDrag(_ todo: TodoItem, to location: CGPoint) { inAppDrag = CalendarInAppDrag(todo: todo, location: location) - activeDropDate = dropDate(at: location) + activeDropDate = dropDate(at: location, for: todo) } private func finishInAppDrag(_ todo: TodoItem, at location: CGPoint?) { - let targetDate = location.flatMap(dropDate(at:)) ?? activeDropDate + let fallbackDate = activeDropDate.flatMap { date in + calendarTaskAlreadyDueOnDate(todo, date) ? nil : date + } + let targetDate = location.flatMap { dropDate(at: $0, for: todo) } ?? fallbackDate activeDropDate = nil draggedTodo = nil inAppDrag = nil @@ -596,9 +603,13 @@ struct CalendarScreen: View { CalendarTaskDragSession.shared.todo = nil } - private func dropDate(at location: CGPoint) -> Date? { + private func dropDate(at location: CGPoint, for todo: TodoItem?) -> Date? { dateDropTargetFrames.values .filter { $0.frame.contains(location) } + .filter { target in + guard let todo else { return true } + return !calendarTaskAlreadyDueOnDate(todo, target.date) + } .min { lhs, rhs in (lhs.frame.width * lhs.frame.height) < (rhs.frame.width * rhs.frame.height) } @@ -753,15 +764,19 @@ private struct CalendarMonthGrid: View { LazyVGrid(columns: columns, spacing: CalendarMonthGridMetrics.spacing) { ForEach(Self.makeDays(for: displayMonth)) { day in let dayTasks = tasksByDay[Calendar.current.startOfDay(for: day.date)].orEmpty + let dropEligibleDraggedTodo = draggedTodo.flatMap { todo in + calendarTaskAlreadyDueOnDate(todo, day.date) ? nil : todo + } CalendarMonthDayCell( day: day, isSelected: Calendar.current.isDate(day.date, inSameDayAs: selectedDate), isToday: Calendar.current.isDateInToday(day.date), isEnabled: canSelectDate(day.date), - isDropTarget: activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: day.date) } ?? false, + isDropTarget: (activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: day.date) } ?? false) && + (draggedTodo == nil || dropEligibleDraggedTodo != nil), taskCount: dayTasks.count, accentColor: accentColor, - draggedTodo: draggedTodo, + draggedTodo: dropEligibleDraggedTodo, onSelectDate: onSelectDate, onDropDateChange: onDropDateChange, onMoveTaskToDate: onMoveTaskToDate, @@ -965,6 +980,9 @@ private struct CalendarWeekCard: View { let normalizedDate = Calendar.current.startOfDay(for: date) let taskCount = tasksByDay[normalizedDate].orEmpty.count let isEnabled = canSelectDate(date) + let dropEligibleDraggedTodo = draggedTodo.flatMap { todo in + calendarTaskAlreadyDueOnDate(todo, date) ? nil : todo + } CalendarWeekDayCell( date: date, taskCount: taskCount, @@ -972,8 +990,9 @@ private struct CalendarWeekCard: View { isToday: Calendar.current.isDate(date, inSameDayAs: today), isEnabled: isEnabled, accentColor: accentColor, - isDropTarget: activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: date) } ?? false, - draggedTodo: draggedTodo, + isDropTarget: (activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: date) } ?? false) && + (draggedTodo == nil || dropEligibleDraggedTodo != nil), + draggedTodo: dropEligibleDraggedTodo, onSelect: { onSelectDate(date) }, onDropDateChange: onDropDateChange, onMoveTaskToDate: onMoveTaskToDate, @@ -1119,7 +1138,7 @@ private struct CalendarWeekDayCell: View { onMove: onMoveTaskToDate, onDateChange: onDropDateChange ) - .calendarInAppDateDropTargetFrame(date: date, enabled: isEnabled) + .calendarInAppDateDropTargetFrame(date: date, enabled: isEnabled && draggedTodo != nil) .opacity(isEnabled ? 1 : 0.48) } @@ -1208,7 +1227,14 @@ private struct CalendarDateDropDelegate: DropDelegate { let onDateChange: (Date?) -> Void func validateDrop(info: DropInfo) -> Bool { - canDrop && info.hasItemsConforming(to: calendarTaskDragContentTypes) + guard canDrop, + info.hasItemsConforming(to: calendarTaskDragContentTypes) else { + return false + } + if let todo = draggedTodo ?? CalendarTaskDragSession.shared.todo { + return canMove(todo) + } + return true } func dropEntered(info: DropInfo) { @@ -1232,6 +1258,9 @@ private struct CalendarDateDropDelegate: DropDelegate { guard let draggedTodo = draggedTodo ?? CalendarTaskDragSession.shared.todo else { return performProviderDrop(info: info) } + guard canMove(draggedTodo) else { + return false + } onMove(draggedTodo, Calendar.current.startOfDay(for: date)) return true } @@ -1248,13 +1277,17 @@ private struct CalendarDateDropDelegate: DropDelegate { } let todoId = rawId as String DispatchQueue.main.async { - if let todo = resolveTodo(todoId) { + if let todo = resolveTodo(todoId), canMove(todo) { onMove(todo, targetDate) } } } return true } + + private func canMove(_ todo: TodoItem) -> Bool { + !calendarTaskAlreadyDueOnDate(todo, date) + } } private struct CalendarInAppDateDropTargetFrameModifier: ViewModifier { @@ -1319,6 +1352,10 @@ private extension View { onDateChange(nil) return false } + guard !calendarTaskAlreadyDueOnDate(todo, targetDate) else { + onDateChange(nil) + return false + } onDateChange(nil) onMove(todo, targetDate) return true @@ -1329,6 +1366,12 @@ private extension View { } return } + if active, + let todo = draggedTodo ?? CalendarTaskDragSession.shared.todo, + calendarTaskAlreadyDueOnDate(todo, date) { + onDateChange(nil) + return + } onDateChange(active ? Calendar.current.startOfDay(for: date) : nil) } } @@ -1615,7 +1658,7 @@ private struct CalendarMonthDayCell: View { onMove: onMoveTaskToDate, onDateChange: onDropDateChange ) - .calendarInAppDateDropTargetFrame(date: day.date, enabled: isEnabled) + .calendarInAppDateDropTargetFrame(date: day.date, enabled: isEnabled && draggedTodo != nil) .opacity(day.isCurrentMonth ? 1 : 0.45) } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index ccd41a8a..261b0d2a 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -201,7 +201,7 @@ struct TodoListScreen: View { buildSections( items: viewModel.items, mode: viewModel.mode, - includeEmptyEarlierTarget: viewModel.mode.supportsTaskReschedule && (draggedTodo != nil || inAppDrag != nil) + includeEmptyEarlierTarget: false ) } @@ -529,15 +529,15 @@ struct TodoListScreen: View { return } TodoTaskDragSession.shared.handledDropSignature = dropSignature - guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDate) else { + guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDay) else { return } UIImpactFeedbackGenerator(style: .light).impactOccurred() if todo.isRecurring { - pendingRescheduleDrop = TodoRescheduleDrop(todo: todo, targetDate: targetDate) + pendingRescheduleDrop = TodoRescheduleDrop(todo: todo, targetDate: targetDay) } else { - Task { await viewModel.moveTask(todo, toDay: targetDate, scope: .occurrence) } + Task { await viewModel.moveTask(todo, toDay: targetDay, scope: .occurrence) } } } @@ -545,6 +545,27 @@ struct TodoListScreen: View { viewModel.items.first { $0.id == id || $0.canonicalId == id } } + private func sectionID(containing todo: TodoItem) -> String? { + if let exactSection = groupedSections.first(where: { section in + section.items.contains { item in item.id == todo.id } + }) { + return exactSection.id + } + return groupedSections.first { section in + section.items.contains { item in item.canonicalId == todo.canonicalId } + }?.id + } + + private func canDropTodo(_ todo: TodoItem, into section: TodoTimelineSection) -> Bool { + guard let targetDate = section.targetDate else { + return false + } + if sectionID(containing: todo) == section.id { + return false + } + return !Calendar.current.isDate(todo.due, inSameDayAs: targetDate) + } + private func setActiveDropSection(_ sectionId: String?) { guard activeDropSectionId != sectionId else { return } withAnimation(todoDropPlaceholderAnimation) { @@ -557,17 +578,26 @@ struct TodoListScreen: View { UIImpactFeedbackGenerator(style: .light).impactOccurred() } draggedTodo = todo + TodoTaskDragSession.shared.todo = todo + TodoTaskDragSession.shared.handledDropSignature = nil inAppDrag = TodoInAppDrag(todo: todo, location: location) updateInAppDrag(todo, to: location) } private func updateInAppDrag(_ todo: TodoItem, to location: CGPoint) { inAppDrag = TodoInAppDrag(todo: todo, location: location) - setActiveDropSection(dropSectionID(at: location)) + setActiveDropSection(dropSectionID(at: location, for: todo)) } private func finishInAppDrag(_ todo: TodoItem, at location: CGPoint?) { - let targetSectionID = location.flatMap(dropSectionID(at:)) ?? activeDropSectionId + let targetSectionID = location.flatMap { dropSectionID(at: $0, for: todo) } ?? + activeDropSectionId.flatMap { sectionID in + guard let section = groupedSections.first(where: { $0.id == sectionID }), + canDropTodo(todo, into: section) else { + return nil + } + return sectionID + } let targetDate = targetSectionID .flatMap { sectionID in groupedSections.first { $0.id == sectionID }?.targetDate } setActiveDropSection(nil) @@ -576,6 +606,8 @@ struct TodoListScreen: View { dropTargetFrames = [:] if let targetDate { requestReschedule(todo, to: targetDate) + } else { + TodoTaskDragSession.shared.todo = nil } } @@ -584,11 +616,18 @@ struct TodoListScreen: View { draggedTodo = nil inAppDrag = nil dropTargetFrames = [:] + TodoTaskDragSession.shared.todo = nil } - private func dropSectionID(at location: CGPoint) -> String? { + private func dropSectionID(at location: CGPoint, for todo: TodoItem) -> String? { dropTargetFrames.values .filter { $0.frame.contains(location) } + .filter { target in + guard let section = groupedSections.first(where: { $0.id == target.sectionID }) else { + return false + } + return canDropTodo(todo, into: section) + } .min { lhs, rhs in (lhs.frame.width * lhs.frame.height) < (rhs.frame.width * rhs.frame.height) }? @@ -714,24 +753,26 @@ struct TodoListScreen: View { } } ForEach(groupedSections) { section in + let isDropEligibleSection = draggedTodo.map { canDropTodo($0, into: section) } ?? false + let isActiveDropSection = activeDropSectionId == section.id && isDropEligibleSection Section { ForEach(section.items) { todo in todoRow(todo, in: section) .todoInAppDropTargetFrame( targetID: "standard-row-\(section.id)-\(todo.id)", section: section, - enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + enabled: viewModel.mode.supportsTaskReschedule && isDropEligibleSection ) .listRowBackground(todo.id == highlightedTodoId ? colors.surfaceVariant : colors.surface) } if viewModel.mode.supportsTaskReschedule, - activeDropSectionId == section.id, + isActiveDropSection, section.targetDate != nil { - TodoDropPlaceholder(isActive: activeDropSectionId == section.id) + TodoDropPlaceholder(isActive: isActiveDropSection) .todoInAppDropTargetFrame( targetID: "standard-placeholder-\(section.id)", section: section, - enabled: true + enabled: isDropEligibleSection ) .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 6, trailing: 20)) .listRowBackground(colors.surface) @@ -743,6 +784,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -754,7 +796,7 @@ struct TodoListScreen: View { .todoInAppDropTargetFrame( targetID: "standard-spacer-\(section.id)", section: section, - enabled: draggedTodo != nil + enabled: isDropEligibleSection ) .listRowInsets(EdgeInsets()) .scheduledTodoDropTarget( @@ -764,6 +806,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -771,13 +814,13 @@ struct TodoListScreen: View { } } header: { Text(section.title) - .foregroundStyle(activeDropSectionId == section.id ? colors.error : colors.onSurfaceVariant) + .foregroundStyle(isActiveDropSection ? colors.error : colors.onSurfaceVariant) .frame(maxWidth: .infinity, minHeight: 38, alignment: .leading) .contentShape(Rectangle()) .todoInAppDropTargetFrame( targetID: "standard-header-\(section.id)", section: section, - enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + enabled: viewModel.mode.supportsTaskReschedule && isDropEligibleSection ) .timelinePinnedSectionHeaderBackground() .scheduledTodoDropTarget( @@ -787,6 +830,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -988,6 +1032,7 @@ struct TodoListScreen: View { onMove: { droppedTodo, targetDate in requestReschedule(droppedTodo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -1087,6 +1132,7 @@ struct TodoListScreen: View { onMove: { droppedTodo, targetDate in requestReschedule(droppedTodo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -1128,16 +1174,18 @@ struct TodoListScreen: View { ) -> some View { let canCollapseSection = canCollapseTimelineSection(section) let isCollapsed = canCollapseSection && collapsedSectionIDs.contains(section.id) + let isDropEligibleSection = draggedTodo.map { canDropTodo($0, into: section) } ?? false + let isActiveDropSection = activeDropSectionId == section.id && isDropEligibleSection Section { if viewModel.mode.supportsTaskReschedule, - activeDropSectionId == section.id, + isActiveDropSection, section.targetDate != nil { - TodoDropPlaceholder(isActive: activeDropSectionId == section.id) + TodoDropPlaceholder(isActive: isActiveDropSection) .todoInAppDropTargetFrame( targetID: "minimal-placeholder-\(section.id)", section: section, - enabled: true + enabled: isDropEligibleSection ) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 8, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) @@ -1150,6 +1198,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -1162,7 +1211,7 @@ struct TodoListScreen: View { .todoInAppDropTargetFrame( targetID: "minimal-row-\(section.id)-\(todo.id)", section: section, - enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + enabled: viewModel.mode.supportsTaskReschedule && isDropEligibleSection ) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) @@ -1177,7 +1226,7 @@ struct TodoListScreen: View { } header: { TimelineSectionHeader( title: section.title, - isActiveDropTarget: activeDropSectionId == section.id, + isActiveDropTarget: isActiveDropSection, isCollapsible: canCollapseSection, isCollapsed: isCollapsed, onTap: canCollapseSection ? { @@ -1186,12 +1235,12 @@ struct TodoListScreen: View { ) .id(timelineSectionScrollID(section.id)) .padding(.top, isFirstSection ? 0 : 8) - .frame(maxWidth: .infinity, minHeight: viewModel.mode.supportsTaskReschedule && draggedTodo != nil ? 44 : nil, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .todoInAppDropTargetFrame( targetID: "minimal-header-\(section.id)", section: section, - enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + enabled: viewModel.mode.supportsTaskReschedule && isDropEligibleSection ) .timelinePinnedSectionHeaderBackground() .scheduledTodoDropTarget( @@ -1201,6 +1250,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -2232,10 +2282,18 @@ private struct ScheduledTodoDropDelegate: DropDelegate { let draggedTodo: TodoItem? let resolveTodo: (String) -> TodoItem? let onMove: (TodoItem, Date) -> Void + let canMoveTodo: (TodoItem, TodoTimelineSection) -> Bool let onSectionChange: (String?) -> Void func validateDrop(info: DropInfo) -> Bool { - section.targetDate != nil && info.hasItemsConforming(to: todoDragContentTypes) + guard section.targetDate != nil, + info.hasItemsConforming(to: todoDragContentTypes) else { + return false + } + if let todo = draggedTodo ?? TodoTaskDragSession.shared.todo { + return canMoveTodo(todo, section) + } + return true } func dropEntered(info: DropInfo) { @@ -2260,6 +2318,9 @@ private struct ScheduledTodoDropDelegate: DropDelegate { let targetDate = section.targetDate else { return performProviderDrop(info: info) } + guard canMoveTodo(todo, section) else { + return false + } onMove(todo, targetDate) return true } @@ -2275,7 +2336,7 @@ private struct ScheduledTodoDropDelegate: DropDelegate { } let todoId = rawId as String DispatchQueue.main.async { - if let todo = resolveTodo(todoId) { + if let todo = resolveTodo(todoId), canMoveTodo(todo, section) { onMove(todo, targetDate) } } @@ -2290,6 +2351,7 @@ private extension View { draggedTodo: TodoItem?, resolveTodo: @escaping (String) -> TodoItem?, onMove: @escaping (TodoItem, Date) -> Void, + canMoveTodo: @escaping (TodoItem, TodoTimelineSection) -> Bool, onSectionChange: @escaping (String?) -> Void ) -> some View { self @@ -2300,6 +2362,7 @@ private extension View { draggedTodo: draggedTodo, resolveTodo: resolveTodo, onMove: onMove, + canMoveTodo: canMoveTodo, onSectionChange: onSectionChange ) ) @@ -2315,6 +2378,10 @@ private extension View { onSectionChange(nil) return false } + guard canMoveTodo(todo, section) else { + onSectionChange(nil) + return false + } onSectionChange(nil) onMove(todo, targetDate) return true @@ -2325,6 +2392,12 @@ private extension View { } return } + if active, + let todo = draggedTodo ?? TodoTaskDragSession.shared.todo, + !canMoveTodo(todo, section) { + onSectionChange(nil) + return + } onSectionChange(active ? section.id : nil) } } From c0813a47b48f81f12f071323cfcbad00314d5ad6 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 14:42:53 -0400 Subject: [PATCH 05/19] feat(todos): add list deletion and refine timeline spacing Introduce the ability to delete custom todo lists from both Android and iOS platforms. This includes a delete button in the list settings sheet and a confirmation dialog to prevent accidental data loss. Additionally, unify and refine UI spacing across the timeline, completed, and calendar screens for a more compact and consistent layout. - **List Management**: - Add `onDeleteList` callback and `showDeleteListConfirmation` state to `TodoListScreen`. - Implement `ListSettingsDeleteButton` in the list settings bottom sheet. - Add "Delete list" option with a destructive alert dialog in iOS `ListSettingsSheet`. - Update `TodoListViewModel` to handle list deletion with optimistic updates. - **UI & Layout Refinement**: - Standardize timeline spacing (e.g., 2dp for same-date tasks, 6dp for date groups) across Android and iOS. - Reduce default row heights (58dp to 56dp) and minimal vertical padding for a more compact view. - Centralize timeline metrics in `TodoTimelineMetrics` on iOS. - Refine section header heights and spacings in `TodoListScreen`, `CompletedScreen`, and `CalendarScreen`. - Implement `timelineTaskBottomSpacing` logic to handle dynamic padding between tasks and date dividers. --- .../feature/calendar/CalendarScreen.kt | 11 +- .../feature/completed/CompletedScreen.kt | 49 +++++- .../compose/feature/todos/TodoListScreen.kt | 161 +++++++++++++++--- .../Feature/Calendar/CalendarScreen.swift | 4 +- .../Feature/Completed/CompletedScreen.swift | 3 +- .../Tday/Feature/Todos/TodoListScreen.swift | 53 +++++- 6 files changed, 236 insertions(+), 45 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 3a990b23..bb4a1157 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -232,6 +232,8 @@ private val CalendarPeriodCardPageHeight = 78.dp private val CalendarPeriodWeekDayCellHeight = 72.dp private val CalendarPeriodPageHorizontalGutter = 2.dp private val CalendarPeriodCardBottomPadding = 18.dp +private val CalendarTaskListSameDateSpacing = 2.dp +private val CalendarTaskRowHeight = 56.dp private val CalendarTaskDragDueTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()) private const val CalendarMonthPagerPageCount = 240 @@ -658,7 +660,10 @@ fun CalendarScreen( if (selectedDatePendingTasks.isNotEmpty()) { item { - Column(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(CalendarTaskListSameDateSpacing), + ) { selectedDatePendingTasks.forEachIndexed { index, todo -> key(todo.id) { CalendarTodoRow( @@ -2088,7 +2093,7 @@ private fun CalendarTodoRow( Box( modifier = Modifier .fillMaxWidth() - .height(58.dp), + .height(CalendarTaskRowHeight), ) { Row( modifier = Modifier @@ -2339,7 +2344,7 @@ private fun CalendarCompletedTodoRow( Card( modifier = Modifier .fillMaxWidth() - .height(58.dp), + .height(CalendarTaskRowHeight), shape = rowShape, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt index ab1c8a4a..852725b3 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt @@ -114,6 +114,23 @@ import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale +private val CompletedTimelineSameDateTaskSpacing = 2.dp +private val CompletedTimelineDateGroupSpacing = 6.dp +private val CompletedTimelineSectionTopSpacing = 6.dp +private val CompletedTimelineHeaderBodySpacing = 2.dp +private val CompletedTimelineCollapsedSectionSpacing = 4.dp +private val CompletedSwipeRowHeight = 56.dp + +private fun completedTaskBottomSpacing( + itemIndex: Int, + lastIndex: Int, + showDateDivider: Boolean, +) = if (showDateDivider || itemIndex == lastIndex) { + CompletedTimelineDateGroupSpacing +} else { + CompletedTimelineSameDateTaskSpacing +} + private enum class CompletedRestorePhase { Completed, Unchecked, @@ -255,7 +272,14 @@ fun CompletedScreen( ), fadeOutSpec = null, ) - .padding(top = if (sectionIndex == 0) 0.dp else 8.dp), + .padding( + top = if (sectionIndex == 0) 0.dp else CompletedTimelineSectionTopSpacing, + bottom = if (isCollapsed) { + CompletedTimelineCollapsedSectionSpacing + } else { + CompletedTimelineHeaderBodySpacing + }, + ), section = section, isCollapsed = isCollapsed, onHeaderClick = { @@ -270,6 +294,12 @@ fun CompletedScreen( } if (!isCollapsed) { section.items.forEachIndexed { itemIndex, completed -> + val showCompletedDateDivider = shouldShowDateDivider( + afterItemIndex = itemIndex, + inSectionIndex = sectionIndex, + sections = timelineSections, + collapsedSectionKeys = collapsedSectionKeys, + ) item(key = "completed-row-${section.key}-${completed.id}") { CompletedSwipeRow( modifier = Modifier @@ -287,15 +317,16 @@ fun CompletedScreen( easing = FastOutSlowInEasing, ), ) - .padding(top = 4.dp), + .padding( + bottom = completedTaskBottomSpacing( + itemIndex = itemIndex, + lastIndex = section.items.lastIndex, + showDateDivider = showCompletedDateDivider, + ), + ), item = completed, lists = uiState.lists, - showDateDivider = shouldShowDateDivider( - afterItemIndex = itemIndex, - inSectionIndex = sectionIndex, - sections = timelineSections, - collapsedSectionKeys = collapsedSectionKeys, - ), + showDateDivider = showCompletedDateDivider, onInfo = { editTargetId = completed.id }, onDelete = { onDelete(completed) }, onUncomplete = { onUncomplete(completed) }, @@ -621,7 +652,7 @@ private fun CompletedSwipeRow( Box( modifier = Modifier .fillMaxWidth() - .height(58.dp), + .height(CompletedSwipeRowHeight), ) { Row( modifier = Modifier diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index e7dd7813..121733e4 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -226,6 +226,24 @@ import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt +private val TimelineSameDateTaskSpacing = 2.dp +private val TimelineDateGroupSpacing = 6.dp +private val TimelineSectionTopSpacing = 6.dp +private val TimelineHeaderBodySpacing = 2.dp +private val TimelineCollapsedSectionSpacing = 4.dp + +private fun timelineTaskBottomSpacing( + itemIndex: Int, + lastIndex: Int, + showDateDivider: Boolean, +): Dp { + return if (showDateDivider || itemIndex == lastIndex) { + TimelineDateGroupSpacing + } else { + TimelineSameDateTaskSpacing + } +} + @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun TodoListScreen( @@ -242,6 +260,7 @@ fun TodoListScreen( onComplete: (todo: TodoItem) -> Unit, onDelete: (todo: TodoItem) -> Unit, onUpdateListSettings: (listId: String, name: String, color: String?, iconKey: String?) -> Unit, + onDeleteList: (listId: String) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -399,6 +418,7 @@ fun TodoListScreen( remember(uiState.mode) { mutableStateMapOf() } var pendingRescheduleDrop by remember(uiState.mode) { mutableStateOf(null) } var showListSettingsSheet by rememberSaveable { mutableStateOf(false) } + var showDeleteListConfirmation by rememberSaveable { mutableStateOf(false) } var showSummarySheet by rememberSaveable(uiState.mode) { mutableStateOf(false) } var listSettingsTargetId by rememberSaveable { mutableStateOf(null) } var listSettingsName by rememberSaveable { mutableStateOf("") } @@ -444,8 +464,8 @@ fun TodoListScreen( targetValue = if (fabPressed) 2.dp else 0.dp, label = "todoFabOffsetY", ) - val timelineItemSpacing = if (usesTodayStyle) 4.dp else 8.dp - val timelineHeaderBodySpacing = if (usesTodayStyle) 4.dp else 8.dp + val timelineItemSpacing = TimelineDateGroupSpacing + val timelineHeaderBodySpacing = TimelineHeaderBodySpacing fun highlightedTodoListTarget(todoId: String): Pair? { var itemIndex = 0 timelineSections.forEach { section -> @@ -726,13 +746,13 @@ fun TodoListScreen( enabled = isDropEligibleSection, dropTargets = timelineDropTargetBounds, ) - .padding(top = if (sectionIndex == 0) 0.dp else 8.dp), + .padding(top = if (sectionIndex == 0) 0.dp else TimelineSectionTopSpacing), section = section, useMinimalStyle = usesTodayStyle, isCollapsed = isCollapsed, isDropTarget = isActiveDropSection && isDropEligibleSection, bottomSpacing = if (isCollapsed) { - timelineItemSpacing + TimelineCollapsedSectionSpacing } else { timelineHeaderBodySpacing }, @@ -790,11 +810,7 @@ fun TodoListScreen( dropTargets = timelineDropTargetBounds, ) .padding( - bottom = if (isCollapsed || section.items.isEmpty()) { - timelineItemSpacing - } else { - 8.dp - }, + bottom = TimelineDateGroupSpacing, ), active = true, useMinimalStyle = usesTodayStyle, @@ -807,6 +823,12 @@ fun TodoListScreen( section.key == "earlier" && (uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY) section.items.forEachIndexed { itemIndex, todo -> + val showTimelineDateDivider = shouldShowDateDivider( + afterItemIndex = itemIndex, + inSectionIndex = sectionIndex, + sections = timelineSections, + collapsedSectionKeys = collapsedSectionKeys, + ) item( key = "timeline-todo-${section.key}-${todo.id}", contentType = "timeline-todo", @@ -837,11 +859,11 @@ fun TodoListScreen( dropTargets = timelineDropTargetBounds, ) .padding( - bottom = if (itemIndex == section.items.lastIndex) { - timelineItemSpacing - } else { - 8.dp - }, + bottom = timelineTaskBottomSpacing( + itemIndex = itemIndex, + lastIndex = section.items.lastIndex, + showDateDivider = showTimelineDateDivider, + ), ), todo = todo, mode = uiState.mode, @@ -849,12 +871,7 @@ fun TodoListScreen( useMinimalStyle = usesTodayStyle, flashHighlight = flashTodoId == todo.id || flashTodoId == todo.canonicalId, showEarlierDateTimeSubtitle = showEarlierDateTimeSubtitle, - showDateDivider = shouldShowDateDivider( - afterItemIndex = itemIndex, - inSectionIndex = sectionIndex, - sections = timelineSections, - collapsedSectionKeys = collapsedSectionKeys, - ), + showDateDivider = showTimelineDateDivider, onComplete = { onComplete(todo) }, onDelete = { onDelete(todo) }, onInfo = { @@ -1099,6 +1116,50 @@ fun TodoListScreen( showListSettingsSheet = false listSettingsTargetId = null }, + onDelete = { + showListSettingsSheet = false + showDeleteListConfirmation = true + }, + ) + } + + val deleteConfirmationListId = selectedListId + if ( + showDeleteListConfirmation && + uiState.mode == TodoListMode.LIST && + !deleteConfirmationListId.isNullOrBlank() + ) { + AlertDialog( + onDismissRequest = { showDeleteListConfirmation = false }, + title = { + Text( + text = stringResource(R.string.todos_delete_list_title), + fontWeight = FontWeight.ExtraBold, + ) + }, + text = { + Text(text = stringResource(R.string.todos_delete_list_message)) + }, + confirmButton = { + TextButton( + onClick = { + showDeleteListConfirmation = false + onDeleteList(deleteConfirmationListId) + listSettingsTargetId = null + }, + ) { + Text( + text = stringResource(R.string.action_delete), + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.ExtraBold, + ) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteListConfirmation = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, ) } } @@ -1399,6 +1460,7 @@ private fun ListSettingsBottomSheet( onListIconChange: (String) -> Unit, onDismiss: () -> Unit, onSave: () -> Unit, + onDelete: () -> Unit, ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val focusManager = androidx.compose.ui.platform.LocalFocusManager.current @@ -1688,11 +1750,66 @@ private fun ListSettingsBottomSheet( } } } + + Spacer(Modifier.height(2.dp)) + ListSettingsDeleteButton(onClick = onDelete) } } } } +@Composable +private fun ListSettingsDeleteButton( + onClick: () -> Unit, +) { + val view = LocalView.current + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.97f else 1f, + label = "listSettingsDeleteButtonScale", + ) + + Card( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + onClick = { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + onClick() + }, + interactionSource = interactionSource, + shape = RoundedCornerShape(24.dp), + border = BorderStroke(1.5.dp, colorScheme.error.copy(alpha = 0.45f)), + colors = CardDefaults.cardColors(containerColor = colorScheme.errorContainer.copy(alpha = 0.22f)), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp, pressedElevation = 0.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = colorScheme.error, + ) + Text( + text = stringResource(R.string.action_delete_list), + style = MaterialTheme.typography.titleMedium, + color = colorScheme.error, + fontWeight = FontWeight.ExtraBold, + ) + } + } +} + @Composable private fun ListSettingsActionButton( icon: ImageVector, @@ -1808,7 +1925,7 @@ private fun TimelineSectionHeader( } else { baseChevronColor } - val minimumHeaderHeight = if (useMinimalStyle) 34.dp else 48.dp + val minimumHeaderHeight = if (useMinimalStyle) 32.dp else 44.dp val headerClickModifier = when { onHeaderClick != null -> Modifier.clickable( interactionSource = headerInteractionSource, @@ -2594,7 +2711,7 @@ private const val SEARCH_RESULT_SCROLL_MAX_DURATION_MS = 2400 private const val SEARCH_RESULT_CENTER_SCROLL_DURATION_MS = 520 private const val SEARCH_RESULT_ESTIMATED_ROW_HEIGHT_DP = 72f private val SWIPE_ROW_CONTENT_VERTICAL_PADDING = 2.dp -private val SWIPE_ROW_HEIGHT = 58.dp +private val SWIPE_ROW_HEIGHT = 56.dp private val TASK_CHECKMARK_GREEN = Color(0xFF6FBF86) private val TODO_DUE_TIME_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index a363e378..6102b195 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -75,8 +75,8 @@ private enum CalendarMonthGridMetrics { } private enum CalendarTaskListMetrics { - static let rowSpacing: CGFloat = 0 - static let rowVerticalPadding: CGFloat = 4 + static let rowSpacing = TodoTimelineMetrics.sameDateTaskSpacing + static let rowVerticalPadding = TodoTimelineMetrics.minimalRowVerticalPadding } private enum CalendarModeCardMetrics { diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 24b0f35e..b0fe63ea 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -125,6 +125,7 @@ struct CompletedScreen: View { .listStyle(.plain) .scrollContentBackground(.hidden) .contentMargins(.top, 0, for: .scrollContent) + .listRowSpacing(0) .listSectionSpacing(0) .environment(\.defaultMinListRowHeight, 1) .disableVerticalScrollBounce() @@ -184,7 +185,7 @@ struct CompletedScreen: View { ) .listRowInsets( EdgeInsets( - top: isFirstSection ? 0 : 8, + top: isFirstSection ? 0 : TodoTimelineMetrics.sectionTopSpacing, leading: 0, bottom: 0, trailing: 0 diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 261b0d2a..c85a715c 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -43,7 +43,10 @@ enum TodoTimelineMetrics { static let minimalRowSubtitleSize: CGFloat = 13 static let minimalRowIndicatorSize: CGFloat = 14 static let minimalRowTrailingIndicatorPadding: CGFloat = 24 - static let minimalRowVerticalPadding: CGFloat = 10 + static let minimalRowVerticalPadding: CGFloat = 8 + static let sameDateTaskSpacing: CGFloat = 2 + static let sectionTopSpacing: CGFloat = 6 + static let sectionHeaderBottomPadding: CGFloat = 2 static let titleCollapseDistance: CGFloat = 64 static let topBarRowHeight: CGFloat = 56 static let topBarButtonFrame: CGFloat = 56 @@ -173,6 +176,7 @@ struct TimelineTopBarAction { struct TodoListScreen: View { let highlightedTodoId: String? + let onListDeleted: () -> Void @State private var viewModel: TodoListViewModel @Environment(\.tdayColors) private var colors @Environment(\.dismiss) private var dismiss @@ -191,8 +195,16 @@ struct TodoListScreen: View { @State private var flashTodoId: String? @State private var highlightedScrollRequestID = 0 - init(container: AppContainer, mode: TodoListMode, listId: String?, listName: String?, highlightedTodoId: String?) { + init( + container: AppContainer, + mode: TodoListMode, + listId: String?, + listName: String?, + highlightedTodoId: String?, + onListDeleted: @escaping () -> Void = {} + ) { self.highlightedTodoId = highlightedTodoId + self.onListDeleted = onListDeleted _viewModel = State(initialValue: TodoListViewModel(container: container, mode: mode, listId: listId, listName: listName)) _collapsedSectionIDs = State(initialValue: mode == .priority || mode == .all ? ["earlier"] : []) } @@ -501,9 +513,17 @@ struct TodoListScreen: View { } private var listSettingsSheetContent: some View { - ListSettingsSheet(list: viewModel.lists.first { $0.id == viewModel.listId }) { name, color, iconKey in - Task { await viewModel.updateListSettings(name: name, color: color, iconKey: iconKey) } - } + ListSettingsSheet( + list: viewModel.lists.first(where: { $0.id == viewModel.listId }), + onSubmit: { name, color, iconKey in + Task { await viewModel.updateListSettings(name: name, color: color, iconKey: iconKey) } + }, + onDelete: { + Task { + await viewModel.deleteList(onOptimisticDelete: onListDeleted) + } + } + ) } private func handleItemsChanged() { @@ -880,7 +900,7 @@ struct TodoListScreen: View { title: section.title, isActiveDropTarget: activeDropSectionId == section.id ) - .padding(.top, index == 0 ? 0 : 8) + .padding(.top, index == 0 ? 0 : TodoTimelineMetrics.sectionTopSpacing) .timelinePinnedSectionHeaderBackground() .listRowInsets( EdgeInsets( @@ -1234,7 +1254,7 @@ struct TodoListScreen: View { } : nil ) .id(timelineSectionScrollID(section.id)) - .padding(.top, isFirstSection ? 0 : 8) + .padding(.top, isFirstSection ? 0 : TodoTimelineMetrics.sectionTopSpacing) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .todoInAppDropTargetFrame( @@ -1744,7 +1764,7 @@ struct TimelineSectionHeader: View { } .padding(.top, 2) .padding(.horizontal, TodoTimelineMetrics.horizontalPadding) - .padding(.bottom, 4) + .padding(.bottom, TodoTimelineMetrics.sectionHeaderBottomPadding) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) @@ -2109,12 +2129,14 @@ private let todoListSettingsColorKeys = [ private struct ListSettingsSheet: View { let list: ListSummary? let onSubmit: (String, String?, String?) -> Void + let onDelete: () -> Void @Environment(\.dismiss) private var dismiss @Environment(\.tdayColors) private var tdayColors @State private var name = "" @State private var color = "PINK" @State private var iconKey = "inbox" + @State private var showingDeleteConfirmation = false private let colors = todoListSettingsColorKeys private let icons = ["inbox", "briefcase", "calendar", "list.bullet", "star", "heart"] @@ -2146,6 +2168,12 @@ private struct ListSettingsSheet: View { Label(value.replacingOccurrences(of: ".", with: " "), systemImage: value).tag(value) } } + + Button(role: .destructive) { + showingDeleteConfirmation = true + } label: { + Label("Delete list", systemImage: "trash") + } } .scrollContentBackground(.hidden) .background(tdayColors.bottomSheetBackground) @@ -2153,6 +2181,15 @@ private struct ListSettingsSheet: View { .background(tdayColors.bottomSheetBackground) .disableVerticalScrollBounce() .toolbar(.hidden, for: .navigationBar) + .alert("Delete list?", isPresented: $showingDeleteConfirmation) { + Button("Cancel", role: .cancel) {} + Button("Delete", role: .destructive) { + onDelete() + dismiss() + } + } message: { + Text("This will delete this list, every task in it, and completed history for those tasks.") + } .task { name = list?.name ?? "" color = normalizedTodoListColorKey(list?.color) From 05f534e0aa0c92fdfc1358736edfd5a57b962538 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 15:06:48 -0400 Subject: [PATCH 06/19] feat(list): implement list deletion and persistence of list associations for completed tasks This update introduces the ability to delete entire lists and ensures that completed task history maintains its relationship to the parent list. It includes backend database migrations, offline sync support, and UI enhancements across Android and iOS. - **Backend Changes**: - Add `listID` column to the `CompletedTodos` table with associated database migration. - Update `ListService` to perform cascading deletions of associated tasks and instances when a list is deleted. - Enhance `CompletedTodoService` to support updates and ensure `listID` is captured upon task completion. - Refactor `CompletedTodoRoutes` to support targeted deletion by ID and partial updates. - **Sync & Data Layer**: - Implement `DELETE_LIST` mutation kind in `SyncManager` for both Android and iOS. - Update `OfflineSyncState` and cache mappers to include `listId` for completed records. - Refactor `ListRepository` to support optimistic deletion of lists and their associated tasks/completed items. - Migrate Android Room database to version 4 to include `listId` in `cached_completed`. - **UI & UX Enhancements**: - **Android**: - Implement `ListDeleteConfirmationDialog` with a custom styled `Dialog`. - Update `HomeScreen` to handle list row animations and prevent redundant cascade reveals during deletions. - Add "Earlier" section collapsing logic for list-specific views in `TodoListScreen`. - **iOS**: - Implement `ListDeleteConfirmationOverlay` for list settings. - Add spring-animated transitions for list additions and removals on the `HomeScreen`. - Ensure navigation pops back to Home when a list is deleted from its detail view. - **Infrastructure**: - Update shared models and API DTOs to include `listID` in completed task responses. - Improve idempotency in list deletion endpoints. --- .../java/com/ohmz/tday/compose/TdayApp.kt | 13 ++ .../compose/core/data/OfflineSyncModels.kt | 2 + .../compose/core/data/cache/CacheMappers.kt | 3 + .../data/completed/CompletedRepository.kt | 1 + .../core/data/db/DatabaseMigrations.kt | 8 ++ .../compose/core/data/db/DatabaseModule.kt | 2 +- .../tday/compose/core/data/db/Entities.kt | 1 + .../compose/core/data/db/EntityMappers.kt | 2 + .../tday/compose/core/data/db/TdayDatabase.kt | 2 +- .../compose/core/data/list/ListRepository.kt | 85 ++++++++++++ .../compose/core/data/sync/SyncManager.kt | 43 +++++- .../compose/core/data/todo/TodoRepository.kt | 1 + .../tday/compose/core/model/DomainModels.kt | 1 + .../tday/compose/feature/home/HomeScreen.kt | 39 +++++- .../compose/feature/todos/TodoListScreen.kt | 122 ++++++++++++---- .../feature/todos/TodoListViewModel.kt | 41 ++++++ .../app/src/main/res/values/strings.xml | 3 + .../data/OfflineSyncStateSerializationTest.kt | 6 +- .../core/data/cache/CacheMappersTest.kt | 5 + .../Core/Data/Cache/OfflineCacheManager.swift | 1 + .../Data/Completed/CompletedRepository.swift | 3 +- .../Core/Data/Database/SwiftDataModels.swift | 2 + .../Tday/Core/Data/List/ListRepository.swift | 80 ++++++++++- .../Tday/Core/Data/Sync/CacheMappers.swift | 3 + .../Tday/Core/Data/Sync/SyncManager.swift | 75 ++++++++-- .../Tday/Core/Data/Todo/TodoRepository.swift | 2 + ios-swiftUI/Tday/Core/Model/ApiModels.swift | 1 + .../Tday/Core/Model/DomainModels.swift | 1 + .../Tday/Core/Model/OfflineSyncModels.swift | 2 + .../Tday/Feature/App/AppRootView.swift | 11 +- .../Tday/Feature/Home/HomeScreen.swift | 53 +++++-- .../Tday/Feature/Todos/TodoListScreen.swift | 130 +++++++++++++++--- .../Feature/Todos/TodoListViewModel.swift | 18 +++ .../CompletedSyncMergeTests.swift | 22 ++- .../ohmz/tday/shared/model/CompletedModels.kt | 1 + .../com/ohmz/tday/db/tables/CompletedTodos.kt | 1 + .../ohmz/tday/routes/CompletedTodoRoutes.kt | 49 +++++-- .../kotlin/com/ohmz/tday/routes/ListRoutes.kt | 2 +- .../tday/services/CompletedTodoService.kt | 32 +++++ .../com/ohmz/tday/services/ListService.kt | 35 ++++- .../com/ohmz/tday/services/TodoService.kt | 1 + .../V6__add_completed_todo_project_id.sql | 29 ++++ .../com/ohmz/tday/routes/ListRoutesTest.kt | 29 +++- 43 files changed, 862 insertions(+), 101 deletions(-) create mode 100644 tday-backend/src/main/resources/db/migration/V6__add_completed_todo_project_id.sql diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt index 7fdf5373..b434cb73 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt @@ -556,6 +556,12 @@ fun TdayApp( listName = listName, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + onListDeleted = { + navController.navigate(AppRoute.Home.route) { + popUpTo(AppRoute.Home.route) { inclusive = false } + launchSingleTop = true + } + }, ) } @@ -816,6 +822,7 @@ private fun TodosRoute( mode: TodoListMode, onBack: () -> Unit, onTaskDeleted: () -> Unit, + onListDeleted: () -> Unit = {}, highlightTodoId: String? = null, listId: String? = null, listName: String? = null, @@ -855,6 +862,12 @@ private fun TodosRoute( iconKey = iconKey, ) }, + onDeleteList = { targetListId -> + viewModel.deleteList( + listId = targetListId, + onOptimisticDelete = onListDeleted, + ) + }, ) } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt index e0f3f70f..2b5f5e11 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt @@ -51,6 +51,7 @@ data class CachedCompletedRecord( val completedAtEpochMs: Long = 0L, val rrule: String? = null, val instanceDateEpochMs: Long? = null, + val listId: String? = null, val listName: String? = null, val listColor: String? = null, ) @@ -79,6 +80,7 @@ data class PendingMutationRecord( enum class MutationKind { CREATE_LIST, UPDATE_LIST, + DELETE_LIST, CREATE_TODO, UPDATE_TODO, DELETE_TODO, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt index 384c2ac3..306a3d0e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt @@ -112,6 +112,7 @@ internal fun completedToCache(item: CompletedItem): CachedCompletedRecord { completedAtEpochMs = item.completedAt?.toEpochMilli() ?: 0L, rrule = item.rrule, instanceDateEpochMs = item.instanceDate?.toEpochMilli(), + listId = item.listId, listName = item.listName, listColor = item.listColor, ) @@ -132,6 +133,7 @@ internal fun completedFromCache(cache: CachedCompletedRecord): CompletedItem { }, rrule = cache.rrule, instanceDate = cache.instanceDateEpochMs?.let(Instant::ofEpochMilli), + listId = cache.listId, listName = cache.listName, listColor = cache.listColor, ) @@ -171,6 +173,7 @@ internal fun mapCompletedDto(dto: CompletedTodoDto): CompletedItem { completedAt = parseOptionalInstant(dto.completedAt), rrule = dto.rrule, instanceDate = parseOptionalInstant(dto.instanceDate), + listId = dto.listID, listName = dto.listName, listColor = dto.listColor, ) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt index 35dc7dcf..a86658e3 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt @@ -147,6 +147,7 @@ class CompletedRepository @Inject constructor( completedAtEpochMs = completed.completedAtEpochMs.takeIf { it > 0L } ?: timestampMs, rrule = payload.rrule, + listId = normalizedListId, listName = listMeta?.name, listColor = listMeta?.color, ) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt index fcf68f3f..2a3d8a0b 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt @@ -110,3 +110,11 @@ val MIGRATION_2_3 = object : Migration(2, 3) { ) } } + +val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE `cached_completed` ADD COLUMN `listId` TEXT", + ) + } +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt index 38d1fae6..d016df3e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt @@ -21,7 +21,7 @@ object DatabaseModule { TdayDatabase::class.java, "tday_offline_cache.db", ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .allowMainThreadQueries() .build() } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt index 2863c632..4d740605 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt @@ -52,6 +52,7 @@ data class CachedCompletedEntity( val completedAtEpochMs: Long, val rrule: String?, val instanceDateEpochMs: Long?, + val listId: String?, val listName: String?, val listColor: String?, ) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt index e14924c9..f05916d3 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt @@ -66,6 +66,7 @@ fun CachedCompletedRecord.toEntity() = CachedCompletedEntity( completedAtEpochMs = completedAtEpochMs, rrule = rrule, instanceDateEpochMs = instanceDateEpochMs, + listId = listId, listName = listName, listColor = listColor, ) @@ -80,6 +81,7 @@ fun CachedCompletedEntity.toRecord() = CachedCompletedRecord( completedAtEpochMs = completedAtEpochMs, rrule = rrule, instanceDateEpochMs = instanceDateEpochMs, + listId = listId, listName = listName, listColor = listColor, ) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt index 7b6a2ca3..fdd4ddd4 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt @@ -11,7 +11,7 @@ import androidx.room.RoomDatabase PendingMutationEntity::class, SyncMetadataEntity::class, ], - version = 3, + version = 4, exportSchema = false, ) abstract class TdayDatabase : RoomDatabase() { diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt index 4d5bdc6f..f8d4c20c 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt @@ -15,6 +15,7 @@ import com.ohmz.tday.compose.core.data.isLikelyUnrecoverableMutationError import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.data.sync.SyncManager import com.ohmz.tday.compose.core.model.CreateListRequest +import com.ohmz.tday.compose.core.model.DeleteListRequest import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.UpdateListRequest import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter @@ -240,6 +241,87 @@ class ListRepository @Inject constructor( } } + suspend fun deleteList( + listId: String, + onOptimisticDelete: () -> Unit = {}, + ) { + val normalizedListId = listId.trim() + if (normalizedListId.isBlank()) return + + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.DELETE_LIST, + targetId = normalizedListId, + timestampEpochMs = timestampMs, + ) + val isLocalOnly = normalizedListId.startsWith(LOCAL_LIST_PREFIX) + + cacheManager.updateOfflineState { state -> + val deletedTodoIds = state.todos + .filter { it.listId == normalizedListId } + .map { it.canonicalId } + .toSet() + + val prunedMutations = state.pendingMutations.filterNot { mutation -> + mutation.targetId == normalizedListId || + mutation.listId == normalizedListId || + deletedTodoIds.contains(mutation.targetId) + } + + state.copy( + lists = state.lists.filterNot { it.id == normalizedListId }, + todos = state.todos.filterNot { it.listId == normalizedListId }, + completedItems = state.completedItems.filterNot { completed -> + completed.listId == normalizedListId || + completed.originalTodoId?.let(deletedTodoIds::contains) == true + }, + pendingMutations = if (isLocalOnly) { + prunedMutations + } else { + prunedMutations + pendingMutation + }, + ) + } + + onOptimisticDelete() + + if (isLocalOnly) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val immediateError = runCatching { + requireApiBody( + api.deleteListByBody(DeleteListRequest(id = normalizedListId)), + "Could not delete list", + ) + }.exceptionOrNull() + + if (immediateError != null && isLikelyUnrecoverableMutationError( + immediateError, + pendingMutation + ) + ) { + throw immediateError + } + + if (immediateError == null) { + cacheManager.updateOfflineState { state -> + state.copy( + pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }, + ) + } + Log.d(LOG_TAG, "deleteList success listId=$normalizedListId") + } else { + Log.w( + LOG_TAG, + "deleteList deferred listId=$normalizedListId reason=${immediateError.message}" + ) + } + } + private fun buildListsForState(state: OfflineSyncState): List { val todoCountsByList = state.todos .asSequence() @@ -263,6 +345,9 @@ class ListRepository @Inject constructor( todos = state.todos.map { if (it.listId == localListId) it.copy(listId = serverListId) else it }, + completedItems = state.completedItems.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, pendingMutations = state.pendingMutations.map { it.copy( targetId = if (it.targetId == localListId) serverListId else it.targetId, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt index 22ebbcef..26bde6ab 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/sync/SyncManager.kt @@ -26,6 +26,7 @@ import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.model.CompletedItem import com.ohmz.tday.compose.core.model.CreateListRequest import com.ohmz.tday.compose.core.model.CreateTodoRequest +import com.ohmz.tday.compose.core.model.DeleteListRequest import com.ohmz.tday.compose.core.model.DeleteTodoRequest import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoCompleteRequest @@ -274,6 +275,16 @@ class SyncManager @Inject constructor( true } + MutationKind.DELETE_LIST -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_LIST_PREFIX)) return@runCatching true + requireApiBody( + api.deleteListByBody(DeleteListRequest(id = targetId)), + "Could not delete list", + ) + true + } + MutationKind.CREATE_TODO -> { val localTodoId = mutation.targetId ?: return@runCatching false if (!localTodoId.startsWith(LOCAL_TODO_PREFIX)) return@runCatching true @@ -536,16 +547,29 @@ class SyncManager @Inject constructor( localState: OfflineSyncState, remote: RemoteSnapshot, ): OfflineSyncState { - val remoteTodos = remote.todos.map(::todoToCache) + val pendingDeletedListIds = localState.pendingMutations + .filter { it.kind == MutationKind.DELETE_LIST } + .mapNotNull { it.targetId } + .toSet() + val remoteTodos = remote.todos + .filterNot { it.listId != null && pendingDeletedListIds.contains(it.listId) } + .map(::todoToCache) val remoteLists = remote.lists.map(::listToCache) - val remoteCompleted = remote.completedItems.map(::completedToCache).toMutableList() + val remoteCompleted = remote.completedItems + .filterNot { it.listId != null && pendingDeletedListIds.contains(it.listId) } + .map(::completedToCache) + .toMutableList() val pendingTodoCanonicalIds = localState.pendingMutations .filter { it.kind.affectsTodo() } .mapNotNull { it.targetId } .toSet() val pendingListIds = localState.pendingMutations - .filter { it.kind == MutationKind.CREATE_LIST || it.kind == MutationKind.UPDATE_LIST } + .filter { + it.kind == MutationKind.CREATE_LIST || + it.kind == MutationKind.UPDATE_LIST || + it.kind == MutationKind.DELETE_LIST + } .mapNotNull { it.targetId } .toSet() val pendingDeleteAllCanonicals = localState.pendingMutations @@ -621,6 +645,10 @@ class SyncManager @Inject constructor( val localList = localListById[listId] val remoteList = remoteListById[listId] + if (remoteList != null && pendingDeletedListIds.contains(remoteList.id)) { + return@forEach + } + if (remoteList == null && localList != null) { val hasPendingLocalMutation = pendingListIds.contains(localList.id) val isUnsyncedLocalList = localList.id.startsWith(LOCAL_LIST_PREFIX) @@ -686,7 +714,11 @@ class SyncManager @Inject constructor( .mapNotNull { it.targetId } .toSet() val pendingListIds = existingPending - .filter { it.kind == MutationKind.CREATE_LIST || it.kind == MutationKind.UPDATE_LIST } + .filter { + it.kind == MutationKind.CREATE_LIST || + it.kind == MutationKind.UPDATE_LIST || + it.kind == MutationKind.DELETE_LIST + } .mapNotNull { it.targetId } .toSet() val pendingLocalListCreates = existingPending @@ -871,6 +903,9 @@ class SyncManager @Inject constructor( todos = state.todos.map { if (it.listId == localListId) it.copy(listId = serverListId) else it }, + completedItems = state.completedItems.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, pendingMutations = state.pendingMutations.map { it.copy( targetId = if (it.targetId == localListId) serverListId else it.targetId, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt index f855ed2a..0a9cec6b 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/todo/TodoRepository.kt @@ -553,6 +553,7 @@ class TodoRepository @Inject constructor( completedAtEpochMs = timestampMs, rrule = todo.rrule, instanceDateEpochMs = todo.instanceDateEpochMillis, + listId = todo.listId, listName = listMeta?.name, listColor = listMeta?.color, ) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt index 507212f5..43bc4741 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/DomainModels.kt @@ -157,6 +157,7 @@ data class CompletedItem( val completedAt: Instant? = null, val rrule: String?, val instanceDate: Instant?, + val listId: String? = null, val listName: String? = null, val listColor: String? = null, ) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 160b849d..431c6b39 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -3,6 +3,7 @@ package com.ohmz.tday.compose.feature.home import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState @@ -273,6 +274,7 @@ fun HomeScreen( var hasCapturedInitialListSnapshot by rememberSaveable { mutableStateOf(false) } var hasShownListDataOnce by rememberSaveable { mutableStateOf(false) } var lastListStructureSignature by rememberSaveable { mutableStateOf("") } + var lastListIdsSignature by rememberSaveable { mutableStateOf("") } var visibleListStage by rememberSaveable { mutableIntStateOf(0) } var animateListCascade by rememberSaveable { mutableStateOf(false) } var searchResultOpening by rememberSaveable { mutableStateOf(false) } @@ -329,6 +331,9 @@ fun HomeScreen( } } } + val listIdsSignature = remember(uiState.summary.lists) { + uiState.summary.lists.joinToString(separator = "|") { it.id } + } val listById = remember(uiState.summary.lists) { uiState.summary.lists.associateBy { it.id } } val normalizedSearchQuery = remember(searchQuery) { searchQuery.trim().lowercase(Locale.getDefault()) } val overdueCount = remember(uiState.searchableTodos) { @@ -383,6 +388,7 @@ fun HomeScreen( hasCapturedInitialListSnapshot = true hasShownListDataOnce = lists.isNotEmpty() lastListStructureSignature = listStructureSignature + lastListIdsSignature = listIdsSignature return@LaunchedEffect } @@ -392,7 +398,17 @@ fun HomeScreen( return@LaunchedEffect } + val previousListIds = lastListIdsSignature + .split('|') + .filter { it.isNotBlank() } + val currentListIds = lists.map { it.id } + val isDeletionOnly = previousListIds.isNotEmpty() && + currentListIds.size < previousListIds.size && + currentListIds.all { it in previousListIds } + val isMetadataOnlyChange = previousListIds == currentListIds + lastListStructureSignature = listStructureSignature + lastListIdsSignature = listIdsSignature if (lists.isEmpty()) { visibleListStage = 0 animateListCascade = false @@ -406,6 +422,12 @@ fun HomeScreen( return@LaunchedEffect } + if (isDeletionOnly || isMetadataOnlyChange) { + visibleListStage = targetFinalStage + animateListCascade = false + return@LaunchedEffect + } + animateListCascade = true visibleListStage = 0 delay(70) @@ -596,8 +618,16 @@ fun HomeScreen( contentType = { _, _ -> "list_row" }, ) { index, list -> if (visibleListStage >= index + 2) { + val listRowPlacementModifier = Modifier.animateItem( + fadeInSpec = tween(durationMillis = 180), + placementSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + fadeOutSpec = tween(durationMillis = 130), + ) if (animateListCascade) { - TopDownCascadeReveal { + TopDownCascadeReveal(modifier = listRowPlacementModifier) { ListRow( name = list.name, colorKey = list.color, @@ -611,6 +641,7 @@ fun HomeScreen( } } else { ListRow( + modifier = listRowPlacementModifier, name = list.name, colorKey = list.color, iconKey = list.iconKey, @@ -2025,6 +2056,7 @@ private fun calendarTileColor(colorScheme: ColorScheme): Color { @Composable private fun TopDownCascadeReveal( + modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { var revealed by remember { mutableStateOf(false) } @@ -2044,7 +2076,7 @@ private fun TopDownCascadeReveal( } Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .graphicsLayer { this.alpha = alpha @@ -2239,6 +2271,7 @@ private fun CategoryCard( @Composable private fun ListRow( + modifier: Modifier = Modifier, name: String, colorKey: String?, iconKey: String?, @@ -2272,7 +2305,7 @@ private fun ListRow( val displayName = capitalizeFirstListLetter(name) Card( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(70.dp) .semantics(mergeDescendants = true) {} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 121733e4..4bb064f8 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -193,6 +193,8 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.zIndex import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat @@ -397,9 +399,11 @@ fun TodoListScreen( } } val isCollapsibleTimelineMode = - uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY + uiState.mode == TodoListMode.ALL || + uiState.mode == TodoListMode.PRIORITY || + uiState.mode == TodoListMode.LIST var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } - var collapsedSectionKeys by rememberSaveable(uiState.mode, highlightedTodoId) { + var collapsedSectionKeys by rememberSaveable(uiState.mode, uiState.listId, highlightedTodoId) { mutableStateOf( if (isCollapsibleTimelineMode && highlightedTodoId.isNullOrBlank()) { setOf("earlier") @@ -512,7 +516,7 @@ fun TodoListScreen( } } LaunchedEffect(uiState.mode) { - if (uiState.mode == TodoListMode.PRIORITY) { + if (uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST) { collapsedSectionKeys = collapsedSectionKeys + "earlier" } } @@ -706,6 +710,7 @@ fun TodoListScreen( TodoListMode.OVERDUE -> true TodoListMode.SCHEDULED -> true TodoListMode.PRIORITY -> section.key == "earlier" + TodoListMode.LIST -> section.key == "earlier" else -> false } val sectionCanCollapse = sectionModeCanCollapse && sectionHasTasks @@ -1129,38 +1134,97 @@ fun TodoListScreen( uiState.mode == TodoListMode.LIST && !deleteConfirmationListId.isNullOrBlank() ) { - AlertDialog( + ListDeleteConfirmationDialog( onDismissRequest = { showDeleteListConfirmation = false }, - title = { - Text( - text = stringResource(R.string.todos_delete_list_title), - fontWeight = FontWeight.ExtraBold, - ) - }, - text = { - Text(text = stringResource(R.string.todos_delete_list_message)) + onConfirm = { + showDeleteListConfirmation = false + onDeleteList(deleteConfirmationListId) + listSettingsTargetId = null }, - confirmButton = { - TextButton( - onClick = { - showDeleteListConfirmation = false - onDeleteList(deleteConfirmationListId) - listSettingsTargetId = null - }, - ) { + ) + } +} + +@Composable +private fun ListDeleteConfirmationDialog( + onDismissRequest: () -> Unit, + onConfirm: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val view = LocalView.current + val isDarkTheme = colorScheme.background.luminance() < 0.5f + val dialogContainerColor = if (isDarkTheme) { + colorScheme.surface.copy(alpha = 0.98f) + } else { + colorScheme.surface + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 34.dp) + .sizeIn(maxWidth = 420.dp), + shape = RoundedCornerShape(30.dp), + colors = CardDefaults.cardColors(containerColor = dialogContainerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(22.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - text = stringResource(R.string.action_delete), - color = MaterialTheme.colorScheme.error, + text = stringResource(R.string.todos_delete_list_title), + style = MaterialTheme.typography.headlineSmall, + color = colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, ) + Text( + text = stringResource(R.string.todos_delete_list_message), + style = MaterialTheme.typography.bodyLarge, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.16f, + ) } - }, - dismissButton = { - TextButton(onClick = { showDeleteListConfirmation = false }) { - Text(stringResource(R.string.action_cancel)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismissRequest) { + Text( + text = stringResource(R.string.action_cancel), + color = colorScheme.primary, + fontWeight = FontWeight.ExtraBold, + ) + } + Spacer(Modifier.size(10.dp)) + TextButton( + onClick = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + onConfirm() + }, + ) { + Text( + text = stringResource(R.string.action_delete), + color = colorScheme.error, + fontWeight = FontWeight.ExtraBold, + ) + } } - }, - ) + } + } } } @@ -2305,7 +2369,7 @@ private fun buildTimelineSections( items = items, zoneId = zoneId, futureOnly = false, - placesEarlierBeforeToday = false, + placesEarlierBeforeToday = true, includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt index 3166ee00..b3f6581a 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListViewModel.kt @@ -549,6 +549,47 @@ class TodoListViewModel @Inject constructor( } } + fun deleteList( + listId: String, + onOptimisticDelete: () -> Unit, + ) { + val currentState = _uiState.value + val resolvedListId = when { + listId.isNotBlank() -> listId + !currentState.listId.isNullOrBlank() -> currentState.listId + else -> return + } + + viewModelScope.launch { + runCatching { + listRepository.deleteList( + listId = resolvedListId, + onOptimisticDelete = { + _uiState.update { current -> + current.copy( + lists = current.lists.filterNot { it.id == resolvedListId }, + items = current.items.filterNot { it.listId == resolvedListId }, + errorMessage = null, + ) + } + onOptimisticDelete() + }, + ) + }.onSuccess { + rescheduleReminders() + }.onFailure { error -> + Log.e(TAG, "deleteList failed listId=$resolvedListId", error) + _uiState.update { + it.copy(errorMessage = error.userFacingMessage("Could not delete list.")) + } + hydrateFromCache( + mode = _uiState.value.mode, + listId = _uiState.value.listId, + ) + } + } + } + private fun rescheduleReminders() { viewModelScope.launch(Dispatchers.Default) { runCatching { reminderScheduler.rescheduleAll() } diff --git a/android-compose/app/src/main/res/values/strings.xml b/android-compose/app/src/main/res/values/strings.xml index 019351a9..cf82050c 100644 --- a/android-compose/app/src/main/res/values/strings.xml +++ b/android-compose/app/src/main/res/values/strings.xml @@ -13,6 +13,7 @@ Edit Edit task Delete task + Delete list Delete Complete More @@ -121,6 +122,8 @@ Creating your task summary… List settings Save list settings + Delete list? + This will delete this list, every task in it, and completed history for those tasks. Overdue, %1$s Due %1$s diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/OfflineSyncStateSerializationTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/OfflineSyncStateSerializationTest.kt index a4b86052..a0f8f660 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/OfflineSyncStateSerializationTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/OfflineSyncStateSerializationTest.kt @@ -64,6 +64,7 @@ class OfflineSyncStateSerializationTest { priority = "Low", dueEpochMs = 1700003600000L, completedAtEpochMs = 1700004000000L, + listId = "l1", ), ), lists = listOf( @@ -72,10 +73,9 @@ class OfflineSyncStateSerializationTest { pendingMutations = listOf( PendingMutationRecord( mutationId = "m1", - kind = MutationKind.CREATE_TODO, + kind = MutationKind.DELETE_LIST, timestampEpochMs = 1700000000000L, - targetId = "t1", - title = "New task", + targetId = "l1", ), ), aiSummaryEnabled = false, diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt index ac33f297..ed1ac980 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt @@ -135,6 +135,7 @@ class CacheMappersTest { assertEquals(item.priority, cached.priority) assertEquals(item.due.toEpochMilli(), cached.dueEpochMs) assertEquals(item.completedAt?.toEpochMilli() ?: 0L, cached.completedAtEpochMs) + assertEquals(item.listId, cached.listId) } @Test @@ -197,6 +198,7 @@ class CacheMappersTest { assertEquals(dto.originalTodoID, item.originalTodoId) assertEquals(dto.title, item.title) assertEquals(dto.priority, item.priority) + assertEquals(dto.listID, item.listId) } // --- mapListDto --- @@ -373,6 +375,7 @@ class CacheMappersTest { completedAt = completedInstant, rrule = null, instanceDate = null, + listId = "list-1", listName = "Work", listColor = "#00FF00", ) @@ -387,6 +390,7 @@ class CacheMappersTest { completedAtEpochMs = completedInstant.toEpochMilli(), rrule = null, instanceDateEpochMs = null, + listId = "list-1", listName = "Work", listColor = "#00FF00", ) @@ -406,6 +410,7 @@ class CacheMappersTest { priority = "High", due = dueInstant.toString(), completedAt = completedInstant.toString(), + listID = "list-1", ) private fun makeListDto() = ListDto( diff --git a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift index e1469ba5..b0be1654 100644 --- a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift @@ -81,6 +81,7 @@ final class OfflineCacheManager { completedAtEpochMs: $0.completedAtEpochMs, rrule: $0.rrule, instanceDateEpochMs: $0.instanceDateEpochMs, + listId: $0.listId, listName: $0.listName, listColor: $0.listColor ) diff --git a/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift b/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift index ec43d676..ca33e26b 100644 --- a/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift @@ -40,7 +40,7 @@ final class CompletedRepository { instanceDateEpochMs: item.instanceDate?.epochMilliseconds, pinned: false, completed: false, - listId: state.lists.first(where: { $0.name == item.listName })?.id, + listId: item.listId ?? state.lists.first(where: { $0.name == item.listName })?.id, updatedAtEpochMs: now ) ) @@ -98,6 +98,7 @@ final class CompletedRepository { completedAtEpochMs: current.completedAtEpochMs, rrule: payload.rrule, instanceDateEpochMs: current.instanceDateEpochMs, + listId: normalizedListID, listName: state.lists.first(where: { $0.id == payload.listId })?.name, listColor: state.lists.first(where: { $0.id == payload.listId })?.color ) diff --git a/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift b/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift index 526628bb..884b300b 100644 --- a/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift +++ b/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift @@ -64,6 +64,7 @@ final class CachedCompletedEntity { var completedAtEpochMs: Int64 var rrule: String? var instanceDateEpochMs: Int64? + var listId: String? var listName: String? var listColor: String? @@ -77,6 +78,7 @@ final class CachedCompletedEntity { completedAtEpochMs = record.completedAtEpochMs rrule = record.rrule instanceDateEpochMs = record.instanceDateEpochMs + listId = record.listId listName = record.listName listColor = record.listColor } diff --git a/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift b/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift index 84aaf634..d2162ee1 100644 --- a/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift @@ -196,6 +196,66 @@ final class ListRepository { } } + func deleteList( + listId: String, + onOptimisticDelete: () -> Void = {} + ) async throws { + let normalizedListID = listId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedListID.isEmpty else { + return + } + + let now = Date().epochMilliseconds + let mutationID = UUID().uuidString + let isLocalOnly = normalizedListID.hasPrefix(LOCAL_LIST_PREFIX) + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + let deletedTodoIDs = Set(state.todos.filter { $0.listId == normalizedListID }.map(\.canonicalId)) + + nextState.lists.removeAll { $0.id == normalizedListID } + nextState.todos.removeAll { $0.listId == normalizedListID } + nextState.completedItems.removeAll { completed in + completed.listId == normalizedListID || + completed.originalTodoId.map { deletedTodoIDs.contains($0) } == true + } + nextState.pendingMutations.removeAll { mutation in + mutation.targetId == normalizedListID || + mutation.listId == normalizedListID || + mutation.targetId.map { deletedTodoIDs.contains($0) } == true + } + if !isLocalOnly { + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: mutationID, + kind: .deleteList, + targetId: normalizedListID, + timestampEpochMs: now, + title: nil, + description: nil, + priority: nil, + dueEpochMs: nil, + rrule: nil, + listId: nil, + pinned: nil, + completed: nil, + instanceDateEpochMs: nil, + name: nil, + color: nil, + iconKey: nil + ) + ) + } + return nextState + } + + onOptimisticDelete() + + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) + if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { + throw error + } + } + private func buildLists(from state: OfflineSyncState) -> [ListSummary] { let todoCounts = Dictionary(grouping: state.todos.filter { !$0.completed }, by: { $0.listId }) .mapValues(\.count) @@ -226,7 +286,25 @@ final class ListRepository { updatedAtEpochMs: todo.updatedAtEpochMs ) }, - completedItems: state.completedItems, + completedItems: state.completedItems.map { completed in + guard completed.listId == localListID else { + return completed + } + return CachedCompletedRecord( + id: completed.id, + originalTodoId: completed.originalTodoId, + title: completed.title, + description: completed.description, + priority: completed.priority, + dueEpochMs: completed.dueEpochMs, + completedAtEpochMs: completed.completedAtEpochMs, + rrule: completed.rrule, + instanceDateEpochMs: completed.instanceDateEpochMs, + listId: serverListID, + listName: completed.listName, + listColor: completed.listColor + ) + }, lists: state.lists.map { list in guard list.id == localListID else { return list diff --git a/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift b/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift index 0cadf315..3910b900 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift @@ -236,6 +236,7 @@ func mapCompletedDTO(_ dto: CompletedTodoDTO) -> CompletedItem { completedAt: parseOptionalDate(dto.completedAt), rrule: dto.rrule, instanceDate: parseOptionalDate(dto.instanceDate), + listId: dto.listID, listName: dto.listName, listColor: dto.listColor ) @@ -252,6 +253,7 @@ func completedToCache(_ item: CompletedItem) -> CachedCompletedRecord { completedAtEpochMs: item.completedAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0, rrule: item.rrule, instanceDateEpochMs: item.instanceDate.map { Int64($0.timeIntervalSince1970 * 1000.0) }, + listId: item.listId, listName: item.listName, listColor: item.listColor ) @@ -268,6 +270,7 @@ func completedFromCache(_ record: CachedCompletedRecord) -> CompletedItem { completedAt: record.completedAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.completedAtEpochMs) / 1000.0) : nil, rrule: record.rrule, instanceDate: record.instanceDateEpochMs.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) }, + listId: record.listId, listName: record.listName, listColor: record.listColor ) diff --git a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift index 634ad0d8..08f55c45 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift @@ -29,9 +29,15 @@ private struct RemoteSnapshot { func mergeCompletedRecordsWithPendingOverrides( localRecords: [CachedCompletedRecord], remoteRecords: [CachedCompletedRecord], - pendingTodoTargets: Set + pendingTodoTargets: Set, + pendingDeletedListIds: Set = [] ) -> [CachedCompletedRecord] { - var mergedRecords = remoteRecords + var mergedRecords = remoteRecords.filter { record in + guard let listId = record.listId else { + return true + } + return !pendingDeletedListIds.contains(listId) + } for canonicalID in pendingTodoTargets { let localRecordsForTodo = localRecords.filter { $0.originalTodoId == canonicalID } @@ -148,14 +154,25 @@ final class SyncManager { let pendingListTargets = Set( localState.pendingMutations.compactMap { mutation -> String? in switch mutation.kind { - case .createList, .updateList: + case .createList, .updateList, .deleteList: return mutation.targetId default: return nil } } ) - var remoteTodosByKey = Dictionary(uniqueKeysWithValues: remote.todos.map { (todoMergeKey(item: $0), todoToCache($0)) }) + let pendingDeletedListIds = Set( + localState.pendingMutations.compactMap { mutation -> String? in + mutation.kind == .deleteList ? mutation.targetId : nil + } + ) + let remoteTodos = remote.todos.filter { todo in + guard let listId = todo.listId else { + return true + } + return !pendingDeletedListIds.contains(listId) + } + var remoteTodosByKey = Dictionary(uniqueKeysWithValues: remoteTodos.map { (todoMergeKey(item: $0), todoToCache($0)) }) var mergedTodos: [CachedTodoRecord] = [] for localTodo in localState.todos { @@ -195,12 +212,15 @@ final class SyncManager { remoteListsByID.removeValue(forKey: localList.id) } } - mergedLists.append(contentsOf: remoteListsByID.values) + mergedLists.append( + contentsOf: remoteListsByID.values.filter { !pendingDeletedListIds.contains($0.id) } + ) let mergedCompleted = mergeCompletedRecordsWithPendingOverrides( localRecords: localState.completedItems, remoteRecords: remote.completedItems.map(completedToCache), - pendingTodoTargets: pendingTodoTargets + pendingTodoTargets: pendingTodoTargets, + pendingDeletedListIds: pendingDeletedListIds ) let generatedMutations = buildLocalWinsMutations(localState: localState, remote: remote) @@ -224,6 +244,16 @@ final class SyncManager { mutation.kind.affectsTodo ? mutation.targetId : nil } ) + let pendingListTargets = Set( + localState.pendingMutations.compactMap { mutation -> String? in + switch mutation.kind { + case .createList, .updateList, .deleteList: + return mutation.targetId + default: + return nil + } + } + ) var generated: [PendingMutationRecord] = [] for todo in localState.todos @@ -255,7 +285,7 @@ final class SyncManager { } } - for list in localState.lists where !list.id.hasPrefix(LOCAL_LIST_PREFIX) { + for list in localState.lists where !list.id.hasPrefix(LOCAL_LIST_PREFIX) && !pendingListTargets.contains(list.id) { guard let remoteUpdatedAt = remote.listUpdatedAtByID[list.id], list.updatedAtEpochMs > remoteUpdatedAt else { continue } @@ -346,6 +376,13 @@ final class SyncManager { payload: UpdateListRequest(id: targetID, name: mutation.name, color: mutation.color, iconKey: mutation.iconKey) ) + case .deleteList: + guard let targetID else { return } + if targetID.hasPrefix(LOCAL_LIST_PREFIX) { + return + } + _ = try await api.deleteListByBody(payload: DeleteListRequest(id: targetID)) + case .createTodo: guard let localTodoID = mutation.targetId else { return } if !localTodoID.hasPrefix(LOCAL_TODO_PREFIX) { @@ -506,6 +543,7 @@ final class SyncManager { completedAtEpochMs: item.completedAtEpochMs, rrule: item.rrule, instanceDateEpochMs: item.instanceDateEpochMs, + listId: item.listId, listName: item.listName, listColor: item.listColor ) @@ -560,7 +598,25 @@ final class SyncManager { } return todo }, - completedItems: state.completedItems, + completedItems: state.completedItems.map { completed in + guard completed.listId == localListID else { + return completed + } + return CachedCompletedRecord( + id: completed.id, + originalTodoId: completed.originalTodoId, + title: completed.title, + description: completed.description, + priority: completed.priority, + dueEpochMs: completed.dueEpochMs, + completedAtEpochMs: completed.completedAtEpochMs, + rrule: completed.rrule, + instanceDateEpochMs: completed.instanceDateEpochMs, + listId: serverListID, + listName: completed.listName, + listColor: completed.listColor + ) + }, lists: state.lists.map { list in if list.id == localListID { return CachedListRecord( @@ -651,7 +707,8 @@ private extension MutationKind { .uncompleteTodo: return true case .createList, - .updateList: + .updateList, + .deleteList: return false } } diff --git a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift index a64e5a4a..918ea6ae 100644 --- a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift @@ -331,6 +331,7 @@ final class TodoRepository { completedAtEpochMs: now, rrule: todo.rrule, instanceDateEpochMs: todo.instanceDateEpochMilliseconds, + listId: todo.listId, listName: state.lists.first(where: { $0.id == todo.listId })?.name, listColor: state.lists.first(where: { $0.id == todo.listId })?.color ), @@ -501,6 +502,7 @@ final class TodoRepository { completedAtEpochMs: item.completedAtEpochMs, rrule: item.rrule, instanceDateEpochMs: item.instanceDateEpochMs, + listId: item.listId, listName: item.listName, listColor: item.listColor ) diff --git a/ios-swiftUI/Tday/Core/Model/ApiModels.swift b/ios-swiftUI/Tday/Core/Model/ApiModels.swift index 0c9dcb1d..5303521b 100644 --- a/ios-swiftUI/Tday/Core/Model/ApiModels.swift +++ b/ios-swiftUI/Tday/Core/Model/ApiModels.swift @@ -314,6 +314,7 @@ struct CompletedTodoDTO: Codable, Equatable { let rrule: String? let userID: String? let instanceDate: String? + let listID: String? let listName: String? let listColor: String? } diff --git a/ios-swiftUI/Tday/Core/Model/DomainModels.swift b/ios-swiftUI/Tday/Core/Model/DomainModels.swift index b8098422..5a16b1b1 100644 --- a/ios-swiftUI/Tday/Core/Model/DomainModels.swift +++ b/ios-swiftUI/Tday/Core/Model/DomainModels.swift @@ -239,6 +239,7 @@ struct CompletedItem: Identifiable, Equatable, Hashable, Codable { let completedAt: Date? let rrule: String? let instanceDate: Date? + let listId: String? let listName: String? let listColor: String? } diff --git a/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift b/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift index bf3847d6..dc4f00ac 100644 --- a/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift +++ b/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift @@ -49,6 +49,7 @@ struct CachedCompletedRecord: Identifiable, Equatable, Codable { let completedAtEpochMs: Int64 let rrule: String? let instanceDateEpochMs: Int64? + let listId: String? let listName: String? let listColor: String? } @@ -56,6 +57,7 @@ struct CachedCompletedRecord: Identifiable, Equatable, Codable { enum MutationKind: String, Codable, CaseIterable { case createList = "CREATE_LIST" case updateList = "UPDATE_LIST" + case deleteList = "DELETE_LIST" case createTodo = "CREATE_TODO" case updateTodo = "UPDATE_TODO" case deleteTodo = "DELETE_TODO" diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index 5ec192a7..857ebaee 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -57,7 +57,16 @@ struct AppRootView: View { case .priorityTodos: TodoListScreen(container: container, mode: .priority, listId: nil, listName: nil, highlightedTodoId: nil) case let .listTodos(listId, listName): - TodoListScreen(container: container, mode: .list, listId: listId, listName: listName, highlightedTodoId: nil) + TodoListScreen( + container: container, + mode: .list, + listId: listId, + listName: listName, + highlightedTodoId: nil, + onListDeleted: { + appViewModel.navigate(to: .home) + } + ) case .completed: CompletedScreen(container: container) case .calendar: diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 92f6b62b..a4f4b106 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -224,18 +224,12 @@ struct HomeScreen: View { ) if !viewModel.summary.lists.isEmpty { - HomeListsHeader() - - ForEach(viewModel.summary.lists) { list in - HomeListRow( - name: displayName(for: list.name), - colorKey: list.color, - iconKey: list.iconKey, - count: list.todoCount - ) { - closeSearch() - onNavigate(.listTodos(listId: list.id, listName: displayName(for: list.name))) - } + HomeListsSection( + lists: viewModel.summary.lists, + displayName: displayName(for:) + ) { list, name in + closeSearch() + onNavigate(.listTodos(listId: list.id, listName: name)) } } @@ -1075,6 +1069,41 @@ private struct HomeListsHeader: View { } } +private struct HomeListsSection: View { + let lists: [ListSummary] + let displayName: (String) -> String + let onOpenList: (ListSummary, String) -> Void + + private var listIDs: [String] { + lists.map(\.id) + } + + var body: some View { + VStack(alignment: .leading, spacing: HomeMetrics.sectionSpacing) { + HomeListsHeader() + + ForEach(lists) { list in + let name = displayName(list.name) + HomeListRow( + name: name, + colorKey: list.color, + iconKey: list.iconKey, + count: list.todoCount + ) { + onOpenList(list, name) + } + .transition( + .asymmetric( + insertion: .opacity.combined(with: .move(edge: .top)), + removal: .opacity.combined(with: .scale(scale: 0.98, anchor: .top)) + ) + ) + } + } + .animation(.spring(response: 0.34, dampingFraction: 0.88), value: listIDs) + } +} + private struct HomeListRow: View { let name: String let colorKey: String? diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index c85a715c..6342d188 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -184,6 +184,7 @@ struct TodoListScreen: View { @State private var editingTodo: TodoItem? @State private var showingSummary = false @State private var showingListSettings = false + @State private var showingDeleteListConfirmation = false @State private var draggedTodo: TodoItem? @State private var inAppDrag: TodoInAppDrag? @State private var activeDropSectionId: String? @@ -206,7 +207,7 @@ struct TodoListScreen: View { self.highlightedTodoId = highlightedTodoId self.onListDeleted = onListDeleted _viewModel = State(initialValue: TodoListViewModel(container: container, mode: mode, listId: listId, listName: listName)) - _collapsedSectionIDs = State(initialValue: mode == .priority || mode == .all ? ["earlier"] : []) + _collapsedSectionIDs = State(initialValue: mode == .priority || mode == .all || mode == .list ? ["earlier"] : []) } private var groupedSections: [TodoTimelineSection] { @@ -325,6 +326,26 @@ struct TodoListScreen: View { } .allowsHitTesting(false) } + .overlay { + if showingDeleteListConfirmation { + ListDeleteConfirmationOverlay( + onCancel: { + withAnimation(.spring(response: 0.24, dampingFraction: 0.9)) { + showingDeleteListConfirmation = false + } + }, + onDelete: { + showingDeleteListConfirmation = false + Task { + await viewModel.deleteList(onOptimisticDelete: onListDeleted) + } + } + ) + .transition(.opacity.combined(with: .scale(scale: 0.96))) + .zIndex(30) + } + } + .animation(.spring(response: 0.24, dampingFraction: 0.9), value: showingDeleteListConfirmation) .navigationBackButtonBehavior() .navigationTitleTypography( largeTitleColor: modeAccentColor, @@ -518,9 +539,10 @@ struct TodoListScreen: View { onSubmit: { name, color, iconKey in Task { await viewModel.updateListSettings(name: name, color: color, iconKey: iconKey) } }, - onDelete: { - Task { - await viewModel.deleteList(onOptimisticDelete: onListDeleted) + onDeleteRequest: { + showingListSettings = false + withAnimation(.spring(response: 0.24, dampingFraction: 0.9)) { + showingDeleteListConfirmation = true } } ) @@ -1297,6 +1319,9 @@ struct TodoListScreen: View { if viewModel.mode == .overdue { return true } + if viewModel.mode == .list { + return section.id == "earlier" && section.isCollapsible + } return viewModel.mode == .priority && section.isCollapsible } @@ -2126,17 +2151,88 @@ private let todoListSettingsColorKeys = [ "RED", ] +private struct ListDeleteConfirmationOverlay: View { + let onCancel: () -> Void + let onDelete: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + ZStack { + colors.bottomSheetScrim + .ignoresSafeArea() + .contentShape(Rectangle()) + .onTapGesture(perform: onCancel) + + VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 14) { + Text("Delete list?") + .font(.tdayRounded(.title2, weight: .black)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.82) + + Text("This will delete this list, every task in it, and completed history for those tasks.") + .font(.tdayRounded(.body, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(spacing: 24) { + Spacer(minLength: 0) + + Button(action: onCancel) { + Text("Cancel") + .font(.tdayRounded(.headline, weight: .heavy)) + .foregroundStyle(colors.primary) + .padding(.horizontal, 4) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + + Button(role: .destructive, action: onDelete) { + Text("Delete") + .font(.tdayRounded(.headline, weight: .heavy)) + .foregroundStyle(colors.error) + .padding(.horizontal, 4) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 20) + .frame(maxWidth: 330, alignment: .leading) + .background( + colors.bottomSheetSurface, + in: RoundedRectangle(cornerRadius: 30, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 30, style: .continuous) + .stroke(colors.cardStroke, lineWidth: 1) + } + .shadow(color: Color.black.opacity(colors.isDark ? 0.34 : 0.14), radius: 24, x: 0, y: 12) + .padding(.horizontal, 34) + .contentShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) + .onTapGesture {} + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .contain) + } +} + private struct ListSettingsSheet: View { let list: ListSummary? let onSubmit: (String, String?, String?) -> Void - let onDelete: () -> Void + let onDeleteRequest: () -> Void @Environment(\.dismiss) private var dismiss @Environment(\.tdayColors) private var tdayColors @State private var name = "" @State private var color = "PINK" @State private var iconKey = "inbox" - @State private var showingDeleteConfirmation = false private let colors = todoListSettingsColorKeys private let icons = ["inbox", "briefcase", "calendar", "list.bullet", "star", "heart"] @@ -2169,10 +2265,13 @@ private struct ListSettingsSheet: View { } } - Button(role: .destructive) { - showingDeleteConfirmation = true - } label: { - Label("Delete list", systemImage: "trash") + if list != nil { + Button(role: .destructive) { + dismiss() + onDeleteRequest() + } label: { + Label("Delete list", systemImage: "trash") + } } } .scrollContentBackground(.hidden) @@ -2181,15 +2280,6 @@ private struct ListSettingsSheet: View { .background(tdayColors.bottomSheetBackground) .disableVerticalScrollBounce() .toolbar(.hidden, for: .navigationBar) - .alert("Delete list?", isPresented: $showingDeleteConfirmation) { - Button("Cancel", role: .cancel) {} - Button("Delete", role: .destructive) { - onDelete() - dismiss() - } - } message: { - Text("This will delete this list, every task in it, and completed history for those tasks.") - } .task { name = list?.name ?? "" color = normalizedTodoListColorKey(list?.color) @@ -2536,7 +2626,7 @@ private func buildSections( return buildFutureTimelineSections( items: items, calendar: calendar, - placesEarlierBeforeToday: false, + placesEarlierBeforeToday: true, includeEmptyEarlierTarget: includeEmptyEarlierTarget ) } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift index bfa72bde..2bed7cba 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift @@ -155,6 +155,24 @@ final class TodoListViewModel { } } + func deleteList(onOptimisticDelete: @escaping () -> Void) async { + guard let listId else { return } + do { + try await container.listRepository.deleteList( + listId: listId, + onOptimisticDelete: { + self.lists.removeAll { $0.id == listId } + self.items.removeAll { $0.listId == listId } + self.errorMessage = nil + onOptimisticDelete() + } + ) + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not delete list.") + hydrateFromCache() + } + } + func parseTaskTitleNlp(text: String, referenceDueEpochMs: Int64) async -> TodoTitleNlpResponse? { await container.todoRepository.parseTodoTitleNlp(text: text, referenceDueEpochMs: referenceDueEpochMs) } diff --git a/ios-swiftUI/Tests/TdayCoreTests/CompletedSyncMergeTests.swift b/ios-swiftUI/Tests/TdayCoreTests/CompletedSyncMergeTests.swift index 6cab827a..55dd46a5 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/CompletedSyncMergeTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/CompletedSyncMergeTests.swift @@ -44,9 +44,28 @@ final class CompletedSyncMergeTests: XCTestCase { XCTAssertEqual(merged, [local]) } + + func testPendingDeletedListRemovesRemoteCompletedRecordsForThatList() { + let kept = completedRecord(id: "kept", originalTodoId: "todo-1", completedAtEpochMs: 2_000, listId: "list-kept") + let deleted = completedRecord(id: "deleted", originalTodoId: "todo-2", completedAtEpochMs: 1_000, listId: "list-deleted") + + let merged = mergeCompletedRecordsWithPendingOverrides( + localRecords: [], + remoteRecords: [kept, deleted], + pendingTodoTargets: [], + pendingDeletedListIds: ["list-deleted"] + ) + + XCTAssertEqual(merged, [kept]) + } } -private func completedRecord(id: String, originalTodoId: String, completedAtEpochMs: Int64) -> CachedCompletedRecord { +private func completedRecord( + id: String, + originalTodoId: String, + completedAtEpochMs: Int64, + listId: String? = nil +) -> CachedCompletedRecord { CachedCompletedRecord( id: id, originalTodoId: originalTodoId, @@ -57,6 +76,7 @@ private func completedRecord(id: String, originalTodoId: String, completedAtEpoc completedAtEpochMs: completedAtEpochMs, rrule: nil, instanceDateEpochMs: nil, + listId: listId, listName: nil, listColor: nil ) diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt index f7fd8846..14d589b9 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt @@ -21,6 +21,7 @@ data class CompletedTodoDto( val rrule: String? = null, val userID: String? = null, val instanceDate: String? = null, + val listID: String? = null, val listName: String? = null, val listColor: String? = null, ) diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt index 5ee625e6..bab26ac4 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt @@ -18,6 +18,7 @@ object CompletedTodos : Table("CompletedTodo") { val rrule = text("rrule").nullable() val userID = varchar("userID", 30).references(Users.id).index() val instanceDate = datetime("instanceDate").nullable() + val listID = varchar("projectID", 30).references(Lists.id).nullable().index() val listName = varchar("projectName", 255).nullable() val listColor = varchar("projectColor", 32).nullable() diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt index 178cb88b..0365a7f7 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt @@ -1,14 +1,14 @@ package com.ohmz.tday.routes +import arrow.core.Either +import com.ohmz.tday.di.inject +import com.ohmz.tday.domain.AppError import com.ohmz.tday.domain.withAuth import com.ohmz.tday.services.CompletedTodoService +import com.ohmz.tday.shared.model.DeleteCompletedTodoRequest +import com.ohmz.tday.shared.model.UpdateCompletedTodoRequest import io.ktor.server.request.* import io.ktor.server.routing.* -import kotlinx.serialization.Serializable -import com.ohmz.tday.di.inject - -@Serializable -private data class CompletedTodoPatchBody(val id: String) fun Route.completedTodoRoutes() { val completedTodoService by inject() @@ -23,16 +23,45 @@ fun Route.completedTodoRoutes() { delete { call.withAuth { user -> - completedTodoService.deleteAll(user.id) - .map { mapOf("message" to "completed todos cleared") } + val body = runCatching { call.receiveNullable() }.getOrNull() + if (body?.id?.isNotBlank() == true) { + completedTodoService.deleteById(user.id, body.id) + .map { count -> + mapOf("message" to if (count > 0) "completed todo removed" else "completed todo already removed") + } + } else { + completedTodoService.deleteAll(user.id) + .map { mapOf("message" to "completed todos cleared") } + } } } patch { call.withAuth { user -> - val body = call.receive() - completedTodoService.deleteById(user.id, body.id) - .map { mapOf("message" to "completed todo removed") } + val body = call.receive() + val fields = mutableMapOf() + body.title?.let { fields["title"] = it } + body.description?.let { fields["description"] = it } + body.priority?.let { fields["priority"] = it } + body.due?.let { due -> + val parsed = parseTodoDateTime(due) + ?: return@withAuth Either.Left( + AppError.BadRequest("due must be a valid ISO-8601 datetime"), + ) + fields["due"] = parsed + } + body.rrule?.let { fields["rrule"] = it } + body.listID?.let { fields["listID"] = it } + if (fields.isEmpty()) { + return@withAuth completedTodoService.deleteById(user.id, body.id) + .map { count -> + mapOf("message" to if (count > 0) "completed todo removed" else "completed todo already removed") + } + } + completedTodoService.update(user.id, body.id, fields) + .map { count -> + mapOf("message" to if (count > 0) "completed todo updated" else "completed todo already removed") + } } } } diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/ListRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/ListRoutes.kt index ef32d3ae..f9389cbf 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/ListRoutes.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/ListRoutes.kt @@ -57,7 +57,7 @@ fun Route.listRoutes() { val deletedIds = listService.deleteMany(user.id, ids).bind() DeleteListResponse( message = if (ids.size == 1) { - "list deleted" + if (deletedIds.isEmpty()) "list already deleted" else "list deleted" } else { "${deletedIds.size} lists deleted" }, diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt index 2f1d573b..6d12046e 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt @@ -3,6 +3,7 @@ package com.ohmz.tday.services import arrow.core.Either import arrow.core.right import com.ohmz.tday.db.tables.CompletedTodos +import com.ohmz.tday.db.tables.Lists import com.ohmz.tday.domain.AppError import com.ohmz.tday.models.response.CompletedTodoResponse import com.ohmz.tday.security.FieldEncryption @@ -12,13 +13,17 @@ import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.update import kotlinx.coroutines.Dispatchers +import java.time.LocalDateTime import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import com.ohmz.tday.shared.model.Priority interface CompletedTodoService { suspend fun getAll(userId: String): Either> suspend fun deleteAll(userId: String): Either suspend fun deleteById(userId: String, id: String): Either + suspend fun update(userId: String, id: String, fields: Map): Either } class CompletedTodoServiceImpl( @@ -50,6 +55,32 @@ class CompletedTodoServiceImpl( return count.right() } + override suspend fun update(userId: String, id: String, fields: Map): Either { + val count = newSuspendedTransaction(Dispatchers.IO) { + val list = (fields["listID"] as? String)?.let { listId -> + Lists.selectAll().where { + (Lists.id eq listId) and (Lists.userID eq userId) + }.firstOrNull() + } + CompletedTodos.update({ (CompletedTodos.id eq id) and (CompletedTodos.userID eq userId) }) { stmt -> + fields["title"]?.let { stmt[CompletedTodos.title] = it as String } + fields["description"]?.let { + stmt[CompletedTodos.description] = fieldEncryption.encryptIfSensitive("description", it as? String) + } + fields["priority"]?.let { stmt[CompletedTodos.priority] = Priority.valueOf(it as String) } + fields["due"]?.let { stmt[CompletedTodos.due] = it as LocalDateTime } + fields["rrule"]?.let { stmt[CompletedTodos.rrule] = it as? String } + fields["listID"]?.let { listId -> + stmt[CompletedTodos.listID] = listId as? String + stmt[CompletedTodos.listName] = list?.get(Lists.name) + stmt[CompletedTodos.listColor] = list?.get(Lists.color)?.name + } + } + } + if (count > 0) cache.invalidateCompletedCaches(userId) + return count.right() + } + private fun ResultRow.toCompletedResponse(): CompletedTodoResponse = CompletedTodoResponse( id = this[CompletedTodos.id], originalTodoID = this[CompletedTodos.originalTodoID], @@ -63,6 +94,7 @@ class CompletedTodoServiceImpl( rrule = this[CompletedTodos.rrule], userID = this[CompletedTodos.userID], instanceDate = this[CompletedTodos.instanceDate]?.toString(), + listID = this[CompletedTodos.listID], listName = this[CompletedTodos.listName], listColor = this[CompletedTodos.listColor], ) diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt index 12cf0da7..369487a3 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt @@ -3,8 +3,10 @@ package com.ohmz.tday.services import arrow.core.Either import arrow.core.right import arrow.core.raise.either +import com.ohmz.tday.db.tables.CompletedTodos import com.ohmz.tday.db.enums.ListColor import com.ohmz.tday.db.tables.Lists +import com.ohmz.tday.db.tables.TodoInstances import com.ohmz.tday.db.tables.Todos import com.ohmz.tday.db.util.CuidGenerator import com.ohmz.tday.domain.AppError @@ -120,9 +122,36 @@ class ListServiceImpl(private val cache: CacheService) : ListService { return@newSuspendedTransaction emptyList() } - Todos.update({ (Todos.userID eq userId) and (Todos.listID inList existingIds) }) { - it[Todos.listID] = null + val todoIds = Todos + .select(Todos.id) + .where { (Todos.userID eq userId) and (Todos.listID inList existingIds) } + .map { it[Todos.id] } + + if (todoIds.isNotEmpty()) { + CompletedTodos.deleteWhere { + SqlExpressionBuilder.run { + (CompletedTodos.userID eq userId) and + ((CompletedTodos.listID inList existingIds) or (CompletedTodos.originalTodoID inList todoIds)) + } + } + TodoInstances.deleteWhere { + SqlExpressionBuilder.run { + TodoInstances.todoId inList todoIds + } + } + Todos.deleteWhere { + SqlExpressionBuilder.run { + (Todos.userID eq userId) and (Todos.id inList todoIds) + } + } + } else { + CompletedTodos.deleteWhere { + SqlExpressionBuilder.run { + (CompletedTodos.userID eq userId) and (CompletedTodos.listID inList existingIds) + } + } } + Lists.deleteWhere { SqlExpressionBuilder.run { (Lists.userID eq userId) and (Lists.id inList existingIds) @@ -133,6 +162,8 @@ class ListServiceImpl(private val cache: CacheService) : ListService { if (deletedIds.isNotEmpty()) { cache.invalidateListCaches(userId) + cache.invalidateTodoCaches(userId) + cache.invalidateCompletedCaches(userId) } return deletedIds.right() diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt index 0b37702b..94fdf546 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt @@ -187,6 +187,7 @@ class TodoServiceImpl( it[CompletedTodos.rrule] = todo[Todos.rrule] it[CompletedTodos.userID] = userId it[CompletedTodos.instanceDate] = instanceDate + it[CompletedTodos.listID] = todo[Todos.listID] it[CompletedTodos.listName] = list?.get(Lists.name) it[CompletedTodos.listColor] = list?.get(Lists.color)?.name } diff --git a/tday-backend/src/main/resources/db/migration/V6__add_completed_todo_project_id.sql b/tday-backend/src/main/resources/db/migration/V6__add_completed_todo_project_id.sql new file mode 100644 index 00000000..4d8fec1a --- /dev/null +++ b/tday-backend/src/main/resources/db/migration/V6__add_completed_todo_project_id.sql @@ -0,0 +1,29 @@ +ALTER TABLE completedtodo + ADD COLUMN IF NOT EXISTS "projectID" character varying(30); + +UPDATE completedtodo completed +SET "projectID" = todo."projectID" +FROM todos todo +WHERE completed."projectID" IS NULL + AND todo."projectID" IS NOT NULL + AND completed."originalTodoID" = todo.id + AND completed."userID" = todo."userID"; + +CREATE INDEX IF NOT EXISTS completedtodo_projectid + ON completedtodo USING btree ("projectID"); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_completedtodo_projectid__id' + ) THEN + ALTER TABLE completedtodo + ADD CONSTRAINT fk_completedtodo_projectid__id + FOREIGN KEY ("projectID") + REFERENCES project(id) + ON UPDATE RESTRICT + ON DELETE RESTRICT; + END IF; +END $$; diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/ListRoutesTest.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/ListRoutesTest.kt index 0658604b..f3b3b690 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/ListRoutesTest.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/ListRoutesTest.kt @@ -110,6 +110,29 @@ class ListRoutesTest { assertEquals("list_456", payload.getValue("deletedIds").jsonArray[1].jsonPrimitive.content) } + @Test + fun `delete list is idempotent when list is already gone`() = testApplication { + val listService = RecordingListService(deletedIdsToReturn = emptyList()) + + application { + configureListRoutesTestApp(listService) + } + + val response = client.delete("/api/list") { + contentType(ContentType.Application.Json) + setBody( + json.encodeToString( + DeleteListRequest(id = "list_missing"), + ), + ) + } + + assertEquals(HttpStatusCode.OK, response.status) + val payload = json.parseToJsonElement(response.bodyAsText()).jsonObject + assertEquals("list already deleted", payload.getValue("message").jsonPrimitive.content) + assertEquals(0, payload.getValue("deletedIds").jsonArray.size) + } + private fun Application.configureListRoutesTestApp( listService: ListService, ) { @@ -143,7 +166,9 @@ class ListRoutesTest { } } - private class RecordingListService : ListService { + private class RecordingListService( + private val deletedIdsToReturn: List? = null, + ) : ListService { override suspend fun getAll(userId: String): Either> = emptyList().right() @@ -205,6 +230,6 @@ class ListRoutesTest { override suspend fun deleteMany( userId: String, ids: List, - ): Either> = ids.right() + ): Either> = (deletedIdsToReturn ?: ids).right() } } From 9da51aaa3fd8e9b5ab2c4842fe18516bfb244604 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 16:03:44 -0400 Subject: [PATCH 07/19] feat(ux): enhance task completion and restoration animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a multi-stage animation sequence for task completion and restoration across Android (Compose) and iOS (SwiftUI). Task rows now transition through discrete phases—checked, struck-through, and fading—with coordinated vertical offsets and alpha shifts for a more polished feel. - **Animation Refinements**: - Implement a 3-step completion sequence: immediate checkmark toggle, delayed animated strike-through (160ms), and a final fade-out with upward translation (360ms delay, 260ms duration). - Sync restoration animations in the "Completed" screens to follow a similar multi-stage reversal. - Replace standard `TextDecoration.LineThrough` with custom drawing logic (`drawWithContent` in Compose, `overlay` in SwiftUI) to allow for animated strike-through progress. - Standardize animation durations using new constants (e.g., `TASK_COMPLETION_FADE_MS`). - **List & UI Improvements**: - Migrate home and calendar task lists to use `animateItem()` for smoother layout transitions during addition/deletion. - Update list headers to show date/time subtitles when viewing specific lists, matching the behavior of "All" and "Priority" modes. - Add a semi-transparent scrim and click-to-dismiss behavior to the delete confirmation dialog in `TodoListScreen`. - **Platform Parity**: - **iOS**: Create `TodoTimelineTaskTitle` reusable view to handle the new animated strike-through logic. - **Android**: Ensure `HomeTodayTaskRow`, `CalendarTodoRow`, and `SwipeTodoRow` all implement the unified animation phases. --- .../feature/calendar/CalendarScreen.kt | 213 +++++++++++++----- .../feature/completed/CompletedScreen.kt | 60 ++++- .../tday/compose/feature/home/HomeScreen.kt | 76 ++++--- .../compose/feature/todos/TodoListScreen.kt | 210 +++++++++++------ .../Feature/Calendar/CalendarScreen.swift | 76 ++++++- .../Feature/Completed/CompletedScreen.swift | 19 +- .../Tday/Feature/Home/HomeScreen.swift | 27 ++- .../Tday/Feature/Todos/TodoListScreen.swift | 106 ++++++--- 8 files changed, 567 insertions(+), 220 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index bb4a1157..652a3b22 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -136,7 +137,6 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf @@ -152,6 +152,7 @@ import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.mimeTypes import androidx.compose.ui.draganddrop.toAndroidDragEvent import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -169,6 +170,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -234,6 +236,9 @@ private val CalendarPeriodPageHorizontalGutter = 2.dp private val CalendarPeriodCardBottomPadding = 18.dp private val CalendarTaskListSameDateSpacing = 2.dp private val CalendarTaskRowHeight = 56.dp +private const val CALENDAR_TASK_COMPLETION_CHECK_TO_STRIKE_MS = 160L +private const val CALENDAR_TASK_COMPLETION_STRIKE_TO_FADE_MS = 360L +private const val CALENDAR_TASK_COMPLETION_FADE_MS = 260L private val CalendarTaskDragDueTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()) private const val CalendarMonthPagerPageCount = 240 @@ -658,51 +663,66 @@ fun CalendarScreen( ) } - if (selectedDatePendingTasks.isNotEmpty()) { - item { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(CalendarTaskListSameDateSpacing), - ) { - selectedDatePendingTasks.forEachIndexed { index, todo -> - key(todo.id) { - CalendarTodoRow( - todo = todo, - lists = uiState.lists, - showDateDivider = shouldShowDateDivider( - afterItemIndex = index, - items = selectedDatePendingTasks, - zoneId = zoneId, - ), - dragEnabled = calendarTaskRescheduleEnabled, - onComplete = { onCompleteTask(todo) }, - onInfo = { editTargetId = todo.id }, - onDelete = { onDelete(todo) }, - dragging = calendarTaskRescheduleEnabled && draggedCalendarTodo?.id == todo.id, - onDragStart = { position -> - activeDropDateIso = null - draggedCalendarTodoId = todo.id - activeCalendarDrag = CalendarTaskDragState( - todo = todo, - position = position, - ) - updateActiveCalendarDropTarget(position) - }, - onDragMove = { position -> - activeCalendarDrag = CalendarTaskDragState( - todo = todo, - position = position, - ) - updateActiveCalendarDropTarget(position) - }, - onDragEnd = ::finishCalendarDrag, - onDragCancel = ::cancelCalendarDrag, - ) - } - } - } + itemsIndexed( + items = selectedDatePendingTasks, + key = { _, todo -> "calendar-task-${todo.id}" }, + contentType = { _, _ -> "calendar_task_row" }, + ) { index, todo -> + CalendarTodoRow( + modifier = Modifier + .animateItem( + fadeInSpec = tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ), + placementSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + fadeOutSpec = tween( + durationMillis = 140, + easing = FastOutSlowInEasing, + ), + ) + .padding( + bottom = if (index == selectedDatePendingTasks.lastIndex) { + 0.dp + } else { + CalendarTaskListSameDateSpacing + }, + ), + todo = todo, + lists = uiState.lists, + showDateDivider = shouldShowDateDivider( + afterItemIndex = index, + items = selectedDatePendingTasks, + zoneId = zoneId, + ), + dragEnabled = calendarTaskRescheduleEnabled, + onComplete = { onCompleteTask(todo) }, + onInfo = { editTargetId = todo.id }, + onDelete = { onDelete(todo) }, + dragging = calendarTaskRescheduleEnabled && draggedCalendarTodo?.id == todo.id, + onDragStart = { position -> + activeDropDateIso = null + draggedCalendarTodoId = todo.id + activeCalendarDrag = CalendarTaskDragState( + todo = todo, + position = position, + ) + updateActiveCalendarDropTarget(position) + }, + onDragMove = { position -> + activeCalendarDrag = CalendarTaskDragState( + todo = todo, + position = position, + ) + updateActiveCalendarDropTarget(position) + }, + onDragEnd = ::finishCalendarDrag, + onDragCancel = ::cancelCalendarDrag, + ) } - } uiState.errorMessage?.let { message -> item { @@ -2062,7 +2082,11 @@ private fun CalendarTodoRow( val maxElasticDragPx = actionRevealPx * 1.14f var targetOffsetX by remember(todo.id) { mutableFloatStateOf(0f) } var swipeHinting by remember(todo.id) { mutableStateOf(false) } + var localChecked by remember(todo.id) { mutableStateOf(false) } + var localStruck by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } + var completionFading by remember(todo.id) { mutableStateOf(false) } + var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } val animatedOffsetX by animateFloatAsState( @@ -2070,7 +2094,27 @@ private fun CalendarTodoRow( animationSpec = spring(stiffness = Spring.StiffnessLow), label = "calendarTaskSwipeOffset", ) - val showCompletedState = pendingCompletion + val completionAlpha by animateFloatAsState( + targetValue = if (completionFading) 0f else 1f, + animationSpec = tween( + durationMillis = CALENDAR_TASK_COMPLETION_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), + label = "calendarTaskCompletionAlpha", + ) + val completionOffsetY by animateDpAsState( + targetValue = if (completionFading) (-10).dp else 0.dp, + animationSpec = tween( + durationMillis = CALENDAR_TASK_COMPLETION_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), + label = "calendarTaskCompletionOffsetY", + ) + val titleStrikeProgress by animateFloatAsState( + targetValue = if (localStruck) 1f else 0f, + animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), + label = "calendarTaskTitleStrikeProgress", + ) val dueText = DateTimeFormatter.ofPattern("h:mm a") .withZone(ZoneId.systemDefault()) .format(todo.due) @@ -2086,7 +2130,10 @@ private fun CalendarTodoRow( Column( modifier = modifier .fillMaxWidth() - .graphicsLayer { alpha = if (dragging) 0.55f else 1f } + .graphicsLayer { + alpha = if (dragging) completionAlpha * 0.55f else completionAlpha + translationY = completionOffsetY.toPx() + } .semantics(mergeDescendants = true) { }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -2221,17 +2268,17 @@ private fun CalendarTodoRow( verticalAlignment = Alignment.CenterVertically, ) { CalendarCompletionToggleIcon( - imageVector = if (showCompletedState) { + imageVector = if (localChecked) { Icons.Rounded.CheckCircle } else { Icons.Rounded.RadioButtonUnchecked }, - contentDescription = if (showCompletedState) { + contentDescription = if (localChecked) { stringResource(R.string.label_completed) } else { stringResource(R.string.label_mark_complete) }, - tint = if (showCompletedState) { + tint = if (localChecked) { Color(0xFF6FBF86) } else { colorScheme.onSurfaceVariant.copy(alpha = 0.78f) @@ -2240,9 +2287,14 @@ private fun CalendarTodoRow( onClick = { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) targetOffsetX = 0f + localChecked = true pendingCompletion = true coroutineScope.launch { - delay(500) + delay(CALENDAR_TASK_COMPLETION_CHECK_TO_STRIKE_MS) + localStruck = true + delay(CALENDAR_TASK_COMPLETION_STRIKE_TO_FADE_MS) + completionFading = true + delay(CALENDAR_TASK_COMPLETION_FADE_MS) onComplete() } }, @@ -2254,19 +2306,33 @@ private fun CalendarTodoRow( ) { Text( text = todo.title, - color = if (showCompletedState) { + modifier = Modifier.drawWithContent { + drawContent() + if (titleStrikeProgress > 0f) { + val lineEnd = ( + titleLayoutResult + ?.takeIf { it.lineCount > 0 } + ?.getLineRight(0) ?: size.width + ).coerceIn(0f, size.width) + val lineY = size.height * 0.56f + drawLine( + color = colorScheme.onSurface.copy(alpha = 0.65f), + start = Offset(0f, lineY), + end = Offset(lineEnd * titleStrikeProgress, lineY), + strokeWidth = TdayDimens.BorderWidthThick.toPx(), + ) + } + }, + color = if (localStruck) { colorScheme.onSurface.copy(alpha = 0.78f) } else { colorScheme.onSurface }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold, - textDecoration = if (showCompletedState) { - TextDecoration.LineThrough - } else { - TextDecoration.None - }, + textDecoration = TextDecoration.None, maxLines = 2, + onTextLayout = { titleLayoutResult = it }, ) Text( text = dueText, @@ -2322,7 +2388,26 @@ private fun CalendarCompletedTodoRow( val view = LocalView.current val coroutineScope = rememberCoroutineScope() var pendingUncomplete by remember(item.id) { mutableStateOf(false) } + var unstruck by remember(item.id) { mutableStateOf(false) } + var fading by remember(item.id) { mutableStateOf(false) } val showCompletedState = !pendingUncomplete + val showStrikethrough = !unstruck + val rowAlpha by animateFloatAsState( + targetValue = if (fading) 0f else 1f, + animationSpec = tween( + durationMillis = CALENDAR_TASK_COMPLETION_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), + label = "calendarCompletedRestoreAlpha", + ) + val rowOffsetY by animateDpAsState( + targetValue = if (fading) (-10).dp else 0.dp, + animationSpec = tween( + durationMillis = CALENDAR_TASK_COMPLETION_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), + label = "calendarCompletedRestoreOffsetY", + ) val dueText = DateTimeFormatter.ofPattern("h:mm a") .withZone(ZoneId.systemDefault()) .format(item.due) @@ -2338,6 +2423,10 @@ private fun CalendarCompletedTodoRow( Column( modifier = Modifier .fillMaxWidth() + .graphicsLayer { + alpha = rowAlpha + translationY = rowOffsetY.toPx() + } .semantics(mergeDescendants = true) { }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -2372,7 +2461,11 @@ private fun CalendarCompletedTodoRow( ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) pendingUncomplete = true coroutineScope.launch { - delay(500) + delay(180) + unstruck = true + delay(180) + fading = true + delay(CALENDAR_TASK_COMPLETION_FADE_MS) onUndoComplete() } }, @@ -2385,14 +2478,14 @@ private fun CalendarCompletedTodoRow( ) { Text( text = item.title, - color = if (showCompletedState) { + color = if (showStrikethrough) { colorScheme.onSurface.copy(alpha = 0.78f) } else { colorScheme.onSurface }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold, - textDecoration = if (showCompletedState) { + textDecoration = if (showStrikethrough) { TextDecoration.LineThrough } else { TextDecoration.None diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt index 852725b3..9da9adbf 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt @@ -77,6 +77,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -88,8 +89,8 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp @@ -120,6 +121,8 @@ private val CompletedTimelineSectionTopSpacing = 6.dp private val CompletedTimelineHeaderBodySpacing = 2.dp private val CompletedTimelineCollapsedSectionSpacing = 4.dp private val CompletedSwipeRowHeight = 56.dp +private const val COMPLETED_RESTORE_STEP_MS = 180L +private const val COMPLETED_RESTORE_FADE_MS = 260L private fun completedTaskBottomSpacing( itemIndex: Int, @@ -596,6 +599,7 @@ private fun CompletedSwipeRow( var targetOffsetX by remember(item.id) { mutableFloatStateOf(0f) } var swipeHinting by remember(item.id) { mutableStateOf(false) } var restorePhase by remember(item.id) { mutableStateOf(CompletedRestorePhase.Completed) } + var titleLayoutResult by remember(item.id) { mutableStateOf(null) } val animatedOffsetX by animateFloatAsState( targetValue = targetOffsetX, animationSpec = spring(stiffness = Spring.StiffnessLow), @@ -609,14 +613,28 @@ private fun CompletedSwipeRow( val isRestoring = restorePhase != CompletedRestorePhase.Completed val rowAlpha by animateFloatAsState( targetValue = if (isFading) 0f else 1f, - animationSpec = tween(durationMillis = 220), + animationSpec = tween( + durationMillis = COMPLETED_RESTORE_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), label = "completedRestoreRowAlpha", ) val rowScale by animateFloatAsState( targetValue = if (isFading) 0.985f else 1f, - animationSpec = tween(durationMillis = 220), + animationSpec = tween( + durationMillis = COMPLETED_RESTORE_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), label = "completedRestoreRowScale", ) + val rowOffsetY by animateDpAsState( + targetValue = if (isFading) (-10).dp else 0.dp, + animationSpec = tween( + durationMillis = COMPLETED_RESTORE_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), + label = "completedRestoreRowOffsetY", + ) val titleColor by animateColorAsState( targetValue = if (showStrikethrough) { colorScheme.onSurface.copy(alpha = 0.78f) @@ -626,6 +644,11 @@ private fun CompletedSwipeRow( animationSpec = tween(durationMillis = 160), label = "completedRestoreTitleColor", ) + val titleStrikeProgress by animateFloatAsState( + targetValue = if (showStrikethrough) 1f else 0f, + animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing), + label = "completedRestoreTitleStrikeProgress", + ) val completedAtText = COMPLETED_ROW_TIME_FORMATTER .withZone(ZoneId.systemDefault()) .format(item.completedAt ?: item.due) @@ -646,6 +669,7 @@ private fun CompletedSwipeRow( alpha = rowAlpha scaleX = rowScale scaleY = rowScale + translationY = rowOffsetY.toPx() }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -763,11 +787,11 @@ private fun CompletedSwipeRow( targetOffsetX = 0f coroutineScope.launch { restorePhase = CompletedRestorePhase.Unchecked - delay(180) + delay(COMPLETED_RESTORE_STEP_MS) restorePhase = CompletedRestorePhase.Unstruck - delay(180) + delay(COMPLETED_RESTORE_STEP_MS) restorePhase = CompletedRestorePhase.Fading - delay(220) + delay(COMPLETED_RESTORE_FADE_MS) onUncomplete() } }, @@ -780,14 +804,28 @@ private fun CompletedSwipeRow( ) { Text( text = item.title, + modifier = Modifier.drawWithContent { + drawContent() + if (titleStrikeProgress > 0f) { + val lineEnd = ( + titleLayoutResult + ?.takeIf { it.lineCount > 0 } + ?.getLineRight(0) ?: size.width + ).coerceIn(0f, size.width) + val lineY = size.height * 0.56f + drawLine( + color = colorScheme.onSurface.copy(alpha = 0.65f), + start = Offset(0f, lineY), + end = Offset(lineEnd * titleStrikeProgress, lineY), + strokeWidth = TdayDimens.BorderWidthThick.toPx(), + ) + } + }, color = titleColor, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold, - textDecoration = if (showStrikethrough) { - TextDecoration.LineThrough - } else { - TextDecoration.None - }, + maxLines = 2, + onTextLayout = { titleLayoutResult = it }, ) Row( horizontalArrangement = Arrangement.spacedBy(5.dp), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 431c6b39..48df85d1 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -547,20 +547,32 @@ fun HomeScreen( ) } - if (uiState.todayTodos.isNotEmpty()) { - item { - Column(modifier = Modifier.fillMaxWidth()) { - uiState.todayTodos.forEach { todo -> - HomeTodayTaskRow( - todo = todo, - lists = uiState.summary.lists, - onComplete = { onCompleteTask(todo) }, - onDelete = { onDeleteTask(todo) }, - onEdit = { editTargetTodoId = todo.id }, - ) - } - } - } + itemsIndexed( + items = uiState.todayTodos, + key = { _, todo -> "home-today-${todo.id}" }, + contentType = { _, _ -> "home_today_task" }, + ) { _, todo -> + HomeTodayTaskRow( + modifier = Modifier.animateItem( + fadeInSpec = tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ), + placementSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + fadeOutSpec = tween( + durationMillis = 140, + easing = FastOutSlowInEasing, + ), + ), + todo = todo, + lists = uiState.summary.lists, + onComplete = { onCompleteTask(todo) }, + onDelete = { onDeleteTask(todo) }, + onEdit = { editTargetTodoId = todo.id }, + ) } item { @@ -1696,6 +1708,7 @@ private fun HomeTodayCard( @OptIn(ExperimentalFoundationApi::class) @Composable private fun HomeTodayTaskRow( + modifier: Modifier = Modifier, todo: TodoItem, lists: List, onComplete: () -> Unit, @@ -1711,7 +1724,8 @@ private fun HomeTodayTaskRow( val maxElasticDragPx = actionRevealPx * 1.14f var targetOffsetX by remember(todo.id) { mutableFloatStateOf(0f) } var swipeHinting by remember(todo.id) { mutableStateOf(false) } - var localCompleted by remember(todo.id) { mutableStateOf(false) } + var localChecked by remember(todo.id) { mutableStateOf(false) } + var localStruck by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } var completionFading by remember(todo.id) { mutableStateOf(false) } var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } @@ -1722,11 +1736,16 @@ private fun HomeTodayTaskRow( ) val completionAlpha by animateFloatAsState( targetValue = if (completionFading) 0f else 1f, - animationSpec = tween(durationMillis = 220), + animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing), label = "homeTodayCompletionAlpha", ) + val completionOffsetY by animateDpAsState( + targetValue = if (completionFading) (-10).dp else 0.dp, + animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing), + label = "homeTodayCompletionOffsetY", + ) val titleStrikeProgress by animateFloatAsState( - targetValue = if (localCompleted) 1f else 0f, + targetValue = if (localStruck) 1f else 0f, animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), label = "homeTodayTitleStrikeProgress", ) @@ -1748,9 +1767,12 @@ private fun HomeTodayTaskRow( } Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .graphicsLayer { alpha = completionAlpha }, + .graphicsLayer { + alpha = completionAlpha + translationY = completionOffsetY.toPx() + }, ) { Box( modifier = Modifier @@ -1858,12 +1880,14 @@ private fun HomeTodayTaskRow( ) { if (!pendingCompletion) { targetOffsetX = 0f - localCompleted = true + localChecked = true pendingCompletion = true coroutineScope.launch { - delay(500) + delay(160) + localStruck = true + delay(360) completionFading = true - delay(220) + delay(260) onComplete() } } @@ -1871,13 +1895,13 @@ private fun HomeTodayTaskRow( contentAlignment = Alignment.Center, ) { Icon( - imageVector = if (localCompleted) Icons.Rounded.CheckCircle else Icons.Rounded.RadioButtonUnchecked, - contentDescription = if (localCompleted) { + imageVector = if (localChecked) Icons.Rounded.CheckCircle else Icons.Rounded.RadioButtonUnchecked, + contentDescription = if (localChecked) { stringResource(R.string.label_completed) } else { stringResource(R.string.label_mark_complete) }, - tint = if (localCompleted) Color(0xFF6FBF86) else colorScheme.onSurfaceVariant.copy( + tint = if (localChecked) Color(0xFF6FBF86) else colorScheme.onSurfaceVariant.copy( alpha = 0.78f ), modifier = Modifier.size(24.dp), @@ -1914,7 +1938,7 @@ private fun HomeTodayTaskRow( fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, lineHeight = 22.sp, - color = if (localCompleted) { + color = if (localStruck) { colorScheme.onSurface.copy(alpha = 0.78f) } else { colorScheme.onSurface diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 4bb064f8..083dc34c 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -161,6 +161,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush @@ -184,6 +185,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign @@ -826,7 +828,11 @@ fun TodoListScreen( if (!isCollapsed && section.items.isNotEmpty()) { val showEarlierDateTimeSubtitle = section.key == "earlier" && - (uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY) + ( + uiState.mode == TodoListMode.ALL || + uiState.mode == TodoListMode.PRIORITY || + uiState.mode == TodoListMode.LIST + ) section.items.forEachIndexed { itemIndex, todo -> val showTimelineDateDivider = shouldShowDateDivider( afterItemIndex = itemIndex, @@ -1158,70 +1164,92 @@ private fun ListDeleteConfirmationDialog( } else { colorScheme.surface } + val scrimColor = if (isDarkTheme) { + Color.Black.copy(alpha = 0.68f) + } else { + Color.Black.copy(alpha = 0.36f) + } Dialog( onDismissRequest = onDismissRequest, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Card( + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 34.dp) - .sizeIn(maxWidth = 420.dp), - shape = RoundedCornerShape(30.dp), - colors = CardDefaults.cardColors(containerColor = dialogContainerColor), - elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), + .fillMaxSize() + .background(scrimColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismissRequest, + ) + .padding(horizontal = 34.dp), + contentAlignment = Alignment.Center, ) { - Column( + Card( modifier = Modifier .fillMaxWidth() - .padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(22.dp), + .sizeIn(maxWidth = 420.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + shape = RoundedCornerShape(30.dp), + colors = CardDefaults.cardColors(containerColor = dialogContainerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), ) { - Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { - Text( - text = stringResource(R.string.todos_delete_list_title), - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - ) - Text( - text = stringResource(R.string.todos_delete_list_message), - style = MaterialTheme.typography.bodyLarge, - color = colorScheme.onSurfaceVariant, - fontWeight = FontWeight.Bold, - lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.16f, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 24.dp, end = 24.dp, bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(22.dp), ) { - TextButton(onClick = onDismissRequest) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - text = stringResource(R.string.action_cancel), - color = colorScheme.primary, + text = stringResource(R.string.todos_delete_list_title), + style = MaterialTheme.typography.headlineSmall, + color = colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, ) - } - Spacer(Modifier.size(10.dp)) - TextButton( - onClick = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.CLOCK_TICK, - ) - onConfirm() - }, - ) { Text( - text = stringResource(R.string.action_delete), - color = colorScheme.error, - fontWeight = FontWeight.ExtraBold, + text = stringResource(R.string.todos_delete_list_message), + style = MaterialTheme.typography.bodyLarge, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + lineHeight = MaterialTheme.typography.bodyLarge.lineHeight * 1.16f, ) } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismissRequest) { + Text( + text = stringResource(R.string.action_cancel), + color = colorScheme.primary, + fontWeight = FontWeight.ExtraBold, + ) + } + Spacer(Modifier.size(10.dp)) + TextButton( + onClick = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + onConfirm() + }, + ) { + Text( + text = stringResource(R.string.action_delete), + color = colorScheme.error, + fontWeight = FontWeight.ExtraBold, + ) + } + } } } } @@ -2774,6 +2802,9 @@ private const val SEARCH_RESULT_SCROLL_MIN_DURATION_MS = 720 private const val SEARCH_RESULT_SCROLL_MAX_DURATION_MS = 2400 private const val SEARCH_RESULT_CENTER_SCROLL_DURATION_MS = 520 private const val SEARCH_RESULT_ESTIMATED_ROW_HEIGHT_DP = 72f +private const val TASK_COMPLETION_CHECK_TO_STRIKE_MS = 160L +private const val TASK_COMPLETION_STRIKE_TO_FADE_MS = 360L +private const val TASK_COMPLETION_FADE_MS = 260L private val SWIPE_ROW_CONTENT_VERTICAL_PADDING = 2.dp private val SWIPE_ROW_HEIGHT = 56.dp private val TASK_CHECKMARK_GREEN = Color(0xFF6FBF86) @@ -2898,13 +2929,16 @@ private fun SwipeTaskRow( val maxElasticDragPx = actionRevealPx * 1.14f var targetOffsetX by remember(todo.id) { mutableFloatStateOf(0f) } var swipeHinting by remember(todo.id) { mutableStateOf(false) } - var localCompleted by remember(todo.id) { mutableStateOf(false) } + var localChecked by remember(todo.id) { mutableStateOf(false) } + var localStruck by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } var completionFading by remember(todo.id) { mutableStateOf(false) } + var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } val highlightAnim = remember(todo.id) { Animatable(0f) } - val visuallyCompleted = localCompleted || (keepCompletedInline && todo.completed) + val visuallyChecked = localChecked || (keepCompletedInline && todo.completed) + val visuallyStruck = localStruck || (keepCompletedInline && todo.completed) val animatedOffsetX by animateFloatAsState( targetValue = targetOffsetX, animationSpec = spring(stiffness = Spring.StiffnessLow), @@ -2913,9 +2947,25 @@ private fun SwipeTaskRow( val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) val completionAlpha by animateFloatAsState( targetValue = if (completionFading) 0f else 1f, - animationSpec = tween(durationMillis = 220), + animationSpec = tween( + durationMillis = TASK_COMPLETION_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), label = "swipeTaskCompletionAlpha", ) + val completionOffsetY by animateDpAsState( + targetValue = if (completionFading) (-10).dp else 0.dp, + animationSpec = tween( + durationMillis = TASK_COMPLETION_FADE_MS.toInt(), + easing = FastOutSlowInEasing + ), + label = "swipeTaskCompletionOffsetY", + ) + val titleStrikeProgress by animateFloatAsState( + targetValue = if (visuallyStruck) 1f else 0f, + animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), + label = "swipeTaskTitleStrikeProgress", + ) val dueTimeText = TODO_DUE_TIME_FORMATTER.format(todo.due) val dueDateTimeText = TODO_DUE_DATE_TIME_FORMATTER.format(todo.due) val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) @@ -2987,7 +3037,10 @@ private fun SwipeTaskRow( Column( modifier = Modifier .fillMaxWidth() - .graphicsLayer { alpha = if (dragging) completionAlpha * 0.55f else completionAlpha }, + .graphicsLayer { + alpha = if (dragging) completionAlpha * 0.55f else completionAlpha + translationY = completionOffsetY.toPx() + }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { Box( @@ -3141,42 +3194,37 @@ private fun SwipeTaskRow( verticalAlignment = Alignment.CenterVertically, ) { CircularCheckToggleIcon( - imageVector = if (!visuallyCompleted) { + imageVector = if (!visuallyChecked) { Icons.Rounded.RadioButtonUnchecked } else { Icons.Rounded.CheckCircle }, - contentDescription = if (visuallyCompleted) { + contentDescription = if (visuallyChecked) { stringResource(R.string.label_completed) } else { stringResource(R.string.label_mark_complete) }, - tint = if (!visuallyCompleted) { + tint = if (!visuallyChecked) { colorScheme.onSurfaceVariant.copy(alpha = 0.78f) } else { TASK_CHECKMARK_GREEN }, - enabled = !visuallyCompleted && !pendingCompletion, + enabled = !visuallyChecked && !pendingCompletion, onClick = { ViewCompat.performHapticFeedback( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) targetOffsetX = 0f - localCompleted = true + localChecked = true pendingCompletion = true coroutineScope.launch { - if (useDelayedFadeCompletion) { - delay(500) - if (useFadeOnCompletion) { - completionFading = true - delay(220) - } - onComplete() - } else { - delay(if (keepCompletedInline) 120 else 180) - onComplete() - } + delay(TASK_COMPLETION_CHECK_TO_STRIKE_MS) + localStruck = true + delay(TASK_COMPLETION_STRIKE_TO_FADE_MS) + completionFading = true + delay(TASK_COMPLETION_FADE_MS) + onComplete() } }, ) @@ -3188,19 +3236,33 @@ private fun SwipeTaskRow( ) { Text( text = todo.title, - color = if (visuallyCompleted) { + modifier = Modifier.drawWithContent { + drawContent() + if (titleStrikeProgress > 0f) { + val lineEnd = ( + titleLayoutResult + ?.takeIf { it.lineCount > 0 } + ?.getLineRight(0) ?: size.width + ).coerceIn(0f, size.width) + val lineY = size.height * 0.56f + drawLine( + color = colorScheme.onSurface.copy(alpha = 0.65f), + start = Offset(0f, lineY), + end = Offset(lineEnd * titleStrikeProgress, lineY), + strokeWidth = TdayDimens.BorderWidthThick.toPx(), + ) + } + }, + color = if (visuallyStruck) { colorScheme.onSurface.copy(alpha = 0.78f) } else { colorScheme.onSurface }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold, - textDecoration = if (visuallyCompleted) { - TextDecoration.LineThrough - } else { - TextDecoration.None - }, + textDecoration = TextDecoration.None, maxLines = 2, + onTextLayout = { titleLayoutResult = it }, ) if (showDueText) { Text( diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 6102b195..e1ebfe0b 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -17,6 +17,13 @@ private struct CalendarInAppDrag: Equatable { var location: CGPoint } +private enum CalendarTaskCompletionPhase { + case active + case checked + case struck + case fading +} + private struct CalendarDateDropTargetFrame: Equatable { let date: Date let frame: CGRect @@ -309,8 +316,13 @@ struct CalendarScreen: View { Task { await viewModel.delete(todo) } } ) + .transition(.opacity.combined(with: .move(edge: .top))) } } + .animation( + .spring(response: 0.34, dampingFraction: 0.9), + value: pendingItems.map(\.id) + ) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) @@ -2521,16 +2533,33 @@ private struct CalendarPendingTaskRow: View { let onComplete: () -> Void @Environment(\.tdayColors) private var colors + @State private var completionPhase = CalendarTaskCompletionPhase.active + + private var showCheckmark: Bool { + completionPhase != .active || todo.completed + } + + private var showStrikethrough: Bool { + completionPhase == .struck || completionPhase == .fading || todo.completed + } + + private var isCompleting: Bool { + completionPhase != .active + } + + private var isFading: Bool { + completionPhase == .fading + } var body: some View { let priorityIcon = priorityIndicatorSymbolName(todo.priority) VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { - Button(action: onComplete) { - Image(systemName: "circle") + Button(action: startCompletion) { + Image(systemName: showCheckmark ? "checkmark.circle.fill" : "circle") .font(.system(size: TodoTimelineMetrics.minimalRowToggleSize, weight: .regular)) - .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) + .foregroundStyle(showCheckmark ? Color.green : colors.onSurfaceVariant.opacity(0.78)) .frame(width: TodoTimelineMetrics.minimalRowToggleFrame, height: TodoTimelineMetrics.minimalRowToggleFrame) } .buttonStyle( @@ -2542,10 +2571,12 @@ private struct CalendarPendingTaskRow: View { ) VStack(alignment: .leading, spacing: 4) { - Text(todo.title) - .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowTitleSize, weight: .bold)) - .foregroundStyle(colors.onSurface) - .lineLimit(2) + TodoTimelineTaskTitle( + text: todo.title, + isCompleted: showStrikethrough, + titleColor: showStrikethrough ? colors.onSurface.opacity(0.78) : colors.onSurface, + strikeColor: colors.onSurface.opacity(0.65) + ) Text(todo.due.formatted(date: .omitted, time: .shortened)) .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) @@ -2575,6 +2606,37 @@ private struct CalendarPendingTaskRow: View { } .frame(maxWidth: .infinity, alignment: .leading) .background(colors.background) + .opacity(isFading ? 0 : 1) + .scaleEffect(isFading ? 0.985 : 1, anchor: .center) + .offset(y: isFading ? -10 : 0) + .animation(.easeInOut(duration: 0.26), value: isFading) + .allowsHitTesting(!isCompleting) + } + + private func startCompletion() { + guard completionPhase == .active else { + return + } + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.18)) { + completionPhase = .checked + } + try? await Task.sleep(nanoseconds: 160_000_000) + withAnimation(.easeInOut(duration: 0.22)) { + completionPhase = .struck + } + try? await Task.sleep(nanoseconds: 360_000_000) + withAnimation(.easeInOut(duration: 0.26)) { + completionPhase = .fading + } + try? await Task.sleep(nanoseconds: 260_000_000) + onComplete() + if completionPhase == .fading { + completionPhase = .active + } + } } } diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index b0fe63ea..c9752a80 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -327,12 +327,12 @@ private struct CompletedTimelineRow: View { .accessibilityLabel("Undo complete") VStack(alignment: .leading, spacing: 4) { - Text(item.title) - .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowTitleSize, weight: .bold)) - .foregroundStyle(titleColor) - .strikethrough(showStrikethrough, color: colors.onSurface.opacity(0.65)) - .lineLimit(2) - .animation(.easeInOut(duration: 0.16), value: showStrikethrough) + TodoTimelineTaskTitle( + text: item.title, + isCompleted: showStrikethrough, + titleColor: titleColor, + strikeColor: colors.onSurface.opacity(0.65) + ) HStack(spacing: 5) { Image(systemName: "clock") @@ -366,7 +366,8 @@ private struct CompletedTimelineRow: View { } .opacity(isFading ? 0 : 1) .scaleEffect(isFading ? 0.985 : 1, anchor: .center) - .animation(.easeInOut(duration: 0.22), value: isFading) + .offset(y: isFading ? -10 : 0) + .animation(.easeInOut(duration: 0.26), value: isFading) .transition(.opacity.combined(with: .scale(scale: 0.985))) .allowsHitTesting(!isRestoring) .todoTrailingSwipeActions( @@ -393,10 +394,10 @@ private struct CompletedTimelineRow: View { restorePhase = .unstruck } try? await Task.sleep(nanoseconds: 180_000_000) - withAnimation(.easeInOut(duration: 0.22)) { + withAnimation(.easeInOut(duration: 0.26)) { restorePhase = .fading } - try? await Task.sleep(nanoseconds: 220_000_000) + try? await Task.sleep(nanoseconds: 260_000_000) await onUncomplete() } } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index a4f4b106..86276992 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -186,8 +186,13 @@ struct HomeScreen: View { VStack(spacing: 0) { ForEach(viewModel.todayTodos) { todo in homeTodayTaskRow(todo) + .transition(.opacity.combined(with: .move(edge: .top))) } } + .animation( + .spring(response: 0.34, dampingFraction: 0.9), + value: viewModel.todayTodos.map(\.id) + ) } HomeCategoryBoard( @@ -545,6 +550,7 @@ private struct HomeIconCircleButton: View { private enum HomeTodayTaskCompletionPhase { case active case checked + case struck case fading } @@ -575,8 +581,10 @@ private struct HomeTodayTaskRow: View { private var revealProgress: CGFloat { min(1, max(0, -offsetX / revealWidth)) } private var isCompleting: Bool { completionPhase != .active } private var isFading: Bool { completionPhase == .fading } + private var showCheckmark: Bool { completionPhase != .active || todo.completed } + private var showStrikethrough: Bool { completionPhase == .struck || completionPhase == .fading || todo.completed } private var titleColor: Color { - isCompleting ? colors.onSurface.opacity(0.78) : colors.onSurface + showStrikethrough ? colors.onSurface.opacity(0.78) : colors.onSurface } var body: some View { @@ -648,16 +656,17 @@ private struct HomeTodayTaskRow: View { } .opacity(isFading ? 0 : 1) .scaleEffect(isFading ? 0.985 : 1, anchor: .center) - .animation(.easeInOut(duration: 0.22), value: isFading) + .offset(y: isFading ? -10 : 0) + .animation(.easeInOut(duration: 0.26), value: isFading) .allowsHitTesting(!isCompleting) } private var rowContent: some View { HStack(alignment: .center, spacing: 12) { Button(action: startCompletion) { - Image(systemName: isCompleting || todo.completed ? "checkmark.circle.fill" : "circle") + Image(systemName: showCheckmark ? "checkmark.circle.fill" : "circle") .font(.system(size: 24, weight: .regular)) - .foregroundStyle(isCompleting || todo.completed ? Color.green : colors.onSurfaceVariant.opacity(0.78)) + .foregroundStyle(showCheckmark ? Color.green : colors.onSurfaceVariant.opacity(0.78)) .frame(width: 38, height: 38) } .buttonStyle(TdayPressButtonStyle(shadowColor: .black, pressedShadowOpacity: 0, normalShadowOpacity: 0)) @@ -666,7 +675,7 @@ private struct HomeTodayTaskRow: View { VStack(alignment: .leading, spacing: 3) { HomeTodayTaskTitle( text: todo.title, - isCompleted: isCompleting, + isCompleted: showStrikethrough, titleColor: titleColor, strikeColor: colors.onSurface.opacity(0.65) ) @@ -708,11 +717,15 @@ private struct HomeTodayTaskRow: View { } Task { @MainActor in - try? await Task.sleep(nanoseconds: 500_000_000) + try? await Task.sleep(nanoseconds: 160_000_000) withAnimation(.easeInOut(duration: 0.22)) { + completionPhase = .struck + } + try? await Task.sleep(nanoseconds: 360_000_000) + withAnimation(.easeInOut(duration: 0.26)) { completionPhase = .fading } - try? await Task.sleep(nanoseconds: 220_000_000) + try? await Task.sleep(nanoseconds: 260_000_000) await onComplete() if completionPhase == .fading { withAnimation(.easeInOut(duration: 0.16)) { diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 6342d188..b8935c16 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -18,6 +18,12 @@ private struct TodoInAppDrag: Equatable { var location: CGPoint } +private enum TodoCompletionPhase { + case checked + case struck + case fading +} + private struct TodoDropTargetFrame: Equatable { let sectionID: String let frame: CGRect @@ -155,6 +161,39 @@ private struct TimelineTaskFlashHighlight: ViewModifier { } } +struct TodoTimelineTaskTitle: View { + let text: String + let isCompleted: Bool + let titleColor: Color + let strikeColor: Color + var font: Font = .tdayRounded(size: TodoTimelineMetrics.minimalRowTitleSize, weight: .bold) + var lineLimit: Int = 2 + + private var strikeProgress: CGFloat { + isCompleted ? 1 : 0 + } + + var body: some View { + Text(text) + .font(font) + .foregroundStyle(titleColor) + .lineLimit(lineLimit) + .overlay { + GeometryReader { proxy in + Rectangle() + .fill(strikeColor) + .frame(width: proxy.size.width * strikeProgress, height: 1.4) + .position( + x: (proxy.size.width * strikeProgress) / 2, + y: proxy.size.height * 0.55 + ) + } + .allowsHitTesting(false) + } + .animation(.easeInOut(duration: 0.32), value: isCompleted) + } +} + struct TimelineTopBarAction { let systemName: String let tint: Color? @@ -192,7 +231,7 @@ struct TodoListScreen: View { @State private var pendingRescheduleDrop: TodoRescheduleDrop? @State private var collapsedSectionIDs: Set @State private var timelineScrollOffset: CGFloat = 0 - @State private var completingTodoIDs: Set = [] + @State private var completionPhases: [String: TodoCompletionPhase] = [:] @State private var flashTodoId: String? @State private var highlightedScrollRequestID = 0 @@ -253,7 +292,7 @@ struct TodoListScreen: View { private var timelineItemAnimationKey: String { let itemIDs = viewModel.items.map(\.id).joined(separator: "|") - let completingIDs = completingTodoIDs.sorted().joined(separator: "|") + let completingIDs = completionPhases.keys.sorted().joined(separator: "|") return "\(itemIDs)::\(completingIDs)" } @@ -1015,7 +1054,9 @@ struct TodoListScreen: View { _ todo: TodoItem, in section: TodoTimelineSection ) -> some View { - let isCompleting = completingTodoIDs.contains(todo.id) + let completionPhase = completionPhases[todo.id] + let isCompleting = completionPhase != nil + let isFading = completionPhase == .fading let rowContent = VStack(alignment: .leading, spacing: 6) { HStack(spacing: 10) { Circle() @@ -1042,9 +1083,10 @@ struct TodoListScreen: View { .lineLimit(2) } } - .opacity(isCompleting ? 0 : 1) - .scaleEffect(isCompleting ? 0.985 : 1, anchor: .center) - .animation(.easeInOut(duration: 0.16), value: isCompleting) + .opacity(isFading ? 0 : 1) + .scaleEffect(isFading ? 0.985 : 1, anchor: .center) + .offset(y: isFading ? -10 : 0) + .animation(.easeInOut(duration: 0.26), value: isFading) .opacity(draggedTodo?.id == todo.id && activeDropSectionId != nil ? 0.55 : 1) .allowsHitTesting(!isCompleting) .todoTrailingSwipeActions( @@ -1100,16 +1142,20 @@ struct TodoListScreen: View { let subtitleText = minimalTimelineSubtitle(for: todo, in: section) let isOverdueTask = !todo.completed && todo.due < Date() let subtitleColor = isOverdueTask ? colors.error : colors.onSurfaceVariant.opacity(0.8) - let isCompleting = completingTodoIDs.contains(todo.id) + let completionPhase = completionPhases[todo.id] + let isCompleting = completionPhase != nil + let isFading = completionPhase == .fading + let showCheckmark = completionPhase != nil || todo.completed + let showStrikethrough = completionPhase == .struck || completionPhase == .fading || todo.completed return VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { Button { completeTodoWithoutReflow(todo) } label: { - Image(systemName: todo.completed ? "checkmark.circle.fill" : "circle") + Image(systemName: showCheckmark ? "checkmark.circle.fill" : "circle") .font(.system(size: TodoTimelineMetrics.minimalRowToggleSize, weight: .regular)) - .foregroundStyle(todo.completed ? Color.green : colors.onSurfaceVariant.opacity(0.78)) + .foregroundStyle(showCheckmark ? Color.green : colors.onSurfaceVariant.opacity(0.78)) .frame(width: TodoTimelineMetrics.minimalRowToggleFrame, height: TodoTimelineMetrics.minimalRowToggleFrame) } .buttonStyle( @@ -1121,10 +1167,12 @@ struct TodoListScreen: View { ) VStack(alignment: .leading, spacing: 4) { - Text(todo.title) - .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowTitleSize, weight: .bold)) - .foregroundStyle(colors.onSurface) - .lineLimit(2) + TodoTimelineTaskTitle( + text: todo.title, + isCompleted: showStrikethrough, + titleColor: showStrikethrough ? colors.onSurface.opacity(0.78) : colors.onSurface, + strikeColor: colors.onSurface.opacity(0.65) + ) Text(subtitleText) .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) @@ -1152,9 +1200,10 @@ struct TodoListScreen: View { .padding(.vertical, TodoTimelineMetrics.minimalRowVerticalPadding) .contentShape(Rectangle()) } - .opacity(isCompleting ? 0 : (draggedTodo?.id == todo.id && activeDropSectionId != nil ? 0.55 : 1)) - .scaleEffect(isCompleting ? 0.985 : 1, anchor: .center) - .animation(.easeInOut(duration: 0.16), value: isCompleting) + .opacity(isFading ? 0 : (draggedTodo?.id == todo.id && activeDropSectionId != nil ? 0.55 : 1)) + .scaleEffect(isFading ? 0.985 : 1, anchor: .center) + .offset(y: isFading ? -10 : 0) + .animation(.easeInOut(duration: 0.26), value: isFading) .allowsHitTesting(!isCompleting) .transition(.opacity.combined(with: .scale(scale: 0.985))) .modifier(TimelineTaskFlashHighlight(active: flashHighlight)) @@ -1192,18 +1241,24 @@ struct TodoListScreen: View { } private func completeTodoWithoutReflow(_ todo: TodoItem) { - guard !completingTodoIDs.contains(todo.id) else { + guard completionPhases[todo.id] == nil else { return } withAnimation(.easeInOut(duration: 0.16)) { - _ = completingTodoIDs.insert(todo.id) + completionPhases[todo.id] = .checked } - Task { - try? await Task.sleep(nanoseconds: 190_000_000) - await viewModel.complete(todo) - await MainActor.run { - _ = completingTodoIDs.remove(todo.id) + Task { @MainActor in + try? await Task.sleep(nanoseconds: 160_000_000) + withAnimation(.easeInOut(duration: 0.22)) { + completionPhases[todo.id] = .struck } + try? await Task.sleep(nanoseconds: 360_000_000) + withAnimation(.easeInOut(duration: 0.26)) { + completionPhases[todo.id] = .fading + } + try? await Task.sleep(nanoseconds: 260_000_000) + await viewModel.complete(todo) + completionPhases[todo.id] = nil } } @@ -1378,7 +1433,8 @@ struct TodoListScreen: View { private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { let timeText = todo.due.formatted(date: .omitted, time: .shortened) - let dueBodyText = if viewModel.mode == .priority && section.id == "earlier" { + let dueBodyText = if section.id == "earlier" && + (viewModel.mode == .all || viewModel.mode == .priority || viewModel.mode == .list) { timelineDateTimeText(todo.due) } else { timeText @@ -1409,8 +1465,6 @@ struct TodoListScreen: View { return "Overdue, \(dueBodyText)" } return "Due \(dueBodyText)" - default: - return dueBodyText } } } From f6d20cec544ab9f2c990ae6cae17d1884a056373 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 16:08:03 -0400 Subject: [PATCH 08/19] refactor(calendar): enhance calendar card styling with custom shadow and border Introduce a `calendarCardChrome` modifier to centralize and refine the visual appearance of calendar cards. This update replaces the basic shadow and background with a more sophisticated design that adapts to light and dark themes. - **Styling Refinement**: - Implement a dual-shadow system using ambient and key shadows for better depth perception. - Add a subtle 1dp border (stroke) that adjusts its opacity based on the surface luminance (dark vs. light mode). - Centralize card constants including corner radius (24.dp) and shadow elevations. - **Code Health**: - Refactor `CalendarScreen` to use the `calendarCardChrome` extension modifier, reducing duplication in the card composition logic. --- .../feature/calendar/CalendarScreen.kt | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 652a3b22..074bafa1 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -207,6 +207,8 @@ import kotlin.math.roundToInt private val CalendarAccentPurple = Color(0xFF7D67B6) private val CalendarTodayBlue = Color(0xFF509AE6) private val CalendarCardCornerRadius = 24.dp +private val CalendarCardAmbientShadowElevation = 10.dp +private val CalendarCardKeyShadowElevation = 3.dp private val CalendarCardHeaderHeight = 36.dp private val CalendarCardHeaderHorizontalPadding = 6.dp private val CalendarCardNavButtonWidth = 40.dp @@ -578,16 +580,7 @@ fun CalendarScreen( stiffness = Spring.StiffnessMediumLow, ), ) - .shadow( - elevation = 2.dp, - shape = RoundedCornerShape(CalendarCardCornerRadius), - clip = false, - ) - .clip(RoundedCornerShape(CalendarCardCornerRadius)) - .background( - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(CalendarCardCornerRadius), - ), + .calendarCardChrome(), ) { when (selectedViewMode) { CalendarViewMode.MONTH -> CalendarMonthCard( @@ -861,6 +854,46 @@ private enum class CalendarViewMode { DAY, } +@Composable +private fun Modifier.calendarCardChrome(): Modifier { + val colorScheme = MaterialTheme.colorScheme + val isDark = colorScheme.surface.luminance() < 0.5f + val shape = RoundedCornerShape(CalendarCardCornerRadius) + val ambientShadowColor = Color.Black.copy(alpha = if (isDark) 0.24f else 0.055f) + val keyShadowColor = Color.Black.copy(alpha = if (isDark) 0.18f else 0.045f) + val strokeColor = if (isDark) { + Color.White.copy(alpha = 0.08f) + } else { + Color.Black.copy(alpha = 0.035f) + } + + return this + .shadow( + elevation = CalendarCardAmbientShadowElevation, + shape = shape, + clip = false, + ambientColor = ambientShadowColor, + spotColor = ambientShadowColor, + ) + .shadow( + elevation = CalendarCardKeyShadowElevation, + shape = shape, + clip = false, + ambientColor = Color.Transparent, + spotColor = keyShadowColor, + ) + .clip(shape) + .background( + color = colorScheme.surface, + shape = shape, + ) + .border( + width = 1.dp, + color = strokeColor, + shape = shape, + ) +} + @Composable private fun CalendarViewModeTabs( selectedMode: CalendarViewMode, From b8c40d52bf7a0542138390390e4fe3f07b087076 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 16:13:48 -0400 Subject: [PATCH 09/19] feat(ux): enhance task completion and restoration animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a multi-stage animation sequence for task completion and restoration across Android (Compose) and iOS (SwiftUI). Task rows now transition through discrete phases—checked, struck-through, and fading—with coordinated vertical offsets and alpha shifts for a more polished feel. - **Animation Refinements**: - Implement a 3-step completion sequence: immediate checkmark toggle, delayed animated strike-through (160ms), and a final fade-out with upward translation (360ms delay, 260ms duration). - Sync restoration animations in the "Completed" screens to follow a similar multi-stage reversal. - Replace standard `TextDecoration.LineThrough` with custom drawing logic (`drawWithContent` in Compose, `overlay` in SwiftUI) to allow for animated strike-through progress. - Standardize animation durations using new constants (e.g., `TASK_COMPLETION_FADE_MS`). - **List & UI Improvements**: - Migrate home and calendar task lists to use `animateItem()` for smoother layout transitions during addition/deletion. - Update list headers to show date/time subtitles when viewing specific lists, matching the behavior of "All" and "Priority" modes. - Add a semi-transparent scrim and click-to-dismiss behavior to the delete confirmation dialog in `TodoListScreen`. - **Platform Parity**: - **iOS**: Create `TodoTimelineTaskTitle` reusable view to handle the new animated strike-through logic. - **Android**: Ensure `HomeTodayTaskRow`, `CalendarTodoRow`, and `SwipeTodoRow` all implement the unified animation phases. --- .../Tday/Feature/Todos/TodoListScreen.swift | 360 ++++++++++++++++-- 1 file changed, 324 insertions(+), 36 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index b8935c16..5a9c8302 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -2205,6 +2205,77 @@ private let todoListSettingsColorKeys = [ "RED", ] +private let todoListSettingsIconKeys = [ + "inbox", + "sun", + "calendar", + "schedule", + "flag", + "check", + "smile", + "list", + "bookmark", + "key", + "gift", + "cake", + "school", + "bag", + "edit", + "document", + "book", + "work", + "wallet", + "money", + "fitness", + "run", + "food", + "drink", + "health", + "monitor", + "music", + "computer", + "game", + "headphones", + "eco", + "pets", + "child", + "family", + "basket", + "cart", + "mall", + "inventory", + "soccer", + "baseball", + "basketball", + "football", + "tennis", + "train", + "flight", + "boat", + "car", + "umbrella", + "drop", + "snow", + "fire", + "tools", + "scissors", + "architecture", + "code", + "idea", + "chat", + "alert", + "star", + "heart", + "circle", + "square", + "triangle", + "home", + "city", + "bank", + "camera", + "palette", +] + private struct ListDeleteConfirmationOverlay: View { let onCancel: () -> Void let onDelete: () -> Void @@ -2283,67 +2354,204 @@ private struct ListSettingsSheet: View { let onDeleteRequest: () -> Void @Environment(\.dismiss) private var dismiss @Environment(\.tdayColors) private var tdayColors + @FocusState private var nameFieldFocused: Bool @State private var name = "" @State private var color = "PINK" @State private var iconKey = "inbox" - private let colors = todoListSettingsColorKeys - private let icons = ["inbox", "briefcase", "calendar", "list.bullet", "star", "heart"] + private var trimmedName: String { + name.trimmingCharacters(in: .whitespacesAndNewlines) + } + private var canSave: Bool { - !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + !trimmedName.isEmpty + } + + private var accentColor: Color { + todoListAccentColor(for: color) + } + + private var selectedSymbolName: String { + todoListSymbolName(for: iconKey) } var body: some View { - NavigationStack { - VStack(spacing: 0) { - ListSettingsSheetHeader( - canSave: canSave, - onClose: { dismiss() }, - onConfirm: { - onSubmit(name.trimmingCharacters(in: .whitespacesAndNewlines), color, iconKey) - dismiss() + VStack(spacing: 0) { + ListSettingsSheetHeader( + canSave: canSave, + onClose: { dismiss() }, + onConfirm: submit + ) + + ScrollView(showsIndicators: false) { + VStack(spacing: 14) { + ListSettingsSheetSectionTitle(text: "List") + ListSettingsSheetCard { + VStack(spacing: 18) { + ZStack { + Circle() + .fill(accentColor) + .frame(width: 86, height: 86) + + Image(systemName: selectedSymbolName) + .font(.system(size: 38, weight: .semibold)) + .foregroundStyle(.white) + } + + TextField( + "", + text: $name, + prompt: Text("List name") + .foregroundStyle(tdayColors.onSurfaceVariant.opacity(0.78)) + ) + .focused($nameFieldFocused) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .submitLabel(.done) + .onSubmit { + if canSave { + submit() + } + } + .multilineTextAlignment(.center) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(accentColor) + .padding(.horizontal, 14) + .frame(maxWidth: .infinity) + .frame(height: 62) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(tdayColors.bottomSheetControlSurface) + ) + } + .padding(.horizontal, 18) + .padding(.vertical, 18) } - ) - Form { - TextField("Name", text: $name) - Picker("Color", selection: $color) { - ForEach(colors, id: \.self) { value in - Text(value.capitalized).tag(value) + ListSettingsSheetSectionTitle(text: "Color") + ListSettingsSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(todoListSettingsColorKeys, id: \.self) { colorKey in + let swatchColor = todoListAccentColor(for: colorKey) + let isSelected = colorKey == color + Button { + color = colorKey + } label: { + Circle() + .fill(swatchColor) + .frame(width: 42, height: 42) + .frame(width: 48, height: 48) + .overlay { + Circle() + .stroke( + isSelected ? tdayColors.onSurface.opacity(0.3) : .clear, + lineWidth: 3 + ) + .frame(width: 42, height: 42) + } + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 + ) + ) + .accessibilityLabel(formattedOptionName(colorKey)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 14) } } - Picker("Icon", selection: $iconKey) { - ForEach(icons, id: \.self) { value in - Label(value.replacingOccurrences(of: ".", with: " "), systemImage: value).tag(value) + + ListSettingsSheetSectionTitle(text: "Icon") + ListSettingsSheetCard { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(todoListSettingsIconKeys, id: \.self) { optionKey in + let isSelected = optionKey == iconKey + Button { + iconKey = optionKey + } label: { + Circle() + .fill(isSelected ? accentColor.opacity(0.2) : tdayColors.bottomSheetControlSurface) + .frame(width: 46, height: 46) + .overlay { + Circle() + .stroke( + isSelected ? accentColor.opacity(0.55) : .clear, + lineWidth: 2 + ) + } + .overlay { + Image(systemName: todoListSymbolName(for: optionKey)) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(isSelected ? accentColor : tdayColors.onSurfaceVariant) + } + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: 0.08 + ) + ) + .accessibilityLabel(formattedOptionName(optionKey)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 14) } } if list != nil { - Button(role: .destructive) { + ListSettingsSheetDeleteButton { dismiss() onDeleteRequest() - } label: { - Label("Delete list", systemImage: "trash") } + .padding(.top, 2) } } - .scrollContentBackground(.hidden) - .background(tdayColors.bottomSheetBackground) + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 24) } - .background(tdayColors.bottomSheetBackground) + .scrollDismissesKeyboard(.interactively) .disableVerticalScrollBounce() - .toolbar(.hidden, for: .navigationBar) - .task { - name = list?.name ?? "" - color = normalizedTodoListColorKey(list?.color) - iconKey = list?.iconKey ?? "inbox" - } } + .frame(maxWidth: .infinity, alignment: .top) + .background(tdayColors.bottomSheetBackground.ignoresSafeArea()) + .presentationDetents([.fraction(0.8)]) + .presentationDragIndicator(.hidden) + .presentationCornerRadius(34) .presentationBackground { tdayColors.bottomSheetBackground .ignoresSafeArea(.container, edges: .bottom) } + .ignoresSafeArea(.keyboard, edges: .bottom) + .task { + name = list?.name ?? "" + color = normalizedTodoListColorKey(list?.color) + iconKey = normalizedTodoListIconKey(list?.iconKey) + } + } + + private func submit() { + guard canSave else { return } + onSubmit(trimmedName, color, iconKey) + dismiss() + } + + private func formattedOptionName(_ value: String) -> String { + value + .replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: ".", with: " ") + .split(separator: " ") + .map { $0.capitalized } + .joined(separator: " ") } } @@ -2366,11 +2574,11 @@ private struct ListSettingsSheetHeader: View { Spacer(minLength: 0) - Text("List Settings") - .font(.tdayRounded(size: 28, weight: .heavy)) + Text("List settings") + .font(.tdayRounded(size: 22, weight: .heavy)) .foregroundStyle(colors.onSurface) .lineLimit(1) - .minimumScaleFactor(0.78) + .minimumScaleFactor(0.82) Spacer(minLength: 0) @@ -2389,6 +2597,79 @@ private struct ListSettingsSheetHeader: View { } } +private struct ListSettingsSheetSectionTitle: View { + let text: String + + @Environment(\.tdayColors) private var colors + + var body: some View { + Text(text) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(colors.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } +} + +private struct ListSettingsSheetCard: View { + @Environment(\.tdayColors) private var colors + + @ViewBuilder let content: Content + + var body: some View { + content + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(colors.bottomSheetSurface) + ) + } +} + +private struct ListSettingsSheetDeleteButton: View { + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(role: .destructive) { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + action() + } label: { + HStack(spacing: 12) { + Image(systemName: "trash") + .font(.system(size: 22, weight: .semibold)) + .frame(width: 28, height: 28) + + Text("Delete list") + .font(.tdayRounded(size: 18, weight: .heavy)) + + Spacer(minLength: 0) + } + .foregroundStyle(colors.error) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(colors.error.opacity(colors.isDark ? 0.14 : 0.04)) + ) + .overlay { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(colors.error.opacity(0.45), lineWidth: 1.5) + } + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.03, + normalShadowOpacity: 0 + ) + ) + .accessibilityLabel("Delete list") + } +} + private struct ListSettingsSheetActionButton: View { let icon: String let accessibilityLabel: String @@ -2403,7 +2684,7 @@ private struct ListSettingsSheetActionButton: View { Image(systemName: icon) .font(.system(size: 22, weight: .semibold)) .foregroundStyle(colors.onSurface.opacity(enabled ? 1 : 0.55)) - .frame(width: 56, height: 56) + .frame(width: 54, height: 54) .background(colors.bottomSheetControlSurface, in: Circle()) .overlay { Circle() @@ -2978,6 +3259,13 @@ private func normalizedTodoListColorKey(_ key: String?) -> String { } } +private func normalizedTodoListIconKey(_ key: String?) -> String { + guard let key, todoListSettingsIconKeys.contains(key) else { + return "inbox" + } + return key +} + private func todoListSymbolName(for key: String?) -> String { switch key { case "sun": From d58ff30a51fd7ce9d79466628a01dacb1c94e134 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 16:21:53 -0400 Subject: [PATCH 10/19] feat(ios): overhaul Todo list settings UI and icon/color selection Redesign the `ListSettingsSheet` with a modern, custom card-based interface replacing the standard SwiftUI Form. This update significantly expands personalization options and improves the overall user experience. - **UI & UX Enhancements**: - Implement a custom layout with dedicated sections for List name, Color, and Icon. - Add a live preview header showing the selected icon and color in a large circular badge. - Replace the standard list name field with a centered, bold `TextField` in a custom styled container. - Introduce horizontally scrolling pickers for colors and icons using circular swatches with selection indicators. - Custom design the "Delete list" button with a bordered, low-opacity error background. - Set presentation detents to 80% and configure custom corner radius (34pt) for the bottom sheet. - **Content & Logic**: - Significantly expand the available icon set by adding `todoListSettingsIconKeys` (60+ new icons). - Refactor color and icon selection logic to use standardized key mapping and normalization (`normalizedTodoListIconKey`). - Improve keyboard behavior with interactive scroll dismissal and keyboard-aware safe area handling. - Add haptic feedback to the delete action and improve accessibility labels for all selection options. --- .../Feature/Calendar/CalendarScreen.swift | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index e1ebfe0b..0d964d58 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -103,7 +103,8 @@ private enum CalendarModeCardMetrics { } private let calendarTodayTintColor = Color(red: 80.0 / 255.0, green: 154.0 / 255.0, blue: 230.0 / 255.0) -private let calendarModeTransitionAnimation = Animation.spring(response: 0.34, dampingFraction: 0.9, blendDuration: 0.02) +private let calendarModeResizeAnimation = Animation.spring(response: 0.34, dampingFraction: 0.92, blendDuration: 0.02) +private let calendarModeContentFadeAnimation = Animation.easeInOut(duration: 0.12) private struct CalendarCardChromeModifier: ViewModifier { @Environment(\.tdayColors) private var colors @@ -231,7 +232,8 @@ struct CalendarScreen: View { selectedMode: displayMode, accentColor: calendarAccentColor, onSelect: { mode in - withAnimation(calendarModeTransitionAnimation) { + guard mode != displayMode else { return } + withAnimation(calendarModeResizeAnimation) { displayMode = mode if mode != .month { visibleMonth = calendarMonthStart(for: selectedDate) @@ -258,13 +260,7 @@ struct CalendarScreen: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) - calendarModeCard - .id(displayMode) - .transition(.opacity.combined(with: .scale(scale: 0.985, anchor: .top))) - .frame(height: calendarModeCardHeight, alignment: .top) - .clipped() - .modifier(CalendarCardChromeModifier()) - .animation(calendarModeTransitionAnimation, value: displayMode) + animatedCalendarModeCard .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: CalendarModeCardMetrics.shadowBleed, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -338,6 +334,7 @@ struct CalendarScreen: View { .listRowSpacing(0) .listSectionSpacing(0) .environment(\.defaultMinListRowHeight, 1) + .animation(calendarModeResizeAnimation, value: calendarModeCardHeight) .disableVerticalScrollBounce() .background(colors.background) .onPreferenceChange(CalendarDateDropTargetFramePreferenceKey.self) { frames in @@ -456,6 +453,19 @@ struct CalendarScreen: View { ) } + private var animatedCalendarModeCard: some View { + ZStack(alignment: .top) { + calendarModeCard + .id(displayMode) + .transition(.opacity.animation(calendarModeContentFadeAnimation)) + } + .frame(maxWidth: .infinity) + .frame(height: calendarModeCardHeight, alignment: .top) + .clipped() + .modifier(CalendarCardChromeModifier()) + .animation(calendarModeResizeAnimation, value: calendarModeCardHeight) + } + private func isSelectedDay(_ date: Date) -> Bool { Calendar.current.isDate(date, inSameDayAs: selectedDate) } From d2b084fcbf77eddaaf8921a10b612d0ad56bdae9 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 16:35:48 -0400 Subject: [PATCH 11/19] Refactor calendar mode transitions and layout animations in both iOS and Android. This update replaces the previous `animateContentSize` (Android) and simple opacity transitions (iOS) with a coordinated layout system that handles height changes between month, week, and day views more smoothly. It introduces a "below calendar offset" to ensure content following the calendar card moves in sync with height animations, avoiding overlapping or jumping during mode switches. - **Cross-Platform Layout Sync**: - Implement a staged transition logic: when expanding, content below shifts down immediately; when collapsing, the shift is delayed to match the card's animation. - Added constants for lead/trail delays (~110-130ms) to fine-tune the feel of the transition. - Managed transition state via `calendarLayoutMode` and `belowCalendarOffset` to decouple visual selection from physical layout during animations. - **iOS (SwiftUI) Improvements**: - Replaced conditional `if/else` rendering in `animatedCalendarModeCard` with a `ZStack` containing all modes to prevent layout resets. - Added `accessibilityHidden` and `allowsHitTesting` toggles to inactive calendar views. - Applied `.offset(y: belowCalendarOffset)` to the task list header, task items, and error views. - **Android (Compose) Improvements**: - Defined explicit height constants (`CalendarMonthModeCardHeight`, `CalendarPeriodModeCardHeight`) for deterministic animations. - Replaced `animateContentSize` with explicit `Modifier.height()` animation using `animateDpAsState`. - Wrapped mode views in a `ForEach` with alpha animations and `graphicsLayer` for better performance and cross-fading. - Applied offset logic to `ErrorRetryCard` and task list items. --- .../feature/calendar/CalendarScreen.kt | 228 +++++++++++++----- .../Feature/Calendar/CalendarScreen.swift | 113 +++++++-- 2 files changed, 258 insertions(+), 83 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 074bafa1..d6307c0d 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -1,7 +1,6 @@ package com.ohmz.tday.compose.feature.calendar import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -235,9 +234,25 @@ private val CalendarDaySummaryCountSize = 18.sp private val CalendarPeriodCardPageHeight = 78.dp private val CalendarPeriodWeekDayCellHeight = 72.dp private val CalendarPeriodPageHorizontalGutter = 2.dp +private val CalendarPeriodCardTopPadding = 16.dp +private val CalendarPeriodCardOuterSpacing = 14.dp private val CalendarPeriodCardBottomPadding = 18.dp +private val CalendarMonthModeCardHeight = CalendarMonthCardTopPadding + + CalendarCardHeaderHeight + + CalendarMonthCardOuterSpacing + + CalendarMonthWeekdayHeight + + CalendarMonthGridSpacing + + CalendarMonthGridHeight + + CalendarMonthCardBottomPadding +private val CalendarPeriodModeCardHeight = CalendarPeriodCardTopPadding + + CalendarCardHeaderHeight + + CalendarPeriodCardOuterSpacing + + CalendarPeriodCardPageHeight + + CalendarPeriodCardBottomPadding private val CalendarTaskListSameDateSpacing = 2.dp private val CalendarTaskRowHeight = 56.dp +private const val CALENDAR_MODE_BELOW_LEAD_DELAY_MS = 110L +private const val CALENDAR_MODE_BELOW_TRAIL_DELAY_MS = 130L private const val CALENDAR_TASK_COMPLETION_CHECK_TO_STRIKE_MS = 160L private const val CALENDAR_TASK_COMPLETION_STRIKE_TO_FADE_MS = 360L private const val CALENDAR_TASK_COMPLETION_FADE_MS = 260L @@ -374,6 +389,8 @@ fun CalendarScreen( var visibleMonthIso by rememberSaveable { mutableStateOf(minNavigableMonth.toString()) } var selectedDateIso by rememberSaveable { mutableStateOf(today.toString()) } var selectedViewKey by rememberSaveable { mutableStateOf(CalendarViewMode.MONTH.name) } + var calendarLayoutViewKey by rememberSaveable { mutableStateOf(selectedViewKey) } + var belowCalendarOffsetTarget by remember { mutableStateOf(0.dp) } var todayJumpRequestId by rememberSaveable { mutableStateOf(0) } var todayJumpRequest by remember { mutableStateOf(null) } @@ -382,6 +399,26 @@ fun CalendarScreen( val selectedViewMode = remember(selectedViewKey) { CalendarViewMode.entries.firstOrNull { it.name == selectedViewKey } ?: CalendarViewMode.MONTH } + val calendarLayoutViewMode = remember(calendarLayoutViewKey) { + CalendarViewMode.entries.firstOrNull { it.name == calendarLayoutViewKey } + ?: CalendarViewMode.MONTH + } + val calendarCardHeight by animateDpAsState( + targetValue = calendarCardHeightFor(calendarLayoutViewMode), + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "calendarModeCardHeight", + ) + val belowCalendarOffset by animateDpAsState( + targetValue = belowCalendarOffsetTarget, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "calendarModeBelowOffset", + ) val calendarTaskRescheduleEnabled = selectedViewMode != CalendarViewMode.DAY val tasksByDate = remember(uiState.items, zoneId) { uiState.items @@ -418,6 +455,32 @@ fun CalendarScreen( activeDropDateIso = null calendarDropTargetBounds.clear() } + + val currentHeight = calendarCardHeightFor(calendarLayoutViewMode) + val targetHeight = calendarCardHeightFor(selectedViewMode) + val heightDelta = if (targetHeight > currentHeight) { + targetHeight - currentHeight + } else { + currentHeight - targetHeight + } + val isCollapsing = targetHeight < currentHeight + val isExpanding = targetHeight > currentHeight + + if (heightDelta < 1.dp) { + belowCalendarOffsetTarget = 0.dp + calendarLayoutViewKey = selectedViewMode.name + } else if (isCollapsing) { + belowCalendarOffsetTarget = heightDelta + calendarLayoutViewKey = selectedViewMode.name + delay(CALENDAR_MODE_BELOW_TRAIL_DELAY_MS) + belowCalendarOffsetTarget = 0.dp + } else if (isExpanding) { + belowCalendarOffsetTarget = heightDelta + delay(CALENDAR_MODE_BELOW_LEAD_DELAY_MS) + calendarLayoutViewKey = selectedViewMode.name + belowCalendarOffsetTarget = 0.dp + } + } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> @@ -574,72 +637,101 @@ fun CalendarScreen( Box( modifier = Modifier .fillMaxWidth() - .animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - ) + .height(calendarCardHeight) .calendarCardChrome(), ) { - when (selectedViewMode) { - CalendarViewMode.MONTH -> CalendarMonthCard( - visibleMonth = visibleMonth, - minNavigableMonth = minNavigableMonth, - canGoPrevMonth = visibleMonth > minNavigableMonth, - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - draggedTodo = draggedCalendarTodo, - activeDropDate = activeDropDate, - dropTargets = calendarDropTargetBounds, - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onVisibleMonthChanged = { targetMonth -> - if (targetMonth >= minNavigableMonth) { - visibleMonthIso = targetMonth.toString() - } - }, - onSelectDate = ::selectDate, - onDropDateChanged = { date -> - activeDropDateIso = date?.toString() - }, - onMoveTaskToDate = ::requestTaskReschedule, - resolveTodo = resolveTodoForDrop, + CalendarViewMode.entries.forEach { mode -> + val isActive = selectedViewMode == mode + val contentAlpha by animateFloatAsState( + targetValue = if (isActive) 1f else 0f, + animationSpec = tween( + durationMillis = 120, + easing = FastOutSlowInEasing, + ), + label = "calendarMode${mode.name}ContentAlpha", ) + Box( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { alpha = contentAlpha } + .zIndex(if (isActive) 1f else 0f), + ) { + when (mode) { + CalendarViewMode.MONTH -> CalendarMonthCard( + visibleMonth = visibleMonth, + minNavigableMonth = minNavigableMonth, + canGoPrevMonth = visibleMonth > minNavigableMonth, + selectedDate = selectedDate, + today = today, + tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo.takeIf { isActive }, + activeDropDate = activeDropDate.takeIf { isActive }, + dropTargets = calendarDropTargetBounds, + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest.takeIf { isActive }, + onTodayJumpHandled = { requestId -> + if (isActive) clearTodayJumpRequest(requestId) + }, + onVisibleMonthChanged = { targetMonth -> + if (isActive && targetMonth >= minNavigableMonth) { + visibleMonthIso = targetMonth.toString() + } + }, + onSelectDate = { date -> + if (isActive) selectDate(date) + }, + onDropDateChanged = { date -> + if (isActive) activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = { todo, date -> + if (isActive) requestTaskReschedule(todo, date) + }, + resolveTodo = resolveTodoForDrop, + ) - CalendarViewMode.WEEK -> CalendarWeekCard( - selectedDate = selectedDate, - minNavigableMonth = minNavigableMonth, - today = today, - tasksByDate = tasksByDate, - draggedTodo = draggedCalendarTodo, - activeDropDate = activeDropDate, - dropTargets = calendarDropTargetBounds, - canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onSelectDate = ::selectDate, - onDropDateChanged = { date -> - activeDropDateIso = date?.toString() - }, - onMoveTaskToDate = ::requestTaskReschedule, - resolveTodo = resolveTodoForDrop, - ) + CalendarViewMode.WEEK -> CalendarWeekCard( + selectedDate = selectedDate, + minNavigableMonth = minNavigableMonth, + today = today, + tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo.takeIf { isActive }, + activeDropDate = activeDropDate.takeIf { isActive }, + dropTargets = calendarDropTargetBounds, + canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest.takeIf { isActive }, + onTodayJumpHandled = { requestId -> + if (isActive) clearTodayJumpRequest(requestId) + }, + onSelectDate = { date -> + if (isActive) selectDate(date) + }, + onDropDateChanged = { date -> + if (isActive) activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = { todo, date -> + if (isActive) requestTaskReschedule(todo, date) + }, + resolveTodo = resolveTodoForDrop, + ) - CalendarViewMode.DAY -> CalendarDayCard( - selectedDate = selectedDate, - minNavigableMonth = minNavigableMonth, - today = today, - tasksByDate = tasksByDate, - canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onSelectDate = ::selectDate, - ) + CalendarViewMode.DAY -> CalendarDayCard( + selectedDate = selectedDate, + minNavigableMonth = minNavigableMonth, + today = today, + tasksByDate = tasksByDate, + canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest.takeIf { isActive }, + onTodayJumpHandled = { requestId -> + if (isActive) clearTodayJumpRequest(requestId) + }, + onSelectDate = { date -> + if (isActive) selectDate(date) + }, + ) + } + } } } } @@ -652,7 +744,9 @@ fun CalendarScreen( style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(horizontal = 4.dp), + modifier = Modifier + .offset(y = belowCalendarOffset) + .padding(horizontal = 4.dp), ) } @@ -677,6 +771,7 @@ fun CalendarScreen( easing = FastOutSlowInEasing, ), ) + .offset(y = belowCalendarOffset) .padding( bottom = if (index == selectedDatePendingTasks.lastIndex) { 0.dp @@ -722,6 +817,7 @@ fun CalendarScreen( com.ohmz.tday.compose.core.ui.ErrorRetryCard( message = message, onRetry = onRefresh, + modifier = Modifier.offset(y = belowCalendarOffset), ) } } @@ -854,6 +950,12 @@ private enum class CalendarViewMode { DAY, } +private fun calendarCardHeightFor(mode: CalendarViewMode) = when (mode) { + CalendarViewMode.MONTH -> CalendarMonthModeCardHeight + CalendarViewMode.WEEK, + CalendarViewMode.DAY -> CalendarPeriodModeCardHeight +} + @Composable private fun Modifier.calendarCardChrome(): Modifier { val colorScheme = MaterialTheme.colorScheme diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 0d964d58..828f30b6 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -105,6 +105,10 @@ private enum CalendarModeCardMetrics { private let calendarTodayTintColor = Color(red: 80.0 / 255.0, green: 154.0 / 255.0, blue: 230.0 / 255.0) private let calendarModeResizeAnimation = Animation.spring(response: 0.34, dampingFraction: 0.92, blendDuration: 0.02) private let calendarModeContentFadeAnimation = Animation.easeInOut(duration: 0.12) +private let calendarModeBelowLeadAnimation = Animation.easeInOut(duration: 0.14) +private let calendarModeBelowTrailAnimation = Animation.spring(response: 0.30, dampingFraction: 0.9, blendDuration: 0.02) +private let calendarModeBelowLeadDelayNanoseconds: UInt64 = 110_000_000 +private let calendarModeBelowTrailDelayNanoseconds: UInt64 = 130_000_000 private struct CalendarCardChromeModifier: ViewModifier { @Environment(\.tdayColors) private var colors @@ -153,6 +157,9 @@ struct CalendarScreen: View { @State private var selectedDate = Date() @State private var visibleMonth = calendarMonthStart(for: Date()) @State private var displayMode: CalendarDisplayMode = .month + @State private var calendarLayoutMode: CalendarDisplayMode = .month + @State private var belowCalendarOffset: CGFloat = 0 + @State private var calendarModeTransitionTask: Task? @State private var showingCreateTask = false @State private var editingTodo: TodoItem? @State private var calendarTitleCollapseOffset: CGFloat = 0 @@ -189,7 +196,11 @@ struct CalendarScreen: View { } private var calendarModeCardHeight: CGFloat { - switch displayMode { + calendarModeCardHeight(for: calendarLayoutMode) + } + + private func calendarModeCardHeight(for mode: CalendarDisplayMode) -> CGFloat { + switch mode { case .month: return CalendarModeCardMetrics.monthHeight case .week, .day: @@ -232,13 +243,7 @@ struct CalendarScreen: View { selectedMode: displayMode, accentColor: calendarAccentColor, onSelect: { mode in - guard mode != displayMode else { return } - withAnimation(calendarModeResizeAnimation) { - displayMode = mode - if mode != .month { - visibleMonth = calendarMonthStart(for: selectedDate) - } - } + selectCalendarMode(mode) } ) .background { @@ -271,6 +276,7 @@ struct CalendarScreen: View { ErrorRetryView(message: errorMessage) { Task { await viewModel.refresh() } } + .offset(y: belowCalendarOffset) .listRowBackground(Color.clear) } } @@ -279,6 +285,7 @@ struct CalendarScreen: View { .font(.tdayRounded(size: 22, weight: .heavy)) .foregroundStyle(colors.onSurface) .textCase(nil) + .offset(y: belowCalendarOffset) .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 4, trailing: TodoTimelineMetrics.horizontalPadding)) .timelinePinnedSectionHeaderBackground() @@ -319,6 +326,7 @@ struct CalendarScreen: View { .spring(response: 0.34, dampingFraction: 0.9), value: pendingItems.map(\.id) ) + .offset(y: belowCalendarOffset) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) @@ -345,6 +353,10 @@ struct CalendarScreen: View { cancelInAppDrag() } } + .onDisappear { + calendarModeTransitionTask?.cancel() + calendarModeTransitionTask = nil + } .overlay(alignment: .topLeading) { GeometryReader { proxy in if let inAppDrag { @@ -455,9 +467,16 @@ struct CalendarScreen: View { private var animatedCalendarModeCard: some View { ZStack(alignment: .top) { - calendarModeCard - .id(displayMode) - .transition(.opacity.animation(calendarModeContentFadeAnimation)) + ForEach(CalendarDisplayMode.allCases, id: \.self) { mode in + calendarModeContent(for: mode) + .transaction { transaction in + transaction.animation = nil + } + .opacity(displayMode == mode ? 1 : 0) + .animation(calendarModeContentFadeAnimation, value: displayMode) + .allowsHitTesting(displayMode == mode) + .accessibilityHidden(displayMode != mode) + } } .frame(maxWidth: .infinity) .frame(height: calendarModeCardHeight, alignment: .top) @@ -466,24 +485,78 @@ struct CalendarScreen: View { .animation(calendarModeResizeAnimation, value: calendarModeCardHeight) } + private func selectCalendarMode(_ mode: CalendarDisplayMode) { + guard mode != displayMode else { return } + + calendarModeTransitionTask?.cancel() + calendarModeTransitionTask = nil + + let currentHeight = calendarModeCardHeight(for: calendarLayoutMode) + let targetHeight = calendarModeCardHeight(for: mode) + let heightDelta = abs(currentHeight - targetHeight) + let isExpanding = targetHeight > currentHeight + let isCollapsing = targetHeight < currentHeight + + displayMode = mode + if mode != .month { + visibleMonth = calendarMonthStart(for: selectedDate) + } + + if heightDelta < 1 { + withAnimation(calendarModeBelowTrailAnimation) { + belowCalendarOffset = 0 + } + calendarLayoutMode = mode + return + } + + if isCollapsing { + withAnimation(calendarModeResizeAnimation) { + calendarLayoutMode = mode + belowCalendarOffset = heightDelta + } + calendarModeTransitionTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: calendarModeBelowTrailDelayNanoseconds) + guard !Task.isCancelled else { return } + withAnimation(calendarModeBelowTrailAnimation) { + belowCalendarOffset = 0 + } + } + } else if isExpanding { + withAnimation(calendarModeBelowLeadAnimation) { + belowCalendarOffset = heightDelta + } + calendarModeTransitionTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: calendarModeBelowLeadDelayNanoseconds) + guard !Task.isCancelled else { return } + withAnimation(calendarModeResizeAnimation) { + calendarLayoutMode = mode + belowCalendarOffset = 0 + } + } + } + } + private func isSelectedDay(_ date: Date) -> Bool { Calendar.current.isDate(date, inSameDayAs: selectedDate) } @ViewBuilder - private var calendarModeCard: some View { - switch displayMode { + private func calendarModeContent(for mode: CalendarDisplayMode) -> some View { + let isActive = displayMode == mode + + switch mode { case .month: CalendarMonthGrid( visibleMonth: visibleMonth, selectedDate: selectedDate, tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, - draggedTodo: draggedTodo, - activeDropDate: activeDropDate, + draggedTodo: isActive ? draggedTodo : nil, + activeDropDate: isActive ? activeDropDate : nil, canGoPreviousMonth: canGoPreviousMonth, minimumNavigableMonth: minimumNavigableMonth, - todayJumpRequest: todayJumpRequest, + todayJumpRequest: isActive ? todayJumpRequest : nil, onPreviousMonth: { navigateMonth(by: -1) }, onNextMonth: { navigateMonth(by: 1) }, onSelectDate: { selectDate($0) }, @@ -497,11 +570,11 @@ struct CalendarScreen: View { today: Date(), tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, - draggedTodo: draggedTodo, - activeDropDate: activeDropDate, + draggedTodo: isActive ? draggedTodo : nil, + activeDropDate: isActive ? activeDropDate : nil, canGoPreviousWeek: canGoPreviousWeek, canSelectDate: { canNavigate(to: $0) }, - todayJumpRequest: todayJumpRequest, + todayJumpRequest: isActive ? todayJumpRequest : nil, onPreviousWeek: { navigateDay(by: -7) }, onNextWeek: { navigateDay(by: 7) }, onSelectDate: { selectDate($0) }, @@ -517,7 +590,7 @@ struct CalendarScreen: View { accentColor: calendarAccentColor, canGoPreviousDay: canGoPreviousDay, canSelectDate: { canNavigate(to: $0) }, - todayJumpRequest: todayJumpRequest, + todayJumpRequest: isActive ? todayJumpRequest : nil, onPreviousDay: { navigateDay(by: -1) }, onNextDay: { navigateDay(by: 1) }, onSelectDate: { selectDate($0) } From 04cb245f017c8ba30ad41492274ea82d3b73d688 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 16:53:43 -0400 Subject: [PATCH 12/19] refactor(calendar): simplify calendar mode transition logic and animations Streamline the calendar view mode transitions in both iOS (SwiftUI) and Android (Compose) by removing complex manual offset calculations and staggered animations. - **iOS (SwiftUI)**: - Remove `calendarLayoutMode`, `belowCalendarOffset`, and associated transition task logic from `CalendarScreen`. - Replace the `ZStack` of all calendar modes with a single active view, simplifying the view hierarchy. - Remove legacy animation constants and manual delays for view resizing. - Clean up logic by passing state directly to `CalendarMonthGrid`, `CalendarWeekGrid`, and `CalendarDayGrid` without conditional activity checks. - **Android (Compose)**: - Replace manual height and offset animations with `Modifier.animateContentSize()` on the calendar card container. - Remove `calendarLayoutViewKey`, `belowCalendarOffsetTarget`, and related `animateDpAsState` logic. - Remove hardcoded height constants (e.g., `CalendarMonthModeCardHeight`) and transition delay constants. - Replace the loop-based `ZStack`-like approach with a direct `when` statement to render only the selected `CalendarViewMode`. - Remove `offset(y = belowCalendarOffset)` from the task list and error views, allowing the layout to adjust naturally. --- .../feature/calendar/CalendarScreen.kt | 228 +++++------------- .../Feature/Calendar/CalendarScreen.swift | 100 ++------ 2 files changed, 79 insertions(+), 249 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index d6307c0d..074bafa1 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -1,6 +1,7 @@ package com.ohmz.tday.compose.feature.calendar import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -234,25 +235,9 @@ private val CalendarDaySummaryCountSize = 18.sp private val CalendarPeriodCardPageHeight = 78.dp private val CalendarPeriodWeekDayCellHeight = 72.dp private val CalendarPeriodPageHorizontalGutter = 2.dp -private val CalendarPeriodCardTopPadding = 16.dp -private val CalendarPeriodCardOuterSpacing = 14.dp private val CalendarPeriodCardBottomPadding = 18.dp -private val CalendarMonthModeCardHeight = CalendarMonthCardTopPadding + - CalendarCardHeaderHeight + - CalendarMonthCardOuterSpacing + - CalendarMonthWeekdayHeight + - CalendarMonthGridSpacing + - CalendarMonthGridHeight + - CalendarMonthCardBottomPadding -private val CalendarPeriodModeCardHeight = CalendarPeriodCardTopPadding + - CalendarCardHeaderHeight + - CalendarPeriodCardOuterSpacing + - CalendarPeriodCardPageHeight + - CalendarPeriodCardBottomPadding private val CalendarTaskListSameDateSpacing = 2.dp private val CalendarTaskRowHeight = 56.dp -private const val CALENDAR_MODE_BELOW_LEAD_DELAY_MS = 110L -private const val CALENDAR_MODE_BELOW_TRAIL_DELAY_MS = 130L private const val CALENDAR_TASK_COMPLETION_CHECK_TO_STRIKE_MS = 160L private const val CALENDAR_TASK_COMPLETION_STRIKE_TO_FADE_MS = 360L private const val CALENDAR_TASK_COMPLETION_FADE_MS = 260L @@ -389,8 +374,6 @@ fun CalendarScreen( var visibleMonthIso by rememberSaveable { mutableStateOf(minNavigableMonth.toString()) } var selectedDateIso by rememberSaveable { mutableStateOf(today.toString()) } var selectedViewKey by rememberSaveable { mutableStateOf(CalendarViewMode.MONTH.name) } - var calendarLayoutViewKey by rememberSaveable { mutableStateOf(selectedViewKey) } - var belowCalendarOffsetTarget by remember { mutableStateOf(0.dp) } var todayJumpRequestId by rememberSaveable { mutableStateOf(0) } var todayJumpRequest by remember { mutableStateOf(null) } @@ -399,26 +382,6 @@ fun CalendarScreen( val selectedViewMode = remember(selectedViewKey) { CalendarViewMode.entries.firstOrNull { it.name == selectedViewKey } ?: CalendarViewMode.MONTH } - val calendarLayoutViewMode = remember(calendarLayoutViewKey) { - CalendarViewMode.entries.firstOrNull { it.name == calendarLayoutViewKey } - ?: CalendarViewMode.MONTH - } - val calendarCardHeight by animateDpAsState( - targetValue = calendarCardHeightFor(calendarLayoutViewMode), - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - label = "calendarModeCardHeight", - ) - val belowCalendarOffset by animateDpAsState( - targetValue = belowCalendarOffsetTarget, - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - label = "calendarModeBelowOffset", - ) val calendarTaskRescheduleEnabled = selectedViewMode != CalendarViewMode.DAY val tasksByDate = remember(uiState.items, zoneId) { uiState.items @@ -455,32 +418,6 @@ fun CalendarScreen( activeDropDateIso = null calendarDropTargetBounds.clear() } - - val currentHeight = calendarCardHeightFor(calendarLayoutViewMode) - val targetHeight = calendarCardHeightFor(selectedViewMode) - val heightDelta = if (targetHeight > currentHeight) { - targetHeight - currentHeight - } else { - currentHeight - targetHeight - } - val isCollapsing = targetHeight < currentHeight - val isExpanding = targetHeight > currentHeight - - if (heightDelta < 1.dp) { - belowCalendarOffsetTarget = 0.dp - calendarLayoutViewKey = selectedViewMode.name - } else if (isCollapsing) { - belowCalendarOffsetTarget = heightDelta - calendarLayoutViewKey = selectedViewMode.name - delay(CALENDAR_MODE_BELOW_TRAIL_DELAY_MS) - belowCalendarOffsetTarget = 0.dp - } else if (isExpanding) { - belowCalendarOffsetTarget = heightDelta - delay(CALENDAR_MODE_BELOW_LEAD_DELAY_MS) - calendarLayoutViewKey = selectedViewMode.name - belowCalendarOffsetTarget = 0.dp - } - } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> @@ -637,101 +574,72 @@ fun CalendarScreen( Box( modifier = Modifier .fillMaxWidth() - .height(calendarCardHeight) + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) .calendarCardChrome(), ) { - CalendarViewMode.entries.forEach { mode -> - val isActive = selectedViewMode == mode - val contentAlpha by animateFloatAsState( - targetValue = if (isActive) 1f else 0f, - animationSpec = tween( - durationMillis = 120, - easing = FastOutSlowInEasing, - ), - label = "calendarMode${mode.name}ContentAlpha", + when (selectedViewMode) { + CalendarViewMode.MONTH -> CalendarMonthCard( + visibleMonth = visibleMonth, + minNavigableMonth = minNavigableMonth, + canGoPrevMonth = visibleMonth > minNavigableMonth, + selectedDate = selectedDate, + today = today, + tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, + dropTargets = calendarDropTargetBounds, + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onVisibleMonthChanged = { targetMonth -> + if (targetMonth >= minNavigableMonth) { + visibleMonthIso = targetMonth.toString() + } + }, + onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, ) - Box( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { alpha = contentAlpha } - .zIndex(if (isActive) 1f else 0f), - ) { - when (mode) { - CalendarViewMode.MONTH -> CalendarMonthCard( - visibleMonth = visibleMonth, - minNavigableMonth = minNavigableMonth, - canGoPrevMonth = visibleMonth > minNavigableMonth, - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - draggedTodo = draggedCalendarTodo.takeIf { isActive }, - activeDropDate = activeDropDate.takeIf { isActive }, - dropTargets = calendarDropTargetBounds, - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest.takeIf { isActive }, - onTodayJumpHandled = { requestId -> - if (isActive) clearTodayJumpRequest(requestId) - }, - onVisibleMonthChanged = { targetMonth -> - if (isActive && targetMonth >= minNavigableMonth) { - visibleMonthIso = targetMonth.toString() - } - }, - onSelectDate = { date -> - if (isActive) selectDate(date) - }, - onDropDateChanged = { date -> - if (isActive) activeDropDateIso = date?.toString() - }, - onMoveTaskToDate = { todo, date -> - if (isActive) requestTaskReschedule(todo, date) - }, - resolveTodo = resolveTodoForDrop, - ) - CalendarViewMode.WEEK -> CalendarWeekCard( - selectedDate = selectedDate, - minNavigableMonth = minNavigableMonth, - today = today, - tasksByDate = tasksByDate, - draggedTodo = draggedCalendarTodo.takeIf { isActive }, - activeDropDate = activeDropDate.takeIf { isActive }, - dropTargets = calendarDropTargetBounds, - canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest.takeIf { isActive }, - onTodayJumpHandled = { requestId -> - if (isActive) clearTodayJumpRequest(requestId) - }, - onSelectDate = { date -> - if (isActive) selectDate(date) - }, - onDropDateChanged = { date -> - if (isActive) activeDropDateIso = date?.toString() - }, - onMoveTaskToDate = { todo, date -> - if (isActive) requestTaskReschedule(todo, date) - }, - resolveTodo = resolveTodoForDrop, - ) + CalendarViewMode.WEEK -> CalendarWeekCard( + selectedDate = selectedDate, + minNavigableMonth = minNavigableMonth, + today = today, + tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, + dropTargets = calendarDropTargetBounds, + canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, + ) - CalendarViewMode.DAY -> CalendarDayCard( - selectedDate = selectedDate, - minNavigableMonth = minNavigableMonth, - today = today, - tasksByDate = tasksByDate, - canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest.takeIf { isActive }, - onTodayJumpHandled = { requestId -> - if (isActive) clearTodayJumpRequest(requestId) - }, - onSelectDate = { date -> - if (isActive) selectDate(date) - }, - ) - } - } + CalendarViewMode.DAY -> CalendarDayCard( + selectedDate = selectedDate, + minNavigableMonth = minNavigableMonth, + today = today, + tasksByDate = tasksByDate, + canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onSelectDate = ::selectDate, + ) } } } @@ -744,9 +652,7 @@ fun CalendarScreen( style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.ExtraBold, color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier - .offset(y = belowCalendarOffset) - .padding(horizontal = 4.dp), + modifier = Modifier.padding(horizontal = 4.dp), ) } @@ -771,7 +677,6 @@ fun CalendarScreen( easing = FastOutSlowInEasing, ), ) - .offset(y = belowCalendarOffset) .padding( bottom = if (index == selectedDatePendingTasks.lastIndex) { 0.dp @@ -817,7 +722,6 @@ fun CalendarScreen( com.ohmz.tday.compose.core.ui.ErrorRetryCard( message = message, onRetry = onRefresh, - modifier = Modifier.offset(y = belowCalendarOffset), ) } } @@ -950,12 +854,6 @@ private enum class CalendarViewMode { DAY, } -private fun calendarCardHeightFor(mode: CalendarViewMode) = when (mode) { - CalendarViewMode.MONTH -> CalendarMonthModeCardHeight - CalendarViewMode.WEEK, - CalendarViewMode.DAY -> CalendarPeriodModeCardHeight -} - @Composable private fun Modifier.calendarCardChrome(): Modifier { val colorScheme = MaterialTheme.colorScheme diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 828f30b6..292b64f6 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -104,11 +104,6 @@ private enum CalendarModeCardMetrics { private let calendarTodayTintColor = Color(red: 80.0 / 255.0, green: 154.0 / 255.0, blue: 230.0 / 255.0) private let calendarModeResizeAnimation = Animation.spring(response: 0.34, dampingFraction: 0.92, blendDuration: 0.02) -private let calendarModeContentFadeAnimation = Animation.easeInOut(duration: 0.12) -private let calendarModeBelowLeadAnimation = Animation.easeInOut(duration: 0.14) -private let calendarModeBelowTrailAnimation = Animation.spring(response: 0.30, dampingFraction: 0.9, blendDuration: 0.02) -private let calendarModeBelowLeadDelayNanoseconds: UInt64 = 110_000_000 -private let calendarModeBelowTrailDelayNanoseconds: UInt64 = 130_000_000 private struct CalendarCardChromeModifier: ViewModifier { @Environment(\.tdayColors) private var colors @@ -157,9 +152,6 @@ struct CalendarScreen: View { @State private var selectedDate = Date() @State private var visibleMonth = calendarMonthStart(for: Date()) @State private var displayMode: CalendarDisplayMode = .month - @State private var calendarLayoutMode: CalendarDisplayMode = .month - @State private var belowCalendarOffset: CGFloat = 0 - @State private var calendarModeTransitionTask: Task? @State private var showingCreateTask = false @State private var editingTodo: TodoItem? @State private var calendarTitleCollapseOffset: CGFloat = 0 @@ -196,7 +188,7 @@ struct CalendarScreen: View { } private var calendarModeCardHeight: CGFloat { - calendarModeCardHeight(for: calendarLayoutMode) + calendarModeCardHeight(for: displayMode) } private func calendarModeCardHeight(for mode: CalendarDisplayMode) -> CGFloat { @@ -276,7 +268,6 @@ struct CalendarScreen: View { ErrorRetryView(message: errorMessage) { Task { await viewModel.refresh() } } - .offset(y: belowCalendarOffset) .listRowBackground(Color.clear) } } @@ -285,7 +276,6 @@ struct CalendarScreen: View { .font(.tdayRounded(size: 22, weight: .heavy)) .foregroundStyle(colors.onSurface) .textCase(nil) - .offset(y: belowCalendarOffset) .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 4, trailing: TodoTimelineMetrics.horizontalPadding)) .timelinePinnedSectionHeaderBackground() @@ -326,7 +316,6 @@ struct CalendarScreen: View { .spring(response: 0.34, dampingFraction: 0.9), value: pendingItems.map(\.id) ) - .offset(y: belowCalendarOffset) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) @@ -353,10 +342,6 @@ struct CalendarScreen: View { cancelInAppDrag() } } - .onDisappear { - calendarModeTransitionTask?.cancel() - calendarModeTransitionTask = nil - } .overlay(alignment: .topLeading) { GeometryReader { proxy in if let inAppDrag { @@ -466,75 +451,24 @@ struct CalendarScreen: View { } private var animatedCalendarModeCard: some View { - ZStack(alignment: .top) { - ForEach(CalendarDisplayMode.allCases, id: \.self) { mode in - calendarModeContent(for: mode) - .transaction { transaction in - transaction.animation = nil - } - .opacity(displayMode == mode ? 1 : 0) - .animation(calendarModeContentFadeAnimation, value: displayMode) - .allowsHitTesting(displayMode == mode) - .accessibilityHidden(displayMode != mode) + calendarModeContent(for: displayMode) + .transaction { transaction in + transaction.animation = nil } - } - .frame(maxWidth: .infinity) - .frame(height: calendarModeCardHeight, alignment: .top) - .clipped() - .modifier(CalendarCardChromeModifier()) - .animation(calendarModeResizeAnimation, value: calendarModeCardHeight) + .frame(maxWidth: .infinity) + .frame(height: calendarModeCardHeight, alignment: .top) + .clipped() + .modifier(CalendarCardChromeModifier()) + .animation(calendarModeResizeAnimation, value: calendarModeCardHeight) } private func selectCalendarMode(_ mode: CalendarDisplayMode) { guard mode != displayMode else { return } - calendarModeTransitionTask?.cancel() - calendarModeTransitionTask = nil - - let currentHeight = calendarModeCardHeight(for: calendarLayoutMode) - let targetHeight = calendarModeCardHeight(for: mode) - let heightDelta = abs(currentHeight - targetHeight) - let isExpanding = targetHeight > currentHeight - let isCollapsing = targetHeight < currentHeight - displayMode = mode if mode != .month { visibleMonth = calendarMonthStart(for: selectedDate) } - - if heightDelta < 1 { - withAnimation(calendarModeBelowTrailAnimation) { - belowCalendarOffset = 0 - } - calendarLayoutMode = mode - return - } - - if isCollapsing { - withAnimation(calendarModeResizeAnimation) { - calendarLayoutMode = mode - belowCalendarOffset = heightDelta - } - calendarModeTransitionTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: calendarModeBelowTrailDelayNanoseconds) - guard !Task.isCancelled else { return } - withAnimation(calendarModeBelowTrailAnimation) { - belowCalendarOffset = 0 - } - } - } else if isExpanding { - withAnimation(calendarModeBelowLeadAnimation) { - belowCalendarOffset = heightDelta - } - calendarModeTransitionTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: calendarModeBelowLeadDelayNanoseconds) - guard !Task.isCancelled else { return } - withAnimation(calendarModeResizeAnimation) { - calendarLayoutMode = mode - belowCalendarOffset = 0 - } - } - } } private func isSelectedDay(_ date: Date) -> Bool { @@ -543,8 +477,6 @@ struct CalendarScreen: View { @ViewBuilder private func calendarModeContent(for mode: CalendarDisplayMode) -> some View { - let isActive = displayMode == mode - switch mode { case .month: CalendarMonthGrid( @@ -552,11 +484,11 @@ struct CalendarScreen: View { selectedDate: selectedDate, tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, - draggedTodo: isActive ? draggedTodo : nil, - activeDropDate: isActive ? activeDropDate : nil, + draggedTodo: draggedTodo, + activeDropDate: activeDropDate, canGoPreviousMonth: canGoPreviousMonth, minimumNavigableMonth: minimumNavigableMonth, - todayJumpRequest: isActive ? todayJumpRequest : nil, + todayJumpRequest: todayJumpRequest, onPreviousMonth: { navigateMonth(by: -1) }, onNextMonth: { navigateMonth(by: 1) }, onSelectDate: { selectDate($0) }, @@ -570,11 +502,11 @@ struct CalendarScreen: View { today: Date(), tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, - draggedTodo: isActive ? draggedTodo : nil, - activeDropDate: isActive ? activeDropDate : nil, + draggedTodo: draggedTodo, + activeDropDate: activeDropDate, canGoPreviousWeek: canGoPreviousWeek, canSelectDate: { canNavigate(to: $0) }, - todayJumpRequest: isActive ? todayJumpRequest : nil, + todayJumpRequest: todayJumpRequest, onPreviousWeek: { navigateDay(by: -7) }, onNextWeek: { navigateDay(by: 7) }, onSelectDate: { selectDate($0) }, @@ -590,7 +522,7 @@ struct CalendarScreen: View { accentColor: calendarAccentColor, canGoPreviousDay: canGoPreviousDay, canSelectDate: { canNavigate(to: $0) }, - todayJumpRequest: isActive ? todayJumpRequest : nil, + todayJumpRequest: todayJumpRequest, onPreviousDay: { navigateDay(by: -1) }, onNextDay: { navigateDay(by: 1) }, onSelectDate: { selectDate($0) } From 97690ff27b32755d1c9446e1e1ca9cb777ee8bba Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 17:02:34 -0400 Subject: [PATCH 13/19] Refactor calendar mode transition animations in `CalendarScreen`. This update improves the smoothness of switching between calendar display modes (e.g., month vs. week) by manually controlling the height transition. It decouples the container resizing from the content updates to prevent layout artifacts during mode changes. - **Animation & State Management**: - Introduce `@State private var calendarCardHeight` to explicitly manage and animate the calendar container's height. - Refactor `selectCalendarMode` to update `displayMode` and `visibleMonth` within a transaction that disables animations, preventing internal content flickering. - Use `withAnimation` to explicitly trigger the height change for the calendar card. - **UI Adjustments**: - Replace the computed `calendarModeCardHeight` property with the new state variable in the view's frame modifier. - Remove implicit `.animation` modifiers from the list and card content to avoid redundant or conflicting animation triggers. - Set `transaction.disablesAnimations = true` within the calendar content view to ensure a clean state transition before the height animation occurs. --- .../Feature/Calendar/CalendarScreen.swift | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 292b64f6..77a9d838 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -152,6 +152,7 @@ struct CalendarScreen: View { @State private var selectedDate = Date() @State private var visibleMonth = calendarMonthStart(for: Date()) @State private var displayMode: CalendarDisplayMode = .month + @State private var calendarCardHeight: CGFloat = CalendarModeCardMetrics.monthHeight @State private var showingCreateTask = false @State private var editingTodo: TodoItem? @State private var calendarTitleCollapseOffset: CGFloat = 0 @@ -187,10 +188,6 @@ struct CalendarScreen: View { return formatter.string(from: selectedDate) } - private var calendarModeCardHeight: CGFloat { - calendarModeCardHeight(for: displayMode) - } - private func calendarModeCardHeight(for mode: CalendarDisplayMode) -> CGFloat { switch mode { case .month: @@ -331,7 +328,6 @@ struct CalendarScreen: View { .listRowSpacing(0) .listSectionSpacing(0) .environment(\.defaultMinListRowHeight, 1) - .animation(calendarModeResizeAnimation, value: calendarModeCardHeight) .disableVerticalScrollBounce() .background(colors.background) .onPreferenceChange(CalendarDateDropTargetFramePreferenceKey.self) { frames in @@ -454,20 +450,28 @@ struct CalendarScreen: View { calendarModeContent(for: displayMode) .transaction { transaction in transaction.animation = nil + transaction.disablesAnimations = true } .frame(maxWidth: .infinity) - .frame(height: calendarModeCardHeight, alignment: .top) + .frame(height: calendarCardHeight, alignment: .top) .clipped() .modifier(CalendarCardChromeModifier()) - .animation(calendarModeResizeAnimation, value: calendarModeCardHeight) } private func selectCalendarMode(_ mode: CalendarDisplayMode) { guard mode != displayMode else { return } - displayMode = mode - if mode != .month { - visibleMonth = calendarMonthStart(for: selectedDate) + var contentTransaction = Transaction() + contentTransaction.disablesAnimations = true + withTransaction(contentTransaction) { + displayMode = mode + if mode != .month { + visibleMonth = calendarMonthStart(for: selectedDate) + } + } + + withAnimation(calendarModeResizeAnimation) { + calendarCardHeight = calendarModeCardHeight(for: mode) } } From c102628afcc975d77a9db89d6e97258703c9a7c8 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 23:14:22 -0400 Subject: [PATCH 14/19] Refactor UI component logic and optimize performance across Android (Compose) and iOS (SwiftUI) platforms. ### Android (Compose) - **Shared UI Logic**: - Introduce `CollapsingTitleScrollBehavior` and its variants (`rememberLazyListCollapsingTitleScrollBehavior`, `rememberScrollCollapsingTitleScrollBehavior`) to centralize collapsing top bar logic, replacing redundant `NestedScrollConnection` implementations. - Introduce `TaskSwipeRevealState` and `animateTaskSwipeOffsetAsState` to encapsulate swipe-to-action logic, state management (hinting, dragging, settling), and constants. - **Screen Refactors**: - Migrate `HomeScreen`, `TodoListScreen`, `CompletedScreen`, `SettingsScreen`, and `LatestReleaseScreen` to use the new shared scroll and swipe behavior utilities. - Improve drop target detection in `TodoListScreen` to avoid redundant state updates. ### iOS (SwiftUI) - **Performance & Optimization**: - Centralize `DateFormatter` and `ISO8601DateFormatter` instances within private `enum` containers (e.g., `TodoTimelineFormatters`, `ReleaseDateFormatters`) to prevent expensive re-allocation during list rendering. - Refactor `HomeTodayTaskRow` to use a new `.todoTrailingSwipeActions()` modifier, significantly simplifying the view body. - **UX Refinements**: - Improve `SwipeActions` gesture detection by adding a horizontal activation bias and tuning spring animations for a more native feel. - Enhance `PullToRefresh` snapping logic with velocity thresholds and smoother spring transitions. - Fix a bug where swipe actions remained open when the component was disabled. --- .../core/ui/CollapsingTitleScrollBehavior.kt | 189 ++++++++++++++++++ .../compose/core/ui/TaskSwipeRevealState.kt | 101 ++++++++++ .../feature/completed/CompletedScreen.kt | 131 ++---------- .../tday/compose/feature/home/HomeScreen.kt | 45 ++--- .../feature/release/LatestReleaseScreen.kt | 81 +------- .../feature/settings/SettingsScreen.kt | 83 +------- .../compose/feature/todos/TodoListScreen.kt | 153 +++----------- .../Feature/Completed/CompletedScreen.swift | 14 +- .../Tday/Feature/Home/HomeScreen.swift | 127 +----------- .../Feature/Settings/SettingsScreen.swift | 23 ++- .../Tday/Feature/Todos/TodoListScreen.swift | 49 ++++- .../Tday/UI/Component/PullToRefresh.swift | 27 ++- .../Tday/UI/Component/SwipeActions.swift | 23 ++- 13 files changed, 475 insertions(+), 571 deletions(-) create mode 100644 android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/CollapsingTitleScrollBehavior.kt create mode 100644 android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TaskSwipeRevealState.kt diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/CollapsingTitleScrollBehavior.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/CollapsingTitleScrollBehavior.kt new file mode 100644 index 00000000..fc0846de --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/CollapsingTitleScrollBehavior.kt @@ -0,0 +1,189 @@ +package com.ohmz.tday.compose.core.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity + +private const val COLLAPSE_SNAP_THRESHOLD = 0.5f +private const val COLLAPSE_FLING_VELOCITY_THRESHOLD = 120f + +@Stable +class CollapsingTitleScrollBehavior internal constructor( + val collapseProgress: Float, + val collapsePx: Float, + val nestedScrollConnection: NestedScrollConnection, + private val maxCollapsePx: Float, + private val setCollapsePx: (Float) -> Unit, +) { + fun collapseFully() { + setCollapsePx(maxCollapsePx) + } + + fun expandFully() { + setCollapsePx(0f) + } +} + +@Composable +fun rememberLazyListCollapsingTitleScrollBehavior( + listState: LazyListState, + maxCollapseDistance: Dp, + enabled: Boolean = true, + label: String = "titleCollapseProgress", +): CollapsingTitleScrollBehavior { + return rememberCollapsingTitleScrollBehavior( + maxCollapseDistance = maxCollapseDistance, + enabled = enabled, + isScrollInProgress = listState.isScrollInProgress, + isContentAtTop = { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + }, + label = label, + ) +} + +@Composable +fun rememberScrollCollapsingTitleScrollBehavior( + scrollState: ScrollState, + maxCollapseDistance: Dp, + enabled: Boolean = true, + label: String = "titleCollapseProgress", +): CollapsingTitleScrollBehavior { + return rememberCollapsingTitleScrollBehavior( + maxCollapseDistance = maxCollapseDistance, + enabled = enabled, + isScrollInProgress = scrollState.isScrollInProgress, + isContentAtTop = { scrollState.value == 0 }, + label = label, + ) +} + +@Composable +private fun rememberCollapsingTitleScrollBehavior( + maxCollapseDistance: Dp, + enabled: Boolean, + isScrollInProgress: Boolean, + isContentAtTop: () -> Boolean, + label: String, +): CollapsingTitleScrollBehavior { + val density = LocalDensity.current + val maxCollapsePx = with(density) { maxCollapseDistance.toPx() } + var collapsePx by rememberSaveable { mutableFloatStateOf(0f) } + + val nestedScrollConnection = remember(enabled, maxCollapsePx) { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (!enabled || maxCollapsePx <= 0f) return Offset.Zero + + val deltaY = available.y + if (deltaY < 0f) { + val previous = collapsePx + val next = (previous - deltaY).coerceIn(0f, maxCollapsePx) + val consumed = next - previous + if (consumed > 0f) { + collapsePx = next + return Offset(0f, -consumed) + } + return Offset.Zero + } + + if (deltaY > 0f) { + if (!isContentAtTop()) return Offset.Zero + val previous = collapsePx + val next = (previous - deltaY).coerceIn(0f, maxCollapsePx) + val consumed = previous - next + if (consumed > 0f) { + collapsePx = next + return Offset(0f, consumed) + } + } + + return Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (!enabled || maxCollapsePx <= 0f) return Velocity.Zero + if (available.y > 0f && !isContentAtTop()) return Velocity.Zero + + val snapped = smoothTitleCollapseSnapPx( + currentPx = collapsePx, + maxPx = maxCollapsePx, + velocityY = available.y, + ) + if (snapped == collapsePx) return Velocity.Zero + + collapsePx = snapped + return if (available.y == 0f) Velocity.Zero else available + } + } + } + + LaunchedEffect(enabled, isScrollInProgress, collapsePx, maxCollapsePx) { + if (!enabled || + isScrollInProgress || + collapsePx <= 0f || + collapsePx >= maxCollapsePx + ) { + return@LaunchedEffect + } + + collapsePx = if (isContentAtTop()) { + smoothTitleCollapseSnapPx(collapsePx, maxCollapsePx) + } else { + maxCollapsePx + } + } + + val collapseProgressTarget = if (enabled && maxCollapsePx > 0f) { + (collapsePx / maxCollapsePx).coerceIn(0f, 1f) + } else { + 0f + } + val collapseProgress by animateFloatAsState( + targetValue = collapseProgressTarget, + animationSpec = spring( + dampingRatio = 0.92f, + stiffness = Spring.StiffnessMediumLow, + ), + label = label, + ) + + return CollapsingTitleScrollBehavior( + collapseProgress = collapseProgress, + collapsePx = collapsePx, + nestedScrollConnection = nestedScrollConnection, + maxCollapsePx = maxCollapsePx, + setCollapsePx = { nextCollapsePx -> + collapsePx = nextCollapsePx.coerceIn(0f, maxCollapsePx) + }, + ) +} + +private fun smoothTitleCollapseSnapPx( + currentPx: Float, + maxPx: Float, + velocityY: Float = 0f, +): Float { + if (maxPx <= 0f) return 0f + val bounded = currentPx.coerceIn(0f, maxPx) + if (bounded <= 0f || bounded >= maxPx) return bounded + if (velocityY < -COLLAPSE_FLING_VELOCITY_THRESHOLD) return maxPx + if (velocityY > COLLAPSE_FLING_VELOCITY_THRESHOLD) return 0f + return if (bounded / maxPx >= COLLAPSE_SNAP_THRESHOLD) maxPx else 0f +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TaskSwipeRevealState.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TaskSwipeRevealState.kt new file mode 100644 index 00000000..adfc758c --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/ui/TaskSwipeRevealState.kt @@ -0,0 +1,101 @@ +package com.ohmz.tday.compose.core.ui + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay + +private const val SWIPE_OPEN_VELOCITY_PX_PER_SECOND = -1450f +private const val SWIPE_OPEN_THRESHOLD_FRACTION = 0.32f +private const val SWIPE_MAX_ELASTIC_FRACTION = 1.14f +private const val SWIPE_HINT_MS = 150L +private const val SWIPE_HINT_SETTLE_MS = 360L + +@Stable +class TaskSwipeRevealState internal constructor( + private val revealWidthPx: Float, + private val hintOffsetPx: Float, + private val maxElasticDragPx: Float, +) { + var targetOffsetX by mutableFloatStateOf(0f) + private set + + var isHinting by mutableStateOf(false) + private set + + val isOpenOrDragging: Boolean + get() = targetOffsetX != 0f + + fun dragBy(deltaPx: Float) { + targetOffsetX = (targetOffsetX + deltaPx).coerceIn(-maxElasticDragPx, 0f) + } + + fun settle(velocityPxPerSecond: Float) { + val flingOpen = velocityPxPerSecond < SWIPE_OPEN_VELOCITY_PX_PER_SECOND + val dragOpen = targetOffsetX < -(revealWidthPx * SWIPE_OPEN_THRESHOLD_FRACTION) + targetOffsetX = if (flingOpen || dragOpen) -revealWidthPx else 0f + } + + fun close() { + targetOffsetX = 0f + } + + suspend fun playHint() { + if (isHinting) return + isHinting = true + targetOffsetX = -hintOffsetPx + delay(SWIPE_HINT_MS) + targetOffsetX = 0f + delay(SWIPE_HINT_SETTLE_MS) + isHinting = false + } + + fun revealProgress(offsetX: Float): Float { + return (-offsetX / revealWidthPx).coerceIn(0f, 1f) + } +} + +@Composable +fun rememberTaskSwipeRevealState( + key: Any?, + revealWidth: Dp = 176.dp, + hintOffset: Dp = 42.dp, +): TaskSwipeRevealState { + val density = LocalDensity.current + val revealWidthPx = with(density) { revealWidth.toPx() } + val hintOffsetPx = with(density) { + hintOffset.toPx().coerceAtMost(revealWidthPx * 0.24f) + } + val maxElasticDragPx = revealWidthPx * SWIPE_MAX_ELASTIC_FRACTION + + return remember(key, revealWidthPx, hintOffsetPx, maxElasticDragPx) { + TaskSwipeRevealState( + revealWidthPx = revealWidthPx, + hintOffsetPx = hintOffsetPx, + maxElasticDragPx = maxElasticDragPx, + ) + } +} + +@Composable +fun animateTaskSwipeOffsetAsState( + state: TaskSwipeRevealState, + label: String, +): State { + return animateFloatAsState( + targetValue = state.targetOffsetX, + animationSpec = spring(stiffness = Spring.StiffnessLow), + label = label, + ) +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt index 9da9adbf..113af4af 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt @@ -2,10 +2,8 @@ package com.ohmz.tday.compose.feature.completed import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -66,9 +64,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -83,15 +79,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.core.view.HapticFeedbackConstantsCompat @@ -104,7 +97,9 @@ import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.ui.EmptyTaskBackgroundMessage import com.ohmz.tday.compose.core.ui.EmptyTaskWatermark import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton -import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx +import com.ohmz.tday.compose.core.ui.animateTaskSwipeOffsetAsState +import com.ohmz.tday.compose.core.ui.rememberLazyListCollapsingTitleScrollBehavior +import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay @@ -156,79 +151,11 @@ fun CompletedScreen( val timelineSections = remember(uiState.items) { buildCompletedTimelineSections(uiState.items) } - val density = LocalDensity.current - val maxCollapsePx = with(density) { COMPLETED_TITLE_COLLAPSE_DISTANCE_DP.dp.toPx() } - var headerCollapsePx by rememberSaveable { mutableFloatStateOf(0f) } - val collapseProgressTarget = if (maxCollapsePx > 0f) { - (headerCollapsePx / maxCollapsePx).coerceIn(0f, 1f) - } else { - 0f - } - val nestedScrollConnection = remember(listState, maxCollapsePx) { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val deltaY = available.y - if (deltaY < 0f) { - val previous = headerCollapsePx - val next = (previous - deltaY).coerceIn(0f, maxCollapsePx) - val consumed = next - previous - if (consumed > 0f) { - headerCollapsePx = next - return Offset(0f, -consumed) - } - return Offset.Zero - } - - if (deltaY > 0f) { - val isListAtTop = - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - if (!isListAtTop) return Offset.Zero - val previous = headerCollapsePx - val next = (previous - deltaY).coerceIn(0f, maxCollapsePx) - val consumed = previous - next - if (consumed > 0f) { - headerCollapsePx = next - return Offset(0f, consumed) - } - } - return Offset.Zero - } - - override suspend fun onPreFling(available: Velocity): Velocity { - val isListAtTop = - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - if (available.y > 0f && !isListAtTop) return Velocity.Zero - val snapped = snapTitleCollapsePx( - currentPx = headerCollapsePx, - maxPx = maxCollapsePx, - velocityY = available.y, - ) - if (snapped == headerCollapsePx) return Velocity.Zero - headerCollapsePx = snapped - return if (available.y == 0f) Velocity.Zero else available - } - } - } - val collapseProgress by animateFloatAsState( - targetValue = collapseProgressTarget, + val titleScrollBehavior = rememberLazyListCollapsingTitleScrollBehavior( + listState = listState, + maxCollapseDistance = COMPLETED_TITLE_COLLAPSE_DISTANCE_DP.dp, label = "completedTitleCollapseProgress", ) - LaunchedEffect( - listState.isScrollInProgress, - headerCollapsePx, - maxCollapsePx, - ) { - if (listState.isScrollInProgress || headerCollapsePx <= 0f || headerCollapsePx >= maxCollapsePx) { - return@LaunchedEffect - } - val isListAtTop = - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - headerCollapsePx = if (isListAtTop) { - snapTitleCollapsePx(headerCollapsePx, maxCollapsePx) - } else { - maxCollapsePx - } - } var collapsedSectionKeys by rememberSaveable { mutableStateOf(emptySet()) } @@ -242,7 +169,7 @@ fun CompletedScreen( topBar = { CompletedTopBar( onBack = onBack, - collapseProgress = collapseProgress, + collapseProgress = titleScrollBehavior.collapseProgress, ) }, ) { padding -> @@ -257,7 +184,7 @@ fun CompletedScreen( LazyColumn( modifier = Modifier .fillMaxSize() - .nestedScroll(nestedScrollConnection), + .nestedScroll(titleScrollBehavior.nestedScrollConnection), state = listState, contentPadding = PaddingValues(horizontal = 18.dp, vertical = 2.dp), verticalArrangement = Arrangement.spacedBy(0.dp), @@ -591,21 +518,15 @@ private fun CompletedSwipeRow( ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current - val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - val actionRevealPx = with(density) { 176.dp.toPx() } - val swipeHintOffsetPx = with(density) { 42.dp.toPx() }.coerceAtMost(actionRevealPx * 0.24f) - val maxElasticDragPx = actionRevealPx * 1.14f - var targetOffsetX by remember(item.id) { mutableFloatStateOf(0f) } - var swipeHinting by remember(item.id) { mutableStateOf(false) } + val swipeRevealState = rememberTaskSwipeRevealState(item.id) var restorePhase by remember(item.id) { mutableStateOf(CompletedRestorePhase.Completed) } var titleLayoutResult by remember(item.id) { mutableStateOf(null) } - val animatedOffsetX by animateFloatAsState( - targetValue = targetOffsetX, - animationSpec = spring(stiffness = Spring.StiffnessLow), + val animatedOffsetX by animateTaskSwipeOffsetAsState( + state = swipeRevealState, label = "completedSwipeOffset", ) - val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) + val actionRevealProgress = swipeRevealState.revealProgress(animatedOffsetX) val showCompletedCheckmark = restorePhase == CompletedRestorePhase.Completed val showStrikethrough = restorePhase == CompletedRestorePhase.Completed || restorePhase == CompletedRestorePhase.Unchecked @@ -699,7 +620,7 @@ private fun CompletedSwipeRow( HapticFeedbackConstantsCompat.CLOCK_TICK, ) onInfo() - targetOffsetX = 0f + swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -716,7 +637,7 @@ private fun CompletedSwipeRow( HapticFeedbackConstantsCompat.CLOCK_TICK, ) onDelete() - targetOffsetX = 0f + swipeRevealState.close() }, ) } @@ -728,31 +649,21 @@ private fun CompletedSwipeRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> - targetOffsetX = (targetOffsetX + delta).coerceIn( - -maxElasticDragPx, - 0f, - ) + swipeRevealState.dragBy(delta) }, onDragStopped = { velocity -> - val flingOpen = velocity < -1450f - val dragOpen = targetOffsetX < -(actionRevealPx * 0.32f) - targetOffsetX = if (flingOpen || dragOpen) -actionRevealPx else 0f + swipeRevealState.settle(velocity) }, ) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { - if (targetOffsetX != 0f) { - targetOffsetX = 0f - } else if (!swipeHinting && !isRestoring) { - swipeHinting = true + if (swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } else if (!swipeRevealState.isHinting && !isRestoring) { coroutineScope.launch { - targetOffsetX = -swipeHintOffsetPx - delay(150) - targetOffsetX = 0f - delay(360) - swipeHinting = false + swipeRevealState.playHint() } } }, @@ -784,7 +695,7 @@ private fun CompletedSwipeRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) - targetOffsetX = 0f + swipeRevealState.close() coroutineScope.launch { restorePhase = CompletedRestorePhase.Unchecked delay(COMPLETED_RESTORE_STEP_MS) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 48df85d1..677d90b6 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt @@ -152,7 +152,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -207,6 +206,8 @@ import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton +import com.ohmz.tday.compose.core.ui.animateTaskSwipeOffsetAsState +import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox import com.ohmz.tday.compose.ui.theme.TdayDimens @@ -1715,23 +1716,17 @@ private fun HomeTodayTaskRow( onDelete: () -> Unit, onEdit: () -> Unit, ) { - val density = LocalDensity.current val colorScheme = MaterialTheme.colorScheme val view = LocalView.current val coroutineScope = rememberCoroutineScope() - val actionRevealPx = with(density) { 176.dp.toPx() } - val swipeHintOffsetPx = with(density) { 42.dp.toPx() }.coerceAtMost(actionRevealPx * 0.24f) - val maxElasticDragPx = actionRevealPx * 1.14f - var targetOffsetX by remember(todo.id) { mutableFloatStateOf(0f) } - var swipeHinting by remember(todo.id) { mutableStateOf(false) } + val swipeRevealState = rememberTaskSwipeRevealState(todo.id) var localChecked by remember(todo.id) { mutableStateOf(false) } var localStruck by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } var completionFading by remember(todo.id) { mutableStateOf(false) } var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } - val animatedOffsetX by animateFloatAsState( - targetValue = targetOffsetX, - animationSpec = spring(stiffness = androidx.compose.animation.core.Spring.StiffnessLow), + val animatedOffsetX by animateTaskSwipeOffsetAsState( + state = swipeRevealState, label = "homeTodaySwipeOffset", ) val completionAlpha by animateFloatAsState( @@ -1749,7 +1744,7 @@ private fun HomeTodayTaskRow( animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), label = "homeTodayTitleStrikeProgress", ) - val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) + val actionRevealProgress = swipeRevealState.revealProgress(animatedOffsetX) val dueText = HOME_TODAY_DUE_FORMATTER.format(todo.due) val rowShape = RoundedCornerShape(16.dp) val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } @@ -1800,7 +1795,7 @@ private fun HomeTodayTaskRow( HapticFeedbackConstantsCompat.CLOCK_TICK ) onEdit() - targetOffsetX = 0f + swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -1817,7 +1812,7 @@ private fun HomeTodayTaskRow( HapticFeedbackConstantsCompat.CLOCK_TICK ) onDelete() - targetOffsetX = 0f + swipeRevealState.close() }, ) } @@ -1829,31 +1824,21 @@ private fun HomeTodayTaskRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> - targetOffsetX = (targetOffsetX + delta).coerceIn(-maxElasticDragPx, 0f) + swipeRevealState.dragBy(delta) }, onDragStopped = { velocity -> - targetOffsetX = - if (velocity < -1450f || targetOffsetX < -(actionRevealPx * 0.32f)) { - -actionRevealPx - } else { - 0f - } + swipeRevealState.settle(velocity) }, ) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { - if (targetOffsetX != 0f) { - targetOffsetX = 0f - } else if (!swipeHinting && !pendingCompletion) { - swipeHinting = true + if (swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } else if (!swipeRevealState.isHinting && !pendingCompletion) { coroutineScope.launch { - targetOffsetX = -swipeHintOffsetPx - delay(150) - targetOffsetX = 0f - delay(360) - swipeHinting = false + swipeRevealState.playHint() } } }, @@ -1879,7 +1864,7 @@ private fun HomeTodayTaskRow( enabled = !pendingCompletion, ) { if (!pendingCompletion) { - targetOffsetX = 0f + swipeRevealState.close() localChecked = true pendingCompletion = true coroutineScope.launch { diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/release/LatestReleaseScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/release/LatestReleaseScreen.kt index 21c12dc6..26ac764f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/release/LatestReleaseScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/release/LatestReleaseScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -60,19 +59,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.core.view.HapticFeedbackConstantsCompat @@ -83,7 +78,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ohmz.tday.compose.R import com.ohmz.tday.compose.core.data.server.VersionCheckResult -import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx +import com.ohmz.tday.compose.core.ui.rememberScrollCollapsingTitleScrollBehavior import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.launch import java.io.IOException @@ -102,75 +97,11 @@ fun LatestReleaseScreen( val context = LocalContext.current val view = LocalView.current val scrollState = rememberScrollState() - val density = LocalDensity.current - val maxCollapsePx = with(density) { RELEASE_TITLE_COLLAPSE_DISTANCE_DP.dp.toPx() } - var headerCollapsePx by rememberSaveable { mutableFloatStateOf(0f) } - val collapseProgressTarget = if (maxCollapsePx > 0f) { - (headerCollapsePx / maxCollapsePx).coerceIn(0f, 1f) - } else { - 0f - } - val nestedScrollConnection = remember(scrollState, maxCollapsePx) { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val deltaY = available.y - if (deltaY < 0f) { - val previous = headerCollapsePx - val next = (previous - deltaY).coerceIn(0f, maxCollapsePx) - val consumed = next - previous - if (consumed > 0f) { - headerCollapsePx = next - return Offset(0f, -consumed) - } - return Offset.Zero - } - - if (deltaY > 0f) { - if (scrollState.value > 0) return Offset.Zero - val previous = headerCollapsePx - val next = (previous - deltaY).coerceIn(0f, maxCollapsePx) - val consumed = previous - next - if (consumed > 0f) { - headerCollapsePx = next - return Offset(0f, consumed) - } - } - - return Offset.Zero - } - - override suspend fun onPreFling(available: Velocity): Velocity { - if (available.y > 0f && scrollState.value > 0) return Velocity.Zero - val snapped = snapTitleCollapsePx( - currentPx = headerCollapsePx, - maxPx = maxCollapsePx, - velocityY = available.y, - ) - if (snapped == headerCollapsePx) return Velocity.Zero - headerCollapsePx = snapped - return if (available.y == 0f) Velocity.Zero else available - } - } - } - val collapseProgress by animateFloatAsState( - targetValue = collapseProgressTarget, + val titleScrollBehavior = rememberScrollCollapsingTitleScrollBehavior( + scrollState = scrollState, + maxCollapseDistance = RELEASE_TITLE_COLLAPSE_DISTANCE_DP.dp, label = "releaseTitleCollapseProgress", ) - LaunchedEffect( - scrollState.isScrollInProgress, - headerCollapsePx, - maxCollapsePx, - scrollState.value, - ) { - if (scrollState.isScrollInProgress || headerCollapsePx <= 0f || headerCollapsePx >= maxCollapsePx) { - return@LaunchedEffect - } - headerCollapsePx = if (scrollState.value == 0) { - snapTitleCollapsePx(headerCollapsePx, maxCollapsePx) - } else { - maxCollapsePx - } - } val installScope = rememberCoroutineScope() val installerEvent by InAppApkUpdater.installEvent.collectAsStateWithLifecycle() var installUiState by remember { mutableStateOf(ApkInstallUiState.Idle) } @@ -243,7 +174,7 @@ fun LatestReleaseScreen( topBar = { ReleaseTopBar( onBack = onBack, - collapseProgress = collapseProgress, + collapseProgress = titleScrollBehavior.collapseProgress, ) }, ) { padding -> @@ -252,7 +183,7 @@ fun LatestReleaseScreen( .fillMaxSize() .padding(padding) .background(colorScheme.background) - .nestedScroll(nestedScrollConnection) + .nestedScroll(titleScrollBehavior.nestedScrollConnection) .verticalScroll(scrollState) .padding(horizontal = 18.dp, vertical = 2.dp), verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt index 0c469c31..c543a796 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt @@ -42,29 +42,22 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.core.view.HapticFeedbackConstantsCompat @@ -74,7 +67,7 @@ import com.ohmz.tday.compose.R import com.ohmz.tday.compose.core.data.server.VersionCheckResult import com.ohmz.tday.compose.core.model.SessionUser import com.ohmz.tday.compose.core.notification.ReminderOption -import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx +import com.ohmz.tday.compose.core.ui.rememberScrollCollapsingTitleScrollBehavior import com.ohmz.tday.compose.ui.component.TdaySegmentedSlider import com.ohmz.tday.compose.ui.theme.AppThemeMode import com.ohmz.tday.compose.ui.theme.TdayDimens @@ -104,82 +97,18 @@ fun SettingsScreen( val colorScheme = MaterialTheme.colorScheme val isAdminUser = user?.role?.equals("ADMIN", ignoreCase = true) == true val scrollState = rememberScrollState() - val density = LocalDensity.current - val maxCollapsePx = with(density) { SETTINGS_TITLE_COLLAPSE_DISTANCE_DP.dp.toPx() } - var headerCollapsePx by rememberSaveable { mutableFloatStateOf(0f) } - val collapseProgressTarget = if (maxCollapsePx > 0f) { - (headerCollapsePx / maxCollapsePx).coerceIn(0f, 1f) - } else { - 0f - } - val nestedScrollConnection = remember(scrollState, maxCollapsePx) { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - val deltaY = available.y - if (deltaY < 0f) { - val previous = headerCollapsePx - val next = (previous - deltaY).coerceIn(0f, maxCollapsePx) - val consumed = next - previous - if (consumed > 0f) { - headerCollapsePx = next - return Offset(0f, -consumed) - } - return Offset.Zero - } - - if (deltaY > 0f) { - if (scrollState.value > 0) return Offset.Zero - val previous = headerCollapsePx - val next = (previous - deltaY).coerceIn(0f, maxCollapsePx) - val consumed = previous - next - if (consumed > 0f) { - headerCollapsePx = next - return Offset(0f, consumed) - } - } - - return Offset.Zero - } - - override suspend fun onPreFling(available: Velocity): Velocity { - if (available.y > 0f && scrollState.value > 0) return Velocity.Zero - val snapped = snapTitleCollapsePx( - currentPx = headerCollapsePx, - maxPx = maxCollapsePx, - velocityY = available.y, - ) - if (snapped == headerCollapsePx) return Velocity.Zero - headerCollapsePx = snapped - return if (available.y == 0f) Velocity.Zero else available - } - } - } - val collapseProgress by animateFloatAsState( - targetValue = collapseProgressTarget, + val titleScrollBehavior = rememberScrollCollapsingTitleScrollBehavior( + scrollState = scrollState, + maxCollapseDistance = SETTINGS_TITLE_COLLAPSE_DISTANCE_DP.dp, label = "settingsTitleCollapseProgress", ) - LaunchedEffect( - scrollState.isScrollInProgress, - headerCollapsePx, - maxCollapsePx, - scrollState.value, - ) { - if (scrollState.isScrollInProgress || headerCollapsePx <= 0f || headerCollapsePx >= maxCollapsePx) { - return@LaunchedEffect - } - headerCollapsePx = if (scrollState.value == 0) { - snapTitleCollapsePx(headerCollapsePx, maxCollapsePx) - } else { - maxCollapsePx - } - } Scaffold( containerColor = colorScheme.background, topBar = { SettingsTopBar( onBack = onBack, - collapseProgress = collapseProgress, + collapseProgress = titleScrollBehavior.collapseProgress, ) }, ) { padding -> @@ -188,7 +117,7 @@ fun SettingsScreen( .fillMaxSize() .padding(padding) .background(colorScheme.background) - .nestedScroll(nestedScrollConnection) + .nestedScroll(titleScrollBehavior.nestedScrollConnection) .verticalScroll(scrollState) .padding(horizontal = 18.dp, vertical = 2.dp), verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt index 083dc34c..c7353226 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/todos/TodoListScreen.kt @@ -3,10 +3,8 @@ package com.ohmz.tday.compose.feature.todos import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi @@ -149,7 +147,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -174,8 +171,6 @@ import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -192,7 +187,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.window.Dialog @@ -213,7 +207,9 @@ import com.ohmz.tday.compose.core.model.timelineRescheduleTargetDate import com.ohmz.tday.compose.core.ui.EmptyTaskBackgroundMessage import com.ohmz.tday.compose.core.ui.EmptyTaskWatermark import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton -import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx +import com.ohmz.tday.compose.core.ui.animateTaskSwipeOffsetAsState +import com.ohmz.tday.compose.core.ui.rememberLazyListCollapsingTitleScrollBehavior +import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay @@ -320,86 +316,12 @@ fun TodoListScreen( uiState.mode != TodoListMode.TODAY || timelineAnimationsReady val listState = rememberLazyListState() val density = LocalDensity.current - val maxTodayCollapsePx = with(density) { TODAY_TITLE_COLLAPSE_DISTANCE_DP.dp.toPx() } - var todayHeaderCollapsePx by rememberSaveable { mutableFloatStateOf(0f) } - val todayCollapseProgressTarget = if (usesTodayStyle && maxTodayCollapsePx > 0f) { - (todayHeaderCollapsePx / maxTodayCollapsePx).coerceIn(0f, 1f) - } else { - 0f - } - val todayNestedScrollConnection = remember(usesTodayStyle, listState, maxTodayCollapsePx) { - object : NestedScrollConnection { - override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (!usesTodayStyle) return Offset.Zero - val deltaY = available.y - if (deltaY < 0f) { - val previous = todayHeaderCollapsePx - val next = (previous - deltaY).coerceIn(0f, maxTodayCollapsePx) - val consumed = next - previous - if (consumed > 0f) { - todayHeaderCollapsePx = next - return Offset(0f, -consumed) - } - return Offset.Zero - } - - if (deltaY > 0f) { - val isListAtTop = - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - if (!isListAtTop) return Offset.Zero - val previous = todayHeaderCollapsePx - val next = (previous - deltaY).coerceIn(0f, maxTodayCollapsePx) - val consumed = previous - next - if (consumed > 0f) { - todayHeaderCollapsePx = next - return Offset(0f, consumed) - } - } - - return Offset.Zero - } - - override suspend fun onPreFling(available: Velocity): Velocity { - if (!usesTodayStyle) return Velocity.Zero - val isListAtTop = - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - if (available.y > 0f && !isListAtTop) return Velocity.Zero - val snapped = snapTitleCollapsePx( - currentPx = todayHeaderCollapsePx, - maxPx = maxTodayCollapsePx, - velocityY = available.y, - ) - if (snapped == todayHeaderCollapsePx) return Velocity.Zero - todayHeaderCollapsePx = snapped - return if (available.y == 0f) Velocity.Zero else available - } - } - } - val todayCollapseProgress by animateFloatAsState( - targetValue = todayCollapseProgressTarget, + val todayTitleScrollBehavior = rememberLazyListCollapsingTitleScrollBehavior( + listState = listState, + maxCollapseDistance = TODAY_TITLE_COLLAPSE_DISTANCE_DP.dp, + enabled = usesTodayStyle, label = "todayTitleCollapseProgress", ) - LaunchedEffect( - usesTodayStyle, - listState.isScrollInProgress, - todayHeaderCollapsePx, - maxTodayCollapsePx, - ) { - if (!usesTodayStyle || - listState.isScrollInProgress || - todayHeaderCollapsePx <= 0f || - todayHeaderCollapsePx >= maxTodayCollapsePx - ) { - return@LaunchedEffect - } - val isListAtTop = - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 - todayHeaderCollapsePx = if (isListAtTop) { - snapTitleCollapsePx(todayHeaderCollapsePx, maxTodayCollapsePx) - } else { - maxTodayCollapsePx - } - } val isCollapsibleTimelineMode = uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || @@ -496,7 +418,7 @@ fun TodoListScreen( if (uiState.mode != TodoListMode.ALL || highlightedTodoId.isNullOrBlank()) return@LaunchedEffect val target = highlightedTodoListTarget(highlightedTodoId) if (target != null) { - todayHeaderCollapsePx = maxTodayCollapsePx + todayTitleScrollBehavior.collapseFully() delay(SEARCH_RESULT_NAV_SETTLE_DELAY_MS) val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset @@ -562,7 +484,10 @@ fun TodoListScreen( fun updateActiveTimelineDropTarget(position: Offset) { val todo = activeTimelineDrag?.todo ?: draggedScheduledTodo - activeDropSectionKey = todo?.let { timelineDropSectionKeyAt(position, it) } + val nextSectionKey = todo?.let { timelineDropSectionKeyAt(position, it) } + if (activeDropSectionKey != nextSectionKey) { + activeDropSectionKey = nextSectionKey + } } fun finishTimelineDrag(position: Offset?) { @@ -589,7 +514,7 @@ fun TodoListScreen( if (usesTodayStyle) { TodayTopBar( onBack = onBack, - collapseProgress = todayCollapseProgress, + collapseProgress = todayTitleScrollBehavior.collapseProgress, title = uiState.title, titleColor = titleColor, showActionButton = showTopBarActionButton, @@ -674,7 +599,7 @@ fun TodoListScreen( .fillMaxSize() .then( if (usesTodayStyle) { - Modifier.nestedScroll(todayNestedScrollConnection) + Modifier.nestedScroll(todayTitleScrollBehavior.nestedScrollConnection) } else { Modifier }, @@ -2922,13 +2847,8 @@ private fun SwipeTaskRow( ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current - val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - val actionRevealPx = with(density) { 176.dp.toPx() } - val swipeHintOffsetPx = with(density) { 42.dp.toPx() }.coerceAtMost(actionRevealPx * 0.24f) - val maxElasticDragPx = actionRevealPx * 1.14f - var targetOffsetX by remember(todo.id) { mutableFloatStateOf(0f) } - var swipeHinting by remember(todo.id) { mutableStateOf(false) } + val swipeRevealState = rememberTaskSwipeRevealState(todo.id) var localChecked by remember(todo.id) { mutableStateOf(false) } var localStruck by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } @@ -2939,12 +2859,11 @@ private fun SwipeTaskRow( val highlightAnim = remember(todo.id) { Animatable(0f) } val visuallyChecked = localChecked || (keepCompletedInline && todo.completed) val visuallyStruck = localStruck || (keepCompletedInline && todo.completed) - val animatedOffsetX by animateFloatAsState( - targetValue = targetOffsetX, - animationSpec = spring(stiffness = Spring.StiffnessLow), + val animatedOffsetX by animateTaskSwipeOffsetAsState( + state = swipeRevealState, label = "swipeTaskOffset", ) - val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) + val actionRevealProgress = swipeRevealState.revealProgress(animatedOffsetX) val completionAlpha by animateFloatAsState( targetValue = if (completionFading) 0f else 1f, animationSpec = tween( @@ -3016,7 +2935,7 @@ private fun SwipeTaskRow( val listIndicatorColor = listAccentColor(listMeta?.color) LaunchedEffect(flashHighlight) { if (!flashHighlight) return@LaunchedEffect - targetOffsetX = 0f + swipeRevealState.close() highlightAnim.stop() highlightAnim.snapTo(0f) repeat(2) { pulseIndex -> @@ -3069,7 +2988,7 @@ private fun SwipeTaskRow( HapticFeedbackConstantsCompat.CLOCK_TICK, ) onInfo() - targetOffsetX = 0f + swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -3086,7 +3005,7 @@ private fun SwipeTaskRow( HapticFeedbackConstantsCompat.CLOCK_TICK, ) onDelete() - targetOffsetX = 0f + swipeRevealState.close() }, ) } @@ -3103,7 +3022,7 @@ private fun SwipeTaskRow( Modifier.pointerInput(todo.id, dragEnabled) { detectDragGesturesAfterLongPress( onDragStart = { localOffset -> - targetOffsetX = 0f + swipeRevealState.close() val startPosition = rowOriginInRoot + localOffset dragPointerPosition = startPosition onDragStart?.invoke(startPosition) @@ -3137,35 +3056,21 @@ private fun SwipeTaskRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> - targetOffsetX = (targetOffsetX + delta).coerceIn( - -maxElasticDragPx, - 0f, - ) + swipeRevealState.dragBy(delta) }, onDragStopped = { velocity -> - val flingOpen = velocity < -1450f - val dragOpen = targetOffsetX < -(actionRevealPx * 0.32f) - targetOffsetX = if (flingOpen || dragOpen) { - -actionRevealPx - } else { - 0f - } + swipeRevealState.settle(velocity) }, ) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { - if (targetOffsetX != 0f) { - targetOffsetX = 0f - } else if (!swipeHinting && !pendingCompletion && !dragging) { - swipeHinting = true + if (swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } else if (!swipeRevealState.isHinting && !pendingCompletion && !dragging) { coroutineScope.launch { - targetOffsetX = -swipeHintOffsetPx - delay(150) - targetOffsetX = 0f - delay(360) - swipeHinting = false + swipeRevealState.playHint() } } }, @@ -3215,7 +3120,7 @@ private fun SwipeTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) - targetOffsetX = 0f + swipeRevealState.close() localChecked = true pendingCompletion = true coroutineScope.launch { diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index c9752a80..ca801da5 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -429,8 +429,14 @@ private func buildCompletedTimelineSections(items: [CompletedItem]) -> [Timeline } private func completedTimelineSectionTitle(for date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale.current - formatter.dateFormat = "EEEE, MMM d" - return formatter.string(from: date) + CompletedTimelineFormatters.sectionTitle.string(from: date) +} + +private enum CompletedTimelineFormatters { + static let sectionTitle: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "EEEE, MMM d" + return formatter + }() } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 86276992..ee624827 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -563,12 +563,8 @@ private struct HomeTodayTaskRow: View { @Environment(\.tdayColors) private var colors - @State private var offsetX: CGFloat = 0 - @State private var isHinting = false @State private var completionPhase = HomeTodayTaskCompletionPhase.active - private let revealWidth: CGFloat = 152 - private var listMeta: ListSummary? { todo.listId.flatMap { id in lists.first { $0.id == id } } } @@ -578,7 +574,6 @@ private struct HomeTodayTaskRow: View { private var dueText: String { todo.due.formatted(date: .omitted, time: .shortened) } private var subtitleText: String { isOverdue ? "Overdue, \(dueText)" : "Due \(dueText)" } private var subtitleColor: Color { isOverdue ? colors.error : colors.onSurfaceVariant.opacity(0.8) } - private var revealProgress: CGFloat { min(1, max(0, -offsetX / revealWidth)) } private var isCompleting: Bool { completionPhase != .active } private var isFading: Bool { completionPhase == .fading } private var showCheckmark: Bool { completionPhase != .active || todo.completed } @@ -588,72 +583,12 @@ private struct HomeTodayTaskRow: View { } var body: some View { - VStack(spacing: 0) { - ZStack(alignment: .trailing) { - rowContent - .offset(x: offsetX) - .gesture( - DragGesture(minimumDistance: 6) - .onChanged { value in - guard abs(value.translation.width) > abs(value.translation.height) else { return } - let proposed = value.translation.width - if proposed < 0 { - offsetX = max(-revealWidth * 1.12, proposed) - } else { - offsetX = min(0, offsetX + proposed * 0.15) - } - } - .onEnded { value in - let velocity = value.predictedEndTranslation.width - value.translation.width - let shouldOpen = offsetX < -(revealWidth * 0.32) || velocity < -200 - withAnimation(.spring(response: 0.34, dampingFraction: 0.78)) { - offsetX = shouldOpen ? -revealWidth : 0 - } - } - ) - .onTapGesture { - if offsetX != 0 { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } - } else if !isHinting && !isCompleting { - isHinting = true - Task { @MainActor in - withAnimation(.spring(response: 0.26, dampingFraction: 0.78)) { offsetX = -28 } - try? await Task.sleep(nanoseconds: 150_000_000) - withAnimation(.spring(response: 0.38, dampingFraction: 0.68)) { offsetX = 0 } - try? await Task.sleep(nanoseconds: 340_000_000) - isHinting = false - } - } - } - - HStack(spacing: 16) { - Spacer() - HomeTodaySwipeActionButton( - title: "Edit", - systemImage: "square.and.pencil", - tint: TaskSwipeActionTint.edit, - revealProgress: revealProgress, - revealDelay: 0.62 - ) { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } - onEdit() - } - - HomeTodaySwipeActionButton( - title: "Delete", - systemImage: "trash", - tint: TaskSwipeActionTint.delete, - revealProgress: revealProgress, - revealDelay: 0.04 - ) { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { offsetX = 0 } - onDelete() - } - } - .padding(.trailing, 2) - .frame(maxWidth: .infinity) - } - } + rowContent + .todoTrailingSwipeActions( + enabled: !isCompleting, + onEdit: onEdit, + onDelete: onDelete + ) .opacity(isFading ? 0 : 1) .scaleEffect(isFading ? 0.985 : 1, anchor: .center) .offset(y: isFading ? -10 : 0) @@ -712,7 +647,6 @@ private struct HomeTodayTaskRow: View { guard completionPhase == .active else { return } withAnimation(.easeInOut(duration: 0.18)) { - offsetX = 0 completionPhase = .checked } @@ -767,51 +701,6 @@ private struct HomeTodayTaskTitle: View { } } -private struct HomeTodaySwipeActionButton: View { - let title: String - let systemImage: String - let tint: Color - let revealProgress: CGFloat - let revealDelay: CGFloat - let action: () -> Void - - private var easedReveal: CGFloat { - let normalized = max(0, min(1, (revealProgress - revealDelay) / (1 - revealDelay))) - return normalized * normalized * (3 - (2 * normalized)) - } - - var body: some View { - Button(action: action) { - VStack(spacing: 4) { - ZStack { - RoundedRectangle(cornerRadius: 17, style: .continuous) - .fill(tint) - Image(systemName: systemImage) - .font(.system(size: 21, weight: .semibold)) - .foregroundStyle(.white) - } - .frame(width: 56, height: 34) - - Text(title) - .font(.tdayRounded(size: 12, weight: .bold)) - .foregroundStyle(Color(uiColor: .secondaryLabel).opacity(0.82)) - .lineLimit(1) - } - .frame(minWidth: 60) - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0, - normalShadowOpacity: 0 - ) - ) - .opacity(Double(easedReveal)) - .scaleEffect(0.38 + (0.62 * easedReveal)) - .allowsHitTesting(easedReveal > 0.8) - } -} - private struct HomeTodayCard: View { let count: Int let action: () -> Void @@ -1233,7 +1122,7 @@ private struct HomeSearchResultsOverlay: View { return min(contentHeight, maxResultsHeight) } - private let dueFormatter: DateFormatter = { + private static let dueFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "EEE h:mm a" return formatter @@ -1268,7 +1157,7 @@ private struct HomeSearchResultsOverlay: View { .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(dueFormatter.string(from: todo.due)) + Text(Self.dueFormatter.string(from: todo.due)) .font(.tdayRounded(size: 12, weight: .bold)) .foregroundStyle(colors.onSurfaceVariant) .lineLimit(1) diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index 81f8945b..441dffb0 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -945,14 +945,23 @@ private func parseChangelog(_ body: String?) -> [String] { } private func formatIsoDate(_ value: String) -> String { - let parser = ISO8601DateFormatter() - parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let date = parser.date(from: value) ?? { - let fallback = ISO8601DateFormatter() - fallback.formatOptions = [.withInternetDateTime] - return fallback.date(from: value) - }() + let date = ReleaseDateFormatters.internetDateTimeWithFraction.date(from: value) + ?? ReleaseDateFormatters.internetDateTime.date(from: value) guard let date else { return value } return date.formatted(.dateTime.month(.wide).day().year()) } + +private enum ReleaseDateFormatters { + static let internetDateTimeWithFraction: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static let internetDateTime: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() +} diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 5a9c8302..88343c0a 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -3110,26 +3110,53 @@ private func buildFutureTimelineSections( } private func timelineDayTitle(for date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale.current - formatter.dateFormat = "EEE MMM d" - return formatter.string(from: date) + TodoTimelineFormatters.dayTitle.string(from: date) } private func timelineDateTimeText(_ date: Date) -> String { - let formatter = DateFormatter() - formatter.locale = Locale.current - formatter.dateFormat = "MMM d, h:mm a" - return formatter.string(from: date) + TodoTimelineFormatters.dateTime.string(from: date) } private func monthTitle(for date: Date, currentYear: Int, calendar: Calendar) -> String { - let formatter = DateFormatter() - formatter.locale = Locale.current - formatter.dateFormat = calendar.component(.year, from: date) == currentYear ? "LLLL" : "LLLL yyyy" + let formatter: DateFormatter + if calendar.component(.year, from: date) == currentYear { + formatter = TodoTimelineFormatters.month + } else { + formatter = TodoTimelineFormatters.monthAndYear + } return formatter.string(from: date) } +private enum TodoTimelineFormatters { + static let dayTitle: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "EEE MMM d" + return formatter + }() + + static let dateTime: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "MMM d, h:mm a" + return formatter + }() + + static let month: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "LLLL" + return formatter + }() + + static let monthAndYear: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "LLLL yyyy" + return formatter + }() +} + private func monthIndex(for date: Date, calendar: Calendar) -> Int { let year = calendar.component(.year, from: date) let month = calendar.component(.month, from: date) diff --git a/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift b/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift index 372e518c..62235625 100644 --- a/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift +++ b/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift @@ -443,6 +443,7 @@ private struct VerticalScrollSnapObserver: UIViewRepresentable { private var settledTargetOffset: CGFloat = 0 private var snapTimer: Timer? private var isSnapping = false + private let releaseVelocityThreshold: CGFloat = 90 init(collapseDistance: CGFloat) { self.collapseDistance = collapseDistance @@ -473,6 +474,7 @@ private struct VerticalScrollSnapObserver: UIViewRepresentable { case .began: snapTimer?.invalidate() isSnapping = false + scrollView.layer.removeAllAnimations() releaseVelocityY = 0 lastDragDelta = 0 dragStartOffset = offset @@ -493,7 +495,7 @@ private struct VerticalScrollSnapObserver: UIViewRepresentable { private func scheduleSnapCheck() { snapTimer?.invalidate() - snapTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] timer in + snapTimer = Timer.scheduledTimer(withTimeInterval: 0.03, repeats: true) { [weak self] timer in guard let self else { timer.invalidate() return @@ -547,22 +549,26 @@ private struct VerticalScrollSnapObserver: UIViewRepresentable { } private func targetOffset(for currentOffset: CGFloat, distance: CGFloat) -> CGFloat { - let velocityThreshold: CGFloat = 20 - if releaseVelocityY < -velocityThreshold { + if releaseVelocityY < -releaseVelocityThreshold { return distance } - if releaseVelocityY > velocityThreshold { + if releaseVelocityY > releaseVelocityThreshold { return 0 } let dragDelta = currentOffset - dragStartOffset - if dragDelta > 0.5 || lastDragDelta > 0.05 { + if dragDelta > 2 || lastDragDelta > 0.2 { return distance } - if dragDelta < -0.5 || lastDragDelta < -0.05 { + if dragDelta < -2 || lastDragDelta < -0.2 { return 0 } + let progress = currentOffset / distance + if abs(progress - 0.5) > 0.08 { + return progress >= 0.5 ? distance : 0 + } + return settledTargetOffset } @@ -585,11 +591,14 @@ private struct VerticalScrollSnapObserver: UIViewRepresentable { isSnapping = true scrollView.layer.removeAllAnimations() - let initialVelocity = min(abs(releaseVelocityY) / max(collapseDistance, 1), 3) + let remainingDistance = abs(scrollView.contentOffset.y - targetOffset.y) + let progress = min(max(remainingDistance / max(collapseDistance, 1), 0), 1) + let duration = 0.22 + (0.12 * progress) + let initialVelocity = min(abs(releaseVelocityY) / max(collapseDistance, 1), 2.4) UIView.animate( - withDuration: 0.34, + withDuration: duration, delay: 0, - usingSpringWithDamping: 0.88, + usingSpringWithDamping: 0.92, initialSpringVelocity: initialVelocity, options: [.allowUserInteraction, .beginFromCurrentState] ) { diff --git a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift index b5b10be6..8c4ace0a 100644 --- a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift +++ b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift @@ -56,6 +56,9 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { @State private var isHorizontalDragging = false private let revealWidth: CGFloat = 152 + private let horizontalActivationDistance: CGFloat = 8 + private let horizontalActivationBias: CGFloat = 4 + private let openVelocityThreshold: CGFloat = -180 private var revealProgress: CGFloat { min(1, max(0, -offsetX / revealWidth)) @@ -67,11 +70,16 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { .offset(x: offsetX) .contentShape(Rectangle()) .simultaneousGesture( - DragGesture(minimumDistance: 6) + DragGesture(minimumDistance: horizontalActivationDistance) .onChanged { value in guard enabled else { return } - guard abs(value.translation.width) > abs(value.translation.height) else { return } if !isHorizontalDragging { + let horizontalDistance = abs(value.translation.width) + let verticalDistance = abs(value.translation.height) + guard horizontalDistance > horizontalActivationDistance, + horizontalDistance > verticalDistance + horizontalActivationBias else { + return + } dragStartOffsetX = offsetX isHorizontalDragging = true } @@ -89,8 +97,8 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { } guard enabled, isHorizontalDragging else { return } let velocity = value.predictedEndTranslation.width - value.translation.width - let shouldOpen = offsetX < -(revealWidth * 0.32) || velocity < -200 - withAnimation(.spring(response: 0.34, dampingFraction: 0.78)) { + let shouldOpen = offsetX < -(revealWidth * 0.32) || velocity < openVelocityThreshold + withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) { offsetX = shouldOpen ? -revealWidth : 0 } } @@ -103,6 +111,11 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { revealHint() } } + .onChange(of: enabled) { isEnabled in + if !isEnabled { + closeActions() + } + } HStack(spacing: 16) { Spacer() @@ -134,7 +147,7 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { } private func closeActions() { - withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { + withAnimation(.interactiveSpring(response: 0.26, dampingFraction: 0.86)) { offsetX = 0 } } From d9b11766937b46e9021d80a2d18858f5c513a71b Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 23:21:16 -0400 Subject: [PATCH 15/19] ios: refactor SwipeActions to use UIPanGestureRecognizer for smoother gesture handling Replaces the SwiftUI `DragGesture` in `SwipeActions` with a custom `UIViewRepresentable` observer that utilizes `UIPanGestureRecognizer`. This change improves gesture reliability and coordination within scroll views by leveraging UIKit's gesture delegate system. - **Swipe Performance & Logic**: - Implement `HorizontalSwipePanObserver` to manage pan gestures via a `Coordinator` attached to the enclosing `UIScrollView`. - Define a `gestureRecognizerShouldBegin` logic to strictly enforce horizontal swipe detection, preventing accidental triggers during vertical scrolling. - Support simultaneous gesture recognition to ensure the parent scroll view remains responsive. - Refactor `SwipeActions` modifier to apply the new observer as a background element. - **Cleanup**: - Remove legacy state variables (`isHorizontalDragging`, `dragStartOffsetX`) and thresholds from the SwiftUI view. - Update `onChange` closures to use the modern Swift 5.9+ syntax. --- .../Tday/UI/Component/SwipeActions.swift | 183 ++++++++++++++---- 1 file changed, 145 insertions(+), 38 deletions(-) diff --git a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift index 8c4ace0a..054272d7 100644 --- a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift +++ b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift @@ -52,12 +52,8 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { @State private var offsetX: CGFloat = 0 @State private var isHinting = false - @State private var dragStartOffsetX: CGFloat? - @State private var isHorizontalDragging = false private let revealWidth: CGFloat = 152 - private let horizontalActivationDistance: CGFloat = 8 - private let horizontalActivationBias: CGFloat = 4 private let openVelocityThreshold: CGFloat = -180 private var revealProgress: CGFloat { @@ -69,39 +65,13 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { content .offset(x: offsetX) .contentShape(Rectangle()) - .simultaneousGesture( - DragGesture(minimumDistance: horizontalActivationDistance) - .onChanged { value in - guard enabled else { return } - if !isHorizontalDragging { - let horizontalDistance = abs(value.translation.width) - let verticalDistance = abs(value.translation.height) - guard horizontalDistance > horizontalActivationDistance, - horizontalDistance > verticalDistance + horizontalActivationBias else { - return - } - dragStartOffsetX = offsetX - isHorizontalDragging = true - } - let proposed = (dragStartOffsetX ?? offsetX) + value.translation.width - if proposed < 0 { - offsetX = max(-revealWidth * 1.12, min(0, proposed)) - } else { - offsetX = 0 - } - } - .onEnded { value in - defer { - dragStartOffsetX = nil - isHorizontalDragging = false - } - guard enabled, isHorizontalDragging else { return } - let velocity = value.predictedEndTranslation.width - value.translation.width - let shouldOpen = offsetX < -(revealWidth * 0.32) || velocity < openVelocityThreshold - withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) { - offsetX = shouldOpen ? -revealWidth : 0 - } - } + .background( + HorizontalSwipePanObserver( + enabled: enabled, + revealWidth: revealWidth, + openVelocityThreshold: openVelocityThreshold, + offsetX: $offsetX + ) ) .onTapGesture { guard enabled else { return } @@ -111,7 +81,7 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { revealHint() } } - .onChange(of: enabled) { isEnabled in + .onChange(of: enabled) { _, isEnabled in if !isEnabled { closeActions() } @@ -170,6 +140,130 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { } } +private struct HorizontalSwipePanObserver: UIViewRepresentable { + let enabled: Bool + let revealWidth: CGFloat + let openVelocityThreshold: CGFloat + @Binding var offsetX: CGFloat + + func makeCoordinator() -> Coordinator { + Coordinator(offsetX: $offsetX) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.enabled = enabled + context.coordinator.revealWidth = revealWidth + context.coordinator.openVelocityThreshold = openVelocityThreshold + context.coordinator.offsetX = $offsetX + DispatchQueue.main.async { + context.coordinator.attach(to: uiView) + } + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var enabled = true + var revealWidth: CGFloat = 152 + var openVelocityThreshold: CGFloat = -180 + var offsetX: Binding + + private weak var markerView: UIView? + private weak var observedScrollView: UIScrollView? + private var dragStartOffsetX: CGFloat = 0 + private lazy var panRecognizer: UIPanGestureRecognizer = { + let recognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + recognizer.cancelsTouchesInView = false + recognizer.delaysTouchesBegan = false + recognizer.delaysTouchesEnded = false + recognizer.delegate = self + return recognizer + }() + + init(offsetX: Binding) { + self.offsetX = offsetX + } + + deinit { + observedScrollView?.removeGestureRecognizer(panRecognizer) + } + + func attach(to markerView: UIView) { + self.markerView = markerView + guard let scrollView = markerView.enclosingSwipeScrollView() else { + return + } + guard observedScrollView !== scrollView else { + return + } + + observedScrollView?.removeGestureRecognizer(panRecognizer) + observedScrollView = scrollView + scrollView.addGestureRecognizer(panRecognizer) + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard enabled, + gestureRecognizer === panRecognizer, + let scrollView = observedScrollView, + let markerView else { + return false + } + + let location = panRecognizer.location(in: markerView) + guard markerView.bounds.insetBy(dx: 0, dy: -4).contains(location) else { + return false + } + + let velocity = panRecognizer.velocity(in: scrollView) + let horizontalVelocity = abs(velocity.x) + let verticalVelocity = abs(velocity.y) + return horizontalVelocity > 45 && horizontalVelocity > verticalVelocity + 28 + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { + guard enabled, let scrollView = observedScrollView else { + return + } + + switch recognizer.state { + case .began: + dragStartOffsetX = offsetX.wrappedValue + case .changed: + let translation = recognizer.translation(in: scrollView) + let proposed = dragStartOffsetX + translation.x + if proposed < 0 { + offsetX.wrappedValue = max(-revealWidth * 1.12, min(0, proposed)) + } else { + offsetX.wrappedValue = 0 + } + case .ended, .cancelled, .failed: + let velocityX = recognizer.velocity(in: scrollView).x + let shouldOpen = offsetX.wrappedValue < -(revealWidth * 0.32) || + velocityX < openVelocityThreshold + withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) { + offsetX.wrappedValue = shouldOpen ? -revealWidth : 0 + } + dragStartOffsetX = 0 + default: + break + } + } + } +} + private struct TodoSwipePillActionButton: View { let title: String let systemImage: String @@ -245,3 +339,16 @@ private struct SwipeRevealHintModifier: ViewModifier { } } } + +private extension UIView { + func enclosingSwipeScrollView() -> UIScrollView? { + var view: UIView? = self + while let current = view { + if let scrollView = current as? UIScrollView { + return scrollView + } + view = current.superview + } + return nil + } +} From 16ea8359c786171fbc243f3a68f837287b30dc22 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 23:39:58 -0400 Subject: [PATCH 16/19] feat(ux): implement cooldown for offline notifications on iOS and Android Introduce a 10-minute cooldown period for offline notices to prevent redundant alerts during frequent connectivity fluctuations. - **Common Logic**: Added a cooldown mechanism that tracks the last time an offline notice was displayed and suppresses subsequent notices until 10 minutes have elapsed. - **iOS (SwiftUI)**: Updated `AppViewModel` with `offlineNoticeCooldownSeconds` and a `shouldShowOfflineNotice()` helper to gate `offlineNoticeID` increments. - **Android (Compose)**: Introduced an `OfflineNoticeCooldown` internal class and integrated it into `AppViewModel` to manage `offlineNoticeId` updates across session bootstrapping and sync operations. - **Testing**: Added unit tests for `OfflineNoticeCooldown` in Android to verify the 10-minute suppression logic. --- .../tday/compose/feature/app/AppViewModel.kt | 53 +++++++++++++++---- .../compose/feature/app/AppViewModelTest.kt | 15 ++++++ .../Tday/Feature/App/AppViewModel.swift | 17 +++++- 3 files changed, 72 insertions(+), 13 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt index 5d9af45a..73b39f7e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/app/AppViewModel.kt @@ -70,6 +70,25 @@ data class AppUiState( val isCheckingUpdateRelease: Boolean = false, ) +internal const val OFFLINE_NOTICE_COOLDOWN_MS = 10 * 60 * 1000L + +internal class OfflineNoticeCooldown( + private val nowMillis: () -> Long = { System.currentTimeMillis() }, +) { + private var lastNoticeShownAtMs: Long? = null + + fun shouldShowNotice(): Boolean { + val now = nowMillis() + val lastShownAt = lastNoticeShownAtMs + if (lastShownAt != null && now - lastShownAt < OFFLINE_NOTICE_COOLDOWN_MS) { + return false + } + + lastNoticeShownAtMs = now + return true + } +} + @HiltViewModel class AppViewModel @Inject constructor( private val authRepository: AuthRepository, @@ -93,6 +112,7 @@ class AppViewModel @Inject constructor( private var realtimeJob: Job? = null private var connectivityJob: Job? = null private var foregroundReconnectJob: Job? = null + private val offlineNoticeCooldown = OfflineNoticeCooldown() init { _uiState.update { @@ -155,6 +175,8 @@ class AppViewModel @Inject constructor( if (sessionResult != null) { val sessionUser = sessionResult.user val adminUser = isAdmin(sessionUser) + val shouldShowOfflineNotice = sessionResult.isOffline && + offlineNoticeCooldown.shouldShowNotice() val pendingCount = runCatching { cacheManager.loadOfflineState().pendingMutations.size }.getOrDefault(_uiState.value.pendingMutationCount) @@ -180,7 +202,7 @@ class AppViewModel @Inject constructor( adminAiSummaryError = null, isOffline = sessionResult.isOffline, pendingMutationCount = pendingCount, - offlineNoticeId = if (sessionResult.isOffline) { + offlineNoticeId = if (shouldShowOfflineNotice) { it.offlineNoticeId + 1L } else { it.offlineNoticeId @@ -510,11 +532,16 @@ class AppViewModel @Inject constructor( error = syncError, suppressAuthenticationExpired = true, ) + val shouldShowOfflineNotice = isOffline && offlineNoticeCooldown.shouldShowNotice() _uiState.update { it.copy( isManualSyncing = false, isOffline = isOffline, - offlineNoticeId = if (isOffline) it.offlineNoticeId + 1L else it.offlineNoticeId, + offlineNoticeId = if (shouldShowOfflineNotice) { + it.offlineNoticeId + 1L + } else { + it.offlineNoticeId + }, pendingMutationCount = runCatching { cacheManager.loadOfflineState().pendingMutations.size }.getOrDefault(it.pendingMutationCount), @@ -637,15 +664,19 @@ class AppViewModel @Inject constructor( connectionProbeTimeoutMs = connectionProbeTimeoutMs, ) val syncError = result.exceptionOrNull() + val isOffline = syncError != null && + shouldTreatSyncFailureAsOffline( + error = syncError, + suppressAuthenticationExpired = suppressAuthenticationExpired, + ) + val shouldDeferOfflineState = syncError != null && + isLikelyConnectivityIssue(syncError) && + !markOfflineOnConnectivityFailure + val shouldShowOfflineNotice = isOffline && + showOfflineNotice && + !shouldDeferOfflineState && + offlineNoticeCooldown.shouldShowNotice() _uiState.update { - val isOffline = syncError != null && - shouldTreatSyncFailureAsOffline( - error = syncError, - suppressAuthenticationExpired = suppressAuthenticationExpired, - ) - val shouldDeferOfflineState = syncError != null && - isLikelyConnectivityIssue(syncError) && - !markOfflineOnConnectivityFailure it.copy( isOffline = when { syncError == null -> false @@ -653,7 +684,7 @@ class AppViewModel @Inject constructor( isOffline -> true else -> false }, - offlineNoticeId = if (isOffline && showOfflineNotice && !shouldDeferOfflineState) { + offlineNoticeId = if (shouldShowOfflineNotice) { it.offlineNoticeId + 1L } else { it.offlineNoticeId diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt index dd38252d..45367c98 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt @@ -186,6 +186,21 @@ class AppViewModelTest { runCurrent() } + @Test + fun `offline notice cooldown suppresses repeat notices for ten minutes`() { + var now = 1_000L + val cooldown = OfflineNoticeCooldown { now } + + assertTrue(cooldown.shouldShowNotice()) + assertFalse(cooldown.shouldShowNotice()) + + now += OFFLINE_NOTICE_COOLDOWN_MS - 1 + assertFalse(cooldown.shouldShowNotice()) + + now += 1 + assertTrue(cooldown.shouldShowNotice()) + } + private fun makeViewModel(): AppViewModel = AppViewModel( authRepository = authRepository, diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index f05cc644..239a9ec0 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -75,6 +75,9 @@ final class AppViewModel { @ObservationIgnored nonisolated(unsafe) private var networkMonitor: NWPathMonitor? @ObservationIgnored private let networkMonitorQueue = DispatchQueue(label: "tday.network-monitor") @ObservationIgnored private var isForegroundReconnectInFlight = false + @ObservationIgnored private var lastOfflineNoticeShownAt: Date? + + private static let offlineNoticeCooldownSeconds: TimeInterval = 10 * 60 init(container: AppContainer) { self.container = container @@ -143,7 +146,7 @@ final class AppViewModel { pendingApprovalMessage = nil canResetServerTrust = true isOffline = sessionResult.isOffline - if sessionResult.isOffline { + if sessionResult.isOffline && shouldShowOfflineNotice() { offlineNoticeID += 1 } finishBootstrap() @@ -432,7 +435,7 @@ final class AppViewModel { case let .failure(error): isOffline = isLikelyConnectivityIssue(error) || (suppressAuthenticationExpired && isSessionAuthenticationIssue(error)) - if isOffline && showOfflineNotice { + if isOffline && showOfflineNotice && shouldShowOfflineNotice() { offlineNoticeID += 1 } if !isOffline { @@ -442,6 +445,16 @@ final class AppViewModel { } } + private func shouldShowOfflineNotice(now: Date = Date()) -> Bool { + if let lastOfflineNoticeShownAt, + now.timeIntervalSince(lastOfflineNoticeShownAt) < Self.offlineNoticeCooldownSeconds { + return false + } + + lastOfflineNoticeShownAt = now + return true + } + private func observeOfflineSyncFailures() { offlineSyncFailureTask = Task { for await _ in NotificationCenter.default.notifications(named: .offlineSyncAttemptFailed) { From c8e1d10abe91f6988cb42b4b5f0b6998c0835d6c Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Mon, 25 May 2026 23:40:22 -0400 Subject: [PATCH 17/19] feat(ux): implement cooldown for offline notifications on iOS and Android Introduce a 10-minute cooldown period for offline notices to prevent redundant alerts during frequent connectivity fluctuations. - **Common Logic**: Added a cooldown mechanism that tracks the last time an offline notice was displayed and suppresses subsequent notices until 10 minutes have elapsed. - **iOS (SwiftUI)**: Updated `AppViewModel` with `offlineNoticeCooldownSeconds` and a `shouldShowOfflineNotice()` helper to gate `offlineNoticeID` increments. - **Android (Compose)**: Introduced an `OfflineNoticeCooldown` internal class and integrated it into `AppViewModel` to manage `offlineNoticeId` updates across session bootstrapping and sync operations. - **Testing**: Added unit tests for `OfflineNoticeCooldown` in Android to verify the 10-minute suppression logic. --- ios-swiftUI/Tday/Core/UI/OfflineBanner.swift | 21 ++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/ios-swiftUI/Tday/Core/UI/OfflineBanner.swift b/ios-swiftUI/Tday/Core/UI/OfflineBanner.swift index 4b564b82..14b7e7cc 100644 --- a/ios-swiftUI/Tday/Core/UI/OfflineBanner.swift +++ b/ios-swiftUI/Tday/Core/UI/OfflineBanner.swift @@ -8,6 +8,7 @@ struct OfflineBanner: View { @Environment(\.tdayColors) private var colors @GestureState private var dragOffsetY: CGFloat = 0 @State private var isPresented = false + @State private var lastPresentedNoticeID = 0 @State private var dismissalTask: Task? var body: some View { @@ -18,13 +19,13 @@ struct OfflineBanner: View { } .animation(.spring(response: 0.26, dampingFraction: 0.86), value: isPresented) .onAppear { - updatePresentation(visible) + updatePresentation(visible, noticeID: noticeID) } .onChange(of: visible) { _, newValue in - updatePresentation(newValue) + updatePresentation(newValue, noticeID: noticeID) } - .onChange(of: noticeID) { _, _ in - updatePresentation(visible) + .onChange(of: noticeID) { _, newValue in + updatePresentation(visible, noticeID: newValue) } } @@ -103,14 +104,18 @@ struct OfflineBanner: View { min(abs(min(dragOffsetY, 0)) / 88, 1) } - private func updatePresentation(_ shouldShow: Bool) { - dismissalTask?.cancel() - + private func updatePresentation(_ shouldShow: Bool, noticeID: Int) { guard shouldShow else { - dismissNotice(cancelTimer: false) + dismissNotice() return } + guard noticeID > lastPresentedNoticeID else { + return + } + + dismissalTask?.cancel() + lastPresentedNoticeID = noticeID isPresented = true dismissalTask = Task { try? await Task.sleep(nanoseconds: 2_000_000_000) From aa7be41eab9b03e5eee81f033652d1be60395903 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 26 May 2026 13:05:51 -0400 Subject: [PATCH 18/19] fix(calendar): improve drag-and-drop interaction and expand todo visibility This update refines the calendar view's gesture handling and data fetching logic across iOS and Android. It ensures all todo items are loaded into the calendar state and prevents scrolling interference during drag-and-drop operations on iOS. - **Calendar Data Loading**: - Update `CalendarViewModel` (iOS and Android) to fetch todos using `TodoListMode.ALL` (or `.all`) instead of scheduled items only. This affects initial state creation, cache hydration, and manual refresh cycles. - **iOS UI & Gesture Handling**: - Disable scrolling in `CalendarScreen` when an active in-app drag operation is detected (`inAppDrag != nil`). - Update the gesture recognizer coordinator to prevent simultaneous recognition with the scroll view's pan gesture, ensuring more reliable drag-and-drop triggers. --- .../tday/compose/feature/calendar/CalendarViewModel.kt | 6 +++--- ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift | 7 ++++++- ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift | 2 +- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt index 49aa9101..c228b049 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt @@ -50,7 +50,7 @@ class CalendarViewModel @Inject constructor( runCatching { CalendarUiState( isLoading = false, - items = todoRepository.fetchTodosSnapshot(mode = TodoListMode.SCHEDULED), + items = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL), completedItems = completedRepository.fetchCompletedItemsSnapshot(), lists = listRepository.fetchListsSnapshot(), errorMessage = null, @@ -96,7 +96,7 @@ class CalendarViewModel @Inject constructor( private fun hydrateFromCache() { runCatching { - val todos = todoRepository.fetchTodosSnapshot(mode = TodoListMode.SCHEDULED) + val todos = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL) val completedItems = completedRepository.fetchCompletedItemsSnapshot() val lists = listRepository.fetchListsSnapshot() Triple(todos, completedItems, lists) @@ -139,7 +139,7 @@ class CalendarViewModel @Inject constructor( ) .onFailure { /* fall back to cache */ } } - val todos = todoRepository.fetchTodos(mode = TodoListMode.SCHEDULED) + val todos = todoRepository.fetchTodos(mode = TodoListMode.ALL) val completedItems = completedRepository.fetchCompletedItems() val lists = listRepository.fetchLists() Triple(todos, completedItems, lists) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 77a9d838..c9cd86cf 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -324,6 +324,7 @@ struct CalendarScreen: View { .listSectionSeparator(.hidden) .listStyle(.plain) .scrollContentBackground(.hidden) + .scrollDisabled(inAppDrag != nil) .contentMargins(.top, 0, for: .scrollContent) .listRowSpacing(0) .listSectionSpacing(0) @@ -2457,7 +2458,11 @@ private struct CalendarInAppLongPressBridge: UIViewRepresentable { _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { - true + if let scrollView = attachedView as? UIScrollView, + otherGestureRecognizer === scrollView.panGestureRecognizer { + return false + } + return true } @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift index 5255e470..2c99bb1d 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift @@ -106,7 +106,7 @@ final class CalendarViewModel { } private func hydrateFromCache() { - items = container.todoRepository.fetchTodosSnapshot(mode: .scheduled) + items = container.todoRepository.fetchTodosSnapshot(mode: .all) completedItems = container.completedRepository.fetchCompletedItemsSnapshot() lists = container.listRepository.fetchListsSnapshot() errorMessage = nil From ddd5f27b0ba014e6367ec917684bf6819bd85d99 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Tue, 26 May 2026 13:38:19 -0400 Subject: [PATCH 19/19] refactor(calendar): disable placement animation in CalendarScreen Update `CalendarScreen` to set `placementSpec` to null within the item animation configuration. This removes the spring-based placement animation while maintaining the existing fade and duration transitions. --- .../com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index 074bafa1..f84c8029 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -668,10 +668,7 @@ fun CalendarScreen( durationMillis = 180, easing = FastOutSlowInEasing, ), - placementSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), + placementSpec = null, fadeOutSpec = tween( durationMillis = 140, easing = FastOutSlowInEasing,