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/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/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/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..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 @@ -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 @@ -205,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 @@ -232,6 +236,11 @@ 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 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 @@ -263,6 +272,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 +451,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 +461,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 @@ -559,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( @@ -644,48 +656,63 @@ fun CalendarScreen( ) } - if (selectedDatePendingTasks.isNotEmpty()) { - item { - Column(modifier = Modifier.fillMaxWidth()) { - 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 = null, + 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 { @@ -824,6 +851,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, @@ -994,14 +1061,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 +1212,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 +1231,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 +1790,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, @@ -1989,9 +2074,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), @@ -2027,7 +2112,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( @@ -2035,13 +2124,34 @@ 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) 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 @@ -2050,14 +2160,17 @@ 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), ) { Box( modifier = Modifier .fillMaxWidth() - .height(58.dp), + .height(CalendarTaskRowHeight), ) { Row( modifier = Modifier @@ -2185,17 +2298,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) @@ -2204,9 +2317,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() } }, @@ -2218,19 +2336,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, @@ -2238,7 +2370,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 +2384,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), @@ -2286,7 +2418,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) @@ -2295,19 +2446,24 @@ 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( modifier = Modifier .fillMaxWidth() + .graphicsLayer { + alpha = rowAlpha + translationY = rowOffsetY.toPx() + } .semantics(mergeDescendants = true) { }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { Card( modifier = Modifier .fillMaxWidth() - .height(58.dp), + .height(CalendarTaskRowHeight), shape = rowShape, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.background), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), @@ -2335,7 +2491,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() } }, @@ -2348,14 +2508,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 @@ -2368,7 +2528,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 +2543,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 +2719,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 } } @@ -2573,22 +2734,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/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/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..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 @@ -51,6 +49,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 @@ -65,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 @@ -76,20 +73,18 @@ 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 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.text.style.TextDecoration -import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.core.view.HapticFeedbackConstantsCompat @@ -102,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 @@ -113,6 +110,25 @@ 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 const val COMPLETED_RESTORE_STEP_MS = 180L +private const val COMPLETED_RESTORE_FADE_MS = 260L + +private fun completedTaskBottomSpacing( + itemIndex: Int, + lastIndex: Int, + showDateDivider: Boolean, +) = if (showDateDivider || itemIndex == lastIndex) { + CompletedTimelineDateGroupSpacing +} else { + CompletedTimelineSameDateTaskSpacing +} + private enum class CompletedRestorePhase { Completed, Unchecked, @@ -135,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()) } @@ -221,7 +169,7 @@ fun CompletedScreen( topBar = { CompletedTopBar( onBack = onBack, - collapseProgress = collapseProgress, + collapseProgress = titleScrollBehavior.collapseProgress, ) }, ) { padding -> @@ -236,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), @@ -254,7 +202,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 = { @@ -269,6 +224,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 @@ -286,15 +247,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) }, @@ -556,20 +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) } - val animatedOffsetX by animateFloatAsState( - targetValue = targetOffsetX, - animationSpec = spring(stiffness = Spring.StiffnessLow), + var titleLayoutResult by remember(item.id) { mutableStateOf(null) } + 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 @@ -577,14 +534,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) @@ -594,6 +565,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) @@ -602,7 +578,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 @@ -613,13 +590,14 @@ private fun CompletedSwipeRow( alpha = rowAlpha scaleX = rowScale scaleY = rowScale + translationY = rowOffsetY.toPx() }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { Box( modifier = Modifier .fillMaxWidth() - .height(58.dp), + .height(CompletedSwipeRowHeight), ) { Row( modifier = Modifier @@ -642,7 +620,7 @@ private fun CompletedSwipeRow( HapticFeedbackConstantsCompat.CLOCK_TICK, ) onInfo() - targetOffsetX = 0f + swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -659,7 +637,7 @@ private fun CompletedSwipeRow( HapticFeedbackConstantsCompat.CLOCK_TICK, ) onDelete() - targetOffsetX = 0f + swipeRevealState.close() }, ) } @@ -671,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() } } }, @@ -727,14 +695,14 @@ private fun CompletedSwipeRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) - targetOffsetX = 0f + swipeRevealState.close() 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() } }, @@ -747,14 +715,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), @@ -775,7 +757,7 @@ private fun CompletedSwipeRow( } } - if (showPriorityFlag) { + if (showPriorityIcon) { Row( modifier = Modifier.padding(end = 24.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -790,7 +772,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 +863,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 } } @@ -918,22 +901,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 c39c1455..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 @@ -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 @@ -151,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 @@ -206,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 @@ -273,6 +275,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 +332,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 +389,7 @@ fun HomeScreen( hasCapturedInitialListSnapshot = true hasShownListDataOnce = lists.isNotEmpty() lastListStructureSignature = listStructureSignature + lastListIdsSignature = listIdsSignature return@LaunchedEffect } @@ -392,7 +399,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 +423,12 @@ fun HomeScreen( return@LaunchedEffect } + if (isDeletionOnly || isMetadataOnlyChange) { + visibleListStage = targetFinalStage + animateListCascade = false + return@LaunchedEffect + } + animateListCascade = true visibleListStage = 0 delay(70) @@ -525,20 +548,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 { @@ -596,8 +631,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 +654,7 @@ fun HomeScreen( } } else { ListRow( + modifier = listRowPlacementModifier, name = list.name, colorKey = list.color, iconKey = list.iconKey, @@ -1665,45 +1709,47 @@ private fun HomeTodayCard( @OptIn(ExperimentalFoundationApi::class) @Composable private fun HomeTodayTaskRow( + modifier: Modifier = Modifier, todo: TodoItem, lists: List, onComplete: () -> Unit, 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) } - var localCompleted 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( 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", ) - 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 } } 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( @@ -1716,9 +1762,12 @@ private fun HomeTodayTaskRow( } Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .graphicsLayer { alpha = completionAlpha }, + .graphicsLayer { + alpha = completionAlpha + translationY = completionOffsetY.toPx() + }, ) { Box( modifier = Modifier @@ -1746,7 +1795,7 @@ private fun HomeTodayTaskRow( HapticFeedbackConstantsCompat.CLOCK_TICK ) onEdit() - targetOffsetX = 0f + swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -1763,7 +1812,7 @@ private fun HomeTodayTaskRow( HapticFeedbackConstantsCompat.CLOCK_TICK ) onDelete() - targetOffsetX = 0f + swipeRevealState.close() }, ) } @@ -1775,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() } } }, @@ -1825,13 +1864,15 @@ private fun HomeTodayTaskRow( enabled = !pendingCompletion, ) { if (!pendingCompletion) { - targetOffsetX = 0f - localCompleted = true + swipeRevealState.close() + localChecked = true pendingCompletion = true coroutineScope.launch { - delay(500) + delay(160) + localStruck = true + delay(360) completionFading = true - delay(220) + delay(260) onComplete() } } @@ -1839,13 +1880,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), @@ -1882,7 +1923,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 @@ -1902,16 +1943,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), + ) + } + } } } } @@ -1941,36 +1995,36 @@ private fun CategoryGrid( 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), + color = Color(0xFFD98F4B), icon = Icons.Rounded.Schedule, backgroundWatermark = Icons.Rounded.Schedule, title = stringResource(R.string.home_category_scheduled), count = scheduledCount, onClick = onOpenScheduled, ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { 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), count = priorityCount, onClick = onOpenPriority, ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), - color = Color(0xFF4E4E50), + color = Color(0xFFE06F66), + 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(0xFF68717A), icon = Icons.Rounded.Inbox, backgroundWatermark = Icons.Rounded.Inbox, title = stringResource(R.string.home_category_all), @@ -2002,15 +2056,16 @@ 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 private fun TopDownCascadeReveal( + modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { var revealed by remember { mutableStateOf(false) } @@ -2030,7 +2085,7 @@ private fun TopDownCascadeReveal( } Box( - modifier = Modifier + modifier = modifier .fillMaxWidth() .graphicsLayer { this.alpha = alpha @@ -2225,6 +2280,7 @@ private fun CategoryCard( @Composable private fun ListRow( + modifier: Modifier = Modifier, name: String, colorKey: String?, iconKey: String?, @@ -2254,11 +2310,11 @@ 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( - modifier = Modifier + modifier = modifier .fillMaxWidth() .height(70.dp) .semantics(mergeDescendants = true) {} @@ -2378,8 +2434,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 @@ -2390,22 +2447,39 @@ 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)), - 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( @@ -2480,8 +2554,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/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 aced6ef1..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 @@ -161,6 +158,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 @@ -173,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 @@ -184,15 +180,17 @@ 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 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 +import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.zIndex import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat @@ -209,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 @@ -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 @@ -270,11 +289,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) { @@ -298,90 +316,18 @@ 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 + 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") @@ -400,6 +346,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("") } @@ -445,8 +392,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 -> @@ -471,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 @@ -493,7 +440,7 @@ fun TodoListScreen( } } LaunchedEffect(uiState.mode) { - if (uiState.mode == TodoListMode.PRIORITY) { + if (uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST) { collapsedSectionKeys = collapsedSectionKeys + "earlier" } } @@ -503,27 +450,54 @@ 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 + val nextSectionKey = todo?.let { timelineDropSectionKeyAt(position, it) } + if (activeDropSectionKey != nextSectionKey) { + activeDropSectionKey = nextSectionKey + } + } + 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 @@ -540,7 +514,7 @@ fun TodoListScreen( if (usesTodayStyle) { TodayTopBar( onBack = onBack, - collapseProgress = todayCollapseProgress, + collapseProgress = todayTitleScrollBehavior.collapseProgress, title = uiState.title, titleColor = titleColor, showActionButton = showTopBarActionButton, @@ -560,9 +534,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 @@ -627,7 +599,7 @@ fun TodoListScreen( .fillMaxSize() .then( if (usesTodayStyle) { - Modifier.nestedScroll(todayNestedScrollConnection) + Modifier.nestedScroll(todayTitleScrollBehavior.nestedScrollConnection) } else { Modifier }, @@ -665,6 +637,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 @@ -676,6 +649,9 @@ fun TodoListScreen( } else { null } + val isDropEligibleSection = sectionDraggedTodo?.let { todo -> + canDropTodoInTimelineSection(todo, section) + } == true item( key = "timeline-header-${section.key}", @@ -695,26 +671,20 @@ 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), + .padding(top = if (sectionIndex == 0) 0.dp else TimelineSectionTopSpacing), section = section, useMinimalStyle = usesTodayStyle, isCollapsed = isCollapsed, - isDropTarget = isActiveDropSection, + isDropTarget = isActiveDropSection && isDropEligibleSection, bottomSpacing = if (isCollapsed) { - timelineItemSpacing + TimelineCollapsedSectionSpacing } else { timelineHeaderBodySpacing }, @@ -741,7 +711,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", @@ -768,15 +738,11 @@ fun TodoListScreen( .timelineInAppDropTarget( targetId = "placeholder-${section.key}", section = section, - enabled = true, + enabled = isDropEligibleSection, dropTargets = timelineDropTargetBounds, ) .padding( - bottom = if (isCollapsed || section.items.isEmpty()) { - timelineItemSpacing - } else { - 8.dp - }, + bottom = TimelineDateGroupSpacing, ), active = true, useMinimalStyle = usesTodayStyle, @@ -787,8 +753,18 @@ 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, + inSectionIndex = sectionIndex, + sections = timelineSections, + collapsedSectionKeys = collapsedSectionKeys, + ) item( key = "timeline-todo-${section.key}-${todo.id}", contentType = "timeline-todo", @@ -815,15 +791,15 @@ fun TodoListScreen( .timelineInAppDropTarget( targetId = "row-${section.key}-${todo.id}", section = section, - enabled = canRescheduleTasks && draggedScheduledTodoId != null, + enabled = isDropEligibleSection, 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, @@ -831,12 +807,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 = { @@ -1081,10 +1052,135 @@ fun TodoListScreen( showListSettingsSheet = false listSettingsTargetId = null }, + onDelete = { + showListSettingsSheet = false + showDeleteListConfirmation = true + }, + ) + } + + val deleteConfirmationListId = selectedListId + if ( + showDeleteListConfirmation && + uiState.mode == TodoListMode.LIST && + !deleteConfirmationListId.isNullOrBlank() + ) { + ListDeleteConfirmationDialog( + onDismissRequest = { showDeleteListConfirmation = false }, + onConfirm = { + 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 + } + val scrimColor = if (isDarkTheme) { + Color.Black.copy(alpha = 0.68f) + } else { + Color.Black.copy(alpha = 0.36f) + } + + Dialog( + onDismissRequest = onDismissRequest, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(scrimColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismissRequest, + ) + .padding(horizontal = 34.dp), + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .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( + 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.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, + ) { + 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, + ) + } + } + } + } + } + } +} + @Composable private fun TodayTopBar( onBack: () -> Unit, @@ -1381,6 +1477,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 @@ -1670,11 +1767,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, @@ -1790,7 +1942,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, @@ -1952,9 +2104,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,11 +2310,19 @@ private fun buildTimelineSections( includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) - TodoListMode.PRIORITY, TodoListMode.LIST -> buildScheduledSections( + TodoListMode.PRIORITY -> buildScheduledSections( items = items, zoneId = zoneId, futureOnly = false, - placesEarlierBeforeToday = false, + placesEarlierBeforeToday = true, + includeEmptyEarlierTarget = includeEmptyEarlierTarget, + ) + + TodoListMode.LIST -> buildScheduledSections( + items = items, + zoneId = zoneId, + futureOnly = false, + placesEarlierBeforeToday = true, includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) } @@ -2567,8 +2727,11 @@ 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 = 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()) @@ -2684,31 +2847,44 @@ 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) } - var localCompleted 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) } 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 animatedOffsetX by animateFloatAsState( - targetValue = targetOffsetX, - animationSpec = spring(stiffness = Spring.StiffnessLow), + val visuallyChecked = localChecked || (keepCompletedInline && todo.completed) + val visuallyStruck = localStruck || (keepCompletedInline && todo.completed) + 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(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()) @@ -2754,19 +2930,12 @@ 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 - targetOffsetX = 0f + swipeRevealState.close() highlightAnim.stop() highlightAnim.snapTo(0f) repeat(2) { pulseIndex -> @@ -2787,7 +2956,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( @@ -2816,7 +2988,7 @@ private fun SwipeTaskRow( HapticFeedbackConstantsCompat.CLOCK_TICK, ) onInfo() - targetOffsetX = 0f + swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -2833,7 +3005,7 @@ private fun SwipeTaskRow( HapticFeedbackConstantsCompat.CLOCK_TICK, ) onDelete() - targetOffsetX = 0f + swipeRevealState.close() }, ) } @@ -2850,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) @@ -2884,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() } } }, @@ -2941,42 +3099,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 + swipeRevealState.close() + 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() } }, ) @@ -2988,19 +3141,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( @@ -3011,7 +3178,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 +3192,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 +3393,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 } } @@ -3259,22 +3427,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) } } @@ -3286,6 +3454,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 } } @@ -3295,25 +3471,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/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/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/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/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/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/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) 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/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) { diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 16e5328f..c9cd86cf 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 @@ -30,6 +37,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 @@ -71,8 +82,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 { @@ -92,7 +103,7 @@ 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 struct CalendarCardChromeModifier: ViewModifier { @Environment(\.tdayColors) private var colors @@ -141,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 @@ -176,8 +188,8 @@ struct CalendarScreen: View { return formatter.string(from: selectedDate) } - private var calendarModeCardHeight: CGFloat { - switch displayMode { + private func calendarModeCardHeight(for mode: CalendarDisplayMode) -> CGFloat { + switch mode { case .month: return CalendarModeCardMetrics.monthHeight case .week, .day: @@ -220,12 +232,7 @@ struct CalendarScreen: View { selectedMode: displayMode, accentColor: calendarAccentColor, onSelect: { mode in - withAnimation(calendarModeTransitionAnimation) { - displayMode = mode - if mode != .month { - visibleMonth = calendarMonthStart(for: selectedDate) - } - } + selectCalendarMode(mode) } ) .background { @@ -247,13 +254,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) @@ -305,8 +306,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) @@ -318,6 +324,7 @@ struct CalendarScreen: View { .listSectionSeparator(.hidden) .listStyle(.plain) .scrollContentBackground(.hidden) + .scrollDisabled(inAppDrag != nil) .contentMargins(.top, 0, for: .scrollContent) .listRowSpacing(0) .listSectionSpacing(0) @@ -440,13 +447,42 @@ struct CalendarScreen: View { ) } + private var animatedCalendarModeCard: some View { + calendarModeContent(for: displayMode) + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } + .frame(maxWidth: .infinity) + .frame(height: calendarCardHeight, alignment: .top) + .clipped() + .modifier(CalendarCardChromeModifier()) + } + + private func selectCalendarMode(_ mode: CalendarDisplayMode) { + guard mode != displayMode else { return } + + var contentTransaction = Transaction() + contentTransaction.disablesAnimations = true + withTransaction(contentTransaction) { + displayMode = mode + if mode != .month { + visibleMonth = calendarMonthStart(for: selectedDate) + } + } + + withAnimation(calendarModeResizeAnimation) { + calendarCardHeight = calendarModeCardHeight(for: mode) + } + } + 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 { + switch mode { case .month: CalendarMonthGrid( visibleMonth: visibleMonth, @@ -540,7 +576,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 +610,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 +635,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 +796,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 +1012,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 +1022,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 +1170,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 +1259,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 +1290,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 +1309,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 +1384,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 +1398,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 +1690,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) } @@ -2383,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) { @@ -2451,8 +2530,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)) } @@ -2478,16 +2557,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 showPriorityFlag = todo.priority.lowercased() == "high" + 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( @@ -2499,10 +2595,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)) @@ -2511,15 +2609,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)) } @@ -2532,43 +2630,74 @@ 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 + } + } } } 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/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 diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index 5629014a..ca801da5 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 @@ -300,7 +301,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) { @@ -326,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") @@ -344,15 +345,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)) } @@ -365,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( @@ -392,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() } } @@ -427,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 e8a4836e..ee624827 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 } @@ -185,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( @@ -223,18 +229,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)) } } @@ -550,6 +550,7 @@ private struct HomeIconCircleButton: View { private enum HomeTodayTaskCompletionPhase { case active case checked + case struck case fading } @@ -562,106 +563,45 @@ 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 } } } + 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)" } 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 } + 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 { - 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) - .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)) @@ -670,7 +610,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) ) @@ -682,11 +622,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) @@ -698,16 +647,19 @@ private struct HomeTodayTaskRow: View { guard completionPhase == .active else { return } withAnimation(.easeInOut(duration: 0.18)) { - offsetX = 0 completionPhase = .checked } 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)) { @@ -749,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 @@ -869,36 +776,36 @@ private struct HomeCategoryBoard: 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), + color: Color(hex: 0xD98F4B), icon: "clock", watermark: "clock", title: "Scheduled", count: scheduledCount, action: onOpenScheduled ) - } - HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( - color: Color(hex: 0xD48A8C), + color: Color(hex: 0xC97880), icon: "flag.fill", watermark: "flag.fill", title: "Priority", count: priorityCount, action: onOpenPriority ) + } + + HStack(spacing: HomeMetrics.tileGap) { + HomeCategoryTile( + color: Color(hex: 0xE06F66), + icon: "exclamationmark.circle", + watermark: "exclamationmark.circle", + title: "Overdue", + count: overdueCount, + action: onOpenOverdue + ) HomeCategoryTile( - color: Color(hex: 0x4E4E50), + color: Color(hex: 0x68717A), icon: "tray.fill", watermark: "tray.fill", title: "All", @@ -909,7 +816,7 @@ private struct HomeCategoryBoard: View { HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( - color: Color(hex: 0xA8C8B2), + color: Color(hex: 0x719F84), icon: "checkmark", watermark: "checkmark", title: "Completed", @@ -918,7 +825,7 @@ private struct HomeCategoryBoard: View { ) HomeCategoryTile( - color: Color(hex: 0xC3B4DF), + color: Color(hex: 0x9A89D2), icon: "calendar", watermark: nil, title: "Calendar", @@ -1064,6 +971,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? @@ -1082,7 +1024,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 { @@ -1180,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 @@ -1215,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) @@ -1390,7 +1332,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 @@ -1705,21 +1647,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] = [ @@ -1794,7 +1736,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/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 ed4bdcef..88343c0a 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 @@ -43,7 +49,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 @@ -152,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? @@ -173,6 +215,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 @@ -180,6 +223,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? @@ -187,21 +231,29 @@ 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 - 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"] : []) + _collapsedSectionIDs = State(initialValue: mode == .priority || mode == .all || mode == .list ? ["earlier"] : []) } private var groupedSections: [TodoTimelineSection] { buildSections( items: viewModel.items, mode: viewModel.mode, - includeEmptyEarlierTarget: viewModel.mode.supportsTaskReschedule && (draggedTodo != nil || inAppDrag != nil) + includeEmptyEarlierTarget: false ) } @@ -240,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)" } @@ -313,6 +365,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, @@ -501,9 +573,18 @@ 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) } + }, + onDeleteRequest: { + showingListSettings = false + withAnimation(.spring(response: 0.24, dampingFraction: 0.9)) { + showingDeleteListConfirmation = true + } + } + ) } private func handleItemsChanged() { @@ -529,15 +610,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 +626,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 +659,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 +687,8 @@ struct TodoListScreen: View { dropTargetFrames = [:] if let targetDate { requestReschedule(todo, to: targetDate) + } else { + TodoTaskDragSession.shared.todo = nil } } @@ -584,11 +697,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 +834,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 +865,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -754,7 +877,7 @@ struct TodoListScreen: View { .todoInAppDropTargetFrame( targetID: "standard-spacer-\(section.id)", section: section, - enabled: draggedTodo != nil + enabled: isDropEligibleSection ) .listRowInsets(EdgeInsets()) .scheduledTodoDropTarget( @@ -764,6 +887,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -771,13 +895,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 +911,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -836,7 +961,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( @@ -929,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() @@ -956,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( @@ -988,6 +1116,7 @@ struct TodoListScreen: View { onMove: { droppedTodo, targetDate in requestReschedule(droppedTodo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -1009,20 +1138,24 @@ 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) - 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( @@ -1034,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)) @@ -1046,15 +1181,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)) } @@ -1065,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)) @@ -1087,6 +1223,7 @@ struct TodoListScreen: View { onMove: { droppedTodo, targetDate in requestReschedule(droppedTodo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -1104,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 } } @@ -1128,16 +1271,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 +1295,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -1162,7 +1308,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 +1323,7 @@ struct TodoListScreen: View { } header: { TimelineSectionHeader( title: section.title, - isActiveDropTarget: activeDropSectionId == section.id, + isActiveDropTarget: isActiveDropSection, isCollapsible: canCollapseSection, isCollapsed: isCollapsed, onTap: canCollapseSection ? { @@ -1185,13 +1331,13 @@ struct TodoListScreen: View { } : nil ) .id(timelineSectionScrollID(section.id)) - .padding(.top, isFirstSection ? 0 : 8) - .frame(maxWidth: .infinity, minHeight: viewModel.mode.supportsTaskReschedule && draggedTodo != nil ? 44 : nil, alignment: .leading) + .padding(.top, isFirstSection ? 0 : TodoTimelineMetrics.sectionTopSpacing) + .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 +1347,7 @@ struct TodoListScreen: View { onMove: { todo, targetDate in requestReschedule(todo, to: targetDate) }, + canMoveTodo: canDropTodo, onSectionChange: { sectionId in setActiveDropSection(sectionId) } @@ -1227,6 +1374,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 } @@ -1283,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 @@ -1314,8 +1465,6 @@ struct TodoListScreen: View { return "Overdue, \(dueBodyText)" } return "Due \(dueBodyText)" - default: - return dueBodyText } } } @@ -1694,7 +1843,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()) @@ -1735,8 +1884,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)) } @@ -2038,63 +2187,371 @@ 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 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 + + @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 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 = "BLUE" + @State private var color = "PINK" @State private var iconKey = "inbox" - private let colors = ["BLUE", "GREEN", "ORANGE", "PINK", "PURPLE", "GRAY"] - 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 { + ListSettingsSheetDeleteButton { + dismiss() + onDeleteRequest() } + .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 = list?.color ?? "BLUE" - 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: " ") } } @@ -2117,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) @@ -2140,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 @@ -2154,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() @@ -2214,10 +2744,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) { @@ -2242,6 +2780,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 } @@ -2257,7 +2798,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) } } @@ -2272,6 +2813,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 @@ -2282,6 +2824,7 @@ private extension View { draggedTodo: draggedTodo, resolveTodo: resolveTodo, onMove: onMove, + canMoveTodo: canMoveTodo, onSectionChange: onSectionChange ) ) @@ -2297,6 +2840,10 @@ private extension View { onSectionChange(nil) return false } + guard canMoveTodo(todo, section) else { + onSectionChange(nil) + return false + } onSectionChange(nil) onMove(todo, targetDate) return true @@ -2307,6 +2854,12 @@ private extension View { } return } + if active, + let todo = draggedTodo ?? TodoTaskDragSession.shared.todo, + !canMoveTodo(todo, section) { + onSectionChange(nil) + return + } onSectionChange(active ? section.id : nil) } } @@ -2397,11 +2950,18 @@ private func buildSections( placesEarlierBeforeToday: true, includeEmptyEarlierTarget: includeEmptyEarlierTarget ) - case .priority, .list: + case .priority: return buildFutureTimelineSections( items: items, calendar: calendar, - placesEarlierBeforeToday: false, + placesEarlierBeforeToday: true, + includeEmptyEarlierTarget: includeEmptyEarlierTarget + ) + case .list: + return buildFutureTimelineSections( + items: items, + calendar: calendar, + placesEarlierBeforeToday: true, includeEmptyEarlierTarget: includeEmptyEarlierTarget ) } @@ -2550,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) @@ -2578,7 +3165,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 +3174,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: @@ -2640,39 +3238,59 @@ 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" + } +} + +private func normalizedTodoListIconKey(_ key: String?) -> String { + guard let key, todoListSettingsIconKeys.contains(key) else { + return "inbox" } + return key } private func todoListSymbolName(for key: String?) -> String { 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/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 008bd411..da702fe1 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 @@ -757,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) } } 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..054272d7 100644 --- a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift +++ b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift @@ -52,10 +52,9 @@ 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 openVelocityThreshold: CGFloat = -180 private var revealProgress: CGFloat { min(1, max(0, -offsetX / revealWidth)) @@ -66,34 +65,13 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { content .offset(x: offsetX) .contentShape(Rectangle()) - .simultaneousGesture( - DragGesture(minimumDistance: 6) - .onChanged { value in - guard enabled else { return } - guard abs(value.translation.width) > abs(value.translation.height) else { return } - if !isHorizontalDragging { - 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 < -200 - withAnimation(.spring(response: 0.34, dampingFraction: 0.78)) { - offsetX = shouldOpen ? -revealWidth : 0 - } - } + .background( + HorizontalSwipePanObserver( + enabled: enabled, + revealWidth: revealWidth, + openVelocityThreshold: openVelocityThreshold, + offsetX: $offsetX + ) ) .onTapGesture { guard enabled else { return } @@ -103,6 +81,11 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { revealHint() } } + .onChange(of: enabled) { _, isEnabled in + if !isEnabled { + closeActions() + } + } HStack(spacing: 16) { Spacer() @@ -134,7 +117,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 } } @@ -157,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 @@ -232,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 + } +} 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() } }