Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3dd8276
Refactor authentication state management and session handling to impr…
ohmzi May 24, 2026
6b46009
chore: bump app version to 1.44.0
ohmzi May 24, 2026
1ee5de7
feat: enhance responsiveness of OnboardingWizardOverlay
ohmzi May 24, 2026
e6b06b2
Refine iOS resume reload
ohmzi May 24, 2026
a087254
feat: implement session recovery and improve offline error handling
ohmzi May 24, 2026
1018547
feat: add today's tasks list and task actions to Home screen
ohmzi May 24, 2026
cfd9114
refactor: unified swipe actions and improved task completion UI acros…
ohmzi May 24, 2026
9e4947e
refactor: conditional date dividers in todo and completed lists
ohmzi May 24, 2026
716ef49
fix(ui): improve swipe action layering and hit testing
ohmzi May 24, 2026
519d616
style(ui): refine home screen typography and text styles
ohmzi May 24, 2026
59f36ef
refactor: standardize list icon and color helper functions
ohmzi May 24, 2026
5fc539c
feat: implement task drag-and-drop rescheduling for Android and iOS
ohmzi May 25, 2026
9ab6da9
feat: enhance drag-and-drop rescheduling across iOS and Android
ohmzi May 25, 2026
cce4f8b
feat: add "Earlier" drop target and visual placeholders for task resc…
ohmzi May 25, 2026
27e187a
feat: implement custom in-app drag and drop for task rescheduling
ohmzi May 25, 2026
b300124
fix(todos): improve drag-and-drop coordinate handling and UI consistency
ohmzi May 25, 2026
2835421
refactor(todos): simplify drag preview positioning and refine drag-an…
ohmzi May 25, 2026
e44da49
Refactor Todo drag-and-drop interaction to use a UIKit-based gesture …
ohmzi May 25, 2026
126d285
feat: implement native in-app task rescheduling and drag-and-drop for…
ohmzi May 25, 2026
6389859
Refactor Todo drag-and-drop interaction to use a UIKit-based gesture …
ohmzi May 25, 2026
e401181
Refactor calendar paging and styling across iOS and Android.
ohmzi May 25, 2026
ed2e434
feat(calendar, todo): enhance animations and layout transitions
ohmzi May 25, 2026
9e1ede4
Polish iOS calendar shadows
ohmzi May 25, 2026
ece9c1d
refine(calendar): improve pager stability and cleanup drag state
ohmzi May 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -362,6 +370,9 @@ fun TdayApp(
onCreateTask = { _ -> },
onParseTaskTitleNlp = { _, _ -> null },
onCreateList = { _, _, _ -> },
onCompleteTask = {},
onDeleteTask = {},
onUpdateTask = { _, _ -> },
)
}
}
Expand Down Expand Up @@ -584,6 +595,7 @@ fun TdayApp(
onParseTaskTitleNlp = viewModel::parseTaskTitleNlp,
onCompleteTask = viewModel::complete,
onUpdateTask = viewModel::updateTask,
onMoveTask = viewModel::moveTask,
onDelete = { todo ->
viewModel.delete(todo) {
showTaskDeletedToast()
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -356,7 +374,7 @@ class SyncManager @Inject constructor(
rrule = rruleForApi,
listID = listIdForApi,
dateChanged = true,
rruleChanged = true,
rruleChanged = if (isDueOnlyMove) null else true,
instanceDate = null,
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,123 @@ class TodoRepository @Inject constructor(
}
}

suspend fun moveTodo(todo: TodoItem, due: Instant) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

`moveTodo` has a cyclomatic complexity of 20 with "High" risk


Cyclomatic complexity is a software metric that measures the number of
independent paths through a function. A function with high cyclomatic
complexity can be hard to understand and maintain. A higher cyclomatic
complexity indicates that the function has more decision points and is more complex.

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -11,6 +15,11 @@ enum class TodoListMode {
LIST,
}

enum class TaskRescheduleScope {
OCCURRENCE,
SERIES,
}

data class CreateTaskPayload(
val title: String,
val description: String? = null,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading