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 45cb247c..7fdf5373 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 @@ -344,6 +344,14 @@ fun TdayApp( iconKey = iconKey, ) }, + onCompleteTask = { todo -> homeViewModel.completeTodo(todo) }, + onDeleteTask = { todo -> homeViewModel.deleteTodo(todo) }, + onUpdateTask = { todo, payload -> + homeViewModel.updateTask( + todo, + payload + ) + }, ) } else { HomeScreen( @@ -362,6 +370,9 @@ fun TdayApp( onCreateTask = { _ -> }, onParseTaskTitleNlp = { _, _ -> null }, onCreateList = { _, _, _ -> }, + onCompleteTask = {}, + onDeleteTask = {}, + onUpdateTask = { _, _ -> }, ) } } @@ -584,6 +595,7 @@ fun TdayApp( onParseTaskTitleNlp = viewModel::parseTaskTitleNlp, onCompleteTask = viewModel::complete, onUpdateTask = viewModel::updateTask, + onMoveTask = viewModel::moveTask, onDelete = { todo -> viewModel.delete(todo) { showTaskDeletedToast() @@ -828,6 +840,7 @@ private fun TodosRoute( onAddTask = viewModel::addTask, onParseTaskTitleNlp = viewModel::parseTaskTitleNlp, onUpdateTask = viewModel::updateTask, + onMoveTask = viewModel::moveTask, onComplete = viewModel::toggleComplete, onDelete = { todo -> viewModel.delete(todo) { diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt index 096c6a03..b91bfbd2 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/ApiResponseUtils.kt @@ -76,6 +76,17 @@ internal fun isLikelyConnectivityIssue(error: Throwable): Boolean { return false } +internal fun isSessionAuthenticationIssue(error: Throwable): Boolean { + var current: Throwable? = error + while (current != null) { + if (current is ApiCallException && current.statusCode == 401) { + return true + } + current = current.cause?.takeIf { it !== current } + } + return false +} + internal fun isLikelyServerUnavailableStatus(statusCode: Int): Boolean { return statusCode == 408 || statusCode == 502 || 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 6026d6a9..22ebbcef 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 @@ -318,12 +318,30 @@ class SyncManager @Inject constructor( } val remoteTodo = remoteSnapshot.todos.firstOrNull { it.canonicalId == targetId } - val descriptionForApi = mutation.description - ?: if (remoteTodo?.description != null) "" else null - val rruleForApi = mutation.rrule - ?: if (!remoteTodo?.rrule.isNullOrBlank()) "" else null - val listIdForApi = resolvedListId - ?: if (!remoteTodo?.listId.isNullOrBlank()) "" else null + val isDueOnlyMove = mutation.dueEpochMs != null && + mutation.title == null && + mutation.description == null && + mutation.priority == null && + mutation.pinned == null && + mutation.completed == null && + mutation.rrule == null && + mutation.listId == null + val descriptionForApi = if (isDueOnlyMove) { + null + } else { + mutation.description + ?: if (remoteTodo?.description != null) "" else null + } + val rruleForApi = if (isDueOnlyMove) { + null + } else { + mutation.rrule ?: if (!remoteTodo?.rrule.isNullOrBlank()) "" else null + } + val listIdForApi = if (isDueOnlyMove) { + null + } else { + resolvedListId ?: if (!remoteTodo?.listId.isNullOrBlank()) "" else null + } if (mutation.instanceDateEpochMs != null) { requireApiBody( @@ -356,7 +374,7 @@ class SyncManager @Inject constructor( rrule = rruleForApi, listID = listIdForApi, dateChanged = true, - rruleChanged = true, + rruleChanged = if (isDueOnlyMove) null else true, instanceDate = null, ), ), 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 afde972d..f855ed2a 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 @@ -337,6 +337,123 @@ class TodoRepository @Inject constructor( } } + suspend fun moveTodo(todo: TodoItem, due: Instant) { + val canonicalId = todo.canonicalId + if (canonicalId.isBlank()) return + + val instanceDateEpochMs = todo.instanceDateEpochMillis + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.UPDATE_TODO, + targetId = canonicalId, + timestampEpochMs = timestampMs, + dueEpochMs = due.toEpochMilli(), + instanceDateEpochMs = instanceDateEpochMs, + ) + + val isLocalOnly = canonicalId.startsWith(LOCAL_TODO_PREFIX) + cacheManager.updateOfflineState { state -> + val hasExistingUpdateMutation = state.pendingMutations.any { mutation -> + mutation.kind == MutationKind.UPDATE_TODO && + mutation.targetId == canonicalId && + mutation.instanceDateEpochMs == instanceDateEpochMs + } + val updatedMutations = state.pendingMutations + .map { mutation -> + when { + mutation.kind == MutationKind.CREATE_TODO && mutation.targetId == canonicalId -> { + mutation.copy( + dueEpochMs = due.toEpochMilli(), + timestampEpochMs = timestampMs, + ) + } + + mutation.kind == MutationKind.UPDATE_TODO && + mutation.targetId == canonicalId && + mutation.instanceDateEpochMs == instanceDateEpochMs -> { + mutation.copy( + dueEpochMs = due.toEpochMilli(), + timestampEpochMs = timestampMs, + ) + } + + else -> mutation + } + } + state.copy( + todos = state.todos.map { cached -> + val isTarget = cached.canonicalId == canonicalId && + (instanceDateEpochMs == null || cached.instanceDateEpochMs == instanceDateEpochMs) + if (isTarget) { + cached.copy( + dueEpochMs = due.toEpochMilli(), + updatedAtEpochMs = timestampMs, + ) + } else { + cached + } + }, + pendingMutations = if (isLocalOnly || hasExistingUpdateMutation) { + updatedMutations + } else { + updatedMutations + pendingMutation + }, + ) + } + + if (isLocalOnly) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val immediateError = runCatching { + if (instanceDateEpochMs != null) { + requireApiBody( + api.patchTodoInstanceByBody( + TodoInstanceUpdateRequest( + todoId = canonicalId, + instanceDate = Instant.ofEpochMilli(instanceDateEpochMs).toString(), + due = due.toString(), + ), + ), + "Could not reschedule recurring task instance", + ) + } else { + requireApiBody( + api.patchTodoByBody( + UpdateTodoRequest( + id = canonicalId, + due = due.toString(), + dateChanged = true, + instanceDate = null, + ), + ), + "Could not reschedule task", + ) + } + }.exceptionOrNull() + + if (immediateError != null && isLikelyUnrecoverableMutationError( + immediateError, + pendingMutation + ) + ) { + throw immediateError + } + + if (immediateError == null) { + cacheManager.updateOfflineState { state -> + state.copy( + pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }, + ) + } + } else { + Log.w(LOG_TAG, "moveTodo deferred todo=$canonicalId reason=${immediateError.message}") + } + } + suspend fun deleteTodo(todo: TodoItem) { val timestampMs = System.currentTimeMillis() val canonicalId = todo.canonicalId 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 63f622b0..507212f5 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 @@ -1,6 +1,10 @@ package com.ohmz.tday.compose.core.model import java.time.Instant +import java.time.LocalDate +import java.time.YearMonth +import java.time.ZoneId +import java.time.ZonedDateTime enum class TodoListMode { TODAY, @@ -11,6 +15,11 @@ enum class TodoListMode { LIST, } +enum class TaskRescheduleScope { + OCCURRENCE, + SERIES, +} + data class CreateTaskPayload( val title: String, val description: String? = null, @@ -41,6 +50,84 @@ data class TodoItem( get() = instanceDate?.toEpochMilli() } +fun TodoListMode.supportsTaskReschedule(): Boolean { + return when (this) { + TodoListMode.SCHEDULED, + TodoListMode.ALL, + TodoListMode.PRIORITY, + TodoListMode.LIST, + -> true + + TodoListMode.TODAY, + TodoListMode.OVERDUE, + -> false + } +} + +fun TodoItem.repositoryTargetForReschedule(scope: TaskRescheduleScope): TodoItem { + return when (scope) { + TaskRescheduleScope.OCCURRENCE -> this + TaskRescheduleScope.SERIES -> copy(id = canonicalId, instanceDate = null) + } +} + +fun movedDuePreservingTime( + due: Instant, + targetDate: LocalDate, + zoneId: ZoneId = ZoneId.systemDefault(), +): Instant { + val dueTime = due.atZone(zoneId).toLocalTime() + return ZonedDateTime.of(targetDate, dueTime, zoneId).toInstant() +} + +fun createMovedTaskPayload( + todo: TodoItem, + targetDate: LocalDate, + zoneId: ZoneId = ZoneId.systemDefault(), +): CreateTaskPayload { + return CreateTaskPayload( + title = todo.title, + description = todo.description, + priority = todo.priority, + due = movedDuePreservingTime(todo.due, targetDate, zoneId), + rrule = todo.rrule, + listId = todo.listId, + ) +} + +fun timelineRescheduleTargetDate( + sectionKey: String, + today: LocalDate = LocalDate.now(), +): LocalDate? { + val currentMonth = YearMonth.from(today) + if (sectionKey == "earlier") { + return today.minusDays(1) + } + + if (sectionKey.startsWith("day-")) { + val date = runCatching { LocalDate.parse(sectionKey.removePrefix("day-")) }.getOrNull() + ?: return null + return date.takeIf { YearMonth.from(it) >= currentMonth } + } + + if (sectionKey.startsWith("rest-")) { + val month = runCatching { YearMonth.parse(sectionKey.removePrefix("rest-")) }.getOrNull() + ?: return null + val horizonStart = today.plusDays(7) + return horizonStart.takeIf { + month == currentMonth && YearMonth.from(it) == month + } + } + + if (sectionKey.startsWith("month-")) { + val month = runCatching { YearMonth.parse(sectionKey.removePrefix("month-")) }.getOrNull() + ?: return null + return month.takeIf { it >= currentMonth }?.atDay(1) + } + + return null +} + data class ListSummary( val id: String, val name: String, 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 c38d42ec..5d9af45a 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 @@ -9,6 +9,7 @@ import com.ohmz.tday.compose.core.data.auth.AuthRepository import com.ohmz.tday.compose.core.data.auth.SystemCredentialServicing import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue +import com.ohmz.tday.compose.core.data.isSessionAuthenticationIssue import com.ohmz.tday.compose.core.data.server.AppVersionManager import com.ohmz.tday.compose.core.data.server.ServerConfigRepository import com.ohmz.tday.compose.core.data.server.VersionCheckResult @@ -494,14 +495,21 @@ class AppViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isManualSyncing = true) } - val result = syncManager.syncCachedData( - force = true, - replayPendingMutations = true, - notifyOfflineFailure = false, + val result = recoverSessionAndRetrySyncIfNeeded( + after = syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + ), connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, ) val syncError = result.exceptionOrNull() - val isOffline = syncError != null && isLikelyConnectivityIssue(syncError) + val isOffline = syncError != null && + shouldTreatSyncFailureAsOffline( + error = syncError, + suppressAuthenticationExpired = true, + ) _uiState.update { it.copy( isManualSyncing = false, @@ -512,7 +520,15 @@ class AppViewModel @Inject constructor( }.getOrDefault(it.pendingMutationCount), ) } - syncError?.let(::classifyAndShowError) + if (syncError == null && !realtimeClient.isConnected && _uiState.value.authenticated) { + realtimeClient.connect() + } + syncError?.let { + classifyAndShowError( + error = it, + suppressAuthenticationExpired = true, + ) + } launch(Dispatchers.Default) { runCatching { reminderScheduler.rescheduleAll() } } } } @@ -526,6 +542,7 @@ class AppViewModel @Inject constructor( replayPending = true, markOfflineOnConnectivityFailure = false, connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + suppressAuthenticationExpired = true, ) val syncError = result.exceptionOrNull() if (syncError == null || !isLikelyConnectivityIssue(syncError)) return@launch @@ -537,6 +554,7 @@ class AppViewModel @Inject constructor( replayPending = true, showOfflineNotice = true, connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + suppressAuthenticationExpired = true, ) } } @@ -594,7 +612,10 @@ class AppViewModel @Inject constructor( val delayMs = if (hasPending) PENDING_RESYNC_INTERVAL_MS else RESYNC_INTERVAL_MS delay(delayMs) - syncAndUpdateOfflineState(replayPending = hasPending) + syncAndUpdateOfflineState( + replayPending = hasPending, + suppressAuthenticationExpired = true, + ) } } } @@ -604,25 +625,35 @@ class AppViewModel @Inject constructor( showOfflineNotice: Boolean = false, connectionProbeTimeoutMs: Long? = null, markOfflineOnConnectivityFailure: Boolean = true, + suppressAuthenticationExpired: Boolean = false, ): Result { - val result = syncManager.syncCachedData( - force = true, - replayPendingMutations = replayPending, - notifyOfflineFailure = false, + val result = recoverSessionAndRetrySyncIfNeeded( + after = syncManager.syncCachedData( + force = true, + replayPendingMutations = replayPending, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = connectionProbeTimeoutMs, + ), connectionProbeTimeoutMs = connectionProbeTimeoutMs, ) val syncError = result.exceptionOrNull() _uiState.update { - val isOffline = syncError != null && isLikelyConnectivityIssue(syncError) - val shouldMarkOffline = isOffline && markOfflineOnConnectivityFailure + val isOffline = syncError != null && + shouldTreatSyncFailureAsOffline( + error = syncError, + suppressAuthenticationExpired = suppressAuthenticationExpired, + ) + val shouldDeferOfflineState = syncError != null && + isLikelyConnectivityIssue(syncError) && + !markOfflineOnConnectivityFailure it.copy( isOffline = when { syncError == null -> false - shouldMarkOffline -> true - isOffline -> it.isOffline + shouldDeferOfflineState -> it.isOffline + isOffline -> true else -> false }, - offlineNoticeId = if (shouldMarkOffline && showOfflineNotice) { + offlineNoticeId = if (isOffline && showOfflineNotice && !shouldDeferOfflineState) { it.offlineNoticeId + 1L } else { it.offlineNoticeId @@ -633,7 +664,10 @@ class AppViewModel @Inject constructor( ) } if (syncError != null) { - classifyAndShowError(syncError) + classifyAndShowError( + error = syncError, + suppressAuthenticationExpired = suppressAuthenticationExpired, + ) } else if (!realtimeClient.isConnected && _uiState.value.authenticated) { realtimeClient.connect() } @@ -641,8 +675,52 @@ class AppViewModel @Inject constructor( return result } - private fun classifyAndShowError(error: Throwable) { - if (isLikelyConnectivityIssue(error)) return + private suspend fun recoverSessionAndRetrySyncIfNeeded( + after: Result, + connectionProbeTimeoutMs: Long?, + ): Result { + val error = after.exceptionOrNull() ?: return after + if (!isSessionAuthenticationIssue(error)) return after + + val restoredSession = authRepository.restoreSessionForBootstrap() ?: return after + _uiState.update { + it.copy( + authenticated = true, + requiresServerSetup = false, + requiresLogin = false, + serverUrl = serverConfigRepository.getServerUrl(), + user = restoredSession.user, + error = null, + pendingApprovalMessage = null, + isOffline = restoredSession.usedCachedSession, + ) + } + + if (restoredSession.usedCachedSession) { + return after + } + + return syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = connectionProbeTimeoutMs, + ) + } + + private fun shouldTreatSyncFailureAsOffline( + error: Throwable, + suppressAuthenticationExpired: Boolean, + ): Boolean { + return isLikelyConnectivityIssue(error) || + (suppressAuthenticationExpired && isSessionAuthenticationIssue(error)) + } + + private fun classifyAndShowError( + error: Throwable, + suppressAuthenticationExpired: Boolean = false, + ) { + if (shouldTreatSyncFailureAsOffline(error, suppressAuthenticationExpired)) return snackbarManager.showError(error.userFacingMessage()) { if (error !is ApiCallException || error.statusCode != 401) syncNow() } @@ -657,14 +735,20 @@ class AppViewModel @Inject constructor( when (event) { is RealtimeEvent.Connected -> { if (_uiState.value.isOffline) { - syncAndUpdateOfflineState(replayPending = true) + syncAndUpdateOfflineState( + replayPending = true, + suppressAuthenticationExpired = true, + ) } } is RealtimeEvent.TodoChanged, is RealtimeEvent.ListChanged, is RealtimeEvent.CompletedChanged, -> { - syncAndUpdateOfflineState(replayPending = false) + syncAndUpdateOfflineState( + replayPending = false, + suppressAuthenticationExpired = true, + ) } is RealtimeEvent.Disconnected -> { delay(REALTIME_RECONNECT_DELAY_MS) @@ -686,6 +770,7 @@ class AppViewModel @Inject constructor( syncAndUpdateOfflineState( replayPending = true, connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + suppressAuthenticationExpired = true, ) if (!realtimeClient.isConnected) { realtimeClient.connect() @@ -703,6 +788,7 @@ class AppViewModel @Inject constructor( replayPending = true, showOfflineNotice = true, connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + suppressAuthenticationExpired = true, ) } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt index ded95f04..a8b31e98 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt @@ -2,14 +2,14 @@ package com.ohmz.tday.compose.feature.calendar import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import kotlinx.coroutines.flow.distinctUntilChanged import java.time.LocalDate internal data class CalendarTodayJumpRequest( @@ -17,55 +17,62 @@ internal data class CalendarTodayJumpRequest( val targetDate: LocalDate, ) -internal enum class CalendarPagerSlot { - PREVIOUS, - CURRENT, - NEXT, -} - -internal data class CalendarPagerPage( - val slot: CalendarPagerSlot, - val value: T, +internal data class CalendarPagerScrollRequest( + val id: Int, + val page: Int, ) +private const val CalendarPagerPreloadRadius = 1 + @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun CalendarPagingContent( - pages: List>, - pagerState: PagerState, - centerPageIndex: Int, - onSettledAwayFromCenter: (CalendarPagerSlot) -> Unit, +internal fun CalendarPagingContent( + pageCount: Int, + currentPage: Int, + onPageSettled: (Int) -> Unit, modifier: Modifier = Modifier, - pageContent: @Composable (T) -> Unit, + scrollRequest: CalendarPagerScrollRequest? = null, + onScrollRequestHandled: (Int) -> Unit = {}, + pageKey: (Int) -> Any = { it }, + pageContent: @Composable (Int) -> Unit, ) { - var handledSettledPage by remember { mutableStateOf(null) } + val boundedPageCount = pageCount.coerceAtLeast(1) + val targetPage = currentPage.coerceIn(0, boundedPageCount - 1) + val pagerState = rememberPagerState(initialPage = targetPage) { boundedPageCount } + val latestTargetPage by rememberUpdatedState(targetPage) + val latestOnPageSettled by rememberUpdatedState(onPageSettled) - LaunchedEffect(centerPageIndex, pages) { - handledSettledPage = null - if (pagerState.currentPage != centerPageIndex) { - pagerState.scrollToPage(centerPageIndex) + LaunchedEffect(targetPage, boundedPageCount) { + if (!pagerState.isScrollInProgress && pagerState.currentPage != targetPage) { + pagerState.scrollToPage(targetPage) } } - LaunchedEffect(pagerState.settledPage, centerPageIndex, pages) { - val settledPage = pagerState.settledPage - if (settledPage == centerPageIndex || handledSettledPage == settledPage) return@LaunchedEffect - val settledSlot = pages.getOrNull(settledPage)?.slot ?: return@LaunchedEffect - handledSettledPage = settledPage - onSettledAwayFromCenter(settledSlot) + LaunchedEffect(scrollRequest?.id, boundedPageCount) { + val request = scrollRequest ?: return@LaunchedEffect + val requestedPage = request.page.coerceIn(0, boundedPageCount - 1) + if (pagerState.currentPage != requestedPage) { + pagerState.animateScrollToPage(requestedPage) + } + onScrollRequestHandled(request.id) + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect { settledPage -> + if (settledPage != latestTargetPage) { + latestOnPageSettled(settledPage) + } + } } HorizontalPager( state = pagerState, modifier = modifier, - key = { page -> pages.getOrNull(page)?.slot ?: page }, - beyondViewportPageCount = 1, + key = pageKey, + beyondViewportPageCount = (boundedPageCount - 1).coerceAtMost(CalendarPagerPreloadRadius), ) { page -> - pages.getOrNull(page)?.let { calendarPage -> - pageContent(calendarPage.value) - } + pageContent(page) } } - -internal fun List>.indexOfSlot(slot: CalendarPagerSlot): Int = - indexOfFirst { it.slot == slot } 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 41b75751..7e6620df 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt @@ -1,25 +1,23 @@ package com.ohmz.tday.compose.feature.calendar -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource @@ -41,34 +39,86 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.DirectionsRun import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.automirrored.rounded.MenuBook +import androidx.compose.material.icons.rounded.AcUnit +import androidx.compose.material.icons.rounded.AccountBalance +import androidx.compose.material.icons.rounded.AccountBalanceWallet import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Architecture +import androidx.compose.material.icons.rounded.Backpack +import androidx.compose.material.icons.rounded.BeachAccess +import androidx.compose.material.icons.rounded.Bookmark import androidx.compose.material.icons.rounded.BorderColor +import androidx.compose.material.icons.rounded.Build +import androidx.compose.material.icons.rounded.Cake import androidx.compose.material.icons.rounded.CalendarMonth +import androidx.compose.material.icons.rounded.CalendarToday +import androidx.compose.material.icons.rounded.CameraAlt +import androidx.compose.material.icons.rounded.CardGiftcard +import androidx.compose.material.icons.rounded.ChangeHistory +import androidx.compose.material.icons.rounded.ChatBubbleOutline import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.ChevronLeft import androidx.compose.material.icons.rounded.ChevronRight +import androidx.compose.material.icons.rounded.ChildCare +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Code +import androidx.compose.material.icons.rounded.Computer +import androidx.compose.material.icons.rounded.ContentCut import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.DesktopWindows +import androidx.compose.material.icons.rounded.DirectionsBoat import androidx.compose.material.icons.rounded.DirectionsCar +import androidx.compose.material.icons.rounded.Eco +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.FamilyRestroom +import androidx.compose.material.icons.rounded.Favorite import androidx.compose.material.icons.rounded.FitnessCenter import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Flight +import androidx.compose.material.icons.rounded.Headphones import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Inbox +import androidx.compose.material.icons.rounded.Inventory +import androidx.compose.material.icons.rounded.Key +import androidx.compose.material.icons.rounded.Lightbulb import androidx.compose.material.icons.rounded.LocalBar -import androidx.compose.material.icons.rounded.LocalHospital +import androidx.compose.material.icons.rounded.LocalMall +import androidx.compose.material.icons.rounded.LocationCity +import androidx.compose.material.icons.rounded.Medication +import androidx.compose.material.icons.rounded.Mood import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.Payments +import androidx.compose.material.icons.rounded.Pets +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 +import androidx.compose.material.icons.rounded.School +import androidx.compose.material.icons.rounded.ShoppingBasket +import androidx.compose.material.icons.rounded.ShoppingCart +import androidx.compose.material.icons.rounded.SportsBaseball +import androidx.compose.material.icons.rounded.SportsBasketball +import androidx.compose.material.icons.rounded.SportsEsports +import androidx.compose.material.icons.rounded.SportsFootball +import androidx.compose.material.icons.rounded.SportsSoccer +import androidx.compose.material.icons.rounded.SportsTennis +import androidx.compose.material.icons.rounded.Square +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material.icons.rounded.Train +import androidx.compose.material.icons.rounded.WaterDrop import androidx.compose.material.icons.rounded.WbSunny +import androidx.compose.material.icons.rounded.Whatshot import androidx.compose.material.icons.rounded.Work +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -78,14 +128,17 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.ripple import androidx.compose.runtime.Composable 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 import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -93,9 +146,15 @@ 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.composed +import androidx.compose.ui.draganddrop.DragAndDropEvent +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.shadow import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance @@ -103,6 +162,9 @@ 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.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -110,16 +172,19 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +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.unit.sp +import androidx.compose.ui.zIndex import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import com.ohmz.tday.compose.R import com.ohmz.tday.compose.core.model.CompletedItem import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.ui.snapTitleCollapsePx @@ -133,7 +198,9 @@ import java.time.YearMonth import java.time.ZoneId import java.time.format.DateTimeFormatter import java.time.format.TextStyle +import java.time.temporal.ChronoUnit import java.util.Locale +import kotlin.math.roundToInt private val CalendarAccentPurple = Color(0xFF7D67B6) private val CalendarTodayBlue = Color(0xFF509AE6) @@ -165,6 +232,36 @@ private val CalendarPeriodCardPageHeight = 78.dp private val CalendarPeriodWeekDayCellHeight = 72.dp private val CalendarPeriodPageHorizontalGutter = 2.dp private val CalendarPeriodCardBottomPadding = 18.dp +private val CalendarTaskDragDueTimeFormatter: DateTimeFormatter = + DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()) +private const val CalendarMonthPagerPageCount = 240 +private const val CalendarWeekPagerPageCount = 1040 +private const val CalendarDayPagerPageCount = 3650 + +private fun shouldShowDateDivider( + afterItemIndex: Int, + items: List, + zoneId: ZoneId, +): Boolean { + val currentTodo = items.getOrNull(afterItemIndex) ?: return false + val nextTodo = items.getOrNull(afterItemIndex + 1) ?: return false + return LocalDate.ofInstant(currentTodo.due, zoneId) != LocalDate.ofInstant(nextTodo.due, zoneId) +} + +private data class CalendarTaskRescheduleDrop( + val todo: TodoItem, + val targetDate: LocalDate, +) + +private data class CalendarTaskDragState( + val todo: TodoItem, + val position: Offset, +) + +private data class CalendarDateDropTargetBounds( + val date: LocalDate, + val bounds: Rect, +) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -176,9 +273,11 @@ fun CalendarScreen( onParseTaskTitleNlp: suspend (title: String, referenceDueEpochMs: Long) -> TodoTitleNlpResponse?, onCompleteTask: (TodoItem) -> Unit, onUpdateTask: (TodoItem, CreateTaskPayload) -> Unit, + onMoveTask: (todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) -> Unit, onDelete: (TodoItem) -> Unit, ) { val zoneId = remember { ZoneId.systemDefault() } + val view = LocalView.current val today = remember { LocalDate.now(zoneId) } val minNavigableMonth = remember(zoneId) { YearMonth.now(zoneId) } val listState = rememberLazyListState() @@ -268,6 +367,7 @@ fun CalendarScreen( val selectedViewMode = remember(selectedViewKey) { CalendarViewMode.entries.firstOrNull { it.name == selectedViewKey } ?: CalendarViewMode.MONTH } + val calendarTaskRescheduleEnabled = selectedViewMode != CalendarViewMode.DAY val tasksByDate = remember(uiState.items, zoneId) { uiState.items .groupBy { LocalDate.ofInstant(it.due, zoneId) } @@ -289,11 +389,37 @@ fun CalendarScreen( var editTargetId by rememberSaveable { mutableStateOf(null) } var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } var createDueEpochMs by rememberSaveable { mutableStateOf(null) } + var draggedCalendarTodoId by rememberSaveable { mutableStateOf(null) } + var activeCalendarDrag by remember { mutableStateOf(null) } + var calendarDragContainerOrigin by remember { mutableStateOf(Offset.Zero) } + val calendarDropTargetBounds = + remember { mutableStateMapOf() } + var activeDropDateIso by remember { mutableStateOf(null) } + var pendingRescheduleDrop by remember { mutableStateOf(null) } + LaunchedEffect(selectedViewMode) { + if (selectedViewMode == CalendarViewMode.DAY) { + draggedCalendarTodoId = null + activeCalendarDrag = null + activeDropDateIso = null + calendarDropTargetBounds.clear() + } + } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } } } + val draggedCalendarTodo = remember(draggedCalendarTodoId, uiState.items) { + draggedCalendarTodoId?.let { targetId -> + uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } + } + } + val resolveTodoForDrop: (String) -> TodoItem? = { targetId -> + uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } + } + val activeDropDate = remember(activeDropDateIso) { + activeDropDateIso?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + } fun openCreateTaskSheetForSelectedDate() { val currentDate = LocalDate.now(zoneId) val prefillDue = if (selectedDate == currentDate) { @@ -305,6 +431,59 @@ fun CalendarScreen( createDueEpochMs = prefillDue.toInstant().toEpochMilli() showCreateTaskSheet = true } + fun requestTaskReschedule(todo: TodoItem, targetDate: LocalDate) { + draggedCalendarTodoId = null + activeCalendarDrag = null + activeDropDateIso = null + calendarDropTargetBounds.clear() + val currentDate = LocalDate.ofInstant(todo.due, zoneId) + if (currentDate == targetDate) return + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + if (todo.isRecurring) { + pendingRescheduleDrop = CalendarTaskRescheduleDrop(todo = todo, targetDate = targetDate) + } else { + onMoveTask(todo, targetDate, TaskRescheduleScope.OCCURRENCE) + selectDate(targetDate) + } + } + + fun activeCalendarDropDate(position: Offset): LocalDate? { + return calendarDropTargetBounds.values + .asSequence() + .filter { target -> target.bounds.contains(position) } + .minByOrNull { target -> target.bounds.width * target.bounds.height } + ?.date + } + + fun updateActiveCalendarDropTarget(position: Offset) { + activeDropDateIso = activeCalendarDropDate(position)?.toString() + } + + fun finishCalendarDrag(position: Offset?) { + val drag = activeCalendarDrag + val targetDate = position?.let(::activeCalendarDropDate) + ?: activeDropDate + activeCalendarDrag = null + draggedCalendarTodoId = null + activeDropDateIso = null + calendarDropTargetBounds.clear() + if (drag != null && targetDate != null) { + requestTaskReschedule(drag.todo, targetDate) + } + } + + fun cancelCalendarDrag() { + activeCalendarDrag = null + draggedCalendarTodoId = null + activeDropDateIso = null + calendarDropTargetBounds.clear() + } + + LaunchedEffect(draggedCalendarTodoId) { + if (draggedCalendarTodoId == null) { + calendarDropTargetBounds.clear() + } + } LaunchedEffect(listState.isScrollInProgress, monthTitleSnapThresholdPx) { if (listState.isScrollInProgress) return@LaunchedEffect if (listState.firstVisibleItemIndex != 0) return@LaunchedEffect @@ -344,7 +523,10 @@ fun CalendarScreen( Box( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .onGloballyPositioned { coordinates -> + calendarDragContainerOrigin = coordinates.positionInRoot() + }, ) { CompositionLocalProvider(LocalOverscrollConfiguration provides null) { LazyColumn( @@ -371,6 +553,12 @@ fun CalendarScreen( Box( modifier = Modifier .fillMaxWidth() + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + ) .shadow( elevation = 2.dp, shape = RoundedCornerShape(CalendarCardCornerRadius), @@ -382,71 +570,64 @@ fun CalendarScreen( shape = RoundedCornerShape(CalendarCardCornerRadius), ), ) { - AnimatedContent( - targetState = selectedViewMode, - transitionSpec = { - val enteringForward = targetState.ordinal > initialState.ordinal - val enter = slideInHorizontally( - animationSpec = tween(durationMillis = 200), - initialOffsetX = { fullWidth -> - if (enteringForward) fullWidth / 4 else -fullWidth / 4 - }, - ) - val exit = slideOutHorizontally( - animationSpec = tween(durationMillis = 180), - targetOffsetX = { fullWidth -> - if (enteringForward) -fullWidth / 4 else fullWidth / 4 - }, - ) - (enter togetherWith exit).using(SizeTransform(clip = true)) - }, - label = "calendarViewModeAnimatedContent", - ) { mode -> - when (mode) { - CalendarViewMode.MONTH -> CalendarMonthCard( - visibleMonth = visibleMonth, - canGoPrevMonth = visibleMonth > minNavigableMonth, - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onPrevMonth = { - if (visibleMonth > minNavigableMonth) { - visibleMonthIso = visibleMonth.minusMonths(1).toString() - } - }, - onNextMonth = { - visibleMonthIso = visibleMonth.plusMonths(1).toString() - }, - onSelectDate = ::selectDate, - ) + when (selectedViewMode) { + CalendarViewMode.MONTH -> CalendarMonthCard( + visibleMonth = visibleMonth, + minNavigableMonth = minNavigableMonth, + canGoPrevMonth = visibleMonth > minNavigableMonth, + selectedDate = selectedDate, + today = today, + tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, + dropTargets = calendarDropTargetBounds, + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onVisibleMonthChanged = { targetMonth -> + if (targetMonth >= minNavigableMonth) { + visibleMonthIso = targetMonth.toString() + } + }, + onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, + ) - CalendarViewMode.WEEK -> CalendarWeekCard( - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), - canSelectDate = ::canNavigateTo, - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onPrevWeek = { selectDate(selectedDate.minusWeeks(1)) }, - onNextWeek = { selectDate(selectedDate.plusWeeks(1)) }, - onSelectDate = ::selectDate, - ) + CalendarViewMode.WEEK -> CalendarWeekCard( + selectedDate = selectedDate, + minNavigableMonth = minNavigableMonth, + today = today, + tasksByDate = tasksByDate, + draggedTodo = draggedCalendarTodo, + activeDropDate = activeDropDate, + dropTargets = calendarDropTargetBounds, + canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onSelectDate = ::selectDate, + onDropDateChanged = { date -> + activeDropDateIso = date?.toString() + }, + onMoveTaskToDate = ::requestTaskReschedule, + resolveTodo = resolveTodoForDrop, + ) - CalendarViewMode.DAY -> CalendarDayCard( - selectedDate = selectedDate, - today = today, - tasksByDate = tasksByDate, - canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), - todayJumpRequest = todayJumpRequest, - onTodayJumpHandled = ::clearTodayJumpRequest, - onPrevDay = { selectDate(selectedDate.minusDays(1)) }, - onNextDay = { selectDate(selectedDate.plusDays(1)) }, - onSelectDate = ::selectDate, - ) - } + CalendarViewMode.DAY -> CalendarDayCard( + selectedDate = selectedDate, + minNavigableMonth = minNavigableMonth, + today = today, + tasksByDate = tasksByDate, + canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, + onSelectDate = ::selectDate, + ) } } } @@ -466,14 +647,39 @@ fun CalendarScreen( if (selectedDatePendingTasks.isNotEmpty()) { item { Column(modifier = Modifier.fillMaxWidth()) { - selectedDatePendingTasks.forEach { todo -> + 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, ) } } @@ -493,6 +699,22 @@ fun CalendarScreen( item { Spacer(modifier = Modifier.height(96.dp)) } } } + + activeCalendarDrag?.let { drag -> + CalendarTaskDragPreview( + modifier = Modifier + .offset { + val localPosition = drag.position - calendarDragContainerOrigin + IntOffset( + x = (localPosition.x - with(density) { 130.dp.toPx() }).roundToInt(), + y = (localPosition.y - with(density) { 34.dp.toPx() }).roundToInt(), + ) + } + .zIndex(20f), + todo = drag.todo, + lists = uiState.lists, + ) + } } } @@ -513,6 +735,44 @@ fun CalendarScreen( ) } + pendingRescheduleDrop?.let { drop -> + AlertDialog( + onDismissRequest = { pendingRescheduleDrop = null }, + title = { + Text( + text = stringResource(R.string.todos_reschedule_recurring_title), + fontWeight = FontWeight.ExtraBold, + ) + }, + text = { + Text(text = stringResource(R.string.todos_reschedule_recurring_message)) + }, + dismissButton = { + TextButton(onClick = { pendingRescheduleDrop = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + Row { + TextButton(onClick = { + pendingRescheduleDrop = null + onMoveTask(drop.todo, drop.targetDate, TaskRescheduleScope.OCCURRENCE) + selectDate(drop.targetDate) + }) { + Text(stringResource(R.string.todos_reschedule_this_occurrence)) + } + TextButton(onClick = { + pendingRescheduleDrop = null + onMoveTask(drop.todo, drop.targetDate, TaskRescheduleScope.SERIES) + selectDate(drop.targetDate) + }) { + Text(stringResource(R.string.todos_reschedule_entire_series)) + } + } + }, + ) + } + editTarget?.let { todo -> CreateTaskBottomSheet( lists = uiState.lists, @@ -584,100 +844,73 @@ private fun CalendarViewModeTabs( @Composable private fun CalendarWeekCard( selectedDate: LocalDate, + minNavigableMonth: YearMonth, today: LocalDate, tasksByDate: Map>, + draggedTodo: TodoItem?, + activeDropDate: LocalDate?, + dropTargets: MutableMap, canGoPrevWeek: Boolean, canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, - onPrevWeek: () -> Unit, - onNextWeek: () -> Unit, onSelectDate: (LocalDate) -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, ) { val colorScheme = MaterialTheme.colorScheme + val minWeekStart = remember(minNavigableMonth) { startOfWeek(minNavigableMonth.atDay(1)) } val weekStart = remember(selectedDate) { startOfWeek(selectedDate) } val coroutineScope = rememberCoroutineScope() - var pendingTodayJump by remember { mutableStateOf(null) } - val todayJumpDirection = pendingTodayJump?.let { request -> - val targetWeek = startOfWeek(request.targetDate) - when { - targetWeek < weekStart -> CalendarPagerSlot.PREVIOUS - targetWeek > weekStart -> CalendarPagerSlot.NEXT - else -> null - } + val selectedDayOffset = remember(selectedDate) { + (selectedDate.dayOfWeek.value % 7).toLong() } - val previousPageWeek = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { - pendingTodayJump?.targetDate?.let(::startOfWeek) - } else if (canGoPrevWeek) { - weekStart.minusWeeks(1) - } else { - null + val currentPage = remember(minWeekStart, weekStart) { + ChronoUnit.WEEKS.between(minWeekStart, weekStart) + .toInt() + .coerceIn(0, CalendarWeekPagerPageCount - 1) } - val nextPageWeek = if (todayJumpDirection == CalendarPagerSlot.NEXT) { - pendingTodayJump?.targetDate?.let(::startOfWeek) ?: weekStart.plusWeeks(1) - } else { - weekStart.plusWeeks(1) - } - val pages = remember(previousPageWeek, weekStart, nextPageWeek) { - buildList { - previousPageWeek?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } - add(CalendarPagerPage(CalendarPagerSlot.CURRENT, weekStart)) - add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageWeek)) - } - } - val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) - val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } - val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress + var scrollRequest by remember { mutableStateOf(null) } + val isPagingAtRest = scrollRequest == null - fun requestPage(slot: CalendarPagerSlot) { - val targetIndex = pages.indexOfSlot(slot) - if (targetIndex < 0 || !isPagingAtRest) return + fun requestPage(offset: Int) { + val targetIndex = (currentPage + offset).coerceIn(0, CalendarWeekPagerPageCount - 1) + if (targetIndex == currentPage || !isPagingAtRest) return coroutineScope.launch { - pagerState.animateScrollToPage(targetIndex) + scrollRequest = CalendarPagerScrollRequest( + id = System.nanoTime().toInt(), + page = targetIndex, + ) } } - fun settlePage(slot: CalendarPagerSlot) { - pendingTodayJump?.let { request -> - pendingTodayJump = null - onSelectDate(request.targetDate) - onTodayJumpHandled(request.id) - return - } + fun dateForPage(page: Int): LocalDate { + return minWeekStart.plusWeeks(page.toLong()).plusDays(selectedDayOffset) + } - when (slot) { - CalendarPagerSlot.PREVIOUS -> onPrevWeek() - CalendarPagerSlot.NEXT -> onNextWeek() - CalendarPagerSlot.CURRENT -> Unit + fun settlePage(page: Int) { + val targetDate = dateForPage(page) + if (canSelectDate(targetDate)) { + onSelectDate(targetDate) } } LaunchedEffect(todayJumpRequest) { val request = todayJumpRequest ?: return@LaunchedEffect - if (!isPagingAtRest) { - onTodayJumpHandled(request.id) - return@LaunchedEffect - } val targetWeek = startOfWeek(request.targetDate) if (targetWeek == weekStart) { onSelectDate(request.targetDate) onTodayJumpHandled(request.id) } else { - pendingTodayJump = request + val targetPage = ChronoUnit.WEEKS.between(minWeekStart, targetWeek) + .toInt() + .coerceIn(0, CalendarWeekPagerPageCount - 1) + scrollRequest = CalendarPagerScrollRequest(request.id, targetPage) onTodayJumpHandled(request.id) } } - LaunchedEffect(pendingTodayJump?.id, pages) { - val request = pendingTodayJump ?: return@LaunchedEffect - val targetWeek = startOfWeek(request.targetDate) - val targetSlot = if (targetWeek < weekStart) CalendarPagerSlot.PREVIOUS else CalendarPagerSlot.NEXT - val targetIndex = pages.indexOfSlot(targetSlot) - if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { - pagerState.animateScrollToPage(targetIndex) - } - } - Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), @@ -706,7 +939,7 @@ private fun CalendarWeekCard( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_week), enabled = canGoPrevWeek && isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, + onClick = { requestPage(-1) }, ) Box( modifier = Modifier.weight(1f), @@ -725,19 +958,28 @@ private fun CalendarWeekCard( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_week), enabled = isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.NEXT) }, + onClick = { requestPage(1) }, ) } CalendarPagingContent( - pages = pages, - pagerState = pagerState, - centerPageIndex = centerPageIndex, - onSettledAwayFromCenter = ::settlePage, + pageCount = CalendarWeekPagerPageCount, + currentPage = currentPage, + onPageSettled = ::settlePage, + scrollRequest = scrollRequest, + onScrollRequestHandled = { requestId -> + if (scrollRequest?.id == requestId) { + scrollRequest = null + } + }, + pageKey = { page -> "week-${minWeekStart.plusWeeks(page.toLong())}" }, modifier = Modifier .fillMaxWidth() .height(CalendarPeriodCardPageHeight), - ) { displayWeekStart -> + ) { page -> + val displayWeekStart = remember(minWeekStart, page) { + minWeekStart.plusWeeks(page.toLong()) + } val weekDays = remember(displayWeekStart) { List(7) { offset -> displayWeekStart.plusDays(offset.toLong()) } } @@ -758,7 +1000,13 @@ private fun CalendarWeekCard( isSelected = isSelected, isToday = isToday, isEnabled = isEnabled, + isDropTarget = activeDropDate == day, + draggedTodo = draggedTodo.takeIf { isEnabled }, + dropTargets = dropTargets, onClick = { onSelectDate(day) }, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, modifier = Modifier.weight(1f), ) } @@ -775,26 +1023,36 @@ private fun CalendarWeekDayCell( isSelected: Boolean, isToday: Boolean, isEnabled: Boolean, + isDropTarget: Boolean, + draggedTodo: TodoItem?, + dropTargets: MutableMap, onClick: () -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme val containerColor = when { + isDropTarget -> colorScheme.error.copy(alpha = 0.20f) isSelected -> CalendarAccentPurple.copy(alpha = 0.24f) isToday -> CalendarTodayBlue.copy(alpha = 0.16f) else -> colorScheme.background } val borderColor = when { + isDropTarget -> colorScheme.error isSelected -> CalendarAccentPurple.copy(alpha = 0.95f) isToday -> CalendarTodayBlue.copy(alpha = 0.74f) else -> Color.Transparent } val borderWidth = when { + isDropTarget -> 2.dp isSelected -> 1.6.dp isToday -> 1.4.dp else -> 0.dp } val stateTint = when { + isDropTarget -> colorScheme.error isSelected -> CalendarAccentPurple isToday -> CalendarTodayBlue else -> CalendarAccentPurple @@ -804,6 +1062,20 @@ private fun CalendarWeekDayCell( modifier = modifier .height(CalendarPeriodCardPageHeight) .minimumInteractiveComponentSize() + .calendarDateDropTarget( + date = date, + draggedTodo = draggedTodo, + enabled = isEnabled, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, + ) + .calendarInAppDateDropTarget( + targetId = "week-$date", + date = date, + enabled = isEnabled && draggedTodo != null, + dropTargets = dropTargets, + ) .graphicsLayer { alpha = if (isEnabled) 1f else 0.48f }, contentAlignment = Alignment.Center, ) { @@ -836,7 +1108,7 @@ private fun CalendarWeekDayCell( Text( text = date.dayOfMonth.toString(), style = MaterialTheme.typography.titleMedium, - color = if (isSelected || isToday) stateTint else colorScheme.onSurface, + color = if (isDropTarget || isSelected || isToday) stateTint else colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, ) Text( @@ -856,102 +1128,141 @@ private fun CalendarWeekDayCell( } } +@OptIn(ExperimentalFoundationApi::class) +private fun Modifier.calendarDateDropTarget( + date: LocalDate, + draggedTodo: TodoItem?, + enabled: Boolean, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, +): Modifier { + if (!enabled) return this + + return dragAndDropTarget( + shouldStartDragAndDrop = { event -> + event.mimeTypes().any { mimeType -> mimeType.startsWith("text/") } + }, + target = object : DragAndDropTarget { + override fun onEntered(event: DragAndDropEvent) { + onDropDateChanged(date) + } + + override fun onExited(event: DragAndDropEvent) { + onDropDateChanged(null) + } + + override fun onDrop(event: DragAndDropEvent): Boolean { + val todo = draggedTodo ?: event.todoIdText()?.let(resolveTodo) ?: return false + onDropDateChanged(null) + onMoveTaskToDate(todo, date) + return true + } + + override fun onEnded(event: DragAndDropEvent) { + onDropDateChanged(null) + } + }, + ) +} + +private fun DragAndDropEvent.todoIdText(): String? { + val clipData = toAndroidDragEvent().clipData ?: return null + for (index in 0 until clipData.itemCount) { + val text = clipData.getItemAt(index).text?.toString()?.trim() + if (!text.isNullOrBlank()) { + return text + } + } + return null +} + +private fun Modifier.calendarInAppDateDropTarget( + targetId: String, + date: LocalDate, + enabled: Boolean, + dropTargets: MutableMap, +): Modifier { + if (!enabled) return this + + return composed { + DisposableEffect(targetId) { + onDispose { + dropTargets.remove(targetId) + } + } + onGloballyPositioned { coordinates -> + val position = coordinates.positionInRoot() + val size = coordinates.size + dropTargets[targetId] = CalendarDateDropTargetBounds( + date = date, + bounds = Rect( + left = position.x, + top = position.y, + right = position.x + size.width, + bottom = position.y + size.height, + ), + ) + } + } +} + @Composable private fun CalendarDayCard( selectedDate: LocalDate, + minNavigableMonth: YearMonth, today: LocalDate, tasksByDate: Map>, canGoPrevDay: Boolean, + canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, - onPrevDay: () -> Unit, - onNextDay: () -> Unit, onSelectDate: (LocalDate) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() - var pendingTodayJump by remember { mutableStateOf(null) } - val todayJumpDirection = pendingTodayJump?.let { request -> - when { - request.targetDate < selectedDate -> CalendarPagerSlot.PREVIOUS - request.targetDate > selectedDate -> CalendarPagerSlot.NEXT - else -> null - } - } - val previousPageDay = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { - pendingTodayJump?.targetDate - } else if (canGoPrevDay) { - selectedDate.minusDays(1) - } else { - null - } - val nextPageDay = if (todayJumpDirection == CalendarPagerSlot.NEXT) { - pendingTodayJump?.targetDate ?: selectedDate.plusDays(1) - } else { - selectedDate.plusDays(1) - } - val pages = remember(previousPageDay, selectedDate, nextPageDay) { - buildList { - previousPageDay?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } - add(CalendarPagerPage(CalendarPagerSlot.CURRENT, selectedDate)) - add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageDay)) - } - } - val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) - val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } - val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress - - fun requestPage(slot: CalendarPagerSlot) { - val targetIndex = pages.indexOfSlot(slot) - if (targetIndex < 0 || !isPagingAtRest) return + val minDate = remember(minNavigableMonth) { minNavigableMonth.atDay(1) } + val currentPage = remember(minDate, selectedDate) { + ChronoUnit.DAYS.between(minDate, selectedDate) + .toInt() + .coerceIn(0, CalendarDayPagerPageCount - 1) + } + var scrollRequest by remember { mutableStateOf(null) } + val isPagingAtRest = scrollRequest == null + + fun requestPage(offset: Int) { + val targetIndex = (currentPage + offset).coerceIn(0, CalendarDayPagerPageCount - 1) + if (targetIndex == currentPage || !isPagingAtRest) return coroutineScope.launch { - pagerState.animateScrollToPage(targetIndex) + scrollRequest = CalendarPagerScrollRequest( + id = System.nanoTime().toInt(), + page = targetIndex, + ) } } - fun settlePage(slot: CalendarPagerSlot) { - pendingTodayJump?.let { request -> - pendingTodayJump = null - onSelectDate(request.targetDate) - onTodayJumpHandled(request.id) - return - } + fun dateForPage(page: Int): LocalDate { + return minDate.plusDays(page.toLong()) + } - when (slot) { - CalendarPagerSlot.PREVIOUS -> onPrevDay() - CalendarPagerSlot.NEXT -> onNextDay() - CalendarPagerSlot.CURRENT -> Unit - } + fun settlePage(page: Int) { + onSelectDate(dateForPage(page)) } LaunchedEffect(todayJumpRequest) { val request = todayJumpRequest ?: return@LaunchedEffect - if (!isPagingAtRest) { - onTodayJumpHandled(request.id) - return@LaunchedEffect - } if (request.targetDate == selectedDate) { onSelectDate(request.targetDate) onTodayJumpHandled(request.id) } else { - pendingTodayJump = request + val targetPage = ChronoUnit.DAYS.between(minDate, request.targetDate) + .toInt() + .coerceIn(0, CalendarDayPagerPageCount - 1) + scrollRequest = CalendarPagerScrollRequest(request.id, targetPage) onTodayJumpHandled(request.id) } } - LaunchedEffect(pendingTodayJump?.id, pages) { - val request = pendingTodayJump ?: return@LaunchedEffect - val targetSlot = if (request.targetDate < selectedDate) { - CalendarPagerSlot.PREVIOUS - } else { - CalendarPagerSlot.NEXT - } - val targetIndex = pages.indexOfSlot(targetSlot) - if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { - pagerState.animateScrollToPage(targetIndex) - } - } - Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), @@ -980,7 +1291,7 @@ private fun CalendarDayCard( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_day), enabled = canGoPrevDay && isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, + onClick = { requestPage(-1) }, ) Box( modifier = Modifier.weight(1f), @@ -999,22 +1310,33 @@ private fun CalendarDayCard( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_day), enabled = isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.NEXT) }, + onClick = { requestPage(1) }, ) } CalendarPagingContent( - pages = pages, - pagerState = pagerState, - centerPageIndex = centerPageIndex, - onSettledAwayFromCenter = ::settlePage, + pageCount = CalendarDayPagerPageCount, + currentPage = currentPage, + onPageSettled = ::settlePage, + scrollRequest = scrollRequest, + onScrollRequestHandled = { requestId -> + if (scrollRequest?.id == requestId) { + scrollRequest = null + } + }, + pageKey = { page -> "day-${dateForPage(page)}" }, modifier = Modifier .fillMaxWidth() .height(CalendarPeriodCardPageHeight), - ) { displayDate -> + ) { page -> + val displayDate = remember(minDate, page) { dateForPage(page) } val taskCount = tasksByDate[displayDate]?.size ?: 0 Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(Color.Transparent) + .padding(horizontal = 6.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text( @@ -1022,7 +1344,10 @@ private fun CalendarDayCard( style = MaterialTheme.typography.headlineSmall.copy( fontSize = CalendarDaySummaryTitleSize, ), - color = if (displayDate == today) CalendarAccentPurple else colorScheme.onSurface, + color = when { + displayDate == today -> CalendarAccentPurple + else -> colorScheme.onSurface + }, fontWeight = FontWeight.ExtraBold, ) Text( @@ -1224,103 +1549,67 @@ private fun CalendarCircleButton( @Composable private fun CalendarMonthCard( visibleMonth: YearMonth, + minNavigableMonth: YearMonth, canGoPrevMonth: Boolean, selectedDate: LocalDate, today: LocalDate, tasksByDate: Map>, + draggedTodo: TodoItem?, + activeDropDate: LocalDate?, + dropTargets: MutableMap, + canSelectDate: (LocalDate) -> Boolean, todayJumpRequest: CalendarTodayJumpRequest?, onTodayJumpHandled: (Int) -> Unit, - onPrevMonth: () -> Unit, - onNextMonth: () -> Unit, + onVisibleMonthChanged: (YearMonth) -> Unit, onSelectDate: (LocalDate) -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, ) { val colorScheme = MaterialTheme.colorScheme val coroutineScope = rememberCoroutineScope() - var pendingTodayJump by remember { mutableStateOf(null) } - val todayJumpDirection = pendingTodayJump?.let { request -> - val targetMonth = YearMonth.from(request.targetDate) - when { - targetMonth < visibleMonth -> CalendarPagerSlot.PREVIOUS - targetMonth > visibleMonth -> CalendarPagerSlot.NEXT - else -> null - } + val currentPage = remember(minNavigableMonth, visibleMonth) { + ChronoUnit.MONTHS.between(minNavigableMonth, visibleMonth) + .toInt() + .coerceIn(0, CalendarMonthPagerPageCount - 1) } - val previousPageMonth = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { - pendingTodayJump?.targetDate?.let(YearMonth::from) - } else if (canGoPrevMonth) { - visibleMonth.minusMonths(1) - } else { - null - } - val nextPageMonth = if (todayJumpDirection == CalendarPagerSlot.NEXT) { - pendingTodayJump?.targetDate?.let(YearMonth::from) ?: visibleMonth.plusMonths(1) - } else { - visibleMonth.plusMonths(1) - } - val pages = remember(previousPageMonth, visibleMonth, nextPageMonth) { - buildList { - previousPageMonth?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } - add(CalendarPagerPage(CalendarPagerSlot.CURRENT, visibleMonth)) - add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageMonth)) - } - } - val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) - val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } - val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress + var scrollRequest by remember { mutableStateOf(null) } + val isPagingAtRest = scrollRequest == null - fun requestPage(slot: CalendarPagerSlot) { - val targetIndex = pages.indexOfSlot(slot) - if (targetIndex < 0 || !isPagingAtRest) return + fun requestPage(offset: Int) { + val targetIndex = (currentPage + offset).coerceIn(0, CalendarMonthPagerPageCount - 1) + if (targetIndex == currentPage || !isPagingAtRest) return coroutineScope.launch { - pagerState.animateScrollToPage(targetIndex) + scrollRequest = CalendarPagerScrollRequest( + id = System.nanoTime().toInt(), + page = targetIndex, + ) } } - fun settlePage(slot: CalendarPagerSlot) { - pendingTodayJump?.let { request -> - pendingTodayJump = null - onSelectDate(request.targetDate) - onTodayJumpHandled(request.id) - return - } + fun monthForPage(page: Int): YearMonth { + return minNavigableMonth.plusMonths(page.toLong()) + } - when (slot) { - CalendarPagerSlot.PREVIOUS -> onPrevMonth() - CalendarPagerSlot.NEXT -> onNextMonth() - CalendarPagerSlot.CURRENT -> Unit - } + fun settlePage(page: Int) { + onVisibleMonthChanged(monthForPage(page)) } LaunchedEffect(todayJumpRequest) { val request = todayJumpRequest ?: return@LaunchedEffect - if (!isPagingAtRest) { - onTodayJumpHandled(request.id) - return@LaunchedEffect - } val targetMonth = YearMonth.from(request.targetDate) if (targetMonth == visibleMonth) { onSelectDate(request.targetDate) onTodayJumpHandled(request.id) } else { - pendingTodayJump = request + val targetPage = ChronoUnit.MONTHS.between(minNavigableMonth, targetMonth) + .toInt() + .coerceIn(0, CalendarMonthPagerPageCount - 1) + scrollRequest = CalendarPagerScrollRequest(request.id, targetPage) onTodayJumpHandled(request.id) } } - LaunchedEffect(pendingTodayJump?.id, pages) { - val request = pendingTodayJump ?: return@LaunchedEffect - val targetMonth = YearMonth.from(request.targetDate) - val targetSlot = if (targetMonth < visibleMonth) { - CalendarPagerSlot.PREVIOUS - } else { - CalendarPagerSlot.NEXT - } - val targetIndex = pages.indexOfSlot(targetSlot) - if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { - pagerState.animateScrollToPage(targetIndex) - } - } - Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), @@ -1349,7 +1638,7 @@ private fun CalendarMonthCard( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_month), enabled = canGoPrevMonth && isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, + onClick = { requestPage(-1) }, ) Box( modifier = Modifier.weight(1f), @@ -1369,7 +1658,7 @@ private fun CalendarMonthCard( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_month), enabled = isPagingAtRest, - onClick = { requestPage(CalendarPagerSlot.NEXT) }, + onClick = { requestPage(1) }, ) } @@ -1392,14 +1681,21 @@ private fun CalendarMonthCard( } CalendarPagingContent( - pages = pages, - pagerState = pagerState, - centerPageIndex = centerPageIndex, - onSettledAwayFromCenter = ::settlePage, + pageCount = CalendarMonthPagerPageCount, + currentPage = currentPage, + onPageSettled = ::settlePage, + scrollRequest = scrollRequest, + onScrollRequestHandled = { requestId -> + if (scrollRequest?.id == requestId) { + scrollRequest = null + } + }, + pageKey = { page -> "month-${monthForPage(page)}" }, modifier = Modifier .fillMaxWidth() .height(CalendarMonthGridHeight), - ) { displayMonth -> + ) { page -> + val displayMonth = remember(minNavigableMonth, page) { monthForPage(page) } val monthDays = remember(displayMonth) { buildMonthCells(displayMonth) } Column( modifier = Modifier.fillMaxWidth(), @@ -1412,12 +1708,20 @@ private fun CalendarMonthCard( ) { week.forEach { cell -> val taskCount = tasksByDate[cell.date]?.size ?: 0 + val isEnabled = canSelectDate(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 }, + dropTargets = dropTargets, onClick = { onSelectDate(cell.date) }, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, modifier = Modifier.weight(1f), ) } @@ -1485,16 +1789,24 @@ private fun CalendarDayCell( taskCount: Int, isSelected: Boolean, isToday: Boolean, + isEnabled: Boolean, + isDropTarget: Boolean, + draggedTodo: TodoItem?, + dropTargets: MutableMap, onClick: () -> Unit, + onDropDateChanged: (LocalDate?) -> Unit, + onMoveTaskToDate: (TodoItem, LocalDate) -> Unit, + resolveTodo: (String) -> TodoItem?, modifier: Modifier = Modifier, ) { val colorScheme = MaterialTheme.colorScheme val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() val targetCellBackground = when { + isDropTarget -> colorScheme.error.copy(alpha = 0.20f) isSelected -> CalendarAccentPurple.copy(alpha = if (isPressed) 0.32f else 0.24f) isToday -> CalendarTodayBlue.copy(alpha = if (isPressed) 0.24f else 0.16f) - isPressed && cell.isCurrentMonth -> colorScheme.onSurfaceVariant.copy(alpha = 0.12f) + isPressed && isEnabled -> colorScheme.onSurfaceVariant.copy(alpha = 0.12f) else -> Color.Transparent } val cellBackground by animateColorAsState( @@ -1502,9 +1814,10 @@ private fun CalendarDayCell( label = "calendarMonthDateCellBackground", ) val targetCellBorderColor = when { + isDropTarget -> colorScheme.error isSelected -> CalendarAccentPurple.copy(alpha = 0.95f) isToday -> CalendarTodayBlue.copy(alpha = 0.74f) - isPressed && cell.isCurrentMonth -> colorScheme.onSurfaceVariant.copy(alpha = 0.34f) + isPressed && isEnabled -> colorScheme.onSurfaceVariant.copy(alpha = 0.34f) else -> Color.Transparent } val cellBorderColor by animateColorAsState( @@ -1512,9 +1825,10 @@ private fun CalendarDayCell( label = "calendarMonthDateCellBorder", ) val targetCellBorderWidth = when { + isDropTarget -> 2.dp isSelected -> 1.6.dp isToday -> 1.4.dp - isPressed && cell.isCurrentMonth -> 1.2.dp + isPressed && isEnabled -> 1.2.dp else -> 0.dp } val cellBorderWidth by animateDpAsState( @@ -1522,13 +1836,14 @@ private fun CalendarDayCell( label = "calendarMonthDateCellBorderWidth", ) val stateTint = when { + isDropTarget -> colorScheme.error isSelected -> CalendarAccentPurple isToday -> CalendarTodayBlue else -> CalendarAccentPurple } val cellShape = RoundedCornerShape(16.dp) val dayTextColor = when { - isSelected || isToday -> stateTint + isDropTarget || isSelected || isToday -> stateTint cell.isCurrentMonth -> colorScheme.onSurface else -> colorScheme.onSurfaceVariant.copy(alpha = 0.45f) } @@ -1538,8 +1853,22 @@ private fun CalendarDayCell( .fillMaxWidth() .height(CalendarMonthDayCellHeight) .graphicsLayer { alpha = if (cell.isCurrentMonth) 1f else 0.45f } + .calendarDateDropTarget( + date = cell.date, + draggedTodo = draggedTodo, + enabled = isEnabled, + onDropDateChanged = onDropDateChanged, + onMoveTaskToDate = onMoveTaskToDate, + resolveTodo = resolveTodo, + ) + .calendarInAppDateDropTarget( + targetId = "month-${cell.date}", + date = cell.date, + enabled = isEnabled && draggedTodo != null, + dropTargets = dropTargets, + ) .clickable( - enabled = cell.isCurrentMonth, + enabled = isEnabled, interactionSource = interactionSource, indication = null, onClick = onClick, @@ -1580,7 +1909,7 @@ private fun CalendarDayCell( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(2.dp), ) { - if (taskCount > 0 && cell.isCurrentMonth) { + if (taskCount > 0 && isEnabled) { Box( modifier = Modifier .size(CalendarMonthTaskDotSize) @@ -1605,14 +1934,89 @@ private fun CalendarDayCell( } } +@Composable +private fun CalendarTaskDragPreview( + modifier: Modifier = Modifier, + todo: TodoItem, + lists: List, +) { + val colorScheme = MaterialTheme.colorScheme + val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } + val previewShape = RoundedCornerShape(18.dp) + Card( + modifier = modifier + .sizeIn(minWidth = 220.dp, maxWidth = 280.dp), + shape = previewShape, + colors = CardDefaults.cardColors(containerColor = colorScheme.surface.copy(alpha = 0.88f)), + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.55f)), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.RadioButtonUnchecked, + contentDescription = null, + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.76f), + modifier = Modifier.size(22.dp), + ) + Column( + modifier = Modifier.weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = todo.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onSurface, + maxLines = 1, + ) + Text( + text = CalendarTaskDragDueTimeFormatter.format(todo.due), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (listMeta != null) { + Icon( + imageVector = listIconForKey(listMeta.iconKey), + contentDescription = null, + tint = listAccentColor(listMeta.color), + modifier = Modifier.size(18.dp), + ) + } + if (isHighPriority(todo.priority)) { + Icon( + imageVector = Icons.Rounded.Flag, + contentDescription = null, + tint = priorityColor(todo.priority), + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) @Composable private fun CalendarTodoRow( modifier: Modifier = Modifier, todo: TodoItem, lists: List, + showDateDivider: Boolean, + dragEnabled: Boolean, onComplete: () -> Unit, onInfo: () -> Unit, onDelete: () -> Unit, + dragging: Boolean, + onDragStart: (Offset) -> Unit, + onDragMove: (Offset) -> Unit, + onDragEnd: (Offset?) -> Unit, + onDragCancel: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -1624,6 +2028,8 @@ private fun CalendarTodoRow( var targetOffsetX by remember(todo.id) { mutableFloatStateOf(0f) } var swipeHinting by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } + var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } + var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } val animatedOffsetX by animateFloatAsState( targetValue = targetOffsetX, animationSpec = spring(stiffness = Spring.StiffnessLow), @@ -1644,6 +2050,7 @@ private fun CalendarTodoRow( Column( modifier = modifier .fillMaxWidth() + .graphicsLayer { alpha = if (dragging) 0.55f else 1f } .semantics(mergeDescendants = true) { }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -1692,7 +2099,46 @@ private fun CalendarTodoRow( Card( modifier = Modifier .fillMaxSize() + .onGloballyPositioned { coordinates -> + rowOriginInRoot = coordinates.positionInRoot() + } .graphicsLayer { translationX = animatedOffsetX } + .then( + if (dragEnabled) { + Modifier.pointerInput(todo.id) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + targetOffsetX = 0f + val startPosition = rowOriginInRoot + localOffset + dragPointerPosition = startPosition + onDragStart(startPosition) + onDragMove(startPosition) + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + }, + onDrag = { change, dragAmount -> + change.consume() + val nextPosition = + (dragPointerPosition ?: rowOriginInRoot) + dragAmount + dragPointerPosition = nextPosition + onDragMove(nextPosition) + }, + onDragEnd = { + onDragEnd(dragPointerPosition) + dragPointerPosition = null + }, + onDragCancel = { + dragPointerPosition = null + onDragCancel() + }, + ) + } + } else { + Modifier + }, + ) .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> @@ -1819,12 +2265,14 @@ private fun CalendarTodoRow( } } } - Spacer( - modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(colorScheme.outlineVariant.copy(alpha = 0.55f)), - ) + if (showDateDivider) { + Spacer( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(colorScheme.outlineVariant.copy(alpha = 0.55f)), + ) + } } } @@ -2147,22 +2595,74 @@ private fun listAccentColor(colorKey: String?): Color { private fun listIconForKey(iconKey: String?): ImageVector { return when (iconKey?.trim()?.lowercase(Locale.getDefault())) { "sun" -> Icons.Rounded.WbSunny - "calendar" -> Icons.Rounded.CalendarMonth + "calendar" -> Icons.Rounded.CalendarToday "schedule" -> Icons.Rounded.Schedule "flag" -> Icons.Rounded.Flag "check" -> Icons.Rounded.Check + "smile" -> Icons.Rounded.Mood + "list" -> Icons.AutoMirrored.Rounded.List + "bookmark" -> Icons.Rounded.Bookmark + "key" -> Icons.Rounded.Key + "gift" -> Icons.Rounded.CardGiftcard + "cake" -> Icons.Rounded.Cake + "school" -> Icons.Rounded.School + "bag" -> Icons.Rounded.Backpack + "edit" -> Icons.Rounded.Edit + "document" -> Icons.Rounded.Description "inbox" -> Icons.Rounded.Inbox "book" -> Icons.AutoMirrored.Rounded.MenuBook - "briefcase" -> Icons.Rounded.Work - "health" -> Icons.Rounded.LocalHospital + "work", "briefcase" -> Icons.Rounded.Work + "wallet" -> Icons.Rounded.AccountBalanceWallet + "money" -> Icons.Rounded.Payments + "health" -> Icons.Rounded.Medication "fitness" -> Icons.Rounded.FitnessCenter + "run" -> Icons.AutoMirrored.Rounded.DirectionsRun "food" -> Icons.Rounded.Restaurant - "cocktail" -> Icons.Rounded.LocalBar + "drink", "cocktail" -> Icons.Rounded.LocalBar + "monitor" -> Icons.Rounded.DesktopWindows "music" -> Icons.Rounded.MusicNote - "travel" -> Icons.Rounded.Flight + "computer" -> Icons.Rounded.Computer + "game" -> Icons.Rounded.SportsEsports + "headphones" -> Icons.Rounded.Headphones + "eco" -> Icons.Rounded.Eco + "pets" -> Icons.Rounded.Pets + "child" -> Icons.Rounded.ChildCare + "family" -> Icons.Rounded.FamilyRestroom + "basket" -> Icons.Rounded.ShoppingBasket + "cart" -> Icons.Rounded.ShoppingCart + "mall" -> Icons.Rounded.LocalMall + "inventory" -> Icons.Rounded.Inventory + "soccer" -> Icons.Rounded.SportsSoccer + "baseball" -> Icons.Rounded.SportsBaseball + "basketball" -> Icons.Rounded.SportsBasketball + "football" -> Icons.Rounded.SportsFootball + "tennis" -> Icons.Rounded.SportsTennis + "train" -> Icons.Rounded.Train + "flight", "travel" -> Icons.Rounded.Flight + "boat" -> Icons.Rounded.DirectionsBoat "car" -> Icons.Rounded.DirectionsCar + "umbrella" -> Icons.Rounded.BeachAccess + "drop" -> Icons.Rounded.WaterDrop + "snow" -> Icons.Rounded.AcUnit + "fire" -> Icons.Rounded.Whatshot + "tools" -> Icons.Rounded.Build + "scissors" -> Icons.Rounded.ContentCut + "architecture" -> Icons.Rounded.Architecture + "bank" -> Icons.Rounded.AccountBalance + "code" -> Icons.Rounded.Code + "idea" -> Icons.Rounded.Lightbulb + "chat" -> Icons.Rounded.ChatBubbleOutline + "alert" -> Icons.Rounded.PriorityHigh + "star" -> Icons.Rounded.Star + "heart" -> Icons.Rounded.Favorite + "circle" -> Icons.Rounded.Circle + "square" -> Icons.Rounded.Square + "triangle" -> Icons.Rounded.ChangeHistory "home" -> Icons.Rounded.Home - else -> Icons.AutoMirrored.Rounded.List + "city" -> Icons.Rounded.LocationCity + "camera" -> Icons.Rounded.CameraAlt + "palette" -> Icons.Rounded.Palette + else -> Icons.Rounded.Inbox } } 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 282a9bc8..49aa9101 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 @@ -10,9 +10,12 @@ import com.ohmz.tday.compose.core.data.todo.TodoRepository import com.ohmz.tday.compose.core.model.CompletedItem import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoListMode import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse +import com.ohmz.tday.compose.core.model.movedDuePreservingTime +import com.ohmz.tday.compose.core.model.repositoryTargetForReschedule import com.ohmz.tday.compose.core.notification.TaskReminderScheduler import com.ohmz.tday.compose.core.ui.userFacingMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,6 +25,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject data class CalendarUiState( @@ -231,6 +235,49 @@ class CalendarViewModel @Inject constructor( } fun updateTask(todo: TodoItem, payload: CreateTaskPayload) { + updateTaskInternal( + visibleTodo = todo, + repositoryTodo = todo, + payload = payload, + ) + } + + fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { + val movedDue = movedDuePreservingTime(todo.due, targetDate) + val previousState = _uiState.value + val updatedTodo = todo.copy(due = movedDue) + + _uiState.update { current -> + current.copy( + items = current.items.map { item -> + if (item.id == todo.id) updatedTodo else item + }, + errorMessage = null, + ) + } + + viewModelScope.launch { + runCatching { + todoRepository.moveTodo( + todo = todo.repositoryTargetForReschedule(scope), + due = movedDue, + ) + }.onSuccess { + rescheduleReminders() + loadInternal(forceSync = false, showLoading = false) + }.onFailure { error -> + _uiState.value = previousState.copy( + errorMessage = error.userFacingMessage("Could not update task."), + ) + } + } + } + + private fun updateTaskInternal( + visibleTodo: TodoItem, + repositoryTodo: TodoItem, + payload: CreateTaskPayload, + ) { val normalizedTitle = payload.title.trim() if (normalizedTitle.isBlank()) return @@ -244,7 +291,7 @@ class CalendarViewModel @Inject constructor( val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } val previousState = _uiState.value - val optimisticTodo = todo.copy( + val optimisticTodo = visibleTodo.copy( title = normalizedTitle, description = normalizedDescription, priority = normalizedPriority, @@ -256,7 +303,7 @@ class CalendarViewModel @Inject constructor( _uiState.update { current -> current.copy( items = current.items.map { item -> - if (item.id == todo.id) optimisticTodo else item + if (item.id == visibleTodo.id) optimisticTodo else item }, errorMessage = null, ) @@ -265,7 +312,7 @@ class CalendarViewModel @Inject constructor( viewModelScope.launch { runCatching { todoRepository.updateTodo( - todo = todo, + todo = repositoryTodo, payload = CreateTaskPayload( title = normalizedTitle, description = normalizedDescription, 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 ea180596..71bb5c6b 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 @@ -107,6 +107,7 @@ import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.time.Instant import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -288,6 +289,12 @@ fun CompletedScreen( .padding(top = 4.dp), item = completed, lists = uiState.lists, + showDateDivider = shouldShowDateDivider( + afterItemIndex = itemIndex, + inSectionIndex = sectionIndex, + sections = timelineSections, + collapsedSectionKeys = collapsedSectionKeys, + ), onInfo = { editTargetId = completed.id }, onDelete = { onDelete(completed) }, onUncomplete = { onUncomplete(completed) }, @@ -542,6 +549,7 @@ private fun CompletedSwipeRow( modifier: Modifier = Modifier, item: CompletedItem, lists: List, + showDateDivider: Boolean, onInfo: () -> Unit, onDelete: () -> Unit, onUncomplete: () -> Unit, @@ -801,12 +809,14 @@ private fun CompletedSwipeRow( } } } + if (showDateDivider) { Spacer( modifier = Modifier .fillMaxWidth() .height(1.dp) .background(colorScheme.outlineVariant.copy(alpha = 0.58f)), ) + } } } @@ -956,6 +966,37 @@ private data class CompletedSection( val items: List, ) +private fun shouldShowDateDivider( + afterItemIndex: Int, + inSectionIndex: Int, + sections: List, + collapsedSectionKeys: Set, + zoneId: ZoneId = ZoneId.systemDefault(), +): Boolean { + val section = sections.getOrNull(inSectionIndex) ?: return false + val currentItem = section.items.getOrNull(afterItemIndex) ?: return false + val nextItemInSection = section.items.getOrNull(afterItemIndex + 1) + if (nextItemInSection != null) { + return !currentItem.completedDate() + .isSameLocalDayAs(nextItemInSection.completedDate(), zoneId) + } + + val nextVisibleItem = sections + .asSequence() + .drop(inSectionIndex + 1) + .filter { it.key !in collapsedSectionKeys } + .flatMap { it.items.asSequence() } + .firstOrNull() + ?: return false + + return !currentItem.completedDate().isSameLocalDayAs(nextVisibleItem.completedDate(), zoneId) +} + +private fun CompletedItem.completedDate() = completedAt ?: due + +private fun Instant.isSameLocalDayAs(other: Instant, zoneId: ZoneId): Boolean = + LocalDate.ofInstant(this, zoneId) == LocalDate.ofInstant(other, zoneId) + private fun buildCompletedTimelineSections( items: List, zoneId: ZoneId = ZoneId.systemDefault(), 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 33e05781..c39c1455 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 @@ -6,6 +6,7 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateIntAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -18,9 +19,12 @@ import androidx.compose.foundation.LocalOverscrollConfiguration import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource @@ -45,7 +49,9 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -74,12 +80,14 @@ import androidx.compose.material.icons.rounded.CardGiftcard import androidx.compose.material.icons.rounded.ChangeHistory import androidx.compose.material.icons.rounded.ChatBubbleOutline import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.ChildCare import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Code import androidx.compose.material.icons.rounded.Computer import androidx.compose.material.icons.rounded.ContentCut +import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.DesktopWindows import androidx.compose.material.icons.rounded.DirectionsBoat @@ -110,6 +118,7 @@ import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Payments import androidx.compose.material.icons.rounded.Pets 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 import androidx.compose.material.icons.rounded.School @@ -142,6 +151,7 @@ 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 @@ -152,6 +162,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -177,10 +188,12 @@ 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.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.zIndex @@ -188,15 +201,22 @@ import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import com.ohmz.tday.compose.R import com.ohmz.tday.compose.core.model.CreateTaskPayload +import com.ohmz.tday.compose.core.model.ListSummary +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.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox import com.ohmz.tday.compose.ui.theme.TdayDimens +import com.ohmz.tday.compose.ui.theme.TdayFontFamily import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.time.Instant +import java.time.LocalDate import java.time.LocalTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale @OptIn(ExperimentalLayoutApi::class, ExperimentalFoundationApi::class) @@ -217,6 +237,9 @@ fun HomeScreen( onCreateTask: (payload: CreateTaskPayload) -> Unit, onParseTaskTitleNlp: suspend (title: String, referenceDueEpochMs: Long) -> TodoTitleNlpResponse?, onCreateList: (name: String, color: String?, iconKey: String?) -> Unit, + onCompleteTask: (todo: com.ohmz.tday.compose.core.model.TodoItem) -> Unit, + onDeleteTask: (todo: com.ohmz.tday.compose.core.model.TodoItem) -> Unit, + onUpdateTask: (todo: com.ohmz.tday.compose.core.model.TodoItem, payload: CreateTaskPayload) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val focusManager = LocalFocusManager.current @@ -239,6 +262,10 @@ fun HomeScreen( var searchResultsBounds by remember { mutableStateOf(null) } var rootInRoot by remember { mutableStateOf(Offset.Zero) } var showCreateTask by rememberSaveable { mutableStateOf(false) } + var editTargetTodoId by rememberSaveable { mutableStateOf(null) } + val editTargetTodo = remember(editTargetTodoId, uiState.todayTodos) { + editTargetTodoId?.let { id -> uiState.todayTodos.firstOrNull { it.id == id } } + } var listName by rememberSaveable { mutableStateOf("") } var listColor by rememberSaveable { mutableStateOf(DEFAULT_LIST_COLOR) } var listIconKey by rememberSaveable { mutableStateOf(DEFAULT_LIST_ICON_KEY) } @@ -488,19 +515,41 @@ fun HomeScreen( }, ) } + item { + HomeTodayCard( + count = uiState.summary.todayCount, + onClick = { + closeSearch() + onOpenToday() + }, + ) + } + + 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 }, + ) + } + } + } + } + item { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { CategoryGrid( - todayCount = uiState.summary.todayCount, overdueCount = overdueCount, scheduledCount = uiState.summary.scheduledCount, allCount = uiState.summary.allCount, priorityCount = uiState.summary.priorityCount, completedCount = uiState.summary.completedCount, - onOpenToday = { - closeSearch() - onOpenToday() - }, + calendarCount = uiState.summary.scheduledCount, onOpenOverdue = { closeSearch() onOpenOverdue() @@ -521,21 +570,11 @@ fun HomeScreen( closeSearch() onOpenCompleted() }, - ) - - CategoryCard( - modifier = Modifier.fillMaxWidth(), - color = calendarTileColor(colorScheme), - icon = Icons.Rounded.CalendarToday, - backgroundGrid = true, - title = "Calendar", - count = uiState.summary.scheduledCount, - onClick = { + onOpenCalendar = { closeSearch() onOpenCalendar() }, ) - } } @@ -672,7 +711,11 @@ fun HomeScreen( ) } } - if (index < searchResults.lastIndex) { + if (shouldShowDateDivider( + afterItemIndex = index, + items = searchResults, + ) + ) { Spacer( modifier = Modifier .fillMaxWidth() @@ -708,6 +751,20 @@ fun HomeScreen( ) } + editTargetTodo?.let { todo -> + CreateTaskBottomSheet( + lists = uiState.summary.lists, + editingTask = todo, + onParseTaskTitleNlp = onParseTaskTitleNlp, + onDismiss = { editTargetTodoId = null }, + onCreateTask = { _ -> }, + onUpdateTask = { target, payload -> + onUpdateTask(target, payload) + editTargetTodoId = null + }, + ) + } + if (showCreateList) { CreateListBottomSheet( listName = listName, @@ -731,6 +788,16 @@ fun HomeScreen( } } +private fun shouldShowDateDivider( + afterItemIndex: Int, + items: List, + zoneId: ZoneId = ZoneId.systemDefault(), +): Boolean { + val currentTodo = items.getOrNull(afterItemIndex) ?: return false + val nextTodo = items.getOrNull(afterItemIndex + 1) ?: return false + return LocalDate.ofInstant(currentTodo.due, zoneId) != LocalDate.ofInstant(nextTodo.due, zoneId) +} + @Composable private fun CreateListBottomSheet( listName: String, @@ -1476,35 +1543,402 @@ private fun PressableIconButton( } } +private val HOME_TODAY_DUE_FORMATTER: DateTimeFormatter = + DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()) +private val HOME_TODAY_DATE_FORMATTER: DateTimeFormatter = + DateTimeFormatter.ofPattern("EEE, MMM d").withZone(ZoneId.systemDefault()) + +@Composable +private fun HomeTodayCard( + count: Int, + onClick: () -> Unit, +) { + val view = LocalView.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val animatedScale by animateFloatAsState( + targetValue = if (isPressed) 0.97f else 1f, + label = "todayCardScale" + ) + val animatedOffsetY by animateDpAsState( + targetValue = if (isPressed) 2.dp else 0.dp, + label = "todayCardOffsetY" + ) + val animatedElevation by animateDpAsState( + targetValue = if (isPressed) 2.dp else 9.dp, + label = "todayCardElevation" + ) + val dateLabel = remember { HOME_TODAY_DATE_FORMATTER.format(Instant.now()) } + val color = Color(0xFF6EA8E1) + + Card( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {} + .offset(y = animatedOffsetY) + .graphicsLayer { scaleX = animatedScale; scaleY = animatedScale }, + onClick = { + performGentleHaptic(view) + onClick() + }, + interactionSource = interactionSource, + colors = CardDefaults.cardColors(containerColor = color), + elevation = CardDefaults.cardElevation( + defaultElevation = animatedElevation, + pressedElevation = animatedElevation + ), + shape = RoundedCornerShape(26.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .drawWithCache { + val glow = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.22f), + Color.White.copy(alpha = 0.08f), + Color.Transparent + ), + center = Offset(size.width * 0.22f, size.height * 0.2f), + radius = size.width * 0.72f, + ) + val pearl = Brush.radialGradient( + colors = listOf(Color.White.copy(alpha = 0.10f), Color.Transparent), + center = Offset(size.width * 0.9f, size.height * 0.75f), + radius = size.width * 0.55f, + ) + onDrawWithContent { drawRect(glow); drawRect(pearl); drawContent() } + }, + ) { + Box(modifier = Modifier.matchParentSize()) { + Icon( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 22.dp, y = 12.dp) + .size(124.dp), + imageVector = Icons.Rounded.WbSunny, + contentDescription = null, + tint = lerp(color, Color.White, 0.28f).copy(alpha = 0.4f), + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + Icons.Rounded.WbSunny, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(26.dp) + ) + Text( + text = dateLabel, + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontFamily = TdayFontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 28.sp, + ) + } + Text( + text = count.toString(), + style = MaterialTheme.typography.headlineLarge, + color = Color.White, + fontFamily = TdayFontFamily, + fontSize = 34.sp, + fontWeight = FontWeight.Black, + lineHeight = 40.sp, + ) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun HomeTodayTaskRow( + 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) } + 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), + label = "homeTodaySwipeOffset", + ) + val completionAlpha by animateFloatAsState( + targetValue = if (completionFading) 0f else 1f, + animationSpec = tween(durationMillis = 220), + label = "homeTodayCompletionAlpha", + ) + val titleStrikeProgress by animateFloatAsState( + targetValue = if (localCompleted) 1f else 0f, + animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), + label = "homeTodayTitleStrikeProgress", + ) + val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) + 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 isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) + val subtitleColor = + if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.8f + ) + val subtitleText = if (isOverdue) { + stringResource(R.string.todos_due_overdue_text, dueText) + } else { + stringResource(R.string.todos_due_text, dueText) + } + + Column( + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { alpha = completionAlpha }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(58.dp), + ) { + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 2.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + TaskSwipeActionButton( + icon = Icons.Rounded.Edit, + contentDescription = stringResource(R.string.action_edit_task), + label = stringResource(R.string.action_edit), + tint = Color.White, + background = Color(0xFF4C7DDE), + revealProgress = actionRevealProgress, + revealDelay = 0.62f, + onClick = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK + ) + onEdit() + targetOffsetX = 0f + }, + ) + TaskSwipeActionButton( + icon = Icons.Rounded.Delete, + contentDescription = stringResource(R.string.action_delete_task), + label = stringResource(R.string.action_delete), + tint = Color.White, + background = Color(0xFFFF453A), + revealProgress = actionRevealProgress, + revealDelay = 0.04f, + onClick = { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK + ) + onDelete() + targetOffsetX = 0f + }, + ) + } + + Card( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { translationX = animatedOffsetX } + .draggable( + orientation = Orientation.Horizontal, + state = rememberDraggableState { delta -> + targetOffsetX = (targetOffsetX + delta).coerceIn(-maxElasticDragPx, 0f) + }, + onDragStopped = { velocity -> + targetOffsetX = + if (velocity < -1450f || targetOffsetX < -(actionRevealPx * 0.32f)) { + -actionRevealPx + } else { + 0f + } + }, + ) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + if (targetOffsetX != 0f) { + targetOffsetX = 0f + } else if (!swipeHinting && !pendingCompletion) { + swipeHinting = true + coroutineScope.launch { + targetOffsetX = -swipeHintOffsetPx + delay(150) + targetOffsetX = 0f + delay(360) + swipeHinting = false + } + } + }, + shape = rowShape, + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 4.dp, vertical = 2.dp) + .semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .sizeIn(minWidth = 48.dp, minHeight = 48.dp) + .wrapContentSize(Alignment.Center) + .clip(CircleShape) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true, radius = 24.dp), + enabled = !pendingCompletion, + ) { + if (!pendingCompletion) { + targetOffsetX = 0f + localCompleted = true + pendingCompletion = true + coroutineScope.launch { + delay(500) + completionFading = true + delay(220) + onComplete() + } + } + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (localCompleted) Icons.Rounded.CheckCircle else Icons.Rounded.RadioButtonUnchecked, + contentDescription = if (localCompleted) { + stringResource(R.string.label_completed) + } else { + stringResource(R.string.label_mark_complete) + }, + tint = if (localCompleted) Color(0xFF6FBF86) else colorScheme.onSurfaceVariant.copy( + alpha = 0.78f + ), + modifier = Modifier.size(24.dp), + ) + } + + Column( + modifier = Modifier + .weight(1f) + .padding(start = 4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = todo.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(), + ) + } + }, + style = MaterialTheme.typography.titleMedium, + fontFamily = TdayFontFamily, + fontSize = 18.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 22.sp, + color = if (localCompleted) { + colorScheme.onSurface.copy(alpha = 0.78f) + } else { + colorScheme.onSurface + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + onTextLayout = { titleLayoutResult = it }, + ) + Text( + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + fontFamily = TdayFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + lineHeight = 18.sp, + color = subtitleColor, + ) + } + + 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)) + } + } + } + } + } +} + @Composable private fun CategoryGrid( - todayCount: Int, overdueCount: Int, scheduledCount: Int, allCount: Int, priorityCount: Int, completedCount: Int, - onOpenToday: () -> Unit, + calendarCount: Int, onOpenOverdue: () -> Unit, onOpenScheduled: () -> Unit, onOpenAll: () -> Unit, onOpenPriority: () -> Unit, onOpenCompleted: () -> Unit, + onOpenCalendar: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val completedColor = completedTileColor(colorScheme) Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - CategoryCard( - modifier = Modifier.weight(1f), - color = Color(0xFF6EA8E1), - icon = Icons.Rounded.WbSunny, - backgroundWatermark = Icons.Rounded.WbSunny, - title = stringResource(R.string.home_category_today), - count = todayCount, - onClick = onOpenToday, - ) CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFFDA7661), @@ -1514,8 +1948,6 @@ private fun CategoryGrid( count = overdueCount, onClick = onOpenOverdue, ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFFDDB37D), @@ -1525,6 +1957,8 @@ private fun CategoryGrid( count = scheduledCount, onClick = onOpenScheduled, ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFFD48A8C), @@ -1534,8 +1968,6 @@ private fun CategoryGrid( count = priorityCount, onClick = onOpenPriority, ) - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = Color(0xFF4E4E50), @@ -1545,6 +1977,8 @@ private fun CategoryGrid( count = allCount, onClick = onOpenAll, ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { CategoryCard( modifier = Modifier.weight(1f), color = completedColor, @@ -1554,6 +1988,15 @@ private fun CategoryGrid( count = completedCount, onClick = onOpenCompleted, ) + CategoryCard( + modifier = Modifier.weight(1f), + color = calendarTileColor(colorScheme), + icon = Icons.Rounded.CalendarToday, + backgroundGrid = true, + title = "Calendar", + count = calendarCount, + onClick = onOpenCalendar, + ) } } } @@ -1745,8 +2188,8 @@ private fun CategoryCard( Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Row( modifier = Modifier.fillMaxWidth(), @@ -1757,7 +2200,7 @@ private fun CategoryCard( icon, contentDescription = null, tint = Color.White, - modifier = Modifier.size(28.dp), + modifier = Modifier.size(26.dp), ) if (count != null) { Text( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt index 06ddd02f..4085ebac 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeViewModel.kt @@ -35,6 +35,7 @@ data class HomeUiState( lists = emptyList(), ), val searchableTodos: List = emptyList(), + val todayTodos: List = emptyList(), val errorMessage: String? = null, ) @@ -54,6 +55,7 @@ class HomeViewModel @Inject constructor( isLoading = false, summary = todoRepository.fetchDashboardSummarySnapshot(), searchableTodos = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL), + todayTodos = todoRepository.fetchTodosSnapshot(mode = TodoListMode.TODAY), errorMessage = null, ) }.getOrElse { HomeUiState() }, @@ -75,13 +77,18 @@ class HomeViewModel @Inject constructor( fun refreshFromCache() { runCatching { - todoRepository.fetchDashboardSummarySnapshot() to todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL) - }.onSuccess { (summary, todos) -> + Triple( + todoRepository.fetchDashboardSummarySnapshot(), + todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL), + todoRepository.fetchTodosSnapshot(mode = TodoListMode.TODAY), + ) + }.onSuccess { (summary, todos, todayTodos) -> _uiState.update { current -> current.copy( isLoading = activeLoadingRefreshes > 0, summary = if (current.summary == summary) current.summary else summary, searchableTodos = if (current.searchableTodos == todos) current.searchableTodos else todos, + todayTodos = if (current.todayTodos == todayTodos) current.todayTodos else todayTodos, errorMessage = null, ) } @@ -122,14 +129,19 @@ class HomeViewModel @Inject constructor( ) .onFailure { /* fall back to local cache */ } } - todoRepository.fetchDashboardSummary() to todoRepository.fetchTodos(mode = TodoListMode.ALL) - }.onSuccess { (summary, todos) -> + Triple( + todoRepository.fetchDashboardSummary(), + todoRepository.fetchTodos(mode = TodoListMode.ALL), + todoRepository.fetchTodos(mode = TodoListMode.TODAY), + ) + }.onSuccess { (summary, todos, todayTodos) -> _uiState.update { current -> val keepLoading = activeLoadingRefreshes > if (showLoading) 1 else 0 current.copy( isLoading = keepLoading, summary = if (current.summary == summary) current.summary else summary, searchableTodos = if (current.searchableTodos == todos) current.searchableTodos else todos, + todayTodos = if (current.todayTodos == todayTodos) current.todayTodos else todayTodos, errorMessage = null, ) } @@ -207,6 +219,44 @@ class HomeViewModel @Inject constructor( ) } + fun completeTodo(todo: TodoItem) { + _uiState.update { current -> + current.copy(todayTodos = current.todayTodos.filterNot { it.id == todo.id }) + } + viewModelScope.launch { + runCatching { todoRepository.completeTodo(todo) } + .onSuccess { refreshInternal(forceSync = false, showLoading = false) } + .onFailure { error -> + _uiState.update { it.copy(errorMessage = error.userFacingMessage("Could not complete task.")) } + refreshFromCache() + } + } + } + + fun deleteTodo(todo: TodoItem) { + _uiState.update { current -> + current.copy(todayTodos = current.todayTodos.filterNot { it.id == todo.id }) + } + viewModelScope.launch { + runCatching { todoRepository.deleteTodo(todo) } + .onSuccess { refreshInternal(forceSync = false, showLoading = false) } + .onFailure { error -> + _uiState.update { it.copy(errorMessage = error.userFacingMessage("Could not delete task.")) } + refreshFromCache() + } + } + } + + fun updateTask(todo: TodoItem, payload: CreateTaskPayload) { + viewModelScope.launch { + runCatching { todoRepository.updateTodo(todo, payload) } + .onSuccess { refreshInternal(forceSync = false, showLoading = false) } + .onFailure { error -> + _uiState.update { it.copy(errorMessage = error.userFacingMessage("Could not update task.")) } + } + } + } + val lists: List get() = _uiState.value.summary.lists } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt index 67ff929f..8e73ff42 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt @@ -15,13 +15,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -359,7 +360,7 @@ fun OnboardingWizardOverlay( unfocusedPlaceholderColor = colorScheme.onSurface.copy(alpha = 0.4f), ) - Box( + BoxWithConstraints( modifier = Modifier .fillMaxSize() .background(Color.Black.copy(alpha = 0.25f)) @@ -370,11 +371,19 @@ fun OnboardingWizardOverlay( ), contentAlignment = Alignment.Center, ) { + val targetCardWidth = if (maxWidth >= WIZARD_WIDE_LAYOUT_BREAKPOINT) { + WIZARD_WIDE_CARD_WIDTH + } else { + WIZARD_CARD_MAX_WIDTH + } + val cardWidth = minOf( + targetCardWidth, + maxWidth - (WIZARD_SCREEN_EDGE_PADDING * 2), + ) + Card( modifier = Modifier - .fillMaxWidth() - .widthIn(max = 460.dp) - .padding(horizontal = 18.dp), + .width(cardWidth), shape = RoundedCornerShape(32.dp), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), @@ -396,7 +405,7 @@ fun OnboardingWizardOverlay( drawContent() } } - .padding(18.dp), + .padding(WIZARD_CARD_CONTENT_PADDING), ) { Icon( imageVector = if (step == WizardStep.SERVER) Icons.Rounded.Language else Icons.Rounded.Lock, @@ -404,10 +413,13 @@ fun OnboardingWizardOverlay( tint = lerp(colorScheme.surface, colorScheme.primary, 0.3f).copy(alpha = 0.25f), modifier = Modifier .align(Alignment.BottomEnd) - .size(130.dp), + .size(WIZARD_WATERMARK_SIZE), ) - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { Text( text = stringResource(R.string.onboarding_title), style = MaterialTheme.typography.headlineSmall, @@ -1020,3 +1032,9 @@ private fun WizardStepChip( private const val CREDENTIAL_PROMPT_SETTLE_DELAY_MS = 600L private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") +private val WIZARD_CARD_MAX_WIDTH = 440.dp +private val WIZARD_CARD_CONTENT_PADDING = 18.dp +private val WIZARD_SCREEN_EDGE_PADDING = 20.dp +private val WIZARD_WIDE_LAYOUT_BREAKPOINT = 600.dp +private val WIZARD_WIDE_CARD_WIDTH = 360.dp +private val WIZARD_WATERMARK_SIZE = 130.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 97843d5e..aced6ef1 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 @@ -1,7 +1,5 @@ package com.ohmz.tday.compose.feature.todos -import android.content.ClipData -import android.view.View import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing @@ -15,11 +13,9 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.draganddrop.dragAndDropSource -import androidx.compose.foundation.draganddrop.dragAndDropTarget import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.horizontalScroll @@ -150,9 +146,11 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.ripple 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 import androidx.compose.runtime.rememberCoroutineScope @@ -161,12 +159,10 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.DragAndDropEvent -import androidx.compose.ui.draganddrop.DragAndDropTarget -import androidx.compose.ui.draganddrop.DragAndDropTransferData -import androidx.compose.ui.draganddrop.mimeTypes +import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer @@ -180,6 +176,9 @@ 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 +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView @@ -190,18 +189,23 @@ 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.zIndex import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import com.ohmz.tday.compose.R import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoListMode import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter +import com.ohmz.tday.compose.core.model.supportsTaskReschedule +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 @@ -234,11 +238,14 @@ fun TodoListScreen( onAddTask: (payload: CreateTaskPayload) -> Unit, onParseTaskTitleNlp: suspend (title: String, referenceDueEpochMs: Long) -> TodoTitleNlpResponse?, onUpdateTask: (todo: TodoItem, payload: CreateTaskPayload) -> Unit, + onMoveTask: (todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) -> Unit, onComplete: (todo: TodoItem) -> Unit, onDelete: (todo: TodoItem) -> Unit, onUpdateListSettings: (listId: String, name: String, color: String?, iconKey: String?) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val view = LocalView.current + val zoneId = remember { ZoneId.systemDefault() } val selectedList = uiState.lists.firstOrNull { it.id == uiState.listId } val selectedListColorKey = selectedList?.color val usesTodayStyle = @@ -261,10 +268,13 @@ fun TodoListScreen( uiState.mode == TodoListMode.TODAY && !uiState.hasHydratedSnapshot && uiState.items.isEmpty() - val timelineSections = remember(uiState.mode, uiState.items) { + var draggedScheduledTodoId by rememberSaveable(uiState.mode) { mutableStateOf(null) } + val canRescheduleTasks = uiState.mode.supportsTaskReschedule() + val timelineSections = remember(uiState.mode, uiState.items, draggedScheduledTodoId) { buildTimelineSections( mode = uiState.mode, items = uiState.items, + includeEmptyEarlierTarget = canRescheduleTasks && draggedScheduledTodoId != null, ) } var timelineAnimationsReady by remember(uiState.mode, uiState.listId) { @@ -383,8 +393,12 @@ fun TodoListScreen( var flashTodoId by remember(uiState.mode) { mutableStateOf(null) } var quickAddDueEpochMs by rememberSaveable { mutableStateOf(null) } var editTargetTodoId by rememberSaveable { mutableStateOf(null) } - var draggedScheduledTodoId by rememberSaveable(uiState.mode) { mutableStateOf(null) } var activeDropSectionKey by remember(uiState.mode) { mutableStateOf(null) } + var activeTimelineDrag by remember(uiState.mode) { mutableStateOf(null) } + var timelineDragContainerOrigin by remember(uiState.mode) { mutableStateOf(Offset.Zero) } + val timelineDropTargetBounds = + remember(uiState.mode) { mutableStateMapOf() } + var pendingRescheduleDrop by remember(uiState.mode) { mutableStateOf(null) } var showListSettingsSheet by rememberSaveable { mutableStateOf(false) } var showSummarySheet by rememberSaveable(uiState.mode) { mutableStateOf(false) } var listSettingsTargetId by rememberSaveable { mutableStateOf(null) } @@ -402,6 +416,21 @@ fun TodoListScreen( uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } } } + val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = { todo, targetDate -> + draggedScheduledTodoId = null + activeDropSectionKey = null + activeTimelineDrag = null + timelineDropTargetBounds.clear() + val currentDate = LocalDate.ofInstant(todo.due, zoneId) + if (currentDate != targetDate) { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + if (todo.isRecurring) { + pendingRescheduleDrop = TaskRescheduleDrop(todo = todo, targetDate = targetDate) + } else { + onMoveTask(todo, targetDate, TaskRescheduleScope.OCCURRENCE) + } + } + } val canSummarizeCurrentMode = uiState.mode != TodoListMode.LIST && uiState.mode != TodoListMode.OVERDUE && @@ -468,6 +497,42 @@ fun TodoListScreen( collapsedSectionKeys = collapsedSectionKeys + "earlier" } } + LaunchedEffect(draggedScheduledTodoId) { + if (draggedScheduledTodoId == null) { + timelineDropTargetBounds.clear() + } + } + + fun updateActiveTimelineDropTarget(position: Offset) { + activeDropSectionKey = timelineDropTargetBounds.values + .asSequence() + .filter { target -> target.bounds.contains(position) } + .minByOrNull { target -> target.bounds.height } + ?.sectionKey + } + + 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 + } + ?: activeDropSectionKey + val targetDate = targetKey + ?.let { key -> timelineSections.firstOrNull { section -> section.key == key } } + ?.targetDate + activeTimelineDrag = null + draggedScheduledTodoId = null + activeDropSectionKey = null + timelineDropTargetBounds.clear() + if (drag != null && targetDate != null) { + requestTaskReschedule(drag.todo, targetDate) + } + } Scaffold( containerColor = colorScheme.background, @@ -546,7 +611,11 @@ fun TodoListScreen( }, ) { padding -> Box( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { coordinates -> + timelineDragContainerOrigin = coordinates.positionInRoot() + }, ) { Box( modifier = Modifier @@ -601,37 +670,12 @@ fun TodoListScreen( val sectionCanCollapse = sectionModeCanCollapse && sectionHasTasks val isCollapsed = sectionCanCollapse && collapsedSectionKeys.contains(section.key) - val sectionDraggedTodo = if (uiState.mode == TodoListMode.SCHEDULED) { + val isActiveDropSection = activeDropSectionKey == section.key + val sectionDraggedTodo = if (canRescheduleTasks) { draggedScheduledTodo } else { null } - val onSectionDropTargetChanged: (Boolean) -> Unit = { active -> - if (active) { - activeDropSectionKey = section.key - } else if (activeDropSectionKey == section.key) { - activeDropSectionKey = null - } - } - val onSectionDragEnd: (() -> Unit)? = - if (uiState.mode == TodoListMode.SCHEDULED) { - { - draggedScheduledTodoId = null - activeDropSectionKey = null - } - } else { - null - } - val onMoveTaskToSectionDate: ((TodoItem, LocalDate) -> Unit)? = - if (uiState.mode == TodoListMode.SCHEDULED) { - { todo, targetDate -> - draggedScheduledTodoId = null - activeDropSectionKey = null - onUpdateTask(todo, createMovedTaskPayload(todo, targetDate)) - } - } else { - null - } item( key = "timeline-header-${section.key}", @@ -650,18 +694,25 @@ fun TodoListScreen( } TimelineSectionHeader( modifier = headerModifier - .timelineSectionDropTarget( + .fillMaxWidth() + .heightIn( + min = if (canRescheduleTasks && draggedScheduledTodoId != null) { + if (usesTodayStyle) 44.dp else 56.dp + } else { + 1.dp + }, + ) + .timelineInAppDropTarget( + targetId = "header-${section.key}", section = section, - draggedTodo = sectionDraggedTodo, - onDropTargetChanged = onSectionDropTargetChanged, - onDragTodoEnd = onSectionDragEnd, - onMoveTaskToDate = onMoveTaskToSectionDate, + enabled = canRescheduleTasks && draggedScheduledTodoId != null, + dropTargets = timelineDropTargetBounds, ) .padding(top = if (sectionIndex == 0) 0.dp else 8.dp), section = section, useMinimalStyle = usesTodayStyle, isCollapsed = isCollapsed, - isDropTarget = activeDropSectionKey == section.key, + isDropTarget = isActiveDropSection, bottomSpacing = if (isCollapsed) { timelineItemSpacing } else { @@ -690,6 +741,49 @@ fun TodoListScreen( ) } + if (canRescheduleTasks && isActiveDropSection && section.targetDate != null) { + item( + key = "timeline-drop-placeholder-${section.key}", + contentType = "timeline-drop-placeholder", + ) { + var placeholderModifier: Modifier = Modifier + if (timelineAnimationsEnabled) { + placeholderModifier = placeholderModifier.animateItem( + fadeInSpec = tween( + durationMillis = 150, + easing = FastOutSlowInEasing, + ), + placementSpec = tween( + durationMillis = 260, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = tween( + durationMillis = 120, + easing = FastOutSlowInEasing, + ), + ) + } + TimelineDropPlaceholder( + modifier = placeholderModifier + .timelineInAppDropTarget( + targetId = "placeholder-${section.key}", + section = section, + enabled = true, + dropTargets = timelineDropTargetBounds, + ) + .padding( + bottom = if (isCollapsed || section.items.isEmpty()) { + timelineItemSpacing + } else { + 8.dp + }, + ), + active = true, + useMinimalStyle = usesTodayStyle, + ) + } + } + if (!isCollapsed && section.items.isNotEmpty()) { val showEarlierDateTimeSubtitle = section.key == "earlier" && @@ -718,12 +812,11 @@ fun TodoListScreen( } TimelineTaskRow( modifier = rowModifier - .timelineSectionDropTarget( + .timelineInAppDropTarget( + targetId = "row-${section.key}-${todo.id}", section = section, - draggedTodo = sectionDraggedTodo, - onDropTargetChanged = onSectionDropTargetChanged, - onDragTodoEnd = onSectionDragEnd, - onMoveTaskToDate = onMoveTaskToSectionDate, + enabled = canRescheduleTasks && draggedScheduledTodoId != null, + dropTargets = timelineDropTargetBounds, ) .padding( bottom = if (itemIndex == section.items.lastIndex) { @@ -738,20 +831,44 @@ fun TodoListScreen( useMinimalStyle = usesTodayStyle, flashHighlight = flashTodoId == todo.id || flashTodoId == todo.canonicalId, showEarlierDateTimeSubtitle = showEarlierDateTimeSubtitle, + showDateDivider = shouldShowDateDivider( + afterItemIndex = itemIndex, + inSectionIndex = sectionIndex, + sections = timelineSections, + collapsedSectionKeys = collapsedSectionKeys, + ), onComplete = { onComplete(todo) }, onDelete = { onDelete(todo) }, onInfo = { editTargetTodoId = todo.id }, draggedTodo = sectionDraggedTodo, - onDragTodoStart = if (uiState.mode == TodoListMode.SCHEDULED) { - { + onDragTodoStart = if (canRescheduleTasks) { + { position -> activeDropSectionKey = null + timelineDropTargetBounds.clear() draggedScheduledTodoId = todo.id + activeTimelineDrag = + TimelineInAppDrag(todo, position) } } else { null }, + onDragTodoMove = { position -> + activeTimelineDrag = + activeTimelineDrag?.copy(position = position) + ?: TimelineInAppDrag(todo, position) + updateActiveTimelineDropTarget(position) + }, + onDragTodoEnd = { position -> + finishTimelineDrag(position) + }, + onDragTodoCancel = { + activeTimelineDrag = null + draggedScheduledTodoId = null + activeDropSectionKey = null + timelineDropTargetBounds.clear() + }, ) } } @@ -801,6 +918,23 @@ fun TodoListScreen( message = emptyStateMessageForMode(uiState.mode), ) } + + activeTimelineDrag?.let { drag -> + TimelineTaskDragPreview( + modifier = Modifier + .offset { + val localPosition = drag.position - timelineDragContainerOrigin + IntOffset( + x = (localPosition.x - with(density) { 130.dp.toPx() }).roundToInt(), + y = (localPosition.y - with(density) { 34.dp.toPx() }).roundToInt(), + ) + } + .zIndex(20f), + todo = drag.todo, + lists = uiState.lists, + mode = uiState.mode, + ) + } } } @@ -864,6 +998,42 @@ fun TodoListScreen( ) } + pendingRescheduleDrop?.let { drop -> + AlertDialog( + onDismissRequest = { pendingRescheduleDrop = null }, + title = { + Text( + text = stringResource(R.string.todos_reschedule_recurring_title), + fontWeight = FontWeight.ExtraBold, + ) + }, + text = { + Text(text = stringResource(R.string.todos_reschedule_recurring_message)) + }, + dismissButton = { + TextButton(onClick = { pendingRescheduleDrop = null }) { + Text(stringResource(R.string.action_cancel)) + } + }, + confirmButton = { + Row { + TextButton(onClick = { + pendingRescheduleDrop = null + onMoveTask(drop.todo, drop.targetDate, TaskRescheduleScope.OCCURRENCE) + }) { + Text(stringResource(R.string.todos_reschedule_this_occurrence)) + } + TextButton(onClick = { + pendingRescheduleDrop = null + onMoveTask(drop.todo, drop.targetDate, TaskRescheduleScope.SERIES) + }) { + Text(stringResource(R.string.todos_reschedule_entire_series)) + } + } + }, + ) + } + editTargetTodo?.let { todo -> CreateTaskBottomSheet( lists = uiState.lists, @@ -1608,6 +1778,8 @@ private fun TimelineSectionHeader( } val headerTextColor = if (isHeaderPressed) { androidx.compose.ui.graphics.lerp(baseHeaderColor, colorScheme.onSurface, 0.16f) + } else if (isDropTarget) { + colorScheme.error } else { baseHeaderColor } @@ -1644,13 +1816,7 @@ private fun TimelineSectionHeader( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(18.dp)) - .background( - if (isDropTarget) { - colorScheme.primary.copy(alpha = 0.1f) - } else { - Color.Transparent - }, - ) + .background(Color.Transparent) .padding(horizontal = 4.dp) .heightIn(min = minimumHeaderHeight) .then(headerClickModifier), @@ -1687,6 +1853,117 @@ private fun TimelineSectionHeader( } } +@Composable +private fun TimelineDropPlaceholder( + modifier: Modifier = Modifier, + active: Boolean, + useMinimalStyle: Boolean, +) { + val colorScheme = MaterialTheme.colorScheme + val placeholderHeight by animateDpAsState( + targetValue = if (active) { + if (useMinimalStyle) 66.dp else 72.dp + } else { + if (useMinimalStyle) 46.dp else 52.dp + }, + animationSpec = tween(durationMillis = 180, easing = FastOutSlowInEasing), + label = "timelineDropPlaceholderHeight", + ) + Box( + modifier = modifier + .fillMaxWidth() + .height(placeholderHeight) + .clip(RoundedCornerShape(18.dp)) + .background( + if (active) { + colorScheme.error.copy(alpha = 0.10f) + } else { + colorScheme.surfaceVariant.copy(alpha = 0.16f) + }, + ) + .border( + BorderStroke( + width = if (active) 1.5.dp else 1.dp, + color = if (active) { + colorScheme.error.copy(alpha = 0.64f) + } else { + colorScheme.onSurfaceVariant.copy(alpha = 0.16f) + }, + ), + RoundedCornerShape(18.dp), + ), + ) +} + +@Composable +private fun TimelineTaskDragPreview( + modifier: Modifier = Modifier, + todo: TodoItem, + lists: List, + mode: TodoListMode, +) { + val colorScheme = MaterialTheme.colorScheme + val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } + val showListIndicator = listMeta != null && mode != TodoListMode.LIST + val previewShape = RoundedCornerShape(18.dp) + Card( + modifier = modifier + .sizeIn(minWidth = 220.dp, maxWidth = 280.dp), + shape = previewShape, + colors = CardDefaults.cardColors(containerColor = colorScheme.surface.copy(alpha = 0.88f)), + border = BorderStroke(1.dp, colorScheme.outlineVariant.copy(alpha = 0.55f)), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.RadioButtonUnchecked, + contentDescription = null, + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.76f), + modifier = Modifier.size(22.dp), + ) + Column( + modifier = Modifier.weight(1f, fill = false), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = todo.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onSurface, + maxLines = 1, + ) + Text( + text = TODO_DUE_TIME_FORMATTER.format(todo.due), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + if (showListIndicator) { + Icon( + imageVector = listIconForKey(listMeta?.iconKey), + contentDescription = null, + tint = listAccentColor(listMeta?.color), + modifier = Modifier.size(18.dp), + ) + } + if (isHighPriority(todo.priority)) { + Icon( + imageVector = Icons.Rounded.Flag, + contentDescription = null, + tint = priorityColor(todo.priority), + modifier = Modifier.size(18.dp), + ) + } + } + } +} + @Composable private fun TimelineTaskRow( modifier: Modifier = Modifier, @@ -1696,11 +1973,15 @@ private fun TimelineTaskRow( useMinimalStyle: Boolean, flashHighlight: Boolean, showEarlierDateTimeSubtitle: Boolean, + showDateDivider: Boolean, onComplete: () -> Unit, onDelete: () -> Unit, onInfo: () -> Unit, draggedTodo: TodoItem? = null, - onDragTodoStart: (() -> Unit)? = null, + onDragTodoStart: ((Offset) -> Unit)? = null, + onDragTodoMove: (Offset) -> Unit = {}, + onDragTodoEnd: (Offset?) -> Unit = {}, + onDragTodoCancel: () -> Unit = {}, ) { Box( modifier = modifier.fillMaxWidth(), @@ -1715,6 +1996,13 @@ private fun TimelineTaskRow( onInfo = onInfo, showDuePrefix = true, showDueDateInSubtitle = showEarlierDateTimeSubtitle, + showDateDivider = showDateDivider, + dragEnabled = onDragTodoStart != null, + dragging = draggedTodo?.id == todo.id, + onDragStart = { position -> onDragTodoStart?.invoke(position) }, + onDragMove = onDragTodoMove, + onDragEnd = onDragTodoEnd, + onDragCancel = onDragTodoCancel, ) } else if ( useMinimalStyle && @@ -1736,9 +2024,13 @@ private fun TimelineTaskRow( onInfo = onInfo, showDuePrefix = true, showDueDateInSubtitle = showEarlierDateTimeSubtitle, + showDateDivider = showDateDivider, dragEnabled = onDragTodoStart != null, dragging = draggedTodo?.id == todo.id, - onDragStart = { onDragTodoStart?.invoke() }, + onDragStart = { position -> onDragTodoStart?.invoke(position) }, + onDragMove = onDragTodoMove, + onDragEnd = onDragTodoEnd, + onDragCancel = onDragTodoCancel, ) } else if (useMinimalStyle) { TodayTodoRow( @@ -1756,45 +2048,47 @@ private fun TimelineTaskRow( } } -@OptIn(ExperimentalFoundationApi::class) -private fun Modifier.timelineSectionDropTarget( +private fun Modifier.timelineInAppDropTarget( + targetId: String, section: TodoSection, - draggedTodo: TodoItem?, - onDropTargetChanged: (Boolean) -> Unit, - onDragTodoEnd: (() -> Unit)?, - onMoveTaskToDate: ((TodoItem, LocalDate) -> Unit)?, + enabled: Boolean, + dropTargets: MutableMap, ): Modifier { - if (section.targetDate == null || draggedTodo == null || onMoveTaskToDate == null) { + if (!enabled || section.targetDate == null) { return this } - return dragAndDropTarget( - shouldStartDragAndDrop = { event -> - event.mimeTypes().any { mimeType -> mimeType.startsWith("text/") } - }, - target = object : DragAndDropTarget { - override fun onEntered(event: DragAndDropEvent) { - onDropTargetChanged(true) - } - - override fun onExited(event: DragAndDropEvent) { - onDropTargetChanged(false) + return composed { + DisposableEffect(targetId) { + onDispose { + dropTargets.remove(targetId) } + } + onGloballyPositioned { coordinates -> + val position = coordinates.positionInRoot() + val size = coordinates.size + dropTargets[targetId] = TimelineDropTargetBounds( + sectionKey = section.key, + bounds = Rect( + left = position.x, + top = position.y, + right = position.x + size.width, + bottom = position.y + size.height, + ), + ) + } + } +} - override fun onDrop(event: DragAndDropEvent): Boolean { - val targetDate = section.targetDate ?: return false - onDropTargetChanged(false) - onMoveTaskToDate(draggedTodo, targetDate) - return true - } +private data class TimelineDropTargetBounds( + val sectionKey: String, + val bounds: Rect, +) - override fun onEnded(event: DragAndDropEvent) { - onDropTargetChanged(false) - onDragTodoEnd?.invoke() - } - }, - ) -} +private data class TimelineInAppDrag( + val todo: TodoItem, + val position: Offset, +) private data class TodoSection( val key: String, @@ -1804,6 +2098,39 @@ private data class TodoSection( val targetDate: LocalDate? = null, ) +private data class TaskRescheduleDrop( + val todo: TodoItem, + val targetDate: LocalDate, +) + +private fun shouldShowDateDivider( + afterItemIndex: Int, + inSectionIndex: Int, + sections: List, + collapsedSectionKeys: Set, + zoneId: ZoneId = ZoneId.systemDefault(), +): Boolean { + val section = sections.getOrNull(inSectionIndex) ?: return false + val currentTodo = section.items.getOrNull(afterItemIndex) ?: return false + val nextTodoInSection = section.items.getOrNull(afterItemIndex + 1) + if (nextTodoInSection != null) { + return !currentTodo.due.isSameLocalDayAs(nextTodoInSection.due, zoneId) + } + + val nextVisibleTodo = sections + .asSequence() + .drop(inSectionIndex + 1) + .filter { it.key !in collapsedSectionKeys } + .flatMap { it.items.asSequence() } + .firstOrNull() + ?: return false + + return !currentTodo.due.isSameLocalDayAs(nextVisibleTodo.due, zoneId) +} + +private fun Instant.isSameLocalDayAs(other: Instant, zoneId: ZoneId): Boolean = + LocalDate.ofInstant(this, zoneId) == LocalDate.ofInstant(other, zoneId) + private enum class TodaySectionSlot { MORNING, AFTERNOON, TONIGHT, } @@ -1811,6 +2138,7 @@ private enum class TodaySectionSlot { private fun buildTimelineSections( mode: TodoListMode, items: List, + includeEmptyEarlierTarget: Boolean = false, ): List { val zoneId = ZoneId.systemDefault() return when (mode) { @@ -1827,6 +2155,7 @@ private fun buildTimelineSections( zoneId = zoneId, futureOnly = false, placesEarlierBeforeToday = true, + includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) TodoListMode.PRIORITY, TodoListMode.LIST -> buildScheduledSections( @@ -1834,6 +2163,7 @@ private fun buildTimelineSections( zoneId = zoneId, futureOnly = false, placesEarlierBeforeToday = false, + includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) } } @@ -1938,6 +2268,7 @@ private fun buildScheduledSections( zoneId: ZoneId, futureOnly: Boolean, placesEarlierBeforeToday: Boolean = true, + includeEmptyEarlierTarget: Boolean = false, ): List { val now = Instant.now() val sorted = items.asSequence().filter { todo -> @@ -1960,7 +2291,7 @@ private fun buildScheduledSections( date = date, zoneId = zoneId, ), - targetDate = date, + targetDate = timelineRescheduleTargetDate("day-$date", today), ) } @@ -1981,16 +2312,19 @@ private fun buildScheduledSections( val earlierSection = if (!futureOnly) { val earlierItems = groupedByDate.asSequence().filter { (date, _) -> date < today } .flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList() - earlierItems.takeIf { it.isNotEmpty() }?.let { + if (earlierItems.isNotEmpty() || includeEmptyEarlierTarget) { TodoSection( key = "earlier", title = "Earlier", - items = it, + items = earlierItems, quickAddDefaults = quickAddDefaultsForDate( - date = today, + date = today.minusDays(1), zoneId = zoneId, ), + targetDate = timelineRescheduleTargetDate("earlier", today), ) + } else { + null } } else { null @@ -2025,6 +2359,7 @@ private fun buildScheduledSections( date = currentMonth.atEndOfMonth(), zoneId = zoneId, ), + targetDate = timelineRescheduleTargetDate("rest-$currentMonth", today), ) val futureMonthsWithData = @@ -2049,6 +2384,7 @@ private fun buildScheduledSections( date = targetMonth.atDay(1), zoneId = zoneId, ), + targetDate = timelineRescheduleTargetDate("month-$targetMonth", today), ) targetMonth = targetMonth.plusMonths(1) } @@ -2146,24 +2482,6 @@ private fun quickAddDefaultsForTodaySection( return ZonedDateTime.of(today, time, zoneId).toInstant().toEpochMilli() } -private fun createMovedTaskPayload( - todo: TodoItem, - targetDate: LocalDate, - zoneId: ZoneId = ZoneId.systemDefault(), -): CreateTaskPayload { - val dueTime = todo.due.atZone(zoneId).toLocalTime() - val movedDue = ZonedDateTime.of(targetDate, dueTime, zoneId).toInstant() - - return CreateTaskPayload( - title = todo.title, - description = todo.description, - priority = todo.priority, - due = movedDue, - rrule = todo.rrule, - listId = todo.listId, - ) -} - private suspend fun LazyListState.animateSearchResultScrollToItem( targetIndex: Int, targetKey: String, @@ -2267,6 +2585,13 @@ private fun AllTaskSwipeRow( onInfo: () -> Unit, showDuePrefix: Boolean, showDueDateInSubtitle: Boolean = false, + showDateDivider: Boolean, + dragEnabled: Boolean = false, + dragging: Boolean = false, + onDragStart: ((Offset) -> Unit)? = null, + onDragMove: (Offset) -> Unit = {}, + onDragEnd: (Offset?) -> Unit = {}, + onDragCancel: () -> Unit = {}, ) { SwipeTaskRow( todo = todo, @@ -2280,7 +2605,14 @@ private fun AllTaskSwipeRow( showDueText = true, showDuePrefix = showDuePrefix, showDueDateInSubtitle = showDueDateInSubtitle, + showDateDivider = showDateDivider, useDelayedFadeCompletion = false, + dragEnabled = dragEnabled, + dragging = dragging, + onDragStart = onDragStart, + onDragMove = onDragMove, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, ) } @@ -2295,9 +2627,13 @@ private fun TodayTaskSwipeRow( onInfo: () -> Unit, showDuePrefix: Boolean, showDueDateInSubtitle: Boolean = false, + showDateDivider: Boolean, dragEnabled: Boolean = false, dragging: Boolean = false, - onDragStart: (() -> Unit)? = null, + onDragStart: ((Offset) -> Unit)? = null, + onDragMove: (Offset) -> Unit = {}, + onDragEnd: (Offset?) -> Unit = {}, + onDragCancel: () -> Unit = {}, ) { SwipeTaskRow( todo = todo, @@ -2311,10 +2647,14 @@ private fun TodayTaskSwipeRow( showDueText = true, showDuePrefix = showDuePrefix, showDueDateInSubtitle = showDueDateInSubtitle, + showDateDivider = showDateDivider, useDelayedFadeCompletion = mode != TodoListMode.TODAY, dragEnabled = dragEnabled, dragging = dragging, onDragStart = onDragStart, + onDragMove = onDragMove, + onDragEnd = onDragEnd, + onDragCancel = onDragCancel, ) } @@ -2332,11 +2672,15 @@ private fun SwipeTaskRow( showDueText: Boolean, showDuePrefix: Boolean, showDueDateInSubtitle: Boolean = false, + showDateDivider: Boolean = false, useDelayedFadeCompletion: Boolean = false, useFadeOnCompletion: Boolean = false, dragEnabled: Boolean = false, dragging: Boolean = false, - onDragStart: (() -> Unit)? = null, + onDragStart: ((Offset) -> Unit)? = null, + onDragMove: (Offset) -> Unit = {}, + onDragEnd: (Offset?) -> Unit = {}, + onDragCancel: () -> Unit = {}, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -2350,6 +2694,8 @@ private fun SwipeTaskRow( var localCompleted by remember(todo.id) { mutableStateOf(false) } var pendingCompletion by remember(todo.id) { mutableStateOf(false) } var completionFading by remember(todo.id) { mutableStateOf(false) } + 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( @@ -2495,23 +2841,40 @@ private fun SwipeTaskRow( Card( modifier = Modifier .fillMaxSize() + .onGloballyPositioned { coordinates -> + rowOriginInRoot = coordinates.positionInRoot() + } .graphicsLayer { translationX = animatedOffsetX } .then( if (dragEnabled) { - Modifier.dragAndDropSource { - detectTapGestures( - onLongPress = { - onDragStart?.invoke() - startTransfer( - DragAndDropTransferData( - clipData = ClipData.newPlainText( - "todo-id", - todo.id - ), - flags = View.DRAG_FLAG_GLOBAL, - ), + Modifier.pointerInput(todo.id, dragEnabled) { + detectDragGesturesAfterLongPress( + onDragStart = { localOffset -> + targetOffsetX = 0f + val startPosition = rowOriginInRoot + localOffset + dragPointerPosition = startPosition + onDragStart?.invoke(startPosition) + onDragMove(startPosition) + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, ) }, + onDrag = { change, dragAmount -> + change.consume() + val nextPosition = (dragPointerPosition + ?: rowOriginInRoot) + dragAmount + dragPointerPosition = nextPosition + onDragMove(nextPosition) + }, + onDragEnd = { + onDragEnd(dragPointerPosition) + dragPointerPosition = null + }, + onDragCancel = { + dragPointerPosition = null + onDragCancel() + }, ) } } else { @@ -2675,12 +3038,14 @@ private fun SwipeTaskRow( } } } + if (showDateDivider) { Spacer( modifier = Modifier .fillMaxWidth() .height(1.dp) .background(colorScheme.outlineVariant.copy(alpha = 0.58f)), ) + } } } 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 12630189..3166ee00 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 @@ -11,10 +11,13 @@ import com.ohmz.tday.compose.core.data.sync.SyncManager import com.ohmz.tday.compose.core.data.todo.TodoRepository import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.TaskRescheduleScope import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoListMode import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter +import com.ohmz.tday.compose.core.model.movedDuePreservingTime +import com.ohmz.tday.compose.core.model.repositoryTargetForReschedule import com.ohmz.tday.compose.core.notification.TaskReminderScheduler import com.ohmz.tday.compose.core.ui.userFacingMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -24,6 +27,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.LocalDate import javax.inject.Inject data class TodoListUiState( @@ -291,6 +295,63 @@ class TodoListViewModel @Inject constructor( } fun updateTask(todo: TodoItem, payload: CreateTaskPayload) { + updateTaskInternal( + visibleTodo = todo, + repositoryTodo = todo, + payload = payload, + ) + } + + fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { + val movedDue = movedDuePreservingTime(todo.due, targetDate) + val previousState = _uiState.value + val mode = previousState.mode + val currentListId = previousState.listId + val updatedTodo = todo.copy(due = movedDue) + + _uiState.update { current -> + current.copy( + items = current.items.map { item -> + if (item.id == todo.id) updatedTodo else item + }, + errorMessage = null, + ) + } + + viewModelScope.launch { + runCatching { + todoRepository.moveTodo( + todo = todo.repositoryTargetForReschedule(scope), + due = movedDue, + ) + }.onSuccess { + rescheduleReminders() + runCatching { + val todos = todoRepository.fetchTodosCached(mode = mode, listId = currentListId) + val lists = listRepository.fetchLists() + todos to lists + }.onSuccess { (todos, lists) -> + _uiState.update { current -> + current.copy( + lists = if (current.lists == lists) current.lists else lists, + items = if (current.items == todos) current.items else todos, + errorMessage = null, + ) + } + }.onFailure { refreshInternal(forceSync = false, showLoading = false) } + }.onFailure { error -> + _uiState.value = previousState.copy( + errorMessage = error.userFacingMessage("Could not update task."), + ) + } + } + } + + private fun updateTaskInternal( + visibleTodo: TodoItem, + repositoryTodo: TodoItem, + payload: CreateTaskPayload, + ) { val normalizedTitle = payload.title.trim() if (normalizedTitle.isBlank()) return @@ -306,7 +367,7 @@ class TodoListViewModel @Inject constructor( val previousState = _uiState.value val mode = previousState.mode val currentListId = previousState.listId - val updatedTodo = todo.copy( + val updatedTodo = visibleTodo.copy( title = normalizedTitle, description = normalizedDescription, priority = normalizedPriority, @@ -317,11 +378,11 @@ class TodoListViewModel @Inject constructor( _uiState.update { current -> val optimisticItems = current.items - .map { item -> if (item.id == todo.id) updatedTodo else item } + .map { item -> if (item.id == visibleTodo.id) updatedTodo else item } .filterNot { item -> current.mode == TodoListMode.LIST && !current.listId.isNullOrBlank() && - item.id == todo.id && + item.id == visibleTodo.id && item.listId != current.listId } current.copy(items = optimisticItems, errorMessage = null) @@ -330,7 +391,7 @@ class TodoListViewModel @Inject constructor( viewModelScope.launch { runCatching { todoRepository.updateTodo( - todo = todo, + todo = repositoryTodo, payload = CreateTaskPayload( title = normalizedTitle, description = normalizedDescription, diff --git a/android-compose/app/src/main/res/values/strings.xml b/android-compose/app/src/main/res/values/strings.xml index 3e429983..019351a9 100644 --- a/android-compose/app/src/main/res/values/strings.xml +++ b/android-compose/app/src/main/res/values/strings.xml @@ -112,6 +112,10 @@ Today Tomorrow Rest of %1$s + Move repeating task? + Choose whether to move only this task occurrence or the entire repeating series. + This occurrence + Entire series Summary Close summary Creating your task summary… diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/ApiResponseUtilsTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/ApiResponseUtilsTest.kt index 17fb79b3..e538c30a 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/ApiResponseUtilsTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/ApiResponseUtilsTest.kt @@ -34,4 +34,15 @@ class ApiResponseUtilsTest { ), ) } + + @Test + fun `unauthorized responses are treated as session auth issues not connectivity`() { + val error = ApiCallException( + statusCode = 401, + message = "Unauthorized", + ) + + assertTrue(isSessionAuthenticationIssue(error)) + assertFalse(isLikelyConnectivityIssue(error)) + } } diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt new file mode 100644 index 00000000..8a50a420 --- /dev/null +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/model/TaskRescheduleTest.kt @@ -0,0 +1,67 @@ +package com.ohmz.tday.compose.core.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +class TaskRescheduleTest { + private val zoneId = ZoneId.of("America/Toronto") + + @Test + fun `movedDuePreservingTime keeps original local time`() { + val due = Instant.parse("2026-05-15T18:45:30Z") + val moved = movedDuePreservingTime( + due = due, + targetDate = LocalDate.parse("2026-06-03"), + zoneId = zoneId, + ) + + val movedLocal = moved.atZone(zoneId) + assertEquals(LocalDate.parse("2026-06-03"), movedLocal.toLocalDate()) + assertEquals(14, movedLocal.hour) + assertEquals(45, movedLocal.minute) + assertEquals(30, movedLocal.second) + } + + @Test + fun `timelineRescheduleTargetDate resolves exact day sections`() { + val today = LocalDate.parse("2026-05-24") + + assertEquals( + LocalDate.parse("2026-05-27"), + timelineRescheduleTargetDate("day-2026-05-27", today), + ) + } + + @Test + fun `timelineRescheduleTargetDate resolves current month rest to horizon start`() { + val today = LocalDate.parse("2026-05-24") + + assertEquals( + LocalDate.parse("2026-05-31"), + timelineRescheduleTargetDate("rest-2026-05", today), + ) + } + + @Test + fun `timelineRescheduleTargetDate resolves future month buckets to first day`() { + val today = LocalDate.parse("2026-05-24") + + assertEquals( + LocalDate.parse("2026-07-01"), + timelineRescheduleTargetDate("month-2026-07", today), + ) + } + + @Test + fun `timelineRescheduleTargetDate resolves earlier to yesterday and rejects past month targets`() { + val today = LocalDate.parse("2026-05-24") + + assertEquals(LocalDate.parse("2026-05-23"), timelineRescheduleTargetDate("earlier", today)) + assertNull(timelineRescheduleTargetDate("day-2026-04-30", today)) + assertNull(timelineRescheduleTargetDate("month-2026-04", today)) + } +} 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 new file mode 100644 index 00000000..dd38252d --- /dev/null +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt @@ -0,0 +1,205 @@ +package com.ohmz.tday.compose.feature.app + +import app.cash.turbine.test +import com.ohmz.tday.compose.core.data.ApiCallException +import com.ohmz.tday.compose.core.data.OfflineSyncState +import com.ohmz.tday.compose.core.data.ThemePreferenceStore +import com.ohmz.tday.compose.core.data.auth.AuthRepository +import com.ohmz.tday.compose.core.data.auth.SystemCredentialServicing +import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager +import com.ohmz.tday.compose.core.data.server.AppVersionManager +import com.ohmz.tday.compose.core.data.server.ServerConfigRepository +import com.ohmz.tday.compose.core.data.settings.SettingsRepository +import com.ohmz.tday.compose.core.data.sync.SyncManager +import com.ohmz.tday.compose.core.model.SessionUser +import com.ohmz.tday.compose.core.network.ConnectivityObserver +import com.ohmz.tday.compose.core.network.RealtimeClient +import com.ohmz.tday.compose.core.network.RealtimeEvent +import com.ohmz.tday.compose.core.notification.ReminderOption +import com.ohmz.tday.compose.core.notification.ReminderPreferenceStore +import com.ohmz.tday.compose.core.notification.TaskReminderScheduler +import com.ohmz.tday.compose.core.ui.SnackbarManager +import com.ohmz.tday.compose.feature.auth.MainDispatcherRule +import com.ohmz.tday.compose.ui.theme.AppThemeMode +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AppViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val authRepository = mockk() + private val serverConfigRepository = mockk() + private val syncManager = mockk() + private val settingsRepository = mockk() + private val cacheManager = mockk() + private val themePreferenceStore = mockk() + private val reminderScheduler = mockk() + private val reminderPreferenceStore = mockk() + private val realtimeClient = mockk() + private val connectivityObserver = mockk() + private val appVersionManager = mockk() + private val systemCredentialService = mockk() + private val snackbarManager = SnackbarManager() + + private val versionState = MutableStateFlow( + AppVersionManager.VersionState(isLoadingReleases = false), + ) + private val realtimeEvents = MutableSharedFlow() + private val offlineSyncFailures = MutableSharedFlow() + private val offlineSyncSuccesses = MutableSharedFlow() + private val restoredUser = SessionUser( + id = "user-1", + name = "Taylor", + email = "user@example.com", + role = "USER", + ) + + @Before + fun setUp() { + every { themePreferenceStore.getThemeMode() } returns AppThemeMode.SYSTEM + every { reminderPreferenceStore.getDefaultReminder() } returns ReminderOption.DEFAULT + every { appVersionManager.state } returns versionState + coEvery { appVersionManager.refreshServerCompatibility() } returns Unit + every { serverConfigRepository.hasServerConfigured() } returns true + every { serverConfigRepository.getServerUrl() } returns "https://tday.example.com" + every { cacheManager.loadOfflineState() } returns OfflineSyncState() + every { syncManager.offlineSyncFailures } returns offlineSyncFailures + every { syncManager.offlineSyncSuccesses } returns offlineSyncSuccesses + every { syncManager.hasPendingMutations() } returns false + every { realtimeClient.events } returns realtimeEvents + every { realtimeClient.isConnected } returns false + every { realtimeClient.connect() } returns Unit + every { realtimeClient.disconnect() } returns Unit + every { connectivityObserver.connectivityChanges } returns emptyFlow() + every { settingsRepository.isAiSummaryEnabledSnapshot() } returns true + coEvery { authRepository.syncTimezone() } returns Unit + every { reminderScheduler.rescheduleAll() } returns Unit + every { reminderScheduler.cancelAll() } returns Unit + } + + @Test + fun `foreground reconnect retries sync after restoring session`() = runTest { + val restoredSession = AuthRepository.RestoredSession( + user = restoredUser, + usedCachedSession = false, + ) + coEvery { authRepository.restoreSessionForBootstrap() } returnsMany listOf( + restoredSession, + restoredSession, + ) + coEvery { + syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = null, + ) + } returns Result.success(Unit) + coEvery { + syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + ) + } returnsMany listOf( + Result.failure(ApiCallException(statusCode = 401, message = "Unauthorized")), + Result.success(Unit), + ) + + val viewModel = makeViewModel() + runCurrent() + + snackbarManager.events.test { + viewModel.reconnectAfterForeground() + runCurrent() + runCurrent() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertTrue(viewModel.uiState.value.authenticated) + assertFalse(viewModel.uiState.value.isOffline) + assertEquals(restoredUser, viewModel.uiState.value.user) + + viewModel.logout() + runCurrent() + } + + @Test + fun `foreground reconnect marks app offline when session cannot be restored`() = runTest { + val restoredSession = AuthRepository.RestoredSession( + user = restoredUser, + usedCachedSession = false, + ) + coEvery { authRepository.restoreSessionForBootstrap() } returnsMany listOf( + restoredSession, + null, + ) + coEvery { + syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = null, + ) + } returns Result.success(Unit) + coEvery { + syncManager.syncCachedData( + force = true, + replayPendingMutations = true, + notifyOfflineFailure = false, + connectionProbeTimeoutMs = SyncManager.USER_REFRESH_CONNECTION_TIMEOUT_MS, + ) + } returns Result.failure(ApiCallException(statusCode = 401, message = "Unauthorized")) + + val viewModel = makeViewModel() + runCurrent() + + snackbarManager.events.test { + viewModel.reconnectAfterForeground() + runCurrent() + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertTrue(viewModel.uiState.value.authenticated) + assertTrue(viewModel.uiState.value.isOffline) + assertEquals(restoredUser, viewModel.uiState.value.user) + + viewModel.logout() + runCurrent() + } + + private fun makeViewModel(): AppViewModel = + AppViewModel( + authRepository = authRepository, + serverConfigRepository = serverConfigRepository, + syncManager = syncManager, + settingsRepository = settingsRepository, + cacheManager = cacheManager, + themePreferenceStore = themePreferenceStore, + reminderScheduler = reminderScheduler, + reminderPreferenceStore = reminderPreferenceStore, + snackbarManager = snackbarManager, + realtimeClient = realtimeClient, + connectivityObserver = connectivityObserver, + appVersionManager = appVersionManager, + systemCredentialService = systemCredentialService, + ) +} diff --git a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift index 7d038bf8..634ad0d8 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift @@ -371,6 +371,14 @@ final class SyncManager { let remoteUpdatedAt = remoteSnapshot.todoUpdatedAtByCanonical[targetID] ?? 0 guard remoteUpdatedAt <= mutation.timestampEpochMs else { return } let resolvedListID = mutation.listId.flatMap { resolvedListIDs[$0] ?? $0 } + let isDueOnlyMove = mutation.dueEpochMs != nil && + mutation.title == nil && + mutation.description == nil && + mutation.priority == nil && + mutation.pinned == nil && + mutation.completed == nil && + mutation.rrule == nil && + mutation.listId == nil if let instanceDateEpochMs = mutation.instanceDateEpochMs { _ = try await api.patchTodoInstanceByBody( payload: TodoInstancePatchRequest( @@ -392,10 +400,10 @@ final class SyncManager { priority: mutation.priority, completed: mutation.completed, due: mutation.dueEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() }, - rrule: mutation.rrule, - listID: resolvedListID, + rrule: isDueOnlyMove ? nil : mutation.rrule, + listID: isDueOnlyMove ? nil : resolvedListID, dateChanged: true, - rruleChanged: true, + rruleChanged: isDueOnlyMove ? nil : true, instanceDate: nil ) ) diff --git a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift index ac3b906d..a64e5a4a 100644 --- a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift @@ -181,6 +181,93 @@ final class TodoRepository { } } + func moveTodo(_ todo: TodoItem, due: Date) async throws { + let now = Date().epochMilliseconds + let dueEpochMs = due.epochMilliseconds + let isLocalOnly = todo.canonicalId.hasPrefix(LOCAL_TODO_PREFIX) + + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + let hasExistingUpdateMutation = state.pendingMutations.contains { mutation in + mutation.kind == .updateTodo && + mutation.targetId == todo.canonicalId && + mutation.instanceDateEpochMs == todo.instanceDateEpochMilliseconds + } + nextState.todos = state.todos.map { current in + let sameTodo = current.canonicalId == todo.canonicalId && current.instanceDateEpochMs == todo.instanceDateEpochMilliseconds + guard sameTodo else { return current } + return CachedTodoRecord( + id: current.id, + canonicalId: current.canonicalId, + title: current.title, + description: current.description, + priority: current.priority, + dueEpochMs: dueEpochMs, + rrule: current.rrule, + instanceDateEpochMs: current.instanceDateEpochMs, + pinned: current.pinned, + completed: current.completed, + listId: current.listId, + updatedAtEpochMs: now + ) + } + nextState.pendingMutations = state.pendingMutations.map { mutation in + let samePendingUpdate = mutation.kind == .updateTodo && + mutation.targetId == todo.canonicalId && + mutation.instanceDateEpochMs == todo.instanceDateEpochMilliseconds + guard samePendingUpdate || (mutation.kind == .createTodo && mutation.targetId == todo.canonicalId) else { + return mutation + } + return PendingMutationRecord( + mutationId: mutation.mutationId, + kind: mutation.kind, + targetId: mutation.targetId, + timestampEpochMs: now, + title: mutation.title, + description: mutation.description, + priority: mutation.priority, + dueEpochMs: dueEpochMs, + rrule: mutation.rrule, + listId: mutation.listId, + pinned: mutation.pinned, + completed: mutation.completed, + instanceDateEpochMs: mutation.instanceDateEpochMs, + name: mutation.name, + color: mutation.color, + iconKey: mutation.iconKey + ) + } + if !isLocalOnly && !hasExistingUpdateMutation { + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: UUID().uuidString, + kind: .updateTodo, + targetId: todo.canonicalId, + timestampEpochMs: now, + title: nil, + description: nil, + priority: nil, + dueEpochMs: dueEpochMs, + rrule: nil, + listId: nil, + pinned: nil, + completed: nil, + instanceDateEpochMs: todo.instanceDateEpochMilliseconds, + name: nil, + color: nil, + iconKey: nil + ) + ) + } + return nextState + } + + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) + if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { + throw error + } + } + func deleteTodo(_ todo: TodoItem) async throws { let now = Date().epochMilliseconds _ = try await cacheManager.updateOfflineState { state in diff --git a/ios-swiftUI/Tday/Core/Model/DomainModels.swift b/ios-swiftUI/Tday/Core/Model/DomainModels.swift index 4a147944..b8098422 100644 --- a/ios-swiftUI/Tday/Core/Model/DomainModels.swift +++ b/ios-swiftUI/Tday/Core/Model/DomainModels.swift @@ -44,6 +44,11 @@ enum TodoListMode: String, Codable, CaseIterable, Hashable { } } +enum TaskRescheduleScope: String, Codable, Hashable { + case occurrence + case series +} + struct CreateTaskPayload: Equatable, Hashable, Codable { let title: String let description: String? @@ -80,6 +85,131 @@ struct TodoItem: Identifiable, Equatable, Hashable, Codable { } } +extension TodoListMode { + var supportsTaskReschedule: Bool { + switch self { + case .scheduled, .all, .priority, .list: + return true + case .today, .overdue: + return false + } + } +} + +extension TodoItem { + func repositoryTargetForReschedule(scope: TaskRescheduleScope) -> TodoItem { + switch scope { + case .occurrence: + return self + case .series: + return TodoItem( + id: canonicalId, + canonicalId: canonicalId, + title: title, + description: description, + priority: priority, + due: due, + rrule: rrule, + instanceDate: nil, + pinned: pinned, + completed: completed, + listId: listId, + updatedAt: updatedAt + ) + } + } +} + +func movedDuePreservingTime( + due: Date, + targetDay: Date, + calendar: Calendar = .current +) -> Date? { + let dueComponents = calendar.dateComponents([.hour, .minute, .second, .nanosecond], from: due) + var targetComponents = calendar.dateComponents([.year, .month, .day], from: targetDay) + targetComponents.timeZone = calendar.timeZone + targetComponents.hour = dueComponents.hour + targetComponents.minute = dueComponents.minute + targetComponents.second = dueComponents.second + targetComponents.nanosecond = dueComponents.nanosecond + return calendar.date(from: targetComponents) +} + +func movedTaskPayload( + todo: TodoItem, + targetDay: Date, + calendar: Calendar = .current +) -> CreateTaskPayload? { + guard let movedDue = movedDuePreservingTime(due: todo.due, targetDay: targetDay, calendar: calendar) else { + return nil + } + return CreateTaskPayload( + title: todo.title, + description: todo.description, + priority: todo.priority, + due: movedDue, + rrule: todo.rrule, + listId: todo.listId + ) +} + +func timelineRescheduleTargetDate( + sectionId: String, + today: Date = Date(), + calendar: Calendar = .current +) -> Date? { + let startOfToday = calendar.startOfDay(for: today) + let currentMonthStart = rescheduleMonthStart(for: startOfToday, calendar: calendar) + + if sectionId == "earlier" { + return calendar.date(byAdding: .day, value: -1, to: startOfToday) + } + + if sectionId.hasPrefix("scheduled-") || sectionId.hasPrefix("priority-") { + guard let suffix = sectionId.split(separator: "-").last, + let interval = TimeInterval(String(suffix)) else { + return nil + } + let date = calendar.startOfDay(for: Date(timeIntervalSince1970: interval)) + return rescheduleMonthStart(for: date, calendar: calendar) >= currentMonthStart ? date : nil + } + + if sectionId.hasPrefix("rest-") { + guard let monthIndexValue = Int(sectionId.replacingOccurrences(of: "rest-", with: "")) else { + return nil + } + let horizonStart = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 7, to: startOfToday) ?? startOfToday) + return rescheduleMonthIndex(for: horizonStart, calendar: calendar) == monthIndexValue && + rescheduleMonthStart(for: horizonStart, calendar: calendar) == currentMonthStart ? horizonStart : nil + } + + if sectionId.hasPrefix("month-") { + guard let monthIndexValue = Int(sectionId.replacingOccurrences(of: "month-", with: "")) else { + return nil + } + let currentMonthIndex = rescheduleMonthIndex(for: startOfToday, calendar: calendar) + guard monthIndexValue >= currentMonthIndex else { + return nil + } + let year = (monthIndexValue - 1) / 12 + let month = ((monthIndexValue - 1) % 12) + 1 + return calendar.date(from: DateComponents(year: year, month: month, day: 1)) + } + + return nil +} + +private func rescheduleMonthStart(for date: Date, calendar: Calendar) -> Date { + let components = calendar.dateComponents([.year, .month], from: date) + return calendar.date(from: components).map(calendar.startOfDay) ?? calendar.startOfDay(for: date) +} + +private func rescheduleMonthIndex(for date: Date, calendar: Calendar) -> Int { + let year = calendar.component(.year, from: date) + let month = calendar.component(.month, from: date) + return year * 12 + month +} + struct ListSummary: Identifiable, Equatable, Hashable, Codable { let id: String let name: String diff --git a/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift b/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift index 2918995d..960d0e90 100644 --- a/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift +++ b/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift @@ -34,6 +34,13 @@ func isLikelyConnectivityIssue(_ error: Error) -> Bool { return false } +func isSessionAuthenticationIssue(_ error: Error) -> Bool { + guard let apiError = error as? APIError else { + return false + } + return apiError.statusCode == 401 +} + func isLikelyServerUnavailableStatusCode(_ statusCode: Int) -> Bool { statusCode == 408 || statusCode == 502 || diff --git a/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift b/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift index 89d9b1db..07897b4d 100644 --- a/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift +++ b/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift @@ -4,6 +4,8 @@ private let defaultTaskFabFillColor = Color(red: 110.0 / 255.0, green: 168.0 / 2 struct TaskFloatingActionButton: View { var fillColor = defaultTaskFabFillColor + var pressedShadowOpacity = 0.14 + var normalShadowOpacity = 0.24 let action: () -> Void private var borderColor: Color { @@ -23,7 +25,12 @@ struct TaskFloatingActionButton: View { ) .clipShape(Circle()) } - .buttonStyle(TdayPressButtonStyle()) + .buttonStyle( + TdayPressButtonStyle( + pressedShadowOpacity: pressedShadowOpacity, + normalShadowOpacity: normalShadowOpacity + ) + ) .accessibilityLabel("Create Task") } } @@ -56,12 +63,19 @@ private func taskFabBlend(_ color: Color, with other: Color, amount: CGFloat) -> struct TaskFloatingActionButtonDock: View { var fillColor = defaultTaskFabFillColor + var pressedShadowOpacity = 0.14 + var normalShadowOpacity = 0.24 let action: () -> Void var body: some View { HStack { Spacer() - TaskFloatingActionButton(fillColor: fillColor, action: action) + TaskFloatingActionButton( + fillColor: fillColor, + pressedShadowOpacity: pressedShadowOpacity, + normalShadowOpacity: normalShadowOpacity, + action: action + ) .padding(.trailing, 18) .padding(.vertical, 8) } diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index c740001d..f05cc644 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -370,8 +370,12 @@ final class AppViewModel { replayPendingMutations: true, notifyOfflineFailure: false ) + let recoveredResult = await self.recoverSessionAndRetrySyncIfNeeded( + after: result, + connectionProbeTimeoutSeconds: nil + ) await MainActor.run { - self.applySyncResult(result) + self.applySyncResult(recoveredResult, suppressAuthenticationExpired: true) } await self.rescheduleReminders() } @@ -397,8 +401,12 @@ final class AppViewModel { replayPendingMutations: true, notifyOfflineFailure: false ) + let recoveredResult = await self.recoverSessionAndRetrySyncIfNeeded( + after: result, + connectionProbeTimeoutSeconds: nil + ) await MainActor.run { - self.applySyncResult(result) + self.applySyncResult(recoveredResult, suppressAuthenticationExpired: true) } await self.rescheduleReminders() } @@ -412,13 +420,18 @@ final class AppViewModel { } } - private func applySyncResult(_ result: Result, showOfflineNotice: Bool = false) { + private func applySyncResult( + _ result: Result, + showOfflineNotice: Bool = false, + suppressAuthenticationExpired: Bool = false + ) { switch result { case .success: isOffline = false refreshPendingMutationCount() case let .failure(error): - isOffline = isLikelyConnectivityIssue(error) + isOffline = isLikelyConnectivityIssue(error) || + (suppressAuthenticationExpired && isSessionAuthenticationIssue(error)) if isOffline && showOfflineNotice { offlineNoticeID += 1 } @@ -490,13 +503,49 @@ final class AppViewModel { notifyOfflineFailure: false, connectionProbeTimeoutSeconds: SyncAndRefreshUseCase.userRefreshConnectionTimeoutSeconds ) - applySyncResult(result, showOfflineNotice: showOfflineNotice) - if case .success = result { + let recoveredResult = await recoverSessionAndRetrySyncIfNeeded( + after: result, + connectionProbeTimeoutSeconds: SyncAndRefreshUseCase.userRefreshConnectionTimeoutSeconds + ) + applySyncResult( + recoveredResult, + showOfflineNotice: showOfflineNotice, + suppressAuthenticationExpired: true + ) + if case .success = recoveredResult { startRealtime() await rescheduleReminders() } } + private func recoverSessionAndRetrySyncIfNeeded( + after result: Result, + connectionProbeTimeoutSeconds: TimeInterval? + ) async -> Result { + guard case let .failure(error) = result, isSessionAuthenticationIssue(error) else { + return result + } + + guard let restoredSession = await container.authRepository.restoreSessionForBootstrap() else { + return result + } + + user = restoredSession.user + authenticated = true + isOffline = restoredSession.usedCachedSession + + guard !restoredSession.usedCachedSession else { + return .failure(APIError(message: "Unable to refresh session while offline", statusCode: nil)) + } + + return await container.syncAndRefresh( + force: true, + replayPendingMutations: true, + notifyOfflineFailure: false, + connectionProbeTimeoutSeconds: connectionProbeTimeoutSeconds + ) + } + private func isAdmin(_ user: SessionUser?) -> Bool { user?.role?.uppercased() == "ADMIN" } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 70073b9f..16e5328f 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -1,5 +1,34 @@ import SwiftUI import UIKit +import UniformTypeIdentifiers + +private let calendarTaskDragContentTypes = [UTType.plainText.identifier, UTType.text.identifier] + +private final class CalendarTaskDragSession { + static let shared = CalendarTaskDragSession() + var todo: TodoItem? + var handledDropSignature: String? + + private init() {} +} + +private struct CalendarInAppDrag: Equatable { + let todo: TodoItem + var location: CGPoint +} + +private struct CalendarDateDropTargetFrame: Equatable { + let date: Date + let frame: CGRect +} + +private struct CalendarDateDropTargetFramePreferenceKey: PreferenceKey { + static var defaultValue: [String: CalendarDateDropTargetFrame] = [:] + + static func reduce(value: inout [String: CalendarDateDropTargetFrame], nextValue: () -> [String: CalendarDateDropTargetFrame]) { + value.merge(nextValue(), uniquingKeysWith: { _, newValue in newValue }) + } +} private enum CalendarTitleHandoff { static let collapseDistance: CGFloat = 180 @@ -29,6 +58,8 @@ private enum CalendarPeriodCardMetrics { private enum CalendarMonthGridMetrics { static let spacing: CGFloat = 8 static let height: CGFloat = 292 + static let weekdayHeight: CGFloat = 18 + static let cardBottomPadding: CGFloat = 20 static let dayCellHeight: CGFloat = 42 static let dayHighlightWidth: CGFloat = 42 static let dayHighlightHeight: CGFloat = 40 @@ -39,13 +70,68 @@ private enum CalendarMonthGridMetrics { static let cellCornerRadius: CGFloat = 16 } +private enum CalendarTaskListMetrics { + static let rowSpacing: CGFloat = 0 + static let rowVerticalPadding: CGFloat = 4 +} + +private enum CalendarModeCardMetrics { + static let shadowBleed: CGFloat = 12 + static let monthHeight = CalendarPeriodCardMetrics.topPadding + + CalendarPeriodCardMetrics.headerHeight + + CalendarPeriodCardMetrics.contentSpacing + + CalendarMonthGridMetrics.weekdayHeight + + CalendarMonthGridMetrics.spacing + + CalendarMonthGridMetrics.height + + CalendarMonthGridMetrics.cardBottomPadding + static let periodHeight = CalendarPeriodCardMetrics.topPadding + + CalendarPeriodCardMetrics.headerHeight + + CalendarPeriodCardMetrics.contentSpacing + + CalendarPeriodCardMetrics.pageHeight + + CalendarPeriodCardMetrics.bottomPadding +} + 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 struct CalendarCardChromeModifier: ViewModifier { + @Environment(\.tdayColors) private var colors + + func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: 24, style: .continuous) + + content + .background(colors.surface, in: shape) + .overlay { + shape.stroke(cardStrokeColor, lineWidth: 1) + } + .shadow(color: ambientShadowColor, radius: 18, x: 0, y: 9) + .shadow(color: keyShadowColor, radius: 4, x: 0, y: 2) + } + + private var cardStrokeColor: Color { + colors.isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.035) + } + + private var ambientShadowColor: Color { + Color.black.opacity(colors.isDark ? 0.24 : 0.045) + } + + private var keyShadowColor: Color { + Color.black.opacity(colors.isDark ? 0.18 : 0.04) + } +} private struct CalendarTodayJumpRequest: Equatable { let id: Int let targetDate: Date } +private struct CalendarTaskRescheduleDrop: Equatable { + let todo: TodoItem + let targetDate: Date +} + struct CalendarScreen: View { @State private var viewModel: CalendarViewModel @Environment(\.tdayColors) private var colors @@ -60,6 +146,11 @@ struct CalendarScreen: View { @State private var calendarTitleCollapseOffset: CGFloat = 0 @State private var todayJumpRequestID = 0 @State private var todayJumpRequest: CalendarTodayJumpRequest? + @State private var draggedTodo: TodoItem? + @State private var inAppDrag: CalendarInAppDrag? + @State private var activeDropDate: Date? + @State private var dateDropTargetFrames: [String: CalendarDateDropTargetFrame] = [:] + @State private var pendingRescheduleDrop: CalendarTaskRescheduleDrop? init(container: AppContainer) { _viewModel = State(initialValue: CalendarViewModel(container: container)) @@ -75,12 +166,25 @@ struct CalendarScreen: View { } } + private var calendarTaskRescheduleEnabled: Bool { + displayMode != .day + } + private var selectedDateHeaderText: String { let formatter = DateFormatter() formatter.dateFormat = "EEE, MMM d" return formatter.string(from: selectedDate) } + private var calendarModeCardHeight: CGFloat { + switch displayMode { + case .month: + return CalendarModeCardMetrics.monthHeight + case .week, .day: + return CalendarModeCardMetrics.periodHeight + } + } + private var titleCollapseProgress: CGFloat { let distance = CalendarTitleHandoff.collapseDistance guard distance > 0 else { return 0 } @@ -116,7 +220,7 @@ struct CalendarScreen: View { selectedMode: displayMode, accentColor: calendarAccentColor, onSelect: { mode in - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(calendarModeTransitionAnimation) { displayMode = mode if mode != .month { visibleMonth = calendarMonthStart(for: selectedDate) @@ -144,7 +248,13 @@ struct CalendarScreen: View { .listRowSeparator(.hidden) calendarModeCard - .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .id(displayMode) + .transition(.opacity.combined(with: .scale(scale: 0.985, anchor: .top))) + .frame(height: calendarModeCardHeight, alignment: .top) + .clipped() + .modifier(CalendarCardChromeModifier()) + .animation(calendarModeTransitionAnimation, value: displayMode) + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: CalendarModeCardMetrics.shadowBleed, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } @@ -158,59 +268,86 @@ struct CalendarScreen: View { } } - Section { - if !pendingItems.isEmpty { + Text("Tasks due \(selectedDateHeaderText)") + .font(.tdayRounded(size: 22, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .textCase(nil) + .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 4, trailing: TodoTimelineMetrics.horizontalPadding)) + .timelinePinnedSectionHeaderBackground() + + if !pendingItems.isEmpty { + VStack(spacing: CalendarTaskListMetrics.rowSpacing) { ForEach(pendingItems) { todo in CalendarPendingTaskRow( todo: todo, + list: todo.listId.flatMap { listId in + viewModel.lists.first(where: { $0.id == listId }) + }, onComplete: { Task { await viewModel.complete(todo) } } ) - .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .swipeRevealHintOnTap() - .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - Task { await viewModel.complete(todo) } - } label: { - Label("Complete", systemImage: "checkmark") - } - .tint(.green) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - Task { await viewModel.delete(todo) } - } label: { - Label("Delete", systemImage: "trash") - } - .tint(TaskSwipeActionTint.delete) - - Button { + .opacity(draggedTodo?.id == todo.id && activeDropDate != nil ? 0.55 : 1) + .background(colors.background) + .modifier( + CalendarInAppDragModifier( + enabled: calendarTaskRescheduleEnabled, + todo: todo, + onStart: beginInAppDrag, + onMove: updateInAppDrag, + onEnd: finishInAppDrag, + onCancel: cancelInAppDrag + ) + ) + .todoTrailingSwipeActions( + onEdit: { editingTodo = todo - } label: { - Label("Edit", systemImage: "square.and.pencil") + }, + onDelete: { + Task { await viewModel.delete(todo) } } - .tint(TaskSwipeActionTint.edit) - } - TimelineRowDivider() + ) } } - } header: { - Text("Tasks due \(selectedDateHeaderText)") - .font(.tdayRounded(size: 22, weight: .heavy)) - .foregroundStyle(colors.onSurface) - .textCase(nil) - .listRowInsets(EdgeInsets(top: 8, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) - .timelinePinnedSectionHeaderBackground() + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) } } + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) .listStyle(.plain) .scrollContentBackground(.hidden) .contentMargins(.top, 0, for: .scrollContent) + .listRowSpacing(0) .listSectionSpacing(0) .environment(\.defaultMinListRowHeight, 1) .disableVerticalScrollBounce() .background(colors.background) + .onPreferenceChange(CalendarDateDropTargetFramePreferenceKey.self) { frames in + dateDropTargetFrames = frames + } + .onChange(of: displayMode) { _, mode in + if mode == .day { + cancelInAppDrag() + } + } + .overlay(alignment: .topLeading) { + GeometryReader { proxy in + if let inAppDrag { + let rootFrame = proxy.frame(in: .global) + let previewLocation = CGPoint( + x: inAppDrag.location.x - rootFrame.minX, + y: inAppDrag.location.y - rootFrame.minY + ) + CalendarTaskDragPreview(todo: inAppDrag.todo) + .position(x: previewLocation.x, y: previewLocation.y) + .zIndex(20) + .allowsHitTesting(false) + } + } + .allowsHitTesting(false) + } .navigationBackButtonBehavior() .navigationTitleTypography( largeTitleColor: calendarAccentColor, @@ -224,7 +361,11 @@ struct CalendarScreen: View { calendarTopInset } .safeAreaInset(edge: .bottom) { - TaskFloatingActionButtonDock(fillColor: calendarAccentColor) { + TaskFloatingActionButtonDock( + fillColor: calendarAccentColor, + pressedShadowOpacity: 0.09, + normalShadowOpacity: 0.16 + ) { showingCreateTask = true } } @@ -258,6 +399,30 @@ struct CalendarScreen: View { } ) } + .confirmationDialog( + "Move repeating task?", + isPresented: Binding( + get: { pendingRescheduleDrop != nil }, + set: { isPresented in + if !isPresented { + pendingRescheduleDrop = nil + } + } + ), + titleVisibility: .visible + ) { + Button("This occurrence") { + commitPendingReschedule(scope: .occurrence) + } + Button("Entire series") { + commitPendingReschedule(scope: .series) + } + Button("Cancel", role: .cancel) { + pendingRescheduleDrop = nil + } + } message: { + Text("Choose whether to move only this task occurrence or the entire repeating series.") + } } private var calendarTopInset: some View { @@ -288,12 +453,17 @@ struct CalendarScreen: View { selectedDate: selectedDate, tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, + draggedTodo: draggedTodo, + activeDropDate: activeDropDate, canGoPreviousMonth: canGoPreviousMonth, minimumNavigableMonth: minimumNavigableMonth, todayJumpRequest: todayJumpRequest, onPreviousMonth: { navigateMonth(by: -1) }, onNextMonth: { navigateMonth(by: 1) }, - onSelectDate: { selectDate($0) } + onSelectDate: { selectDate($0) }, + onDropDateChange: { activeDropDate = $0 }, + onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) }, + resolveTodo: resolveTodoForDrop ) case .week: CalendarWeekCard( @@ -301,12 +471,17 @@ struct CalendarScreen: View { today: Date(), tasksByDay: pendingItemsByDay, accentColor: calendarAccentColor, + draggedTodo: draggedTodo, + activeDropDate: activeDropDate, canGoPreviousWeek: canGoPreviousWeek, canSelectDate: { canNavigate(to: $0) }, todayJumpRequest: todayJumpRequest, onPreviousWeek: { navigateDay(by: -7) }, onNextWeek: { navigateDay(by: 7) }, - onSelectDate: { selectDate($0) } + onSelectDate: { selectDate($0) }, + onDropDateChange: { activeDropDate = $0 }, + onMoveTaskToDate: { todo, date in requestReschedule(todo, to: date) }, + resolveTodo: resolveTodoForDrop ) case .day: CalendarDayCard( @@ -353,6 +528,95 @@ struct CalendarScreen: View { todayJumpRequestID += 1 todayJumpRequest = CalendarTodayJumpRequest(id: todayJumpRequestID, targetDate: Date()) } + + private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { + draggedTodo = nil + inAppDrag = nil + activeDropDate = nil + CalendarTaskDragSession.shared.todo = nil + let targetDay = Calendar.current.startOfDay(for: targetDate) + let dropSignature = "\(todo.id)|\(targetDay.timeIntervalSince1970)" + guard CalendarTaskDragSession.shared.handledDropSignature != dropSignature else { + return + } + CalendarTaskDragSession.shared.handledDropSignature = dropSignature + guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDay) else { + return + } + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if todo.isRecurring { + pendingRescheduleDrop = CalendarTaskRescheduleDrop(todo: todo, targetDate: targetDay) + } else { + Task { + await viewModel.moveTask(todo, toDay: targetDay, scope: .occurrence) + await MainActor.run { + selectDate(targetDay) + } + } + } + } + + private func resolveTodoForDrop(id: String) -> TodoItem? { + viewModel.items.first { $0.id == id || $0.canonicalId == id } + } + + private func beginInAppDrag(_ todo: TodoItem, at location: CGPoint) { + if draggedTodo?.id != todo.id { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + draggedTodo = todo + CalendarTaskDragSession.shared.todo = todo + CalendarTaskDragSession.shared.handledDropSignature = nil + inAppDrag = CalendarInAppDrag(todo: todo, location: location) + updateInAppDrag(todo, to: location) + } + + private func updateInAppDrag(_ todo: TodoItem, to location: CGPoint) { + inAppDrag = CalendarInAppDrag(todo: todo, location: location) + activeDropDate = dropDate(at: location) + } + + private func finishInAppDrag(_ todo: TodoItem, at location: CGPoint?) { + let targetDate = location.flatMap(dropDate(at:)) ?? activeDropDate + activeDropDate = nil + draggedTodo = nil + inAppDrag = nil + if let targetDate { + requestReschedule(todo, to: targetDate) + } else { + CalendarTaskDragSession.shared.todo = nil + } + } + + private func cancelInAppDrag() { + activeDropDate = nil + draggedTodo = nil + inAppDrag = nil + CalendarTaskDragSession.shared.todo = nil + } + + private func dropDate(at location: CGPoint) -> Date? { + dateDropTargetFrames.values + .filter { $0.frame.contains(location) } + .min { lhs, rhs in + (lhs.frame.width * lhs.frame.height) < (rhs.frame.width * rhs.frame.height) + } + .map { Calendar.current.startOfDay(for: $0.date) } + } + + private func commitPendingReschedule(scope: TaskRescheduleScope) { + guard let drop = pendingRescheduleDrop else { + return + } + pendingRescheduleDrop = nil + Task { + await viewModel.moveTask(drop.todo, toDay: drop.targetDate, scope: scope) + await MainActor.run { + selectDate(drop.targetDate) + } + } + } } private struct CalendarViewModeTabs: View { @@ -384,12 +648,17 @@ private struct CalendarMonthGrid: View { let selectedDate: Date let tasksByDay: [Date: [TodoItem]] let accentColor: Color + let draggedTodo: TodoItem? + let activeDropDate: Date? let canGoPreviousMonth: Bool let minimumNavigableMonth: Date let todayJumpRequest: CalendarTodayJumpRequest? let onPreviousMonth: () -> Void let onNextMonth: () -> Void let onSelectDate: (Date) -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -412,8 +681,6 @@ private struct CalendarMonthGrid: View { monthContent(for: displayMonth) .onChange(of: todayJumpRequest) { _, request in handleTodayJump(request, from: displayMonth) } .onChange(of: displayMonth) { _, _ in resetPageSelection() } - .background(colors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 5) } private func monthContent(for displayMonth: Date) -> some View { @@ -426,7 +693,6 @@ private struct CalendarMonthGrid: View { let isPagingAtRest = pageSelection == calendarNativePagerCenterIndex let isPreviousEnabled = canGoPrevious && isPagingAtRest let isNextEnabled = isPagingAtRest - return VStack(spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { CalendarNavButton( @@ -461,7 +727,7 @@ private struct CalendarMonthGrid: View { .font(.tdayRounded(size: 12, weight: .heavy)) .foregroundStyle(colors.onSurfaceVariant.opacity(0.48)) .frame(maxWidth: .infinity) - .frame(height: 18) + .frame(height: CalendarMonthGridMetrics.weekdayHeight) } } @@ -479,7 +745,7 @@ private struct CalendarMonthGrid: View { } .padding(.horizontal, CalendarPeriodCardMetrics.horizontalPadding) .padding(.top, CalendarPeriodCardMetrics.topPadding) - .padding(.bottom, 20) + .padding(.bottom, CalendarMonthGridMetrics.cardBottomPadding) .frame(maxWidth: .infinity) } @@ -491,14 +757,24 @@ private struct CalendarMonthGrid: View { 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, taskCount: dayTasks.count, accentColor: accentColor, - onSelectDate: onSelectDate + draggedTodo: draggedTodo, + onSelectDate: onSelectDate, + onDropDateChange: onDropDateChange, + onMoveTaskToDate: onMoveTaskToDate, + resolveTodo: resolveTodo ) } } } + private func canSelectDate(_ date: Date) -> Bool { + calendarMonthStart(for: date) >= minimumNavigableMonth + } + private func monthPages(previousMonth: Date?, displayMonth: Date, nextMonth: Date?) -> [CalendarPagerPage] { var pages: [CalendarPagerPage] = [] @@ -602,12 +878,17 @@ private struct CalendarWeekCard: View { let today: Date let tasksByDay: [Date: [TodoItem]] let accentColor: Color + let draggedTodo: TodoItem? + let activeDropDate: Date? let canGoPreviousWeek: Bool let canSelectDate: (Date) -> Bool let todayJumpRequest: CalendarTodayJumpRequest? let onPreviousWeek: () -> Void let onNextWeek: () -> Void let onSelectDate: (Date) -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @State private var pageSelection = calendarNativePagerCenterIndex @@ -618,8 +899,6 @@ private struct CalendarWeekCard: View { weekContent(for: displaySelectedDate) .onChange(of: todayJumpRequest) { _, request in handleTodayJump(request, from: displaySelectedDate) } .onChange(of: displaySelectedDate) { _, _ in resetPageSelection() } - .background(colors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 5) } private func weekContent(for displaySelectedDate: Date) -> some View { @@ -631,7 +910,6 @@ private struct CalendarWeekCard: View { let previousPageWeekDate = jumpDirection == .previous ? pendingTodayJump?.targetDate : previousWeekDate let nextPageWeekDate = jumpDirection == .next ? pendingTodayJump?.targetDate : nextWeekDate let isPagingAtRest = pageSelection == calendarNativePagerCenterIndex - return VStack(spacing: CalendarPeriodCardMetrics.contentSpacing) { HStack { CalendarNavButton( @@ -694,7 +972,12 @@ private struct CalendarWeekCard: View { isToday: Calendar.current.isDate(date, inSameDayAs: today), isEnabled: isEnabled, accentColor: accentColor, - onSelect: { onSelectDate(date) } + isDropTarget: activeDropDate.map { Calendar.current.isDate($0, inSameDayAs: date) } ?? false, + draggedTodo: draggedTodo, + onSelect: { onSelectDate(date) }, + onDropDateChange: onDropDateChange, + onMoveTaskToDate: onMoveTaskToDate, + resolveTodo: resolveTodo ) } } @@ -789,7 +1072,12 @@ private struct CalendarWeekDayCell: View { let isToday: Bool let isEnabled: Bool let accentColor: Color + let isDropTarget: Bool + let draggedTodo: TodoItem? let onSelect: () -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @@ -823,6 +1111,15 @@ private struct CalendarWeekDayCell: View { } .buttonStyle(.plain) .disabled(!isEnabled) + .calendarTaskDropTarget( + date: date, + canDrop: isEnabled, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMoveTaskToDate, + onDateChange: onDropDateChange + ) + .calendarInAppDateDropTargetFrame(date: date, enabled: isEnabled) .opacity(isEnabled ? 1 : 0.48) } @@ -837,6 +1134,9 @@ private struct CalendarWeekDayCell: View { } private var cellBackground: Color { + if isDropTarget { + return colors.error.opacity(0.20) + } if isSelected { return accentColor.opacity(0.24) } @@ -847,6 +1147,9 @@ private struct CalendarWeekDayCell: View { } private var cellBorderColor: Color { + if isDropTarget { + return colors.error + } if isSelected { return accentColor.opacity(0.95) } @@ -857,6 +1160,9 @@ private struct CalendarWeekDayCell: View { } private var cellBorderWidth: CGFloat { + if isDropTarget { + return 2 + } if isSelected { return 1.6 } @@ -867,6 +1173,9 @@ private struct CalendarWeekDayCell: View { } private var dayTextColor: Color { + if isDropTarget { + return colors.error + } if isSelected { return accentColor } @@ -877,6 +1186,9 @@ private struct CalendarWeekDayCell: View { } private var stateTint: Color { + if isDropTarget { + return colors.error + } if isSelected { return accentColor } @@ -887,6 +1199,141 @@ private struct CalendarWeekDayCell: View { } } +private struct CalendarDateDropDelegate: DropDelegate { + let date: Date + let canDrop: Bool + let draggedTodo: TodoItem? + let resolveTodo: (String) -> TodoItem? + let onMove: (TodoItem, Date) -> Void + let onDateChange: (Date?) -> Void + + func validateDrop(info: DropInfo) -> Bool { + canDrop && info.hasItemsConforming(to: calendarTaskDragContentTypes) + } + + func dropEntered(info: DropInfo) { + if validateDrop(info: info) { + onDateChange(Calendar.current.startOfDay(for: date)) + } + } + + func dropExited(info: DropInfo) { + onDateChange(nil) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + defer { + onDateChange(nil) + } + guard let draggedTodo = draggedTodo ?? CalendarTaskDragSession.shared.todo else { + return performProviderDrop(info: info) + } + onMove(draggedTodo, Calendar.current.startOfDay(for: date)) + return true + } + + private func performProviderDrop(info: DropInfo) -> Bool { + guard canDrop, + let provider = info.itemProviders(for: calendarTaskDragContentTypes).first else { + return false + } + let targetDate = Calendar.current.startOfDay(for: date) + provider.loadObject(ofClass: NSString.self) { object, _ in + guard let rawId = object as? NSString else { + return + } + let todoId = rawId as String + DispatchQueue.main.async { + if let todo = resolveTodo(todoId) { + onMove(todo, targetDate) + } + } + } + return true + } +} + +private struct CalendarInAppDateDropTargetFrameModifier: ViewModifier { + let date: Date + let enabled: Bool + + @ViewBuilder + func body(content: Content) -> some View { + content.background { + if enabled { + GeometryReader { proxy in + Color.clear.preference( + key: CalendarDateDropTargetFramePreferenceKey.self, + value: [ + String(Calendar.current.startOfDay(for: date).timeIntervalSince1970): CalendarDateDropTargetFrame( + date: Calendar.current.startOfDay(for: date), + frame: proxy.frame(in: .global) + ) + ] + ) + } + } + } + } +} + +private extension View { + func calendarInAppDateDropTargetFrame(date: Date, enabled: Bool) -> some View { + modifier(CalendarInAppDateDropTargetFrameModifier(date: date, enabled: enabled)) + } + + func calendarTaskDropTarget( + date: Date, + canDrop: Bool, + draggedTodo: TodoItem?, + resolveTodo: @escaping (String) -> TodoItem?, + onMove: @escaping (TodoItem, Date) -> Void, + onDateChange: @escaping (Date?) -> Void + ) -> some View { + self + .onDrop( + of: calendarTaskDragContentTypes, + delegate: CalendarDateDropDelegate( + date: date, + canDrop: canDrop, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMove, + onDateChange: onDateChange + ) + ) + .dropDestination(for: String.self) { ids, _ in + guard canDrop else { + onDateChange(nil) + return false + } + let targetDate = Calendar.current.startOfDay(for: date) + let todo = draggedTodo + ?? CalendarTaskDragSession.shared.todo + ?? ids.compactMap(resolveTodo).first + guard let todo else { + onDateChange(nil) + return false + } + onDateChange(nil) + onMove(todo, targetDate) + return true + } isTargeted: { active in + guard canDrop else { + if !active { + onDateChange(nil) + } + return + } + onDateChange(active ? Calendar.current.startOfDay(for: date) : nil) + } + } +} + private struct CalendarDayCard: View { let selectedDate: Date let today: Date @@ -908,8 +1355,6 @@ private struct CalendarDayCard: View { dayContent(for: displayDate) .onChange(of: todayJumpRequest) { _, request in handleTodayJump(request, from: displayDate) } .onChange(of: displayDate) { _, _ in resetPageSelection() } - .background(colors.surface, in: RoundedRectangle(cornerRadius: 24, style: .continuous)) - .shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 5) } private func dayContent(for displayDate: Date) -> some View { @@ -982,15 +1427,19 @@ private struct CalendarDayCard: View { } private func daySummary(for date: Date) -> some View { - VStack(alignment: .leading, spacing: 14) { + return VStack(alignment: .leading, spacing: 14) { Text(dateTitle(for: date)) .font(.tdayRounded(size: 25, weight: .heavy)) - .foregroundStyle(Calendar.current.isDate(date, inSameDayAs: today) ? accentColor : colors.onSurface) + .foregroundStyle( + Calendar.current.isDate(date, inSameDayAs: today) ? accentColor : colors.onSurface + ) Text(taskCountText(for: date)) .font(.tdayRounded(size: 18, weight: .heavy)) .foregroundStyle(colors.onSurfaceVariant) } + .padding(.horizontal, 6) + .padding(.vertical, 4) .frame(maxWidth: .infinity, alignment: .leading) } @@ -1097,9 +1546,15 @@ private struct CalendarMonthDayCell: View { let day: CalendarMonthDay let isSelected: Bool let isToday: Bool + let isEnabled: Bool + let isDropTarget: Bool let taskCount: Int let accentColor: Color + let draggedTodo: TodoItem? let onSelectDate: (Date) -> Void + let onDropDateChange: (Date?) -> Void + let onMoveTaskToDate: (TodoItem, Date) -> Void + let resolveTodo: (String) -> TodoItem? @Environment(\.tdayColors) private var colors @@ -1151,7 +1606,16 @@ private struct CalendarMonthDayCell: View { .frame(height: CalendarMonthGridMetrics.dayCellHeight) } .buttonStyle(.plain) - .disabled(!day.isCurrentMonth) + .disabled(!isEnabled) + .calendarTaskDropTarget( + date: day.date, + canDrop: isEnabled, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMoveTaskToDate, + onDateChange: onDropDateChange + ) + .calendarInAppDateDropTargetFrame(date: day.date, enabled: isEnabled) .opacity(day.isCurrentMonth ? 1 : 0.45) } @@ -1160,6 +1624,9 @@ private struct CalendarMonthDayCell: View { } private var dayTextColor: Color { + if isDropTarget { + return colors.error + } if !day.isCurrentMonth { return colors.onSurfaceVariant.opacity(0.48) } @@ -1170,6 +1637,9 @@ private struct CalendarMonthDayCell: View { } private var cellBackground: Color { + if isDropTarget { + return colors.error.opacity(0.20) + } if isSelected { return accentColor.opacity(0.24) } @@ -1180,6 +1650,9 @@ private struct CalendarMonthDayCell: View { } private var cellBorderColor: Color { + if isDropTarget { + return colors.error + } if isSelected { return accentColor.opacity(0.95) } @@ -1190,6 +1663,9 @@ private struct CalendarMonthDayCell: View { } private var cellBorderWidth: CGFloat { + if isDropTarget { + return 2 + } if isSelected { return 1.6 } @@ -1200,6 +1676,9 @@ private struct CalendarMonthDayCell: View { } private var stateTint: Color { + if isDropTarget { + return colors.error + } if isSelected { return accentColor } @@ -1436,8 +1915,8 @@ private struct CalendarTopBarButton: View { .buttonStyle( TdayPressButtonStyle( shadowColor: .black, - pressedShadowOpacity: chrome == .filled ? 0.14 : 0, - normalShadowOpacity: chrome == .filled ? 0.24 : 0 + pressedShadowOpacity: chrome == .filled ? 0.09 : 0, + normalShadowOpacity: chrome == .filled ? 0.15 : 0 ) ) .foregroundStyle(foregroundColor) @@ -1753,13 +2232,256 @@ private extension UIView { } } +private struct CalendarInAppDragModifier: ViewModifier { + let enabled: Bool + let todo: TodoItem + let onStart: (TodoItem, CGPoint) -> Void + let onMove: (TodoItem, CGPoint) -> Void + let onEnd: (TodoItem, CGPoint?) -> Void + let onCancel: () -> Void + + func body(content: Content) -> some View { + if enabled { + content.background { + GeometryReader { _ in + CalendarInAppLongPressBridge( + todo: todo, + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + .allowsHitTesting(false) + } + } + } else { + content + } + } +} + +private struct CalendarInAppLongPressBridge: UIViewRepresentable { + let todo: TodoItem + let onStart: (TodoItem, CGPoint) -> Void + let onMove: (TodoItem, CGPoint) -> Void + let onEnd: (TodoItem, CGPoint?) -> Void + let onCancel: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + todo: todo, + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + context.coordinator.markerView = view + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.todo = todo + context.coordinator.onStart = onStart + context.coordinator.onMove = onMove + context.coordinator.onEnd = onEnd + context.coordinator.onCancel = onCancel + DispatchQueue.main.async { + context.coordinator.attach(to: uiView.calendarEnclosingScrollView() ?? uiView.superview, markerView: uiView) + } + } + + static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { + coordinator.detach() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var todo: TodoItem + var onStart: (TodoItem, CGPoint) -> Void + var onMove: (TodoItem, CGPoint) -> Void + var onEnd: (TodoItem, CGPoint?) -> Void + var onCancel: () -> Void + + weak var markerView: UIView? + private weak var attachedView: UIView? + private let recognizer: UILongPressGestureRecognizer + private var isDragging = false + + init( + todo: TodoItem, + onStart: @escaping (TodoItem, CGPoint) -> Void, + onMove: @escaping (TodoItem, CGPoint) -> Void, + onEnd: @escaping (TodoItem, CGPoint?) -> Void, + onCancel: @escaping () -> Void + ) { + self.todo = todo + self.onStart = onStart + self.onMove = onMove + self.onEnd = onEnd + self.onCancel = onCancel + self.recognizer = UILongPressGestureRecognizer() + super.init() + + recognizer.minimumPressDuration = 0.22 + recognizer.allowableMovement = 24 + recognizer.cancelsTouchesInView = false + recognizer.delaysTouchesBegan = false + recognizer.delaysTouchesEnded = false + recognizer.delegate = self + recognizer.addTarget(self, action: #selector(handleLongPress(_:))) + } + + func attach(to view: UIView?, markerView: UIView) { + self.markerView = markerView + guard let view else { + detach() + return + } + + guard attachedView !== view else { + return + } + + detach() + attachedView = view + view.addGestureRecognizer(recognizer) + } + + func detach() { + if isDragging { + isDragging = false + onCancel() + } + attachedView?.removeGestureRecognizer(recognizer) + attachedView = nil + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let markerView else { + return false + } + + let localPoint = gestureRecognizer.location(in: markerView) + return markerView.bounds.insetBy(dx: -6, dy: -6).contains(localPoint) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard let markerView else { + return false + } + + let localPoint = touch.location(in: markerView) + return markerView.bounds.insetBy(dx: -6, dy: -6).contains(localPoint) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + let location = globalLocation(for: recognizer) + switch recognizer.state { + case .began: + isDragging = true + onStart(todo, location) + case .changed: + guard isDragging else { + return + } + onMove(todo, location) + case .ended: + guard isDragging else { + return + } + isDragging = false + onEnd(todo, location) + case .cancelled, .failed: + guard isDragging else { + return + } + isDragging = false + onCancel() + default: + break + } + } + + private func globalLocation(for recognizer: UILongPressGestureRecognizer) -> CGPoint { + guard let view = recognizer.view else { + return .zero + } + + return view.convert(recognizer.location(in: view), to: nil) + } + } +} + +private struct CalendarTaskDragPreview: View { + let todo: TodoItem + + @Environment(\.tdayColors) private var colors + + var body: some View { + let previewShape = RoundedRectangle(cornerRadius: 18, style: .continuous) + + HStack(spacing: 10) { + Image(systemName: "circle") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.76)) + + VStack(alignment: .leading, spacing: 3) { + Text(todo.title) + .font(.tdayRounded(size: 16, weight: .bold)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + + Text(todo.due.formatted(date: .omitted, time: .shortened)) + .font(.tdayRounded(size: 12, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + } + + Spacer(minLength: 0) + + if todo.priority.lowercased() == "high" { + Image(systemName: "flag.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(priorityColor(todo.priority)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(width: 260, alignment: .leading) + .background(colors.surface) + .clipShape(previewShape) + .overlay( + previewShape.stroke(colors.onSurfaceVariant.opacity(0.14), lineWidth: 1) + ) + .contentShape(previewShape) + .compositingGroup() + .shadow(color: Color.black.opacity(0.18), radius: 16, x: 0, y: 8) + .opacity(0.96) + } +} + private struct CalendarPendingTaskRow: View { let todo: TodoItem + let list: ListSummary? let onComplete: () -> Void @Environment(\.tdayColors) private var colors var body: some View { + let showPriorityFlag = todo.priority.lowercased() == "high" + VStack(spacing: 0) { HStack(alignment: .center, spacing: 12) { Button(action: onComplete) { @@ -1788,9 +2510,213 @@ private struct CalendarPendingTaskRow: View { } Spacer(minLength: 0) + + if list != nil || showPriorityFlag { + 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") + .font(.system(size: TodoTimelineMetrics.minimalRowIndicatorSize, weight: .semibold)) + .foregroundStyle(priorityColor(todo.priority)) + } + } + .padding(.trailing, TodoTimelineMetrics.minimalRowTrailingIndicatorPadding) + } } - .padding(.vertical, TodoTimelineMetrics.minimalRowVerticalPadding) + .padding(.vertical, CalendarTaskListMetrics.rowVerticalPadding) .contentShape(Rectangle()) } + .frame(maxWidth: .infinity, alignment: .leading) + .background(colors.background) } } + +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) + case "GOLD": + return calendarHexColor(0xCFAB57) + case "DEEP_BLUE": + return calendarHexColor(0x4B73D6) + case "ROSE": + return calendarHexColor(0xD9799A) + case "LIGHT_RED": + return calendarHexColor(0xE48888) + case "BRICK": + return calendarHexColor(0xB86A5C) + case "SLATE": + return calendarHexColor(0x7B8593) + default: + return calendarHexColor(0x5C9FE7) + } +} + +private func calendarListSymbolName(for key: String?) -> String { + switch key { + case "sun": + return "sun.max.fill" + case "calendar": + return "calendar" + case "schedule": + return "clock" + case "flag": + return "flag.fill" + case "check": + return "checkmark" + case "smile": + return "face.smiling" + case "list": + return "list.bullet" + case "bookmark": + return "bookmark.fill" + case "key": + return "key.fill" + case "gift": + return "gift.fill" + case "cake": + return "birthday.cake.fill" + case "school": + return "graduationcap.fill" + case "bag": + return "backpack.fill" + case "edit": + return "pencil" + case "document": + return "doc.text.fill" + case "book": + return "book.closed.fill" + case "work": + return "briefcase.fill" + case "wallet": + return "wallet.pass.fill" + case "money": + return "dollarsign.circle.fill" + case "fitness": + return "dumbbell.fill" + case "run": + return "figure.run" + case "food": + return "fork.knife" + case "drink": + return "wineglass.fill" + case "health": + return "cross.case.fill" + case "monitor": + return "display" + case "music": + return "music.note" + case "computer": + return "desktopcomputer" + case "game": + return "gamecontroller.fill" + case "headphones": + return "headphones" + case "eco": + return "leaf.fill" + case "pets": + return "pawprint.fill" + case "child": + return "figure.2.and.child.holdinghands" + case "family": + return "person.3.fill" + case "basket": + return "basket.fill" + case "cart": + return "cart.fill" + case "mall": + return "bag.fill" + case "inventory": + return "archivebox.fill" + case "soccer": + return "soccerball" + case "baseball": + return "baseball.fill" + case "basketball": + return "basketball.fill" + case "football": + return "football.fill" + case "tennis": + return "tennis.racket" + case "train": + return "tram.fill" + case "flight": + return "airplane" + case "boat": + return "ferry.fill" + case "car": + return "car.fill" + case "umbrella": + return "umbrella.fill" + case "drop": + return "drop.fill" + case "snow": + return "snowflake" + case "fire": + return "flame.fill" + case "tools": + return "hammer.fill" + case "scissors": + return "scissors" + case "architecture", "bank": + return "building.columns.fill" + case "code": + return "chevron.left.forwardslash.chevron.right" + case "idea": + return "lightbulb.fill" + case "chat": + return "bubble.left.fill" + case "alert": + return "exclamationmark.triangle.fill" + case "star": + return "star.fill" + case "heart": + return "heart.fill" + case "circle": + return "circle.fill" + case "square": + return "square.fill" + case "triangle": + return "triangle.fill" + case "home": + return "house.fill" + case "city": + return "building.2.fill" + case "camera": + return "camera.fill" + case "palette": + return "paintpalette.fill" + default: + return "tray.fill" + } +} + +private func calendarHexColor(_ hex: UInt) -> Color { + Color( + .sRGB, + red: Double((hex >> 16) & 0xFF) / 255, + green: Double((hex >> 8) & 0xFF) / 255, + blue: Double(hex & 0xFF) / 255, + opacity: 1 + ) +} diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift index d809ad8a..5255e470 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift @@ -74,6 +74,24 @@ final class CalendarViewModel { } } + func moveTask(_ todo: TodoItem, toDay targetDay: Date, scope: TaskRescheduleScope) async { + let calendar = Calendar.current + guard !calendar.isDate(todo.due, inSameDayAs: targetDay), + let movedDue = movedDuePreservingTime(due: todo.due, targetDay: targetDay, calendar: calendar) else { + return + } + + do { + try await container.todoRepository.moveTodo( + todo.repositoryTargetForReschedule(scope: scope), + due: movedDue + ) + hydrateFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not update task.") + } + } + func delete(_ todo: TodoItem) async { do { try await container.todoRepository.deleteTodo(todo) diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index d1fe78bf..5629014a 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -107,7 +107,12 @@ struct CompletedScreen: View { } ForEach(Array(groupedItems.enumerated()), id: \.element.id) { index, section in - completedTimelineSection(section, isFirstSection: index == 0) + completedTimelineSection( + section, + sectionIndex: index, + sections: groupedItems, + isFirstSection: index == 0 + ) } Color.clear @@ -145,19 +150,26 @@ struct CompletedScreen: View { } @ViewBuilder - private func completedTimelineSection(_ section: TimelineSection, isFirstSection: Bool) -> some View { + private func completedTimelineSection( + _ section: TimelineSection, + sectionIndex: Int, + sections: [TimelineSection], + isFirstSection: Bool + ) -> some View { let isCollapsed = collapsedSectionIDs.contains(section.id) Section { if !isCollapsed { - ForEach(Array(section.items.enumerated()), id: \.element.id) { _, item in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, item in completedTimelineRow(item) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .transition(completedRowTransition()) - TimelineRowDivider() - .transition(completedRowTransition()) + if shouldShowDateDivider(after: itemIndex, inSectionAt: sectionIndex, sections: sections) { + TimelineRowDivider() + .transition(completedRowTransition()) + } } } } header: { @@ -194,6 +206,35 @@ struct CompletedScreen: View { } } + private func shouldShowDateDivider( + after itemIndex: Int, + inSectionAt sectionIndex: Int, + sections: [TimelineSection] + ) -> Bool { + guard sections.indices.contains(sectionIndex), + sections[sectionIndex].items.indices.contains(itemIndex) else { + return false + } + + let currentItem = sections[sectionIndex].items[itemIndex] + let currentDate = currentItem.completedAt ?? currentItem.due + let nextItemInSection = sections[sectionIndex].items.dropFirst(itemIndex + 1).first + if let nextItemInSection { + let nextDate = nextItemInSection.completedAt ?? nextItemInSection.due + return !Calendar.current.isDate(currentDate, inSameDayAs: nextDate) + } + + let nextVisibleItem = sections.dropFirst(sectionIndex + 1) + .first { !collapsedSectionIDs.contains($0.id) && !$0.items.isEmpty }? + .items.first + + guard let nextVisibleItem else { + return false + } + let nextDate = nextVisibleItem.completedAt ?? nextVisibleItem.due + return !Calendar.current.isDate(currentDate, inSameDayAs: nextDate) + } + private func completedRowTransition() -> AnyTransition { let insertion = AnyTransition.opacity .combined(with: .move(edge: .top)) @@ -327,22 +368,13 @@ private struct CompletedTimelineRow: View { .animation(.easeInOut(duration: 0.22), value: isFading) .transition(.opacity.combined(with: .scale(scale: 0.985))) .allowsHitTesting(!isRestoring) - .swipeRevealHintOnTap(enabled: !isRestoring) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { + .todoTrailingSwipeActions( + enabled: !isRestoring, + onEdit: onEdit, + onDelete: { Task { await onDelete() } - } label: { - Label("Delete", systemImage: "trash") } - .tint(TaskSwipeActionTint.delete) - - Button { - onEdit() - } label: { - Label("Edit", systemImage: "square.and.pencil") - } - .tint(TaskSwipeActionTint.edit) - } + ) } private func startRestore() { diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 3b16be4d..e8a4836e 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -8,7 +8,9 @@ private enum HomeMetrics { static let compactButtonSize: CGFloat = 30 static let titleAnchorDistance: CGFloat = screenPadding + topBarButtonSize static let tileCornerRadius: CGFloat = 26 - static let tileHeight: CGFloat = 102 + static let tileHeight: CGFloat = 94 + static let tileInnerPadding: CGFloat = 12 + static let todayCardHeight: CGFloat = 70 static let listRowHeight: CGFloat = 70 static let tileWatermarkSize: CGFloat = 116 static let tileWatermarkTrailingInset: CGFloat = 22 @@ -89,6 +91,7 @@ struct HomeScreen: View { @State private var openingSearchResultID: String? @State private var showingCreateTask = false @State private var showingCreateList = false + @State private var editingTodo: TodoItem? init(container: AppContainer, onNavigate: @escaping (AppRoute) -> Void) { self.onNavigate = onNavigate @@ -170,17 +173,29 @@ struct HomeScreen: View { isDisabled: searchExpanded ) + HomeTodayCard( + count: viewModel.summary.todayCount, + action: { + closeSearch() + onNavigate(.todayTodos) + } + ) + + if !viewModel.todayTodos.isEmpty { + VStack(spacing: 0) { + ForEach(viewModel.todayTodos) { todo in + homeTodayTaskRow(todo) + } + } + } + HomeCategoryBoard( - todayCount: viewModel.summary.todayCount, overdueCount: overdueCount, scheduledCount: viewModel.summary.scheduledCount, allCount: viewModel.summary.allCount, priorityCount: viewModel.summary.priorityCount, completedCount: viewModel.summary.completedCount, - onOpenToday: { - closeSearch() - onNavigate(.todayTodos) - }, + calendarCount: viewModel.summary.scheduledCount, onOpenOverdue: { closeSearch() onNavigate(.overdueTodos) @@ -316,6 +331,21 @@ struct HomeScreen: View { } } } + .sheet(item: $editingTodo) { todo in + CreateTaskSheet( + lists: viewModel.lists, + titleText: "Edit task", + submitText: "Save", + initialPayload: CreateTaskPayload(title: todo.title, description: todo.description, priority: todo.priority, due: todo.due, rrule: todo.rrule, listId: todo.listId), + onParseTaskTitleNlp: { title, dueRef in + await viewModel.parseTaskTitleNlp(text: title, referenceDueEpochMs: dueRef) + }, + onDismiss: { editingTodo = nil }, + onSubmit: { payload in + await viewModel.updateTask(todo, payload: payload) + } + ) + } .navigationBackButtonBehavior() } @@ -328,6 +358,17 @@ struct HomeScreen: View { searchResultsFrame = .zero } + @ViewBuilder + private func homeTodayTaskRow(_ todo: TodoItem) -> some View { + HomeTodayTaskRow( + todo: todo, + lists: viewModel.lists, + onComplete: { await viewModel.complete(todo) }, + onDelete: { Task { await viewModel.delete(todo) } }, + onEdit: { editingTodo = todo } + ) + } + private func openSearchResult(_ todo: TodoItem) { guard openingSearchResultID == nil else { return @@ -506,14 +547,317 @@ private struct HomeIconCircleButton: View { } } +private enum HomeTodayTaskCompletionPhase { + case active + case checked + case fading +} + +private struct HomeTodayTaskRow: View { + let todo: TodoItem + let lists: [ListSummary] + let onComplete: () async -> Void + let onDelete: () -> Void + let onEdit: () -> Void + + @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 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 titleColor: Color { + isCompleting ? 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) + } + } + .opacity(isFading ? 0 : 1) + .scaleEffect(isFading ? 0.985 : 1, anchor: .center) + .animation(.easeInOut(duration: 0.22), 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") + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(isCompleting || todo.completed ? Color.green : colors.onSurfaceVariant.opacity(0.78)) + .frame(width: 38, height: 38) + } + .buttonStyle(TdayPressButtonStyle(shadowColor: .black, pressedShadowOpacity: 0, normalShadowOpacity: 0)) + .disabled(isCompleting) + + VStack(alignment: .leading, spacing: 3) { + HomeTodayTaskTitle( + text: todo.title, + isCompleted: isCompleting, + titleColor: titleColor, + strikeColor: colors.onSurface.opacity(0.65) + ) + + Text(subtitleText) + .font(.tdayRounded(size: 13, weight: .semibold)) + .foregroundStyle(subtitleColor) + } + + 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) + } + } + .padding(.vertical, 10) + .padding(.horizontal, 4) + .contentShape(Rectangle()) + } + + private func startCompletion() { + 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) + withAnimation(.easeInOut(duration: 0.22)) { + completionPhase = .fading + } + try? await Task.sleep(nanoseconds: 220_000_000) + await onComplete() + if completionPhase == .fading { + withAnimation(.easeInOut(duration: 0.16)) { + completionPhase = .active + } + } + } + } +} + +private struct HomeTodayTaskTitle: View { + let text: String + let isCompleted: Bool + let titleColor: Color + let strikeColor: Color + + private var strikeProgress: CGFloat { + isCompleted ? 1 : 0 + } + + var body: some View { + Text(text) + .font(.tdayRounded(size: 18, weight: .bold)) + .foregroundStyle(titleColor) + .lineLimit(1) + .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) + } +} + +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 + + private var dateLabel: String { + Date.now.formatted(.dateTime.weekday(.abbreviated).month(.abbreviated).day()) + } + + var body: some View { + let color = Color(hex: 0x6EA8E1) + let shape = RoundedRectangle(cornerRadius: HomeMetrics.tileCornerRadius, style: .continuous) + + Button(action: action) { + ZStack { + shape.fill(color) + shape.fill( + RadialGradient( + colors: [Color.white.opacity(0.22), Color.white.opacity(0.08), .clear], + center: UnitPoint(x: 0.22, y: 0.2), + startRadius: 0, + endRadius: 200 + ) + ) + + Image(systemName: "sun.max.fill") + .font(.system(size: HomeMetrics.tileWatermarkSize, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.15)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) + .offset(x: 28, y: 22) + .clipped() + + HStack { + HStack(spacing: 10) { + Image(systemName: "sun.max.fill") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + Text(dateLabel) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(.white) + } + Spacer() + Text("\(count)") + .font(.tdayRounded(size: 34, weight: .black)) + .foregroundStyle(.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + } + .frame(maxWidth: .infinity) + .frame(height: HomeMetrics.todayCardHeight) + .clipShape(shape) + .contentShape(shape) + } + .buttonStyle(HomeTileButtonStyle()) + } +} + private struct HomeCategoryBoard: View { - let todayCount: Int let overdueCount: Int let scheduledCount: Int let allCount: Int let priorityCount: Int let completedCount: Int - let onOpenToday: () -> Void + let calendarCount: Int let onOpenOverdue: () -> Void let onOpenScheduled: () -> Void let onOpenAll: () -> Void @@ -524,15 +868,6 @@ private struct HomeCategoryBoard: View { var body: some View { VStack(spacing: HomeMetrics.tileGap) { HStack(spacing: HomeMetrics.tileGap) { - HomeCategoryTile( - color: Color(hex: 0x6EA8E1), - icon: "sun.max.fill", - watermark: "sun.max.fill", - title: "Today", - count: todayCount, - action: onOpenToday - ) - HomeCategoryTile( color: Color(hex: 0xDA7661), icon: "exclamationmark.circle", @@ -541,9 +876,7 @@ private struct HomeCategoryBoard: View { count: overdueCount, action: onOpenOverdue ) - } - HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0xDDB37D), icon: "clock", @@ -552,7 +885,9 @@ private struct HomeCategoryBoard: View { count: scheduledCount, action: onOpenScheduled ) + } + HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0xD48A8C), icon: "flag.fill", @@ -561,9 +896,7 @@ private struct HomeCategoryBoard: View { count: priorityCount, action: onOpenPriority ) - } - HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0x4E4E50), icon: "tray.fill", @@ -572,7 +905,9 @@ private struct HomeCategoryBoard: View { count: allCount, action: onOpenAll ) + } + HStack(spacing: HomeMetrics.tileGap) { HomeCategoryTile( color: Color(hex: 0xA8C8B2), icon: "checkmark", @@ -581,18 +916,17 @@ private struct HomeCategoryBoard: View { count: completedCount, action: onOpenCompleted ) - } - HomeCategoryTile( - color: Color(hex: 0xC3B4DF), - icon: "calendar", - watermark: nil, - title: "Calendar", - count: scheduledCount, - backgroundGrid: true, - action: onOpenCalendar - ) - .frame(maxWidth: .infinity) + HomeCategoryTile( + color: Color(hex: 0xC3B4DF), + icon: "calendar", + watermark: nil, + title: "Calendar", + count: calendarCount, + backgroundGrid: true, + action: onOpenCalendar + ) + } } } } @@ -656,22 +990,22 @@ private struct HomeCategoryTile: View { .allowsHitTesting(false) } - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { HStack(alignment: .center) { Image(systemName: icon) - .font(.system(size: 24, weight: .bold)) + .font(.system(size: 22, weight: .bold)) .foregroundStyle(.white) Spacer() Text("\(count)") - .font(.tdayRounded(size: 28, weight: .black)) + .font(.tdayRounded(size: 26, weight: .black)) .foregroundStyle(.white) } Text(title) - .font(.tdayRounded(size: 22, weight: .bold)) + .font(.tdayRounded(size: 20, weight: .bold)) .foregroundStyle(.white) } - .padding(16) + .padding(HomeMetrics.tileInnerPadding) } .frame(maxWidth: .infinity, alignment: .topLeading) .frame(height: HomeMetrics.tileHeight) @@ -835,10 +1169,13 @@ private struct HomeSearchResultsOverlay: View { private let resultVerticalPadding: CGFloat = 8 private let resultSeparatorHeight: CGFloat = 1 + private var resultSeparatorCount: Int { + todos.indices.filter { shouldShowDateDivider(after: $0) }.count + } + private var resultsHeight: CGFloat { - let separatorCount = max(todos.count - 1, 0) let contentHeight = (CGFloat(todos.count) * resultRowHeight) + - (CGFloat(separatorCount) * resultSeparatorHeight) + + (CGFloat(resultSeparatorCount) * resultSeparatorHeight) + resultVerticalPadding return min(contentHeight, maxResultsHeight) } @@ -896,7 +1233,7 @@ private struct HomeSearchResultsOverlay: View { .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) - if index < todos.count - 1 { + if shouldShowDateDivider(after: index) { Rectangle() .fill(colors.onSurface.opacity(0.08)) .frame(height: 1) @@ -917,6 +1254,14 @@ private struct HomeSearchResultsOverlay: View { ) .shadow(color: Color.black.opacity(0.14), radius: 10, x: 0, y: 8) } + + private func shouldShowDateDivider(after index: Int) -> Bool { + guard todos.indices.contains(index), + todos.indices.contains(index + 1) else { + return false + } + return !Calendar.current.isDate(todos[index].due, inSameDayAs: todos[index + 1].due) + } } private struct HomeTileButtonStyle: ButtonStyle { @@ -1492,6 +1837,7 @@ private extension Color { ) ) } + } private extension CGFloat { diff --git a/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift b/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift index e8fa74e4..0f074c89 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift @@ -9,8 +9,11 @@ final class HomeViewModel { var isLoading = true var summary = DashboardSummary(todayCount: 0, scheduledCount: 0, allCount: 0, priorityCount: 0, completedCount: 0, lists: []) var searchableTodos: [TodoItem] = [] + var todayTodos: [TodoItem] = [] var errorMessage: String? + var lists: [ListSummary] { summary.lists } + @ObservationIgnored nonisolated(unsafe) private var observationTask: Task? @ObservationIgnored private var activeLoadingRefreshes = 0 @@ -47,6 +50,7 @@ final class HomeViewModel { func refreshFromCache() { summary = container.todoRepository.fetchDashboardSummarySnapshot() searchableTodos = container.todoRepository.fetchTodosSnapshot(mode: .all) + todayTodos = container.todoRepository.fetchTodosSnapshot(mode: .today) isLoading = activeLoadingRefreshes > 0 errorMessage = nil } @@ -69,6 +73,37 @@ final class HomeViewModel { } } + func complete(_ todo: TodoItem) async { + todayTodos.removeAll { $0.id == todo.id } + do { + try await container.completeTodo(todo) + refreshFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not complete task.") + refreshFromCache() + } + } + + func delete(_ todo: TodoItem) async { + todayTodos.removeAll { $0.id == todo.id } + do { + try await container.todoRepository.deleteTodo(todo) + refreshFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not delete task.") + refreshFromCache() + } + } + + func updateTask(_ todo: TodoItem, payload: CreateTaskPayload) async { + do { + try await container.todoRepository.updateTodo(todo, payload: payload) + refreshFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not update task.") + } + } + func parseTaskTitleNlp(text: String, referenceDueEpochMs: Int64) async -> TodoTitleNlpResponse? { await container.todoRepository.parseTodoTitleNlp(text: text, referenceDueEpochMs: referenceDueEpochMs) } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 4bf10fca..ed4bdcef 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -1,6 +1,36 @@ import SwiftUI +import UIKit import UniformTypeIdentifiers +private let todoDragContentTypes = [UTType.plainText.identifier, UTType.text.identifier] +private let todoTimelineDragCoordinateSpace = "todoTimelineDragCoordinateSpace" + +private final class TodoTaskDragSession { + static let shared = TodoTaskDragSession() + var todo: TodoItem? + var handledDropSignature: String? + + private init() {} +} + +private struct TodoInAppDrag: Equatable { + let todo: TodoItem + var location: CGPoint +} + +private struct TodoDropTargetFrame: Equatable { + let sectionID: String + let frame: CGRect +} + +private struct TodoDropTargetFramePreferenceKey: PreferenceKey { + static var defaultValue: [String: TodoDropTargetFrame] = [:] + + static func reduce(value: inout [String: TodoDropTargetFrame], nextValue: () -> [String: TodoDropTargetFrame]) { + value.merge(nextValue(), uniquingKeysWith: { _, new in new }) + } +} + enum TodoTimelineMetrics { static let horizontalPadding: CGFloat = 18 static let heroTitleSize: CGFloat = 32 @@ -42,6 +72,8 @@ enum TodoTimelineMetrics { } } +private let todoDropPlaceholderAnimation = Animation.spring(response: 0.28, dampingFraction: 0.88, blendDuration: 0.02) + struct TimelinePinnedSectionHeaderBackground: ViewModifier { @Environment(\.tdayColors) private var colors @@ -149,7 +181,10 @@ struct TodoListScreen: View { @State private var showingSummary = false @State private var showingListSettings = false @State private var draggedTodo: TodoItem? + @State private var inAppDrag: TodoInAppDrag? @State private var activeDropSectionId: String? + @State private var dropTargetFrames: [String: TodoDropTargetFrame] = [:] + @State private var pendingRescheduleDrop: TodoRescheduleDrop? @State private var collapsedSectionIDs: Set @State private var timelineScrollOffset: CGFloat = 0 @State private var completingTodoIDs: Set = [] @@ -163,7 +198,11 @@ struct TodoListScreen: View { } private var groupedSections: [TodoTimelineSection] { - buildSections(items: viewModel.items, mode: viewModel.mode) + buildSections( + items: viewModel.items, + mode: viewModel.mode, + includeEmptyEarlierTarget: viewModel.mode.supportsTaskReschedule && (draggedTodo != nil || inAppDrag != nil) + ) } private var isTodayMode: Bool { @@ -239,7 +278,11 @@ struct TodoListScreen: View { var body: some View { modeContent + .coordinateSpace(name: todoTimelineDragCoordinateSpace) .background(colors.background) + .onPreferenceChange(TodoDropTargetFramePreferenceKey.self) { frames in + dropTargetFrames = frames + } .overlay { if viewModel.items.isEmpty, !viewModel.isLoading { ZStack { @@ -254,6 +297,22 @@ struct TodoListScreen: View { .allowsHitTesting(false) } } + .overlay(alignment: .topLeading) { + GeometryReader { proxy in + if let inAppDrag { + let rootFrame = proxy.frame(in: .global) + let previewLocation = CGPoint( + x: inAppDrag.location.x - rootFrame.minX, + y: inAppDrag.location.y - rootFrame.minY + ) + TodoDragPreview(todo: inAppDrag.todo) + .position(x: previewLocation.x, y: previewLocation.y) + .zIndex(20) + .allowsHitTesting(false) + } + } + .allowsHitTesting(false) + } .navigationBackButtonBehavior() .navigationTitleTypography( largeTitleColor: modeAccentColor, @@ -287,6 +346,30 @@ struct TodoListScreen: View { .sheet(isPresented: $showingListSettings) { listSettingsSheetContent } + .confirmationDialog( + "Move repeating task?", + isPresented: Binding( + get: { pendingRescheduleDrop != nil }, + set: { isPresented in + if !isPresented { + pendingRescheduleDrop = nil + } + } + ), + titleVisibility: .visible + ) { + Button("This occurrence") { + commitPendingReschedule(scope: .occurrence) + } + Button("Entire series") { + commitPendingReschedule(scope: .series) + } + Button("Cancel", role: .cancel) { + pendingRescheduleDrop = nil + } + } message: { + Text("Choose whether to move only this task occurrence or the entire repeating series.") + } } @ToolbarContentBuilder @@ -424,13 +507,104 @@ struct TodoListScreen: View { } private func handleItemsChanged() { - activeDropSectionId = nil + setActiveDropSection(nil) draggedTodo = nil + inAppDrag = nil + dropTargetFrames = [:] + TodoTaskDragSession.shared.todo = nil if viewModel.mode == .all, highlightedTodoId != nil { collapsedSectionIDs = [] } } + private func requestReschedule(_ todo: TodoItem, to targetDate: Date) { + setActiveDropSection(nil) + draggedTodo = nil + inAppDrag = nil + dropTargetFrames = [:] + TodoTaskDragSession.shared.todo = nil + let targetDay = Calendar.current.startOfDay(for: targetDate) + let dropSignature = "\(todo.id)|\(targetDay.timeIntervalSince1970)" + guard TodoTaskDragSession.shared.handledDropSignature != dropSignature else { + return + } + TodoTaskDragSession.shared.handledDropSignature = dropSignature + guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDate) else { + return + } + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + if todo.isRecurring { + pendingRescheduleDrop = TodoRescheduleDrop(todo: todo, targetDate: targetDate) + } else { + Task { await viewModel.moveTask(todo, toDay: targetDate, scope: .occurrence) } + } + } + + private func resolveTodoForDrop(id: String) -> TodoItem? { + viewModel.items.first { $0.id == id || $0.canonicalId == id } + } + + private func setActiveDropSection(_ sectionId: String?) { + guard activeDropSectionId != sectionId else { return } + withAnimation(todoDropPlaceholderAnimation) { + activeDropSectionId = sectionId + } + } + + private func beginInAppDrag(_ todo: TodoItem, at location: CGPoint) { + if draggedTodo?.id != todo.id { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + draggedTodo = todo + 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)) + } + + private func finishInAppDrag(_ todo: TodoItem, at location: CGPoint?) { + let targetSectionID = location.flatMap(dropSectionID(at:)) ?? activeDropSectionId + let targetDate = targetSectionID + .flatMap { sectionID in groupedSections.first { $0.id == sectionID }?.targetDate } + setActiveDropSection(nil) + draggedTodo = nil + inAppDrag = nil + dropTargetFrames = [:] + if let targetDate { + requestReschedule(todo, to: targetDate) + } + } + + private func cancelInAppDrag() { + setActiveDropSection(nil) + draggedTodo = nil + inAppDrag = nil + dropTargetFrames = [:] + } + + private func dropSectionID(at location: CGPoint) -> String? { + dropTargetFrames.values + .filter { $0.frame.contains(location) } + .min { lhs, rhs in + (lhs.frame.width * lhs.frame.height) < (rhs.frame.width * rhs.frame.height) + }? + .sectionID + } + + private func commitPendingReschedule(scope: TaskRescheduleScope) { + guard let drop = pendingRescheduleDrop else { + return + } + pendingRescheduleDrop = nil + Task { + await viewModel.moveTask(drop.todo, toDay: drop.targetDate, scope: scope) + } + } + private func matchesHighlightedTodo(_ todo: TodoItem, id: String) -> Bool { todo.id == id || todo.canonicalId == id } @@ -543,32 +717,80 @@ struct TodoListScreen: View { 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 + ) .listRowBackground(todo.id == highlightedTodoId ? colors.surfaceVariant : colors.surface) } - if viewModel.mode == .scheduled, !section.items.isEmpty { + if viewModel.mode.supportsTaskReschedule, + activeDropSectionId == section.id, + section.targetDate != nil { + TodoDropPlaceholder(isActive: activeDropSectionId == section.id) + .todoInAppDropTargetFrame( + targetID: "standard-placeholder-\(section.id)", + section: section, + enabled: true + ) + .listRowInsets(EdgeInsets(top: 4, leading: 20, bottom: 6, trailing: 20)) + .listRowBackground(colors.surface) + .transition(timelineRowTransition()) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + setActiveDropSection(sectionId) + } + ) + } + if viewModel.mode.supportsTaskReschedule, !section.items.isEmpty { Color.clear .frame(height: 8) + .todoInAppDropTargetFrame( + targetID: "standard-spacer-\(section.id)", + section: section, + enabled: draggedTodo != nil + ) .listRowInsets(EdgeInsets()) - .onDrop( - of: [UTType.plainText.identifier], - delegate: ScheduledTodoDropDelegate( - section: section, - draggedTodo: draggedTodo, - onMove: { todo, targetDate in - activeDropSectionId = nil - draggedTodo = nil - Task { await viewModel.moveTask(todo, toDay: targetDate) } - }, - onSectionChange: { sectionId in - activeDropSectionId = sectionId - } - ) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + setActiveDropSection(sectionId) + } ) } } header: { Text(section.title) - .foregroundStyle(activeDropSectionId == section.id ? colors.primary : colors.onSurfaceVariant) + .foregroundStyle(activeDropSectionId == section.id ? 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 + ) .timelinePinnedSectionHeaderBackground() + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + setActiveDropSection(sectionId) + } + ) } } } @@ -576,6 +798,7 @@ struct TodoListScreen: View { .scrollContentBackground(.hidden) .background(colors.background) .disableVerticalScrollBounce() + .animation(todoDropPlaceholderAnimation, value: activeDropSectionId) .animation(.easeInOut(duration: 0.22), value: timelineItemAnimationKey) } @@ -598,12 +821,14 @@ struct TodoListScreen: View { ForEach(Array(groupedSections.enumerated()), id: \.element.id) { index, section in Section { if !section.items.isEmpty { - ForEach(Array(section.items.enumerated()), id: \.element.id) { _, todo in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) - TimelineRowDivider() + if shouldShowDateDivider(after: itemIndex, inSectionAt: index, sections: groupedSections) { + TimelineRowDivider() + } } } } header: { @@ -662,7 +887,12 @@ struct TodoListScreen: View { } ForEach(Array(groupedSections.enumerated()), id: \.element.id) { index, section in - minimalTimelineSection(section, isFirstSection: index == 0) + minimalTimelineSection( + section, + sectionIndex: index, + sections: groupedSections, + isFirstSection: index == 0 + ) } Color.clear @@ -679,6 +909,7 @@ struct TodoListScreen: View { .listRowSpacing(0) .listSectionSpacing(0) .environment(\.defaultMinListRowHeight, 1) + .animation(todoDropPlaceholderAnimation, value: activeDropSectionId) .animation(.easeInOut(duration: 0.22), value: timelineItemAnimationKey) } @@ -730,22 +961,15 @@ struct TodoListScreen: View { .animation(.easeInOut(duration: 0.16), value: isCompleting) .opacity(draggedTodo?.id == todo.id && activeDropSectionId != nil ? 0.55 : 1) .allowsHitTesting(!isCompleting) - .swipeRevealHintOnTap(enabled: !isCompleting) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - Task { await viewModel.delete(todo) } - } label: { - Label("Delete", systemImage: "trash") - } - .tint(TaskSwipeActionTint.delete) - - Button { + .todoTrailingSwipeActions( + enabled: !isCompleting, + onEdit: { editingTodo = todo - } label: { - Label("Edit", systemImage: "square.and.pencil") + }, + onDelete: { + Task { await viewModel.delete(todo) } } - .tint(TaskSwipeActionTint.edit) - } + ) .swipeActions(edge: .leading, allowsFullSwipe: true) { Button { completeTodoWithoutReflow(todo) @@ -757,28 +981,25 @@ struct TodoListScreen: View { return rowContent .transition(.opacity.combined(with: .scale(scale: 0.985))) - .onDrop( - of: [UTType.plainText.identifier], - delegate: ScheduledTodoDropDelegate( - section: section, - draggedTodo: draggedTodo, - onMove: { droppedTodo, targetDate in - activeDropSectionId = nil - draggedTodo = nil - Task { await viewModel.moveTask(droppedTodo, toDay: targetDate) } - }, - onSectionChange: { sectionId in - activeDropSectionId = sectionId - } - ) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { droppedTodo, targetDate in + requestReschedule(droppedTodo, to: targetDate) + }, + onSectionChange: { sectionId in + setActiveDropSection(sectionId) + } ) .modifier( - ScheduledDragModifier( - enabled: viewModel.mode == .scheduled, + TodoInAppDragModifier( + enabled: viewModel.mode.supportsTaskReschedule, todo: todo, - onDragStart: { - draggedTodo = todo - } + onStart: beginInAppDrag, + onMove: updateInAppDrag, + onEnd: finishInAppDrag, + onCancel: cancelInAppDrag ) ) } @@ -850,44 +1071,34 @@ struct TodoListScreen: View { .allowsHitTesting(!isCompleting) .transition(.opacity.combined(with: .scale(scale: 0.985))) .modifier(TimelineTaskFlashHighlight(active: flashHighlight)) - .swipeRevealHintOnTap(enabled: !isCompleting) - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { + .todoTrailingSwipeActions( + enabled: !isCompleting, + onEdit: { + editingTodo = todo + }, + onDelete: { Task { await viewModel.delete(todo) } - } label: { - Label("Delete", systemImage: "trash") } - .tint(TaskSwipeActionTint.delete) - - Button { - editingTodo = todo - } label: { - Label("Edit", systemImage: "square.and.pencil") + ) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { droppedTodo, targetDate in + requestReschedule(droppedTodo, to: targetDate) + }, + onSectionChange: { sectionId in + setActiveDropSection(sectionId) } - .tint(TaskSwipeActionTint.edit) - } - .onDrop( - of: [UTType.plainText.identifier], - delegate: ScheduledTodoDropDelegate( - section: section, - draggedTodo: draggedTodo, - onMove: { droppedTodo, targetDate in - activeDropSectionId = nil - draggedTodo = nil - Task { await viewModel.moveTask(droppedTodo, toDay: targetDate) } - }, - onSectionChange: { sectionId in - activeDropSectionId = sectionId - } - ) ) .modifier( - ScheduledDragModifier( - enabled: viewModel.mode == .scheduled, + TodoInAppDragModifier( + enabled: viewModel.mode.supportsTaskReschedule, todo: todo, - onDragStart: { - draggedTodo = todo - } + onStart: beginInAppDrag, + onMove: updateInAppDrag, + onEnd: finishInAppDrag, + onCancel: cancelInAppDrag ) ) } @@ -909,21 +1120,58 @@ struct TodoListScreen: View { } @ViewBuilder - private func minimalTimelineSection(_ section: TodoTimelineSection, isFirstSection: Bool) -> some View { + private func minimalTimelineSection( + _ section: TodoTimelineSection, + sectionIndex: Int, + sections: [TodoTimelineSection], + isFirstSection: Bool + ) -> some View { let canCollapseSection = canCollapseTimelineSection(section) let isCollapsed = canCollapseSection && collapsedSectionIDs.contains(section.id) Section { + if viewModel.mode.supportsTaskReschedule, + activeDropSectionId == section.id, + section.targetDate != nil { + TodoDropPlaceholder(isActive: activeDropSectionId == section.id) + .todoInAppDropTargetFrame( + targetID: "minimal-placeholder-\(section.id)", + section: section, + enabled: true + ) + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 8, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + .transition(timelineRowTransition()) + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + setActiveDropSection(sectionId) + } + ) + } if !isCollapsed { - ForEach(Array(section.items.enumerated()), id: \.element.id) { _, todo in + ForEach(Array(section.items.enumerated()), id: \.element.id) { itemIndex, todo in minimalTimelineRow(todo, in: section, flashHighlight: shouldFlashTodo(todo)) .id(timelineTodoScrollID(todo.id)) + .todoInAppDropTargetFrame( + targetID: "minimal-row-\(section.id)-\(todo.id)", + section: section, + enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + ) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) .transition(timelineRowTransition()) - TimelineRowDivider() - .transition(timelineRowTransition()) + if shouldShowDateDivider(after: itemIndex, inSectionAt: sectionIndex, sections: sections) { + TimelineRowDivider() + .transition(timelineRowTransition()) + } } } } header: { @@ -938,7 +1186,25 @@ struct TodoListScreen: View { ) .id(timelineSectionScrollID(section.id)) .padding(.top, isFirstSection ? 0 : 8) + .frame(maxWidth: .infinity, minHeight: viewModel.mode.supportsTaskReschedule && draggedTodo != nil ? 44 : nil, alignment: .leading) + .contentShape(Rectangle()) + .todoInAppDropTargetFrame( + targetID: "minimal-header-\(section.id)", + section: section, + enabled: viewModel.mode.supportsTaskReschedule && draggedTodo != nil + ) .timelinePinnedSectionHeaderBackground() + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + onSectionChange: { sectionId in + setActiveDropSection(sectionId) + } + ) .listRowInsets( EdgeInsets( top: 0, @@ -979,13 +1245,39 @@ struct TodoListScreen: View { } } + private func shouldShowDateDivider( + after itemIndex: Int, + inSectionAt sectionIndex: Int, + sections: [TodoTimelineSection] + ) -> Bool { + guard sections.indices.contains(sectionIndex), + sections[sectionIndex].items.indices.contains(itemIndex) else { + return false + } + + let currentTodo = sections[sectionIndex].items[itemIndex] + let nextTodoInSection = sections[sectionIndex].items.dropFirst(itemIndex + 1).first + if let nextTodoInSection { + return !Calendar.current.isDate(currentTodo.due, inSameDayAs: nextTodoInSection.due) + } + + let nextVisibleTodo = sections.dropFirst(sectionIndex + 1) + .first { !isTimelineSectionCollapsed($0) && !$0.items.isEmpty }? + .items.first + + guard let nextVisibleTodo else { + return false + } + return !Calendar.current.isDate(currentTodo.due, inSameDayAs: nextVisibleTodo.due) + } + private func timelineRowTransition() -> AnyTransition { let insertion = AnyTransition.opacity .combined(with: .move(edge: .top)) - .animation(.easeOut(duration: 0.16)) + .animation(todoDropPlaceholderAnimation) let removal = AnyTransition.opacity .combined(with: .move(edge: .top)) - .animation(.easeOut(duration: 0.1)) + .animation(todoDropPlaceholderAnimation) return .asymmetric(insertion: insertion, removal: removal) } @@ -1388,7 +1680,7 @@ struct TimelineSectionHeader: View { HStack(spacing: 8) { Text(title) .font(.tdayRounded(size: TodoTimelineMetrics.sectionTitleSize, weight: .bold)) - .foregroundStyle(isActiveDropTarget ? colors.primary : colors.onSurfaceVariant.opacity(0.78)) + .foregroundStyle(isActiveDropTarget ? colors.error : colors.onSurfaceVariant.opacity(0.78)) .textCase(nil) if isCollapsible { @@ -1404,6 +1696,7 @@ struct TimelineSectionHeader: View { .padding(.horizontal, TodoTimelineMetrics.horizontalPadding) .padding(.bottom, 4) .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) if let onTap { Button(action: onTap) { @@ -1416,6 +1709,327 @@ struct TimelineSectionHeader: View { } } +private struct TodoDragPreview: View { + let todo: TodoItem + + @Environment(\.tdayColors) private var colors + + var body: some View { + let previewShape = RoundedRectangle(cornerRadius: 18, style: .continuous) + + HStack(spacing: 10) { + Image(systemName: "circle") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.76)) + + VStack(alignment: .leading, spacing: 3) { + Text(todo.title) + .font(.tdayRounded(size: 16, weight: .bold)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + Text(todo.due.formatted(date: .omitted, time: .shortened)) + .font(.tdayRounded(size: 12, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + } + + Spacer(minLength: 0) + + if todo.priority.lowercased() == "high" { + Image(systemName: "flag.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(priorityColor(todo.priority)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(width: 260, alignment: .leading) + .background(colors.surface) + .clipShape(previewShape) + .overlay( + previewShape.stroke(colors.onSurfaceVariant.opacity(0.14), lineWidth: 1) + ) + .contentShape(previewShape) + .compositingGroup() + .shadow(color: Color.black.opacity(0.18), radius: 16, x: 0, y: 8) + .opacity(0.96) + } +} + +private struct TodoDropPlaceholder: View { + let isActive: Bool + + @Environment(\.tdayColors) private var colors + + var body: some View { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(isActive ? colors.error.opacity(0.10) : colors.surfaceVariant.opacity(0.18)) + .overlay( + placeholderStroke + ) + .frame(height: isActive ? 70 : 52) + .animation(.easeInOut(duration: 0.18), value: isActive) + .accessibilityHidden(true) + } + + @ViewBuilder + private var placeholderStroke: some View { + if isActive { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(colors.error.opacity(0.72), lineWidth: 1.5) + } else { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke( + colors.onSurfaceVariant.opacity(0.18), + style: StrokeStyle(lineWidth: 1, dash: [7, 7]) + ) + } + } +} + +private struct TodoInAppDragModifier: ViewModifier { + let enabled: Bool + let todo: TodoItem + let onStart: (TodoItem, CGPoint) -> Void + let onMove: (TodoItem, CGPoint) -> Void + let onEnd: (TodoItem, CGPoint?) -> Void + let onCancel: () -> Void + + func body(content: Content) -> some View { + if enabled { + content + .background { + GeometryReader { _ in + TodoInAppLongPressBridge( + enabled: enabled, + todo: todo, + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + .allowsHitTesting(false) + } + } + } else { + content + } + } +} + +private struct TodoInAppLongPressBridge: UIViewRepresentable { + let enabled: Bool + let todo: TodoItem + let onStart: (TodoItem, CGPoint) -> Void + let onMove: (TodoItem, CGPoint) -> Void + let onEnd: (TodoItem, CGPoint?) -> Void + let onCancel: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + enabled: enabled, + todo: todo, + onStart: onStart, + onMove: onMove, + onEnd: onEnd, + onCancel: onCancel + ) + } + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.backgroundColor = .clear + view.isUserInteractionEnabled = false + context.coordinator.markerView = view + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + context.coordinator.enabled = enabled + context.coordinator.todo = todo + context.coordinator.onStart = onStart + context.coordinator.onMove = onMove + context.coordinator.onEnd = onEnd + context.coordinator.onCancel = onCancel + DispatchQueue.main.async { + context.coordinator.attach(to: uiView.enclosingScrollView() ?? uiView.superview, markerView: uiView) + } + } + + static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) { + coordinator.detach() + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + var enabled: Bool + var todo: TodoItem + var onStart: (TodoItem, CGPoint) -> Void + var onMove: (TodoItem, CGPoint) -> Void + var onEnd: (TodoItem, CGPoint?) -> Void + var onCancel: () -> Void + + weak var markerView: UIView? + private weak var attachedView: UIView? + private let recognizer: UILongPressGestureRecognizer + private var isDragging = false + + init( + enabled: Bool, + todo: TodoItem, + onStart: @escaping (TodoItem, CGPoint) -> Void, + onMove: @escaping (TodoItem, CGPoint) -> Void, + onEnd: @escaping (TodoItem, CGPoint?) -> Void, + onCancel: @escaping () -> Void + ) { + self.enabled = enabled + self.todo = todo + self.onStart = onStart + self.onMove = onMove + self.onEnd = onEnd + self.onCancel = onCancel + self.recognizer = UILongPressGestureRecognizer() + super.init() + + recognizer.minimumPressDuration = 0.22 + recognizer.allowableMovement = 24 + recognizer.cancelsTouchesInView = false + recognizer.delaysTouchesBegan = false + recognizer.delaysTouchesEnded = false + recognizer.delegate = self + recognizer.addTarget(self, action: #selector(handleLongPress(_:))) + } + + func attach(to view: UIView?, markerView: UIView) { + self.markerView = markerView + guard enabled, let view else { + detach() + return + } + + guard attachedView !== view else { + return + } + + detach() + attachedView = view + view.addGestureRecognizer(recognizer) + } + + func detach() { + if isDragging { + isDragging = false + onCancel() + } + attachedView?.removeGestureRecognizer(recognizer) + attachedView = nil + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard enabled, let markerView else { + return false + } + + let localPoint = gestureRecognizer.location(in: markerView) + return markerView.bounds.insetBy(dx: -6, dy: -6).contains(localPoint) + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard enabled, let markerView else { + return false + } + + let localPoint = touch.location(in: markerView) + return markerView.bounds.insetBy(dx: -6, dy: -6).contains(localPoint) + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { + let location = globalLocation(for: recognizer) + switch recognizer.state { + case .began: + guard enabled else { + return + } + isDragging = true + onStart(todo, location) + case .changed: + guard isDragging else { + return + } + onMove(todo, location) + case .ended: + guard isDragging else { + return + } + isDragging = false + onEnd(todo, location) + case .cancelled, .failed: + guard isDragging else { + return + } + isDragging = false + onCancel() + default: + break + } + } + + private func globalLocation(for recognizer: UILongPressGestureRecognizer) -> CGPoint { + guard let view = recognizer.view else { + return .zero + } + + return view.convert(recognizer.location(in: view), to: nil) + } + } +} + +private struct TodoInAppDropTargetFrameModifier: ViewModifier { + let targetID: String + let section: TodoTimelineSection + let enabled: Bool + + func body(content: Content) -> some View { + content.background { + if enabled, section.targetDate != nil { + GeometryReader { proxy in + Color.clear.preference( + key: TodoDropTargetFramePreferenceKey.self, + value: [ + targetID: TodoDropTargetFrame( + sectionID: section.id, + frame: proxy.frame(in: .global) + ) + ] + ) + } + } + } + } +} + +private extension View { + func todoInAppDropTargetFrame( + targetID: String, + section: TodoTimelineSection, + enabled: Bool + ) -> some View { + modifier( + TodoInAppDropTargetFrameModifier( + targetID: targetID, + section: section, + enabled: enabled + ) + ) + } +} + private struct TimelineSectionHeaderButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label @@ -1569,6 +2183,11 @@ private struct TodoTimelineSection: Identifiable, Hashable { let targetDate: Date? } +private struct TodoRescheduleDrop: Equatable { + let todo: TodoItem + let targetDate: Date +} + private struct ScheduledDragModifier: ViewModifier { let enabled: Bool let todo: TodoItem @@ -1578,7 +2197,10 @@ private struct ScheduledDragModifier: ViewModifier { func body(content: Content) -> some View { if enabled { content.onDrag { + UIImpactFeedbackGenerator(style: .light).impactOccurred() onDragStart() + TodoTaskDragSession.shared.todo = todo + TodoTaskDragSession.shared.handledDropSignature = nil return NSItemProvider(object: todo.id as NSString) } } else { @@ -1590,15 +2212,18 @@ private struct ScheduledDragModifier: ViewModifier { private struct ScheduledTodoDropDelegate: DropDelegate { let section: TodoTimelineSection let draggedTodo: TodoItem? + let resolveTodo: (String) -> TodoItem? let onMove: (TodoItem, Date) -> Void let onSectionChange: (String?) -> Void func validateDrop(info: DropInfo) -> Bool { - draggedTodo != nil && section.targetDate != nil + section.targetDate != nil && info.hasItemsConforming(to: todoDragContentTypes) } func dropEntered(info: DropInfo) { - onSectionChange(section.id) + if validateDrop(info: info) { + onSectionChange(section.id) + } } func dropExited(info: DropInfo) { @@ -1613,15 +2238,85 @@ private struct ScheduledTodoDropDelegate: DropDelegate { defer { onSectionChange(nil) } - guard let todo = draggedTodo, let targetDate = section.targetDate else { - return false + guard let todo = draggedTodo ?? TodoTaskDragSession.shared.todo, + let targetDate = section.targetDate else { + return performProviderDrop(info: info) } onMove(todo, targetDate) return true } + + private func performProviderDrop(info: DropInfo) -> Bool { + guard let targetDate = section.targetDate, + let provider = info.itemProviders(for: todoDragContentTypes).first else { + return false + } + provider.loadObject(ofClass: NSString.self) { object, _ in + guard let rawId = object as? NSString else { + return + } + let todoId = rawId as String + DispatchQueue.main.async { + if let todo = resolveTodo(todoId) { + onMove(todo, targetDate) + } + } + } + return true + } } -private func buildSections(items: [TodoItem], mode: TodoListMode) -> [TodoTimelineSection] { +private extension View { + func scheduledTodoDropTarget( + section: TodoTimelineSection, + draggedTodo: TodoItem?, + resolveTodo: @escaping (String) -> TodoItem?, + onMove: @escaping (TodoItem, Date) -> Void, + onSectionChange: @escaping (String?) -> Void + ) -> some View { + self + .onDrop( + of: todoDragContentTypes, + delegate: ScheduledTodoDropDelegate( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodo, + onMove: onMove, + onSectionChange: onSectionChange + ) + ) + .dropDestination(for: String.self) { ids, _ in + guard let targetDate = section.targetDate else { + onSectionChange(nil) + return false + } + let todo = draggedTodo + ?? TodoTaskDragSession.shared.todo + ?? ids.compactMap(resolveTodo).first + guard let todo else { + onSectionChange(nil) + return false + } + onSectionChange(nil) + onMove(todo, targetDate) + return true + } isTargeted: { active in + guard section.targetDate != nil else { + if !active { + onSectionChange(nil) + } + return + } + onSectionChange(active ? section.id : nil) + } + } +} + +private func buildSections( + items: [TodoItem], + mode: TodoListMode, + includeEmptyEarlierTarget: Bool = false +) -> [TodoTimelineSection] { let calendar = Calendar.current switch mode { case .today: @@ -1684,18 +2379,31 @@ private func buildSections(items: [TodoItem], mode: TodoListMode) -> [TodoTimeli calendar.startOfDay(for: item.due) } return grouped.keys.sorted().map { date in - TodoTimelineSection( - id: "scheduled-\(date.timeIntervalSince1970)", - title: scheduledSectionTitle(for: date, calendar: calendar), - items: grouped[date]?.sorted(by: todoTimelineSortPrecedes) ?? [], - isCollapsible: false, - targetDate: date - ) - } + TodoTimelineSection( + id: "scheduled-\(date.timeIntervalSince1970)", + title: scheduledSectionTitle(for: date, calendar: calendar), + items: grouped[date]?.sorted(by: todoTimelineSortPrecedes) ?? [], + isCollapsible: false, + targetDate: timelineRescheduleTargetDate( + sectionId: "scheduled-\(date.timeIntervalSince1970)", + calendar: calendar + ) + ) + } case .all: - return buildFutureTimelineSections(items: items, calendar: calendar, placesEarlierBeforeToday: true) + return buildFutureTimelineSections( + items: items, + calendar: calendar, + placesEarlierBeforeToday: true, + includeEmptyEarlierTarget: includeEmptyEarlierTarget + ) case .priority, .list: - return buildFutureTimelineSections(items: items, calendar: calendar, placesEarlierBeforeToday: false) + return buildFutureTimelineSections( + items: items, + calendar: calendar, + placesEarlierBeforeToday: false, + includeEmptyEarlierTarget: includeEmptyEarlierTarget + ) } } @@ -1712,7 +2420,8 @@ private func scheduledSectionTitle(for date: Date, calendar: Calendar) -> String private func buildFutureTimelineSections( items: [TodoItem], calendar: Calendar, - placesEarlierBeforeToday: Bool + placesEarlierBeforeToday: Bool, + includeEmptyEarlierTarget: Bool ) -> [TodoTimelineSection] { let now = Date() let today = calendar.startOfDay(for: now) @@ -1725,12 +2434,13 @@ private func buildFutureTimelineSections( let horizonStart = calendar.date(byAdding: .day, value: 7, to: today) ?? today func daySection(for date: Date, title: String) -> TodoTimelineSection { - TodoTimelineSection( - id: "priority-\(date.timeIntervalSince1970)", + let sectionId = "priority-\(date.timeIntervalSince1970)" + return TodoTimelineSection( + id: sectionId, title: title, items: groupedByDate[date] ?? [], isCollapsible: false, - targetDate: nil + targetDate: timelineRescheduleTargetDate(sectionId: sectionId, calendar: calendar) ) } @@ -1742,13 +2452,13 @@ private func buildFutureTimelineSections( .flatMap { groupedByDate[$0] ?? [] } let earlierSection: TodoTimelineSection? - if !earlierItems.isEmpty { + if !earlierItems.isEmpty || includeEmptyEarlierTarget { earlierSection = TodoTimelineSection( id: "earlier", title: "Earlier", items: earlierItems, - isCollapsible: true, - targetDate: nil + isCollapsible: !earlierItems.isEmpty, + targetDate: timelineRescheduleTargetDate(sectionId: "earlier", today: today, calendar: calendar) ) } else { earlierSection = nil @@ -1785,7 +2495,10 @@ private func buildFutureTimelineSections( title: "Rest of \(monthTitle(for: currentMonthStart, currentYear: currentYear, calendar: calendar))", items: restOfCurrentMonthItems, isCollapsible: false, - targetDate: nil + targetDate: timelineRescheduleTargetDate( + sectionId: "rest-\(currentMonthIndex)", + calendar: calendar + ) ) ) } @@ -1818,7 +2531,10 @@ private func buildFutureTimelineSections( title: monthTitle(for: monthStart, currentYear: currentYear, calendar: calendar), items: monthItems, isCollapsible: false, - targetDate: nil + targetDate: timelineRescheduleTargetDate( + sectionId: "month-\(targetMonthIndex)", + calendar: calendar + ) ) ) diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift index 5c70dd6e..bfa72bde 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift @@ -105,35 +105,25 @@ final class TodoListViewModel { } } - func moveTask(_ todo: TodoItem, toDay targetDay: Date) async { + func moveTask(_ todo: TodoItem, toDay targetDay: Date, scope: TaskRescheduleScope) async { let calendar = Calendar.current guard !calendar.isDate(todo.due, inSameDayAs: targetDay) else { return } - let dueTimeComponents = calendar.dateComponents([.hour, .minute, .second, .nanosecond], from: todo.due) - var targetComponents = calendar.dateComponents([.year, .month, .day], from: targetDay) - targetComponents.timeZone = calendar.timeZone - targetComponents.hour = dueTimeComponents.hour - targetComponents.minute = dueTimeComponents.minute - targetComponents.second = dueTimeComponents.second - targetComponents.nanosecond = dueTimeComponents.nanosecond - - guard let movedDue = calendar.date(from: targetComponents) else { + guard let movedDue = movedDuePreservingTime(due: todo.due, targetDay: targetDay, calendar: calendar) else { return } - await updateTask( - todo, - payload: CreateTaskPayload( - title: todo.title, - description: todo.description, - priority: todo.priority, - due: movedDue, - rrule: todo.rrule, - listId: todo.listId + do { + try await container.todoRepository.moveTodo( + todo.repositoryTargetForReschedule(scope: scope), + due: movedDue ) - ) + hydrateFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not update task.") + } } func complete(_ todo: TodoItem) async { diff --git a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift index aa3eca12..b5b10be6 100644 --- a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift +++ b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift @@ -29,6 +29,177 @@ extension View { func swipeRevealHintOnTap(enabled: Bool = true) -> some View { modifier(SwipeRevealHintModifier(enabled: enabled)) } + + func todoTrailingSwipeActions( + enabled: Bool = true, + onEdit: @escaping () -> Void, + onDelete: @escaping () -> Void + ) -> some View { + modifier( + TodoTrailingSwipeActionsModifier( + enabled: enabled, + onEdit: onEdit, + onDelete: onDelete + ) + ) + } +} + +private struct TodoTrailingSwipeActionsModifier: ViewModifier { + let enabled: Bool + let onEdit: () -> Void + let onDelete: () -> Void + + @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 var revealProgress: CGFloat { + min(1, max(0, -offsetX / revealWidth)) + } + + func body(content: Content) -> some View { + ZStack(alignment: .trailing) { + 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 + } + } + ) + .onTapGesture { + guard enabled else { return } + if offsetX != 0 { + closeActions() + } else { + revealHint() + } + } + + HStack(spacing: 16) { + Spacer() + TodoSwipePillActionButton( + title: "Edit", + systemImage: "square.and.pencil", + tint: TaskSwipeActionTint.edit, + revealProgress: revealProgress, + revealDelay: 0.62 + ) { + closeActions() + onEdit() + } + + TodoSwipePillActionButton( + title: "Delete", + systemImage: "trash", + tint: TaskSwipeActionTint.delete, + revealProgress: revealProgress, + revealDelay: 0.04 + ) { + closeActions() + onDelete() + } + } + .padding(.trailing, 2) + .frame(maxWidth: .infinity) + } + } + + private func closeActions() { + withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) { + offsetX = 0 + } + } + + private func revealHint() { + guard !isHinting else { return } + + 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 + } + } +} + +private struct TodoSwipePillActionButton: 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 SwipeRevealHintModifier: ViewModifier { diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index de22cf6f..1f300118 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 3667E1D45490DE558553F39F /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC35A6A50C3BFA68468FEDF9 /* OfflineBanner.swift */; }; 384B88FF643D87A6157C76C2 /* Nunito.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D9D2A99D6C098B63352D4FB8 /* Nunito.ttf */; }; 3E0BE8F327DB1A2EE5B101C4 /* ReminderPreferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */; }; + 434143484D41505045525300 /* CacheMappersDateParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 434143484D41505045525301 /* CacheMappersDateParsingTests.swift */; }; 494E748270A233BECED5A359 /* TodayTasksWidgetSnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC92D6152EADEDF915AE116B /* TodayTasksWidgetSnapshotStoreTests.swift */; }; 4A7B9C0D1E2F3456789ABC01 /* CompletedSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */; }; 4D2A9B7E2CE3424C9D111001 /* ConnectivityClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */; }; @@ -117,6 +118,7 @@ 37A754219844F42A2230F08B /* SwiftDataModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftDataModels.swift; sourceTree = ""; }; 39ADD9E07BF8B32C3FC7B170 /* TdayTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TdayTheme.swift; sourceTree = ""; }; 42749601AFF38EAE17BD3213 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + 434143484D41505045525301 /* CacheMappersDateParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMappersDateParsingTests.swift; sourceTree = ""; }; 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedSyncMergeTests.swift; sourceTree = ""; }; 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityClassificationTests.swift; sourceTree = ""; }; 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDeepLinkRouter.swift; sourceTree = ""; }; @@ -223,6 +225,7 @@ isa = PBXGroup; children = ( 629820F1BA29236313992076 /* ApiModelContractTests.swift */, + 434143484D41505045525301 /* CacheMappersDateParsingTests.swift */, 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */, 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */, 4F52544D434C49454E543032 /* RealtimeClientTests.swift */, @@ -668,6 +671,7 @@ buildActionMask = 2147483647; files = ( B4995200636F408E4296FF0D /* ApiModelContractTests.swift in Sources */, + 434143484D41505045525300 /* CacheMappersDateParsingTests.swift in Sources */, 4A7B9C0D1E2F3456789ABC01 /* CompletedSyncMergeTests.swift in Sources */, 4D2A9B7E2CE3424C9D111001 /* ConnectivityClassificationTests.swift in Sources */, 4F52544D434C49454E543031 /* RealtimeClientTests.swift in Sources */, diff --git a/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift b/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift index 21543493..b3580277 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/CacheMappersDateParsingTests.swift @@ -19,4 +19,42 @@ final class CacheMappersDateParsingTests: XCTestCase { XCTAssertEqual(components.hour, 21) XCTAssertEqual(components.minute, 0) } + + func testMovedDuePreservingTimeKeepsOriginalLocalTime() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Toronto")! + let due = Date(timeIntervalSince1970: 1_778_870_730) + let target = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 6, day: 3))) + + let moved = try XCTUnwrap(movedDuePreservingTime(due: due, targetDay: target, calendar: calendar)) + let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: moved) + + XCTAssertEqual(components.year, 2026) + XCTAssertEqual(components.month, 6) + XCTAssertEqual(components.day, 3) + XCTAssertEqual(components.hour, 14) + XCTAssertEqual(components.minute, 45) + XCTAssertEqual(components.second, 30) + } + + func testTimelineRescheduleTargetDateResolvesSectionTargets() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "America/Toronto")! + let today = try XCTUnwrap(calendar.date(from: DateComponents(year: 2026, month: 5, day: 24))) + + let dayTarget = try XCTUnwrap(timelineRescheduleTargetDate(sectionId: "priority-1779854400.0", today: today, calendar: calendar)) + XCTAssertEqual(calendar.component(.day, from: dayTarget), 27) + + let restTarget = try XCTUnwrap(timelineRescheduleTargetDate(sectionId: "rest-24317", today: today, calendar: calendar)) + XCTAssertEqual(calendar.component(.day, from: restTarget), 31) + + let monthTarget = try XCTUnwrap(timelineRescheduleTargetDate(sectionId: "month-24319", today: today, calendar: calendar)) + XCTAssertEqual(calendar.component(.year, from: monthTarget), 2026) + XCTAssertEqual(calendar.component(.month, from: monthTarget), 7) + XCTAssertEqual(calendar.component(.day, from: monthTarget), 1) + + let earlierTarget = try XCTUnwrap(timelineRescheduleTargetDate(sectionId: "earlier", today: today, calendar: calendar)) + XCTAssertEqual(calendar.component(.day, from: earlierTarget), 23) + XCTAssertNil(timelineRescheduleTargetDate(sectionId: "month-24316", today: today, calendar: calendar)) + } } diff --git a/ios-swiftUI/Tests/TdayCoreTests/ConnectivityClassificationTests.swift b/ios-swiftUI/Tests/TdayCoreTests/ConnectivityClassificationTests.swift index c03eb2fd..bf3da869 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/ConnectivityClassificationTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/ConnectivityClassificationTests.swift @@ -65,6 +65,19 @@ final class ConnectivityClassificationTests: XCTestCase { ) } + func testUnauthorizedResponsesAreRecoverableSessionIssues() { + XCTAssertTrue( + isSessionAuthenticationIssue( + APIError(message: "Unauthorized", statusCode: 401) + ) + ) + XCTAssertFalse( + isLikelyConnectivityIssue( + APIError(message: "Unauthorized", statusCode: 401) + ) + ) + } + func testGenericServerErrorsUseServerMessage() { XCTAssertEqual( userFacingMessage(for: APIError(message: "Internal Server Error", statusCode: 500)), diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/auth/SessionAuthFlowTest.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/auth/SessionAuthFlowTest.kt index 79db6bd3..4d4c1b7a 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/auth/SessionAuthFlowTest.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/auth/SessionAuthFlowTest.kt @@ -76,7 +76,10 @@ private const val SESSION_TEST_ISSUED_AT = "2026-04-01T00:00:00Z" class SessionAuthFlowTest { @Test fun `credentials callback sets session cookie with configured max age`() = testApplication { - val config = testAppConfig(sessionMaxAgeSec = 2_592_000) + val config = testAppConfig( + isProduction = true, + sessionMaxAgeSec = 2_592_000, + ) val jwtService = JwtServiceImpl(config) val userService = FakeUserService( loginUser = mapOf( @@ -107,6 +110,10 @@ class SessionAuthFlowTest { assertEquals(HttpStatusCode.OK, response.status) val cookieHeader = response.requireCookieHeader(sessionCookieName(config.isProduction)) assertTrue(cookieHeader.contains("Max-Age=2592000")) + assertTrue(cookieHeader.contains("Path=/")) + assertTrue(cookieHeader.contains("HttpOnly")) + assertTrue(cookieHeader.contains("SameSite=Lax")) + assertTrue(cookieHeader.contains("Secure")) } @Test diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/security/TestAppConfig.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/security/TestAppConfig.kt index 29d58269..e8d0ac86 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/security/TestAppConfig.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/security/TestAppConfig.kt @@ -4,6 +4,7 @@ import com.ohmz.tday.config.AppConfig fun testAppConfig( authSecret: String = "test-secret-that-is-at-least-32-chars-long!!", + isProduction: Boolean = false, pbkdf2Iterations: Int = 120_000, sessionMaxAgeSec: Int = 2_592_000, sessionAbsoluteMaxAgeSec: Int = 7_776_000, @@ -20,7 +21,7 @@ fun testAppConfig( port = 8080, databaseUrl = "postgresql://test:test@localhost:5432/testdb", authSecret = authSecret, - isProduction = false, + isProduction = isProduction, corsAllowedOrigins = emptyList(), pbkdf2Iterations = pbkdf2Iterations, sessionMaxAgeSec = sessionMaxAgeSec, diff --git a/tday-web/package.json b/tday-web/package.json index a11ad2e0..06d72e1a 100644 --- a/tday-web/package.json +++ b/tday-web/package.json @@ -1,6 +1,6 @@ { "name": "tday-web", - "version": "1.23.0", + "version": "1.44.0", "private": true, "type": "module", "scripts": { diff --git a/tday-web/src/components/Sidebar/User/UserCard.tsx b/tday-web/src/components/Sidebar/User/UserCard.tsx index b81db848..131af8d6 100644 --- a/tday-web/src/components/Sidebar/User/UserCard.tsx +++ b/tday-web/src/components/Sidebar/User/UserCard.tsx @@ -14,6 +14,7 @@ import { useTranslation } from "react-i18next"; import { useTheme } from "next-themes"; import { LogOut, Moon, Sun, Monitor, Settings, Shield } from "lucide-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useToast } from "@/hooks/use-toast"; const railButtonClass = "group flex h-10 min-h-10 w-10 shrink-0 items-center justify-center rounded-xl text-sidebar-foreground/70 transition-colors duration-200 hover:bg-sidebar-accent/70 hover:text-sidebar-foreground"; @@ -37,6 +38,7 @@ const UserCard = ({ const { t: sidebarDict } = useTranslation("sidebar"); const { setTheme, theme } = useTheme(); const router = useRouter(); + const { toast } = useToast(); const themeLabel = theme === "dark" ? "Dark" : theme === "light" ? "Light" : "System"; @@ -52,7 +54,17 @@ const UserCard = ({ .slice(0, 2) || "U"; const handleLogout = async () => { - await logout(); + try { + await logout(); + } catch (error) { + toast({ + variant: "destructive", + description: + error instanceof Error && error.message + ? error.message + : "Unable to log out. Please try again.", + }); + } }; return ( diff --git a/tday-web/src/components/auth/AuthBootstrapScreen.tsx b/tday-web/src/components/auth/AuthBootstrapScreen.tsx new file mode 100644 index 00000000..6eca33c7 --- /dev/null +++ b/tday-web/src/components/auth/AuthBootstrapScreen.tsx @@ -0,0 +1,9 @@ +import { Loader2 } from "lucide-react"; + +export default function AuthBootstrapScreen() { + return ( +
+ +
+ ); +} diff --git a/tday-web/src/components/auth/UnauthenticatedCacheGuard.tsx b/tday-web/src/components/auth/UnauthenticatedCacheGuard.tsx deleted file mode 100644 index 8ddcef62..00000000 --- a/tday-web/src/components/auth/UnauthenticatedCacheGuard.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { useEffect } from "react"; -import { clearClientUserData } from "@/lib/security/clearClientUserData"; - -export default function UnauthenticatedCacheGuard() { - useEffect(() => { - void clearClientUserData(); - }, []); - - return null; -} diff --git a/tday-web/src/components/landing/OnboardingLanding.tsx b/tday-web/src/components/landing/OnboardingLanding.tsx index 85642675..9194a62c 100644 --- a/tday-web/src/components/landing/OnboardingLanding.tsx +++ b/tday-web/src/components/landing/OnboardingLanding.tsx @@ -10,7 +10,6 @@ import { Wand2, } from "lucide-react"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import UnauthenticatedCacheGuard from "@/components/auth/UnauthenticatedCacheGuard"; type Slide = { title: string; @@ -115,7 +114,6 @@ export default function OnboardingLanding() { return (
-
diff --git a/tday-web/src/lib/security/clearClientUserData.ts b/tday-web/src/lib/security/clearClientUserData.ts index 42dac34b..ac1cfdfb 100644 --- a/tday-web/src/lib/security/clearClientUserData.ts +++ b/tday-web/src/lib/security/clearClientUserData.ts @@ -1,3 +1,7 @@ +type ClearClientUserDataOptions = { + preserveLocalStorageKeys?: string[]; +}; + async function deleteIndexedDbDatabase(name: string): Promise { await new Promise((resolve) => { try { @@ -11,7 +15,9 @@ async function deleteIndexedDbDatabase(name: string): Promise { }); } -export async function clearClientUserData(): Promise { +export async function clearClientUserData( + options: ClearClientUserDataOptions = {}, +): Promise { if (typeof window === "undefined") return; try { @@ -21,8 +27,10 @@ export async function clearClientUserData(): Promise { } try { + const preserveKeys = new Set(options.preserveLocalStorageKeys ?? []); const keys = Object.keys(window.localStorage); for (const key of keys) { + if (preserveKeys.has(key)) continue; window.localStorage.removeItem(key); } } catch { diff --git a/tday-web/src/lib/security/returningBrowser.ts b/tday-web/src/lib/security/returningBrowser.ts new file mode 100644 index 00000000..237356db --- /dev/null +++ b/tday-web/src/lib/security/returningBrowser.ts @@ -0,0 +1,21 @@ +export const RETURNING_BROWSER_STORAGE_KEY = "tday.returning-browser"; + +export function hasReturningBrowser(): boolean { + if (typeof window === "undefined") return false; + + try { + return window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY) === "1"; + } catch { + return false; + } +} + +export function markReturningBrowser(): void { + if (typeof window === "undefined") return; + + try { + window.localStorage.setItem(RETURNING_BROWSER_STORAGE_KEY, "1"); + } catch { + // Ignore localStorage write failures in restricted browser contexts. + } +} diff --git a/tday-web/src/pages/AuthLayout.tsx b/tday-web/src/pages/AuthLayout.tsx index 4f724e2d..50c57975 100644 --- a/tday-web/src/pages/AuthLayout.tsx +++ b/tday-web/src/pages/AuthLayout.tsx @@ -1,23 +1,24 @@ +import { useEffect } from "react"; import { Navigate, Outlet, useParams } from "react-router-dom"; -import { Loader2 } from "lucide-react"; import { SonnerToaster } from "@/components/ui/sonner"; -import UnauthenticatedCacheGuard from "@/components/auth/UnauthenticatedCacheGuard"; import { useAuth } from "@/providers/AuthProvider"; import { DEFAULT_LOCALE } from "@/i18n"; +import AuthBootstrapScreen from "@/components/auth/AuthBootstrapScreen"; +import { markReturningBrowser } from "@/lib/security/returningBrowser"; export default function AuthLayout() { - const { user, isLoading } = useAuth(); + const { user, authState } = useAuth(); const { locale } = useParams(); const loc = locale || DEFAULT_LOCALE; const isApprovedUser = user?.approvalStatus === "APPROVED"; - if (isLoading) { - return ( -
- -
- ); - } + useEffect(() => { + markReturningBrowser(); + }, []); + + if (authState === "loading" || authState === "unavailable") { + return ; + } if (isApprovedUser) { return ; @@ -25,7 +26,6 @@ export default function AuthLayout() { return (
-
diff --git a/tday-web/src/pages/LandingPage.tsx b/tday-web/src/pages/LandingPage.tsx index d1f5cc38..6004c7cf 100644 --- a/tday-web/src/pages/LandingPage.tsx +++ b/tday-web/src/pages/LandingPage.tsx @@ -1,25 +1,27 @@ import { Navigate, useParams } from "react-router-dom"; -import { Loader2 } from "lucide-react"; import { useAuth } from "@/providers/AuthProvider"; import OnboardingLanding from "@/components/landing/OnboardingLanding"; import { DEFAULT_LOCALE } from "@/i18n"; +import AuthBootstrapScreen from "@/components/auth/AuthBootstrapScreen"; +import { hasReturningBrowser } from "@/lib/security/returningBrowser"; export default function LandingPage() { - const { isAuthenticated, isLoading } = useAuth(); + const { authState } = useAuth(); const { locale } = useParams(); const loc = locale || DEFAULT_LOCALE; + const isReturningBrowser = hasReturningBrowser(); - if (isLoading) { - return ( -
- -
- ); + if (authState === "loading" || authState === "unavailable") { + return ; } - if (isAuthenticated) { + if (authState === "authenticated") { return ; } + if (isReturningBrowser) { + return ; + } + return ; } diff --git a/tday-web/src/pages/ProtectedRoute.tsx b/tday-web/src/pages/ProtectedRoute.tsx index 374daf62..28f8c1f3 100644 --- a/tday-web/src/pages/ProtectedRoute.tsx +++ b/tday-web/src/pages/ProtectedRoute.tsx @@ -1,19 +1,15 @@ import { Navigate, Outlet, useParams } from "react-router-dom"; import { useAuth } from "@/providers/AuthProvider"; -import { Loader2 } from "lucide-react"; import { DEFAULT_LOCALE } from "@/i18n"; +import AuthBootstrapScreen from "@/components/auth/AuthBootstrapScreen"; export default function ProtectedRoute() { - const { user, isLoading, isAuthenticated } = useAuth(); + const { user, authState, isAuthenticated } = useAuth(); const { locale } = useParams(); const loc = locale || DEFAULT_LOCALE; - if (isLoading) { - return ( -
- -
- ); + if (authState === "loading" || authState === "unavailable") { + return ; } if (!isAuthenticated) { diff --git a/tday-web/src/providers/AuthProvider.tsx b/tday-web/src/providers/AuthProvider.tsx index 36aa7db6..dd539fd2 100644 --- a/tday-web/src/providers/AuthProvider.tsx +++ b/tday-web/src/providers/AuthProvider.tsx @@ -4,11 +4,25 @@ import { useContext, useEffect, useMemo, + useRef, useState, type ReactNode, } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { api, ApiError } from "@/lib/api-client"; +import { clearClientUserData } from "@/lib/security/clearClientUserData"; +import { + markReturningBrowser, + RETURNING_BROWSER_STORAGE_KEY, +} from "@/lib/security/returningBrowser"; + +const AUTH_SESSION_RETRY_DELAY_MS = 15_000; + +export type AuthSessionState = + | "loading" + | "authenticated" + | "unauthenticated" + | "unavailable"; export type AuthUser = { id: string; @@ -21,6 +35,7 @@ export type AuthUser = { type AuthContextValue = { user: AuthUser | null; + authState: AuthSessionState; isLoading: boolean; isAuthenticated: boolean; login: (email: string, credentialPayload: Record) => Promise<{ ok: boolean; code?: string; message?: string }>; @@ -32,24 +47,73 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); + const [authState, setAuthState] = useState("loading"); const queryClient = useQueryClient(); + const authStateRef = useRef("loading"); + const userRef = useRef(null); + + const applySessionState = useCallback((nextAuthState: AuthSessionState, nextUser: AuthUser | null) => { + authStateRef.current = nextAuthState; + userRef.current = nextUser; + setAuthState(nextAuthState); + setUser(nextUser); + }, []); + + const setSessionAvailability = useCallback((nextAuthState: AuthSessionState) => { + authStateRef.current = nextAuthState; + setAuthState(nextAuthState); + }, []); const fetchSession = useCallback(async () => { try { const data = await api.GET({ url: "/api/auth/session" }); - setUser(data?.user ?? null); - } catch { - setUser(null); - } finally { - setIsLoading(false); + const nextUser = data?.user ?? null; + + if (nextUser) { + markReturningBrowser(); + applySessionState("authenticated", nextUser); + return; + } + + applySessionState("unauthenticated", null); + } catch (error) { + const apiError = error instanceof ApiError ? error : null; + + if (apiError?.status === 401) { + const hadAuthenticatedSession = + authStateRef.current === "authenticated" || userRef.current !== null; + + if (hadAuthenticatedSession) { + queryClient.clear(); + await clearClientUserData({ + preserveLocalStorageKeys: [RETURNING_BROWSER_STORAGE_KEY], + }); + } + + applySessionState("unauthenticated", null); + return; + } + + setSessionAvailability("unavailable"); } - }, []); + }, [applySessionState, queryClient, setSessionAvailability]); useEffect(() => { - fetchSession(); + void fetchSession(); }, [fetchSession]); + useEffect(() => { + if (authState !== "unavailable") return; + + const retryTimer = window.setTimeout(() => { + void fetchSession(); + }, AUTH_SESSION_RETRY_DELAY_MS); + + return () => { + window.clearTimeout(retryTimer); + }; + }, [authState, fetchSession]); + const login = useCallback( async (email: string, credentialPayload: Record) => { try { @@ -58,7 +122,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, ...credentialPayload }), }); + markReturningBrowser(); await fetchSession(); + + if (authStateRef.current === "unauthenticated") { + return { + ok: false, + message: "Unable to establish a session. Please try again.", + }; + } + return { ok: true }; } catch (e) { const err = e instanceof ApiError ? e : null; @@ -73,25 +146,34 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const logout = useCallback(async () => { - queryClient.clear(); - await clearClientUserData(); + markReturningBrowser(); try { await api.POST({ url: "/api/auth/logout", body: "{}" }); - } catch {} - setUser(null); + } catch (error) { + const message = error instanceof ApiError + ? error.message + : "Unable to log out. Please try again."; + throw new Error(message); + } + queryClient.clear(); + await clearClientUserData({ + preserveLocalStorageKeys: [RETURNING_BROWSER_STORAGE_KEY], + }); + applySessionState("unauthenticated", null); window.location.replace(window.location.origin); - }, [queryClient]); + }, [applySessionState, queryClient]); const value = useMemo( () => ({ user, - isLoading, - isAuthenticated: user !== null, + authState, + isLoading: authState === "loading", + isAuthenticated: authState === "authenticated", login, logout, refreshSession: fetchSession, }), - [user, isLoading, login, logout, fetchSession], + [user, authState, login, logout, fetchSession], ); return {children}; @@ -102,50 +184,3 @@ export function useAuth(): AuthContextValue { if (!ctx) throw new Error("useAuth must be used within AuthProvider"); return ctx; } - -async function deleteIndexedDbDatabase(name: string): Promise { - await new Promise((resolve) => { - try { - const request = indexedDB.deleteDatabase(name); - request.onsuccess = () => resolve(); - request.onerror = () => resolve(); - request.onblocked = () => resolve(); - } catch { - resolve(); - } - }); -} - -async function clearClientUserData(): Promise { - if (typeof window === "undefined") return; - - try { - window.sessionStorage.clear(); - } catch {} - - try { - const keys = Object.keys(window.localStorage); - for (const key of keys) { - window.localStorage.removeItem(key); - } - } catch {} - - try { - if ("caches" in window) { - const cacheKeys = await window.caches.keys(); - await Promise.all(cacheKeys.map((cacheKey) => window.caches.delete(cacheKey))); - } - } catch {} - - try { - if (typeof indexedDB === "undefined") return; - if (typeof indexedDB.databases !== "function") return; - const databases = await indexedDB.databases(); - await Promise.all( - databases - .map((database) => database.name?.trim()) - .filter((name): name is string => Boolean(name)) - .map((name) => deleteIndexedDbDatabase(name)), - ); - } catch {} -} diff --git a/tday-web/tests/unit/AuthProvider.test.tsx b/tday-web/tests/unit/AuthProvider.test.tsx index ae32994e..d09f4ced 100644 --- a/tday-web/tests/unit/AuthProvider.test.tsx +++ b/tday-web/tests/unit/AuthProvider.test.tsx @@ -1,10 +1,11 @@ // @vitest-environment jsdom import type { ReactNode } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { renderHook, waitFor } from "@testing-library/react"; import { AuthProvider, useAuth } from "@/providers/AuthProvider"; +import { RETURNING_BROWSER_STORAGE_KEY } from "@/lib/security/returningBrowser"; function createWrapper() { const queryClient = new QueryClient(); @@ -20,10 +21,21 @@ function createWrapper() { describe("AuthProvider", () => { afterEach(() => { + window.localStorage.clear(); + window.sessionStorage.clear(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + function mockResponse(status: number, body: unknown, contentType = "application/json") { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": contentType, + }, + }); + } + it("loads the session on mount and exposes the authenticated user", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response( @@ -61,7 +73,188 @@ describe("AuthProvider", () => { cache: "no-store", credentials: "same-origin", })); + expect(result.current.authState).toBe("authenticated"); expect(result.current.isAuthenticated).toBe(true); expect(result.current.user?.email).toBe("taylor@example.com"); + expect(window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY)).toBe("1"); + }); + + it("treats a 401 session response as unauthenticated", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + mockResponse(401, { + message: "Not authenticated", + }), + ), + ); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("unauthenticated"); + }); + + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeNull(); + }); + + it("treats transient session failures as unavailable without logging the user out", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + mockResponse(500, { + message: "Server unavailable", + }), + ), + ); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("unavailable"); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isAuthenticated).toBe(false); + expect(result.current.user).toBeNull(); + }); + + it("preserves the returning-browser marker when a prior session is invalidated", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + mockResponse(200, { + user: { + id: "user-1", + name: "Taylor", + email: "taylor@example.com", + role: "USER", + approvalStatus: "APPROVED", + timeZone: "UTC", + }, + }), + ) + .mockResolvedValueOnce( + mockResponse(401, { + message: "Not authenticated", + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("authenticated"); + }); + + window.localStorage.setItem("menu-state", "open"); + window.sessionStorage.setItem("draft", "cached"); + + await act(async () => { + await result.current.refreshSession(); + }); + + await waitFor(() => { + expect(result.current.authState).toBe("unauthenticated"); + }); + + expect(window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY)).toBe("1"); + expect(window.localStorage.getItem("menu-state")).toBeNull(); + expect(window.sessionStorage.getItem("draft")).toBeNull(); + }); + + it("preserves the returning-browser marker on logout while clearing other browser data", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + mockResponse(200, { + user: { + id: "user-1", + name: "Taylor", + email: "taylor@example.com", + role: "USER", + approvalStatus: "APPROVED", + timeZone: "UTC", + }, + }), + ) + .mockResolvedValueOnce( + mockResponse(200, { + message: "logged_out", + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("authenticated"); + }); + + window.localStorage.setItem("menu-state", "open"); + window.sessionStorage.setItem("draft", "cached"); + + await act(async () => { + await result.current.logout(); + }); + + expect(window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY)).toBe("1"); + expect(window.localStorage.getItem("menu-state")).toBeNull(); + expect(window.sessionStorage.getItem("draft")).toBeNull(); + expect(result.current.authState).toBe("unauthenticated"); + }); + + it("does not clear auth state locally when the logout request fails", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + mockResponse(200, { + user: { + id: "user-1", + name: "Taylor", + email: "taylor@example.com", + role: "USER", + approvalStatus: "APPROVED", + timeZone: "UTC", + }, + }), + ) + .mockResolvedValueOnce( + mockResponse(500, { + message: "Logout failed", + }), + ); + + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAuth(), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.authState).toBe("authenticated"); + }); + + window.localStorage.setItem("menu-state", "open"); + window.sessionStorage.setItem("draft", "cached"); + + await expect(result.current.logout()).rejects.toThrow("Logout failed"); + + expect(result.current.authState).toBe("authenticated"); + expect(result.current.user?.email).toBe("taylor@example.com"); + expect(window.localStorage.getItem(RETURNING_BROWSER_STORAGE_KEY)).toBe("1"); + expect(window.localStorage.getItem("menu-state")).toBe("open"); + expect(window.sessionStorage.getItem("draft")).toBe("cached"); }); }); diff --git a/tday-web/tests/unit/publicRouteAuthGuard.test.tsx b/tday-web/tests/unit/publicRouteAuthGuard.test.tsx index 8833f931..07017970 100644 --- a/tday-web/tests/unit/publicRouteAuthGuard.test.tsx +++ b/tday-web/tests/unit/publicRouteAuthGuard.test.tsx @@ -5,7 +5,9 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import AuthLayout from "@/pages/AuthLayout"; import LandingPage from "@/pages/LandingPage"; +import ProtectedRoute from "@/pages/ProtectedRoute"; import { useAuth } from "@/providers/AuthProvider"; +import { RETURNING_BROWSER_STORAGE_KEY } from "@/lib/security/returningBrowser"; vi.mock("@/providers/AuthProvider", () => ({ useAuth: vi.fn(), @@ -17,12 +19,6 @@ vi.mock("@/components/landing/OnboardingLanding", () => ({ }, })); -vi.mock("@/components/auth/UnauthenticatedCacheGuard", () => ({ - default: function UnauthenticatedCacheGuard() { - return null; - }, -})); - vi.mock("@/components/ui/sonner", () => ({ SonnerToaster: function SonnerToaster() { return null; @@ -36,6 +32,7 @@ const useAuthMock = vi.mocked(useAuth); function createAuthState(overrides: Partial = {}): AuthState { return { user: null, + authState: "unauthenticated", isLoading: false, isAuthenticated: false, login: vi.fn(), @@ -51,6 +48,7 @@ function renderAuthLayout(initialEntry = "/en/login") { }> Login Screen
} /> + Register Screen
} /> Home Screen
} /> @@ -63,20 +61,35 @@ function renderLandingPage(initialEntry = "/en") { } /> + Login Screen
} /> Home Screen
} /> , ); } +function renderProtectedRoute(initialEntry = "/en/app/tday") { + return render( + + + }> + Home Screen} /> + + Login Screen} /> + + , + ); +} + describe("public auth route guards", () => { afterEach(() => { cleanup(); + window.localStorage.clear(); vi.clearAllMocks(); }); it("keeps auth pages in a loading state while the session is resolving", () => { - useAuthMock.mockReturnValue(createAuthState({ isLoading: true })); + useAuthMock.mockReturnValue(createAuthState({ authState: "loading", isLoading: true })); const { container } = renderAuthLayout(); @@ -87,6 +100,7 @@ describe("public auth route guards", () => { it("redirects approved authenticated users away from login", async () => { useAuthMock.mockReturnValue( createAuthState({ + authState: "authenticated", user: { id: "user-1", name: "Taylor", @@ -108,7 +122,7 @@ describe("public auth route guards", () => { }); it("keeps the landing page in a loading state while the session is resolving", () => { - useAuthMock.mockReturnValue(createAuthState({ isLoading: true })); + useAuthMock.mockReturnValue(createAuthState({ authState: "loading", isLoading: true })); const { container } = renderLandingPage(); @@ -119,6 +133,7 @@ describe("public auth route guards", () => { it("redirects authenticated users from landing to today", async () => { useAuthMock.mockReturnValue( createAuthState({ + authState: "authenticated", user: { id: "user-1", name: "Taylor", @@ -138,4 +153,43 @@ describe("public auth route guards", () => { }); expect(screen.queryByText("Landing Screen")).toBeNull(); }); + + it("keeps the landing page in a reconnecting state while auth is unavailable", () => { + useAuthMock.mockReturnValue(createAuthState({ authState: "unavailable" })); + + const { container } = renderLandingPage(); + + expect(screen.queryByText("Landing Screen")).toBeNull(); + expect(container.querySelector("svg.animate-spin")).not.toBeNull(); + }); + + it("shows onboarding for a first-time unauthenticated browser", () => { + useAuthMock.mockReturnValue(createAuthState()); + + renderLandingPage(); + + expect(screen.queryByText("Landing Screen")).not.toBeNull(); + expect(screen.queryByText("Login Screen")).toBeNull(); + }); + + it("redirects returning unauthenticated browsers to login from landing", async () => { + window.localStorage.setItem(RETURNING_BROWSER_STORAGE_KEY, "1"); + useAuthMock.mockReturnValue(createAuthState()); + + renderLandingPage(); + + await waitFor(() => { + expect(screen.queryByText("Login Screen")).not.toBeNull(); + }); + expect(screen.queryByText("Landing Screen")).toBeNull(); + }); + + it("keeps protected routes in a reconnecting state while auth is unavailable", () => { + useAuthMock.mockReturnValue(createAuthState({ authState: "unavailable" })); + + const { container } = renderProtectedRoute(); + + expect(screen.queryByText("Home Screen")).toBeNull(); + expect(container.querySelector("svg.animate-spin")).not.toBeNull(); + }); });