From 531e3b9cd5658942c06de4db472b91bba03bca2d Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Thu, 28 May 2026 10:17:01 -0400 Subject: [PATCH 01/11] feat(task): implement support for anytime (non-scheduled) tasks across all platforms This update introduces the ability to create and manage tasks without a due date ("Anytime" tasks), affecting the backend, mobile apps (Android/iOS), and web interface. It includes database schema migrations, UI refinements for task creation/editing, and a new "Anytime" feed. ### Core Logic & Backend - **Database Schema**: Updated `Todos` and `CompletedTodos` tables to make the `due` column nullable. Added Android Room migration (v4 to v5) to support nullable `dueEpochMs`. - **Backend Services**: Modified `TodoService` and `CompletedTodoService` to handle optional due dates. Recurring tasks now strictly require a due date. - **Validation**: Updated `ContractValidators` to enforce that recurring tasks (`rrule`) must have a `due` date, while standard tasks do not. ### Android (Compose) - **Anytime Feed**: Introduced `TodoListMode.ANYTIME` and integrated it into `TodoListScreen` and `HomeViewModel`. - **UX Refinements**: - Added a "Schedule" toggle in `CreateTaskBottomSheet` to switch between scheduled and "Anytime" modes. - Implemented `RootFeedDock` and `RootCreateTaskButton` in `TdayApp` for centralized navigation between Home and Anytime feeds. - **Widget**: Updated `TodayTasksWidget` to gracefully handle tasks without due dates. ### iOS (SwiftUI) - **UX Refinements**: - Updated `CreateTaskSheet` with a "Schedule" toggle. - Introduced `RootFeedDock` for quick switching between Home and Anytime views in `AppRootView`. - Enhanced `HomeScreen` and `TodoListScreen` to display "Anytime" instead of a time string when a due date is missing. - **Calendar**: Filtered out tasks without due dates from the `CalendarScreen` to prevent layout issues. ### Web & Common - **API Integration**: Updated TypeScript types and API clients to support optional `due` dates. - **Data Mapping**: Updated cache mappers on both mobile platforms to handle `null` due dates during synchronization and persistence. - **UI Consistency**: Standardized the display of "Anytime" labels across task rows and detail views when no due date is present. --- .../java/com/ohmz/tday/compose/TdayApp.kt | 211 ++++++++++++++---- .../compose/core/data/OfflineSyncModels.kt | 4 +- .../compose/core/data/cache/CacheMappers.kt | 12 +- .../data/completed/CompletedRepository.kt | 12 +- .../core/data/db/DatabaseMigrations.kt | 64 ++++++ .../compose/core/data/db/DatabaseModule.kt | 2 +- .../tday/compose/core/data/db/Entities.kt | 4 +- .../tday/compose/core/data/db/TdayDatabase.kt | 2 +- .../compose/core/data/sync/SyncManager.kt | 8 +- .../compose/core/data/todo/TodoRepository.kt | 42 ++-- .../tday/compose/core/model/DomainModels.kt | 12 +- .../tday/compose/core/navigation/AppRoute.kt | 1 + .../notification/TaskReminderScheduler.kt | 4 +- .../feature/calendar/CalendarScreen.kt | 25 ++- .../feature/calendar/CalendarViewModel.kt | 12 +- .../feature/completed/CompletedScreen.kt | 8 +- .../tday/compose/feature/home/HomeScreen.kt | 171 +++++++++----- .../compose/feature/home/HomeViewModel.kt | 1 + .../compose/feature/todos/TodoListScreen.kt | 174 +++++++++++---- .../feature/todos/TodoListViewModel.kt | 6 +- .../feature/widget/TodayTasksWidget.kt | 9 +- .../ui/component/CreateTaskBottomSheet.kt | 107 ++++++++- .../core/data/cache/CacheMappersTest.kt | 6 +- .../Data/Completed/CompletedRepository.swift | 12 +- .../Core/Data/Database/SwiftDataModels.swift | 4 +- .../Tday/Core/Data/Sync/CacheMappers.swift | 18 +- .../Tday/Core/Data/Sync/SyncManager.swift | 4 +- .../Tday/Core/Data/Todo/TodoRepository.swift | 35 +-- ios-swiftUI/Tday/Core/Model/ApiModels.swift | 8 +- .../Tday/Core/Model/DomainModels.swift | 17 +- .../Tday/Core/Model/OfflineSyncModels.swift | 4 +- .../Tday/Core/Navigation/AppRoute.swift | 5 + .../Notification/TaskReminderScheduler.swift | 5 +- .../TodayTasksWidgetSnapshotStore.swift | 15 +- .../Tday/Feature/App/AppRootView.swift | 89 +++++++- .../Feature/Calendar/CalendarScreen.swift | 17 +- .../Feature/Calendar/CalendarViewModel.swift | 7 +- .../Feature/Completed/CompletedScreen.swift | 14 +- .../Tday/Feature/Home/HomeScreen.swift | 91 +++++++- .../Tday/Feature/Home/HomeViewModel.swift | 2 +- .../Tday/Feature/Todos/TodoListScreen.swift | 193 +++++++++++++--- .../Feature/Todos/TodoListViewModel.swift | 7 +- .../Tday/UI/Component/CreateTaskSheet.swift | 98 ++++++-- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 + .../ohmz/tday/shared/model/CompletedModels.kt | 2 +- .../com/ohmz/tday/shared/model/ListModels.kt | 2 +- .../com/ohmz/tday/shared/model/TodoModels.kt | 4 +- .../shared/validation/ContractValidators.kt | 4 +- .../com/ohmz/tday/db/tables/CompletedTodos.kt | 4 +- .../kotlin/com/ohmz/tday/db/tables/Todos.kt | 2 +- .../ohmz/tday/routes/CompletedTodoRoutes.kt | 21 +- .../kotlin/com/ohmz/tday/routes/TodoRoutes.kt | 41 +++- .../tday/services/CompletedTodoService.kt | 6 +- .../com/ohmz/tday/services/ListService.kt | 2 +- .../com/ohmz/tday/services/TodoService.kt | 20 +- .../com/ohmz/tday/plugins/RateLimitingTest.kt | 2 +- .../com/ohmz/tday/routes/TodoRoutesTest.kt | 129 ++++++++++- .../calendar/query/get-calendar-todo.ts | 8 +- .../completed/query/get-completedTodo.ts | 4 +- .../src/features/list/query/get-list-todos.ts | 6 +- .../todayTodos/query/get-todo-timeline.ts | 8 +- .../src/features/todayTodos/query/get-todo.ts | 8 +- tday-web/src/lib/date/parseApiDateTime.ts | 9 + tday-web/src/types/index.ts | 6 +- tday-web/vitest.config.ts | 1 + 65 files changed, 1424 insertions(+), 411 deletions(-) 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 b434cb73..31d51e61 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 @@ -48,6 +48,7 @@ import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -55,6 +56,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -92,6 +94,9 @@ import com.ohmz.tday.compose.feature.release.LatestReleaseViewModel import com.ohmz.tday.compose.feature.settings.SettingsScreen import com.ohmz.tday.compose.feature.todos.TodoListScreen import com.ohmz.tday.compose.feature.todos.TodoListViewModel +import com.ohmz.tday.compose.ui.component.RootCreateTaskButton +import com.ohmz.tday.compose.ui.component.RootFeedDock +import com.ohmz.tday.compose.ui.component.RootFeedTab import com.ohmz.tday.compose.ui.theme.TdayTheme import io.sentry.android.navigation.SentryNavigationListener import kotlin.math.roundToInt @@ -150,6 +155,10 @@ fun TdayApp( var activeToast by remember { mutableStateOf(null) } var hasShownLaunchUpdateToast by rememberSaveable { mutableStateOf(false) } var isStartupSplashHeld by remember { mutableStateOf(false) } + var rootFeedTab by rememberSaveable { mutableStateOf(RootFeedTab.HOME) } + var rootCreateTaskRequestKey by rememberSaveable { mutableStateOf(0) } + var rootDockCollapsed by rememberSaveable { mutableStateOf(false) } + var rootControlsVisible by rememberSaveable { mutableStateOf(true) } val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -307,52 +316,123 @@ fun TdayApp( ), ) { if (appUiState.authenticated) { - val homeViewModel: HomeViewModel = hiltViewModel() - val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() - OnRouteResume { - homeViewModel.refreshFromCache() - appViewModel.refreshVersionInfo() - } - HomeScreen( - uiState = homeUiState, - onRefresh = homeViewModel::refresh, - onOpenToday = { navController.navigate(AppRoute.TodayTodos.route) }, - onOpenOverdue = { navController.navigate(AppRoute.OverdueTodos.route) }, - onOpenScheduled = { navController.navigate(AppRoute.ScheduledTodos.route) }, - onOpenAll = { navController.navigate(AppRoute.AllTodos.create()) }, - onOpenPriority = { navController.navigate(AppRoute.PriorityTodos.route) }, - onOpenCompleted = { navController.navigate(AppRoute.Completed.route) }, - onOpenCalendar = { navController.navigate(AppRoute.Calendar.route) }, - onOpenSettings = { navController.navigate(AppRoute.Settings.route) }, - onOpenTaskFromSearch = { todoId -> - navController.currentBackStackEntry - ?.savedStateHandle - ?.set(PENDING_SEARCH_HIGHLIGHT_TODO_ID, todoId) - navController.navigate(AppRoute.AllTodos.create()) - }, - onOpenList = { id, name -> - navController.navigate(AppRoute.ListTodos.create(id, name)) - }, - onCreateTask = { payload -> - homeViewModel.createTask(payload) - }, - onParseTaskTitleNlp = homeViewModel::parseTaskTitleNlp, - onCreateList = { name, color, iconKey -> - homeViewModel.createList( - name = name, - color = color, - iconKey = iconKey, + Box(modifier = Modifier.fillMaxSize()) { + when (rootFeedTab) { + RootFeedTab.HOME -> { + val homeViewModel: HomeViewModel = hiltViewModel() + val homeUiState by homeViewModel.uiState.collectAsStateWithLifecycle() + OnRouteResume { + homeViewModel.refreshFromCache() + appViewModel.refreshVersionInfo() + } + HomeScreen( + uiState = homeUiState, + onRefresh = homeViewModel::refresh, + onOpenToday = { navController.navigate(AppRoute.TodayTodos.route) }, + onOpenOverdue = { navController.navigate(AppRoute.OverdueTodos.route) }, + onOpenScheduled = { navController.navigate(AppRoute.ScheduledTodos.route) }, + onOpenAll = { navController.navigate(AppRoute.AllTodos.create()) }, + onOpenPriority = { navController.navigate(AppRoute.PriorityTodos.route) }, + onOpenCompleted = { navController.navigate(AppRoute.Completed.route) }, + onOpenCalendar = { navController.navigate(AppRoute.Calendar.route) }, + onOpenAnytime = { + rootFeedTab = RootFeedTab.ANYTIME + }, + onOpenSettings = { navController.navigate(AppRoute.Settings.route) }, + onOpenTaskFromSearch = { todoId -> + navController.currentBackStackEntry + ?.savedStateHandle + ?.set( + PENDING_SEARCH_HIGHLIGHT_TODO_ID, + todoId + ) + navController.navigate(AppRoute.AllTodos.create()) + }, + onOpenList = { id, name -> + navController.navigate( + AppRoute.ListTodos.create( + id, + name + ) + ) + }, + onCreateTask = { payload -> + homeViewModel.createTask(payload) + }, + onParseTaskTitleNlp = homeViewModel::parseTaskTitleNlp, + onCreateList = { name, color, iconKey -> + homeViewModel.createList( + name = name, + color = color, + iconKey = iconKey, + ) + }, + onCompleteTask = { todo -> + homeViewModel.completeTodo( + todo + ) + }, + onDeleteTask = { todo -> + homeViewModel.deleteTodo( + todo + ) + }, + onUpdateTask = { todo, payload -> + homeViewModel.updateTask( + todo, + payload + ) + }, + showRootFeedDock = false, + showCreateTaskButton = false, + createTaskRequestKey = rootCreateTaskRequestKey, + onRootDockCollapsedChange = { + rootDockCollapsed = it + }, + onRootControlsVisibleChange = { + rootControlsVisible = it + }, + ) + } + + RootFeedTab.ANYTIME -> { + TodosRoute( + mode = TodoListMode.ANYTIME, + onBack = { rootFeedTab = RootFeedTab.HOME }, + onTaskDeleted = ::showTaskDeletedToast, + showRootFeedDock = false, + showCreateTaskButton = false, + createTaskRequestKey = rootCreateTaskRequestKey, + onRootDockCollapsedChange = { + rootDockCollapsed = it + }, + onRootControlsVisibleChange = { + rootControlsVisible = it + }, + ) + } + } + + if (rootControlsVisible) { + RootFeedDock( + activeTab = rootFeedTab, + collapsed = rootDockCollapsed, + onTabSelected = { tab -> rootFeedTab = tab }, + modifier = Modifier + .align(Alignment.BottomStart) + .zIndex(8f), ) - }, - onCompleteTask = { todo -> homeViewModel.completeTodo(todo) }, - onDeleteTask = { todo -> homeViewModel.deleteTodo(todo) }, - onUpdateTask = { todo, payload -> - homeViewModel.updateTask( - todo, - payload + RootCreateTaskButton( + backgroundColor = Color(0xFF6EA8E1), + onClick = { rootCreateTaskRequestKey += 1 }, + modifier = Modifier + .align(Alignment.BottomEnd) + .navigationBarsPadding() + .padding(end = 18.dp, bottom = 18.dp) + .zIndex(8f), ) - }, - ) + } + } } else { HomeScreen( uiState = UnauthenticatedHomeUiState, @@ -364,6 +444,7 @@ fun TdayApp( onOpenPriority = {}, onOpenCompleted = {}, onOpenCalendar = {}, + onOpenAnytime = {}, onOpenSettings = {}, onOpenTaskFromSearch = {}, onOpenList = { _, _ -> }, @@ -464,6 +545,33 @@ fun TdayApp( } } + composable( + route = AppRoute.AnytimeTodos.route, + deepLinks = listOf(navDeepLink { uriPattern = "tday://anytime" }), + ) { + TodosRoute( + mode = TodoListMode.ANYTIME, + onBack = { + navController.navigate(AppRoute.Home.route) { + popUpTo(AppRoute.Home.route) { inclusive = false } + launchSingleTop = true + } + }, + onTaskDeleted = ::showTaskDeletedToast, + rootFeedTab = RootFeedTab.ANYTIME, + onRootFeedTabSelected = { tab -> + when (tab) { + RootFeedTab.HOME -> navController.navigate(AppRoute.Home.route) { + popUpTo(AppRoute.Home.route) { inclusive = false } + launchSingleTop = true + } + + RootFeedTab.ANYTIME -> Unit + } + }, + ) + } + composable( route = AppRoute.TodayTodos.route, deepLinks = listOf(navDeepLink { uriPattern = "tday://todos/today" }), @@ -826,6 +934,13 @@ private fun TodosRoute( highlightTodoId: String? = null, listId: String? = null, listName: String? = null, + rootFeedTab: RootFeedTab? = null, + onRootFeedTabSelected: ((RootFeedTab) -> Unit)? = null, + showRootFeedDock: Boolean = true, + showCreateTaskButton: Boolean = true, + createTaskRequestKey: Int = 0, + onRootDockCollapsedChange: (Boolean) -> Unit = {}, + onRootControlsVisibleChange: (Boolean) -> Unit = {}, ) { val viewModel: TodoListViewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -868,6 +983,13 @@ private fun TodosRoute( onOptimisticDelete = onListDeleted, ) }, + rootFeedTab = rootFeedTab, + onRootFeedTabSelected = onRootFeedTabSelected, + showRootFeedDock = showRootFeedDock, + showCreateTaskButton = showCreateTaskButton, + createTaskRequestKey = createTaskRequestKey, + onRootDockCollapsedChange = onRootDockCollapsedChange, + onRootControlsVisibleChange = onRootControlsVisibleChange, ) } @@ -1072,6 +1194,7 @@ private val UnauthenticatedHomeUiState = HomeUiState( scheduledCount = 0, allCount = 0, priorityCount = 0, + anytimeCount = 0, completedCount = 0, lists = listOf( ListSummary( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt index 2b5f5e11..14784d49 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt @@ -20,7 +20,7 @@ data class CachedTodoRecord( val title: String, val description: String? = null, val priority: String = "Low", - val dueEpochMs: Long, + val dueEpochMs: Long? = null, val rrule: String? = null, val instanceDateEpochMs: Long? = null, val pinned: Boolean = false, @@ -47,7 +47,7 @@ data class CachedCompletedRecord( val title: String, val description: String? = null, val priority: String, - val dueEpochMs: Long, + val dueEpochMs: Long? = null, val completedAtEpochMs: Long = 0L, val rrule: String? = null, val instanceDateEpochMs: Long? = null, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt index 306a3d0e..9bc0e616 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt @@ -25,7 +25,7 @@ internal fun todoToCache(todo: TodoItem): CachedTodoRecord { title = todo.title, description = todo.description, priority = todo.priority, - dueEpochMs = todo.due.toEpochMilli(), + dueEpochMs = todo.due?.toEpochMilli(), rrule = todo.rrule, instanceDateEpochMs = todo.instanceDateEpochMillis, pinned = todo.pinned, @@ -42,7 +42,7 @@ internal fun todoFromCache(cache: CachedTodoRecord): TodoItem { title = cache.title, description = cache.description, priority = cache.priority, - due = Instant.ofEpochMilli(cache.dueEpochMs), + due = cache.dueEpochMs?.let(Instant::ofEpochMilli), rrule = cache.rrule, instanceDate = cache.instanceDateEpochMs?.let(Instant::ofEpochMilli), pinned = cache.pinned, @@ -108,7 +108,7 @@ internal fun completedToCache(item: CompletedItem): CachedCompletedRecord { title = item.title, description = item.description, priority = item.priority, - dueEpochMs = item.due.toEpochMilli(), + dueEpochMs = item.due?.toEpochMilli(), completedAtEpochMs = item.completedAt?.toEpochMilli() ?: 0L, rrule = item.rrule, instanceDateEpochMs = item.instanceDate?.toEpochMilli(), @@ -125,7 +125,7 @@ internal fun completedFromCache(cache: CachedCompletedRecord): CompletedItem { title = cache.title, description = cache.description, priority = cache.priority, - due = Instant.ofEpochMilli(cache.dueEpochMs), + due = cache.dueEpochMs?.let(Instant::ofEpochMilli), completedAt = if (cache.completedAtEpochMs > 0L) { Instant.ofEpochMilli(cache.completedAtEpochMs) } else { @@ -152,7 +152,7 @@ internal fun mapTodoDto(dto: TodoDto): TodoItem { title = dto.title, description = dto.description, priority = dto.priority, - due = parseInstant(dto.due), + due = parseOptionalInstant(dto.due), rrule = dto.rrule, instanceDate = explicitInstance ?: suffixInstance, pinned = dto.pinned, @@ -169,7 +169,7 @@ internal fun mapCompletedDto(dto: CompletedTodoDto): CompletedItem { title = dto.title, description = dto.description, priority = dto.priority, - due = parseInstant(dto.due), + due = parseOptionalInstant(dto.due), completedAt = parseOptionalInstant(dto.completedAt), rrule = dto.rrule, instanceDate = parseOptionalInstant(dto.instanceDate), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt index a86658e3..adde90d2 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt @@ -119,8 +119,8 @@ class CompletedRepository @Inject constructor( title = normalizedTitle, description = payload.description, priority = normalizedPriority, - dueEpochMs = payload.due.toEpochMilli(), - rrule = payload.rrule, + dueEpochMs = payload.due?.toEpochMilli(), + rrule = payload.rrule?.takeIf { payload.due != null }, listId = normalizedListId, updatedAtEpochMs = timestampMs, ) @@ -143,10 +143,10 @@ class CompletedRepository @Inject constructor( title = normalizedTitle, description = payload.description, priority = normalizedPriority, - dueEpochMs = payload.due.toEpochMilli(), + dueEpochMs = payload.due?.toEpochMilli(), completedAtEpochMs = completed.completedAtEpochMs.takeIf { it > 0L } ?: timestampMs, - rrule = payload.rrule, + rrule = payload.rrule?.takeIf { payload.due != null }, listId = normalizedListId, listName = listMeta?.name, listColor = listMeta?.color, @@ -165,8 +165,8 @@ class CompletedRepository @Inject constructor( title = normalizedTitle, description = payload.description, priority = normalizedPriority, - due = payload.due.toString(), - rrule = payload.rrule, + due = payload.due?.toString(), + rrule = payload.rrule?.takeIf { payload.due != null }, listID = normalizedListId, ), ), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt index 2a3d8a0b..224b1bd7 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt @@ -118,3 +118,67 @@ val MIGRATION_3_4 = object : Migration(3, 4) { ) } } + +val MIGRATION_4_5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `cached_todos_new` ( + `id` TEXT NOT NULL PRIMARY KEY, + `canonicalId` TEXT NOT NULL, + `title` TEXT NOT NULL, + `description` TEXT, + `priority` TEXT NOT NULL, + `dueEpochMs` INTEGER, + `rrule` TEXT, + `instanceDateEpochMs` INTEGER, + `pinned` INTEGER NOT NULL, + `completed` INTEGER NOT NULL, + `listId` TEXT, + `updatedAtEpochMs` INTEGER NOT NULL + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO `cached_todos_new` (`id`,`canonicalId`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`instanceDateEpochMs`,`pinned`,`completed`,`listId`,`updatedAtEpochMs`) + SELECT `id`,`canonicalId`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`instanceDateEpochMs`,`pinned`,`completed`,`listId`,`updatedAtEpochMs` FROM `cached_todos` + """.trimIndent(), + ) + db.execSQL("DROP TABLE `cached_todos`") + db.execSQL("ALTER TABLE `cached_todos_new` RENAME TO `cached_todos`") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_listId` ON `cached_todos` (`listId`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_dueEpochMs` ON `cached_todos` (`dueEpochMs`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_completed` ON `cached_todos` (`completed`)") + + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `cached_completed_new` ( + `id` TEXT NOT NULL PRIMARY KEY, + `originalTodoId` TEXT, + `title` TEXT NOT NULL, + `description` TEXT, + `priority` TEXT NOT NULL, + `dueEpochMs` INTEGER, + `completedAtEpochMs` INTEGER NOT NULL, + `rrule` TEXT, + `instanceDateEpochMs` INTEGER, + `listId` TEXT, + `listName` TEXT, + `listColor` TEXT + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO `cached_completed_new` (`id`,`originalTodoId`,`title`,`description`,`priority`,`dueEpochMs`,`completedAtEpochMs`,`rrule`,`instanceDateEpochMs`,`listId`,`listName`,`listColor`) + SELECT `id`,`originalTodoId`,`title`,`description`,`priority`,`dueEpochMs`,`completedAtEpochMs`,`rrule`,`instanceDateEpochMs`,`listId`,`listName`,`listColor` FROM `cached_completed` + """.trimIndent(), + ) + db.execSQL("DROP TABLE `cached_completed`") + db.execSQL("ALTER TABLE `cached_completed_new` RENAME TO `cached_completed`") + db.execSQL( + "CREATE INDEX IF NOT EXISTS `index_cached_completed_completedAtEpochMs` ON `cached_completed` (`completedAtEpochMs`)", + ) + } +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt index d016df3e..66dc066c 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt @@ -21,7 +21,7 @@ object DatabaseModule { TdayDatabase::class.java, "tday_offline_cache.db", ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) .allowMainThreadQueries() .build() } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt index 4d740605..f17f0196 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt @@ -18,7 +18,7 @@ data class CachedTodoEntity( val title: String, val description: String?, val priority: String, - val dueEpochMs: Long, + val dueEpochMs: Long?, val rrule: String?, val instanceDateEpochMs: Long?, val pinned: Boolean, @@ -48,7 +48,7 @@ data class CachedCompletedEntity( val title: String, val description: String?, val priority: String, - val dueEpochMs: Long, + val dueEpochMs: Long?, val completedAtEpochMs: Long, val rrule: String?, val instanceDateEpochMs: Long?, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt index fdd4ddd4..6802ff73 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt @@ -11,7 +11,7 @@ import androidx.room.RoomDatabase PendingMutationEntity::class, SyncMetadataEntity::class, ], - version = 4, + version = 5, exportSchema = false, ) abstract class TdayDatabase : RoomDatabase() { 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 26bde6ab..a5a5df10 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 @@ -302,10 +302,10 @@ class SyncManager @Inject constructor( title = mutation.title?.trim().orEmpty(), description = mutation.description, priority = mutation.priority ?: "Low", - due = Instant.ofEpochMilli( - mutation.dueEpochMs ?: System.currentTimeMillis(), - ).toString(), - rrule = mutation.rrule, + due = mutation.dueEpochMs?.let { + Instant.ofEpochMilli(it).toString() + }, + rrule = mutation.rrule?.takeIf { mutation.dueEpochMs != null }, listID = resolvedListId, ), ), 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 0a9cec6b..a5ecba06 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 @@ -94,6 +94,7 @@ class TodoRepository @Inject constructor( else -> "Low" } val normalizedDue = payload.due + val normalizedRrule = payload.rrule?.takeIf { normalizedDue != null && it.isNotBlank() } val normalizedDescription = payload.description?.trim()?.ifBlank { null } val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } @@ -108,8 +109,8 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue.toEpochMilli(), - rrule = payload.rrule, + dueEpochMs = normalizedDue?.toEpochMilli(), + rrule = normalizedRrule, instanceDateEpochMs = null, pinned = false, completed = false, @@ -126,8 +127,8 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue.toEpochMilli(), - rrule = payload.rrule, + dueEpochMs = normalizedDue?.toEpochMilli(), + rrule = normalizedRrule, listId = normalizedListId, ), ) @@ -145,8 +146,8 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - due = normalizedDue.toString(), - rrule = payload.rrule, + due = normalizedDue?.toString(), + rrule = normalizedRrule, listID = normalizedListId, ), ), @@ -189,7 +190,7 @@ class TodoRepository @Inject constructor( } val normalizedDue = payload.due val normalizedDescription = payload.description?.trim()?.ifBlank { null } - val normalizedRrule = payload.rrule?.takeIf { it.isNotBlank() } + val normalizedRrule = payload.rrule?.takeIf { normalizedDue != null && it.isNotBlank() } val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } val instanceDateEpochMs = todo.instanceDateEpochMillis val timestampMs = System.currentTimeMillis() @@ -202,7 +203,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue.toEpochMilli(), + dueEpochMs = normalizedDue?.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, instanceDateEpochMs = instanceDateEpochMs, @@ -219,7 +220,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue.toEpochMilli(), + dueEpochMs = normalizedDue?.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, updatedAtEpochMs = timestampMs, @@ -234,7 +235,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue.toEpochMilli(), + dueEpochMs = normalizedDue?.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, timestampEpochMs = timestampMs, @@ -259,7 +260,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue.toEpochMilli(), + dueEpochMs = normalizedDue?.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, updatedAtEpochMs = timestampMs, @@ -296,7 +297,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = descriptionForApi, priority = normalizedPriority, - due = normalizedDue.toString(), + due = normalizedDue?.toString(), ), ), "Could not update recurring task instance", @@ -309,7 +310,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = descriptionForApi, priority = normalizedPriority, - due = normalizedDue.toString(), + due = normalizedDue?.toString(), rrule = rruleForApi, listID = listIdForApi, dateChanged = true, @@ -549,7 +550,7 @@ class TodoRepository @Inject constructor( title = todo.title, description = todo.description, priority = todo.priority, - dueEpochMs = todo.due.toEpochMilli(), + dueEpochMs = todo.due?.toEpochMilli(), completedAtEpochMs = timestampMs, rrule = todo.rrule, instanceDateEpochMs = todo.instanceDateEpochMillis, @@ -619,6 +620,9 @@ class TodoRepository @Inject constructor( TodoListMode.SCHEDULED -> "scheduled" TodoListMode.ALL -> "all" TodoListMode.PRIORITY -> "priority" + TodoListMode.ANYTIME -> throw IllegalStateException( + "Summary is available only for Today, Scheduled, All, and Priority screens", + ) TodoListMode.LIST -> throw IllegalStateException( "Summary is available only for Today, Scheduled, All, and Priority screens", ) @@ -666,6 +670,7 @@ class TodoRepository @Inject constructor( val todayTodos = timelineTodos.filter(::isTodayTodo) val now = Instant.now() val scheduledTodos = timelineTodos.filter { isScheduledTodo(it, now) } + val anytimeTodos = timelineTodos.filter { it.due == null } val completedTodos = state.completedItems.map(::completedFromCache) val todoCountsByList = timelineTodos .groupingBy { it.listId } @@ -680,6 +685,7 @@ class TodoRepository @Inject constructor( scheduledCount = scheduledTodos.size, allCount = timelineTodos.size, priorityCount = timelineTodos.count { isPriorityTodo(it.priority) }, + anytimeCount = anytimeTodos.size, completedCount = completedTodos.size, lists = lists, ) @@ -703,6 +709,7 @@ class TodoRepository @Inject constructor( TodoListMode.ALL -> activeTodos TodoListMode.SCHEDULED -> activeTodos.filter { isScheduledTodo(it, now) } TodoListMode.PRIORITY -> activeTodos.filter { isPriorityTodo(it.priority) } + TodoListMode.ANYTIME -> activeTodos.filter { it.due == null } TodoListMode.LIST -> { if (listId.isNullOrBlank()) emptyList() else activeTodos.filter { it.listId == listId } @@ -713,15 +720,16 @@ class TodoRepository @Inject constructor( private fun isTodayTodo(todo: TodoItem): Boolean { val start = Instant.ofEpochMilli(startOfTodayMillis()) val end = Instant.ofEpochMilli(endOfTodayMillis()) - return todo.due >= start && todo.due <= end + val due = todo.due ?: return false + return due >= start && due <= end } private fun isScheduledTodo(todo: TodoItem, now: Instant = Instant.now()): Boolean { - return !todo.due.isBefore(now) + return todo.due?.isBefore(now) == false } private fun isOverdueTodo(todo: TodoItem, now: Instant = Instant.now()): Boolean { - return todo.due.isBefore(now) + return todo.due?.isBefore(now) == true } private fun isPriorityTodo(priority: String?): Boolean { 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 43bc4741..9633753b 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 @@ -12,6 +12,7 @@ enum class TodoListMode { SCHEDULED, ALL, PRIORITY, + ANYTIME, LIST, } @@ -24,7 +25,7 @@ data class CreateTaskPayload( val title: String, val description: String? = null, val priority: String = "Low", - val due: Instant, + val due: Instant?, val rrule: String? = null, val listId: String? = null, ) @@ -35,7 +36,7 @@ data class TodoItem( val title: String, val description: String?, val priority: String, - val due: Instant, + val due: Instant?, val rrule: String?, val instanceDate: Instant?, val pinned: Boolean, @@ -58,6 +59,7 @@ fun TodoListMode.supportsTaskReschedule(): Boolean { TodoListMode.LIST, -> true + TodoListMode.ANYTIME, TodoListMode.TODAY, TodoListMode.OVERDUE, -> false @@ -85,11 +87,12 @@ fun createMovedTaskPayload( targetDate: LocalDate, zoneId: ZoneId = ZoneId.systemDefault(), ): CreateTaskPayload { + val due = todo.due ?: ZonedDateTime.now(zoneId).toInstant() return CreateTaskPayload( title = todo.title, description = todo.description, priority = todo.priority, - due = movedDuePreservingTime(todo.due, targetDate, zoneId), + due = movedDuePreservingTime(due, targetDate, zoneId), rrule = todo.rrule, listId = todo.listId, ) @@ -143,6 +146,7 @@ data class DashboardSummary( val scheduledCount: Int, val allCount: Int, val priorityCount: Int, + val anytimeCount: Int, val completedCount: Int, val lists: List, ) @@ -153,7 +157,7 @@ data class CompletedItem( val title: String, val description: String? = null, val priority: String, - val due: Instant, + val due: Instant?, val completedAt: Instant? = null, val rrule: String?, val instanceDate: Instant?, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt index 83ad436e..c6e4249f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt @@ -7,6 +7,7 @@ sealed class AppRoute(val route: String) { data object ServerSetup : AppRoute("server-setup") data object Login : AppRoute("login") data object Home : AppRoute("home") + data object AnytimeTodos : AppRoute("anytime") data object TodayTodos : AppRoute("todos/today") data object OverdueTodos : AppRoute("todos/overdue") data object ScheduledTodos : AppRoute("todos/scheduled") diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/notification/TaskReminderScheduler.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/notification/TaskReminderScheduler.kt index 8ce76e0c..b16765db 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/notification/TaskReminderScheduler.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/notification/TaskReminderScheduler.kt @@ -75,7 +75,7 @@ class TaskReminderScheduler @Inject constructor( val intent = Intent(context, TaskReminderReceiver::class.java).apply { putExtra(TaskReminderReceiver.EXTRA_TASK_ID, task.id) putExtra(TaskReminderReceiver.EXTRA_TASK_TITLE, task.title) - putExtra(TaskReminderReceiver.EXTRA_TASK_DUE_MILLIS, task.due.toEpochMilli()) + putExtra(TaskReminderReceiver.EXTRA_TASK_DUE_MILLIS, task.due?.toEpochMilli() ?: -1L) putExtra(TaskReminderReceiver.EXTRA_TASK_PRIORITY, task.priority) putExtra( TaskReminderReceiver.EXTRA_INSTANCE_DATE_MILLIS, @@ -114,7 +114,7 @@ class TaskReminderScheduler @Inject constructor( } private fun computeAlarmTime(task: TodoItem, reminder: ReminderOption): Long? { - val dueMillis = task.due.toEpochMilli() + val dueMillis = task.due?.toEpochMilli() ?: return null if (dueMillis <= 0) return null return dueMillis - reminder.offsetMillis } 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 f84c8029..2332d72b 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 @@ -254,7 +254,9 @@ private fun shouldShowDateDivider( ): 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) + val currentDue = currentTodo.due ?: return false + val nextDue = nextTodo.due ?: return false + return LocalDate.ofInstant(currentDue, zoneId) != LocalDate.ofInstant(nextDue, zoneId) } private data class CalendarTaskRescheduleDrop( @@ -276,7 +278,7 @@ private fun calendarTaskAlreadyDueOnDate( todo: TodoItem, date: LocalDate, zoneId: ZoneId = ZoneId.systemDefault(), -): Boolean = LocalDate.ofInstant(todo.due, zoneId) == date +): Boolean = todo.due?.let { LocalDate.ofInstant(it, zoneId) == date } == true @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -385,8 +387,9 @@ fun CalendarScreen( val calendarTaskRescheduleEnabled = selectedViewMode != CalendarViewMode.DAY val tasksByDate = remember(uiState.items, zoneId) { uiState.items - .groupBy { LocalDate.ofInstant(it.due, zoneId) } - .mapValues { (_, tasks) -> tasks.sortedBy { it.due } } + .mapNotNull { todo -> todo.due?.let { due -> due to todo } } + .groupBy({ (due, _) -> LocalDate.ofInstant(due, zoneId) }, { (_, todo) -> todo }) + .mapValues { (_, tasks) -> tasks.sortedBy { it.due ?: java.time.Instant.MAX } } } val selectedDatePendingTasks = tasksByDate[selectedDate].orEmpty() fun canNavigateTo(date: LocalDate): Boolean = YearMonth.from(date) >= minNavigableMonth @@ -2059,7 +2062,7 @@ private fun CalendarTaskDragPreview( maxLines = 1, ) Text( - text = CalendarTaskDragDueTimeFormatter.format(todo.due), + text = todo.due?.let(CalendarTaskDragDueTimeFormatter::format) ?: "Anytime", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = colorScheme.onSurfaceVariant, @@ -2145,9 +2148,9 @@ private fun CalendarTodoRow( animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), label = "calendarTaskTitleStrikeProgress", ) - val dueText = DateTimeFormatter.ofPattern("h:mm a") - .withZone(ZoneId.systemDefault()) - .format(todo.due) + val dueText = todo.due + ?.let { DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(it) } + ?: "Anytime" val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val showListIndicator = listMeta != null val priorityIcon = priorityIconFor(todo.priority) @@ -2438,9 +2441,9 @@ private fun CalendarCompletedTodoRow( ), label = "calendarCompletedRestoreOffsetY", ) - val dueText = DateTimeFormatter.ofPattern("h:mm a") - .withZone(ZoneId.systemDefault()) - .format(item.due) + val dueText = item.due + ?.let { DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(it) } + ?: "Anytime" val listMeta = item.resolveListSummary(lists) val listIndicatorColor = listMeta?.color?.let(::listAccentColor) ?: item.listColor?.let(::listAccentColor) 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 c228b049..67e1a939 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarViewModel.kt @@ -50,7 +50,8 @@ class CalendarViewModel @Inject constructor( runCatching { CalendarUiState( isLoading = false, - items = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL), + items = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL) + .filter { it.due != null }, completedItems = completedRepository.fetchCompletedItemsSnapshot(), lists = listRepository.fetchListsSnapshot(), errorMessage = null, @@ -96,7 +97,8 @@ class CalendarViewModel @Inject constructor( private fun hydrateFromCache() { runCatching { - val todos = todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL) + val todos = + todoRepository.fetchTodosSnapshot(mode = TodoListMode.ALL).filter { it.due != null } val completedItems = completedRepository.fetchCompletedItemsSnapshot() val lists = listRepository.fetchListsSnapshot() Triple(todos, completedItems, lists) @@ -139,7 +141,8 @@ class CalendarViewModel @Inject constructor( ) .onFailure { /* fall back to cache */ } } - val todos = todoRepository.fetchTodos(mode = TodoListMode.ALL) + val todos = + todoRepository.fetchTodos(mode = TodoListMode.ALL).filter { it.due != null } val completedItems = completedRepository.fetchCompletedItems() val lists = listRepository.fetchLists() Triple(todos, completedItems, lists) @@ -243,7 +246,8 @@ class CalendarViewModel @Inject constructor( } fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { - val movedDue = movedDuePreservingTime(todo.due, targetDate) + val due = todo.due ?: return + val movedDue = movedDuePreservingTime(due, targetDate) val previousState = _uiState.value val updatedTodo = todo.copy(due = movedDue) 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 113af4af..bb92698f 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 @@ -572,7 +572,7 @@ private fun CompletedSwipeRow( ) val completedAtText = COMPLETED_ROW_TIME_FORMATTER .withZone(ZoneId.systemDefault()) - .format(item.completedAt ?: item.due) + .format(item.completedAt ?: item.due ?: Instant.EPOCH) val listMeta = item.resolveListSummary(lists) val listIndicatorColor = listMeta?.color?.let(::listAccentColor) ?: item.listColor?.let(::listAccentColor) @@ -975,7 +975,7 @@ private fun shouldShowDateDivider( return !currentItem.completedDate().isSameLocalDayAs(nextVisibleItem.completedDate(), zoneId) } -private fun CompletedItem.completedDate() = completedAt ?: due +private fun CompletedItem.completedDate() = completedAt ?: due ?: Instant.EPOCH private fun Instant.isSameLocalDayAs(other: Instant, zoneId: ZoneId): Boolean = LocalDate.ofInstant(this, zoneId) == LocalDate.ofInstant(other, zoneId) @@ -985,14 +985,14 @@ private fun buildCompletedTimelineSections( zoneId: ZoneId = ZoneId.systemDefault(), ): List { val groupedByDate = items.groupBy { item -> - LocalDate.ofInstant(item.completedAt ?: item.due, zoneId) + LocalDate.ofInstant(item.completedAt ?: item.due ?: Instant.EPOCH, zoneId) } return groupedByDate.keys .sortedDescending() .map { date -> val sectionItems = groupedByDate[date].orEmpty().sortedWith( - compareByDescending { it.completedAt ?: it.due } + compareByDescending { it.completedAt ?: it.due ?: Instant.EPOCH } .thenBy { it.title.lowercase(Locale.getDefault()) } .thenBy { it.id }, ) 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 677d90b6..e0c7fdcd 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 @@ -150,6 +150,7 @@ import androidx.compose.material3.Text 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.mutableIntStateOf @@ -209,6 +210,8 @@ import com.ohmz.tday.compose.core.ui.TaskSwipeActionButton import com.ohmz.tday.compose.core.ui.animateTaskSwipeOffsetAsState import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet +import com.ohmz.tday.compose.ui.component.RootFeedDock +import com.ohmz.tday.compose.ui.component.RootFeedTab import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox import com.ohmz.tday.compose.ui.theme.TdayDimens import com.ohmz.tday.compose.ui.theme.TdayFontFamily @@ -233,6 +236,7 @@ fun HomeScreen( onOpenPriority: () -> Unit, onOpenCompleted: () -> Unit, onOpenCalendar: () -> Unit, + onOpenAnytime: () -> Unit, onOpenSettings: () -> Unit, onOpenTaskFromSearch: (todoId: String) -> Unit, onOpenList: (listId: String, listName: String) -> Unit, @@ -242,6 +246,11 @@ fun HomeScreen( 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, + showRootFeedDock: Boolean = true, + showCreateTaskButton: Boolean = true, + createTaskRequestKey: Int = 0, + onRootDockCollapsedChange: (Boolean) -> Unit = {}, + onRootControlsVisibleChange: (Boolean) -> Unit = {}, ) { val colorScheme = MaterialTheme.colorScheme val focusManager = LocalFocusManager.current @@ -264,6 +273,9 @@ fun HomeScreen( var searchResultsBounds by remember { mutableStateOf(null) } var rootInRoot by remember { mutableStateOf(Offset.Zero) } var showCreateTask by rememberSaveable { mutableStateOf(false) } + var lastHandledCreateTaskRequestKey by rememberSaveable { + mutableIntStateOf(createTaskRequestKey) + } var editTargetTodoId by rememberSaveable { mutableStateOf(null) } val editTargetTodo = remember(editTargetTodoId, uiState.todayTodos) { editTargetTodoId?.let { id -> uiState.todayTodos.firstOrNull { it.id == id } } @@ -305,6 +317,19 @@ fun HomeScreen( BackHandler(enabled = searchExpanded) { closeSearch() } + LaunchedEffect(createTaskRequestKey) { + if (createTaskRequestKey > lastHandledCreateTaskRequestKey) { + lastHandledCreateTaskRequestKey = createTaskRequestKey + closeSearch() + showCreateTask = true + } + } + LaunchedEffect(searchExpanded) { + onRootControlsVisibleChange(!searchExpanded) + } + DisposableEffect(Unit) { + onDispose { onRootControlsVisibleChange(true) } + } LaunchedEffect(searchExpanded, imeVisible) { if (!searchExpanded) { searchImeWasVisible = false @@ -339,7 +364,7 @@ fun HomeScreen( val normalizedSearchQuery = remember(searchQuery) { searchQuery.trim().lowercase(Locale.getDefault()) } val overdueCount = remember(uiState.searchableTodos) { val now = Instant.now() - uiState.searchableTodos.count { todo -> todo.due.isBefore(now) } + uiState.searchableTodos.count { todo -> todo.due?.isBefore(now) == true } } val dueFormatter = remember { java.time.format.DateTimeFormatter.ofPattern("EEE h:mm a") @@ -357,7 +382,7 @@ fun HomeScreen( (todo.listId?.let { listById[it]?.name }?.lowercase(Locale.getDefault()) ?.contains(normalizedSearchQuery) == true) } - .sortedBy { it.due } + .sortedBy { it.due ?: Instant.MAX } .take(20) .toList() } @@ -365,6 +390,11 @@ fun HomeScreen( val showSearchResultsOverlay = searchExpanded && searchQuery.isNotBlank() val density = LocalDensity.current val listState = rememberLazyListState() + val dockCollapsed = + listState.isScrollInProgress || listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 18 + LaunchedEffect(dockCollapsed) { + onRootDockCollapsedChange(dockCollapsed) + } LaunchedEffect(listState.isScrollInProgress, searchExpanded) { if (searchExpanded || listState.isScrollInProgress) return@LaunchedEffect // Snap only when top header row is partially visible. @@ -452,63 +482,67 @@ fun HomeScreen( Scaffold( containerColor = colorScheme.background, floatingActionButton = { - CreateTaskButton( - modifier = Modifier - .offset(y = fabOffsetY) - .graphicsLayer { - scaleX = fabScale - scaleY = fabScale + if (showCreateTaskButton) { + CreateTaskButton( + modifier = Modifier + .offset(y = fabOffsetY) + .graphicsLayer { + scaleX = fabScale + scaleY = fabScale + }, + interactionSource = fabInteractionSource, + onClick = { + showCreateTask = true }, - interactionSource = fabInteractionSource, - onClick = { - showCreateTask = true - }, - ) + ) + } }, ) { padding -> - CompositionLocalProvider(LocalOverscrollConfiguration provides null) { - TdayPullToRefreshBox( - isRefreshing = uiState.isLoading, - onRefresh = onRefresh, - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - Box( + Box(modifier = Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalOverscrollConfiguration provides null) { + TdayPullToRefreshBox( + isRefreshing = uiState.isLoading, + onRefresh = onRefresh, modifier = Modifier .fillMaxSize() - .then( - if (searchExpanded) { - Modifier - .onGloballyPositioned { coordinates -> - val topLeft = coordinates.boundsInRoot().topLeft - if (rootInRoot != topLeft) { - rootInRoot = topLeft + .padding(padding), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .then( + if (searchExpanded) { + Modifier + .onGloballyPositioned { coordinates -> + val topLeft = coordinates.boundsInRoot().topLeft + if (rootInRoot != topLeft) { + rootInRoot = topLeft + } } - } - .pointerInput( - searchBarBounds, - searchResultsBounds, - rootInRoot - ) { - awaitEachGesture { - val down = awaitFirstDown(pass = PointerEventPass.Final) - val tapInRoot = down.position + rootInRoot - val tappedSearchBar = - searchBarBounds?.contains(tapInRoot) == true - val tappedSearchResults = - searchResultsBounds?.contains(tapInRoot) == true - val up = - waitForUpOrCancellation(pass = PointerEventPass.Final) - if (up != null && !tappedSearchBar && !tappedSearchResults) { - closeSearch() + .pointerInput( + searchBarBounds, + searchResultsBounds, + rootInRoot + ) { + awaitEachGesture { + val down = + awaitFirstDown(pass = PointerEventPass.Final) + val tapInRoot = down.position + rootInRoot + val tappedSearchBar = + searchBarBounds?.contains(tapInRoot) == true + val tappedSearchResults = + searchResultsBounds?.contains(tapInRoot) == true + val up = + waitForUpOrCancellation(pass = PointerEventPass.Final) + if (up != null && !tappedSearchBar && !tappedSearchResults) { + closeSearch() + } } } - } - } else { - Modifier - } - ), + } else { + Modifier + } + ), ) { LazyColumn( state = listState, @@ -726,7 +760,12 @@ fun HomeScreen( .semantics(mergeDescendants = true) {} .heightIn(min = 48.dp) .clickable { - openTaskFromSearch(todo.id) + if (todo.due == null) { + closeSearch() + onOpenAnytime() + } else { + openTaskFromSearch(todo.id) + } } .padding(horizontal = 12.dp, vertical = 9.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -748,7 +787,8 @@ fun HomeScreen( fontWeight = FontWeight.ExtraBold, ) Text( - text = dueFormatter.format(todo.due), + text = todo.due?.let(dueFormatter::format) + ?: "Anytime", style = MaterialTheme.typography.bodySmall, color = colorScheme.onSurfaceVariant, maxLines = 1, @@ -777,8 +817,25 @@ fun HomeScreen( } } } + + } } } + if (showRootFeedDock && !searchExpanded) { + RootFeedDock( + activeTab = RootFeedTab.HOME, + collapsed = dockCollapsed, + onTabSelected = { tab -> + if (tab == RootFeedTab.ANYTIME) { + closeSearch() + onOpenAnytime() + } + }, + modifier = Modifier + .align(Alignment.BottomStart) + .zIndex(8f), + ) + } } } @@ -839,7 +896,9 @@ private fun shouldShowDateDivider( ): 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) + val currentDue = currentTodo.due ?: return false + val nextDue = nextTodo.due ?: return false + return LocalDate.ofInstant(currentDue, zoneId) != LocalDate.ofInstant(nextDue, zoneId) } @Composable @@ -1745,12 +1804,12 @@ private fun HomeTodayTaskRow( label = "homeTodayTitleStrikeProgress", ) val actionRevealProgress = swipeRevealState.revealProgress(animatedOffsetX) - val dueText = HOME_TODAY_DUE_FORMATTER.format(todo.due) + val dueText = todo.due?.let(HOME_TODAY_DUE_FORMATTER::format) ?: "Anytime" val rowShape = RoundedCornerShape(16.dp) val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val listIndicatorColor = listColorAccent(listMeta?.color) val priorityIcon = priorityIconFor(todo.priority) - val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) + val isOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true val subtitleColor = if (isOverdue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant.copy( alpha = 0.8f 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 4085ebac..931903ed 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 @@ -31,6 +31,7 @@ data class HomeUiState( scheduledCount = 0, allCount = 0, priorityCount = 0, + anytimeCount = 0, completedCount = 0, lists = emptyList(), ), 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 c7353226..59554550 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 @@ -211,6 +211,8 @@ import com.ohmz.tday.compose.core.ui.animateTaskSwipeOffsetAsState import com.ohmz.tday.compose.core.ui.rememberLazyListCollapsingTitleScrollBehavior import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet +import com.ohmz.tday.compose.ui.component.RootFeedDock +import com.ohmz.tday.compose.ui.component.RootFeedTab import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -261,6 +263,13 @@ fun TodoListScreen( onDelete: (todo: TodoItem) -> Unit, onUpdateListSettings: (listId: String, name: String, color: String?, iconKey: String?) -> Unit, onDeleteList: (listId: String) -> Unit, + rootFeedTab: RootFeedTab? = null, + onRootFeedTabSelected: ((RootFeedTab) -> Unit)? = null, + showRootFeedDock: Boolean = true, + showCreateTaskButton: Boolean = true, + createTaskRequestKey: Int = 0, + onRootDockCollapsedChange: (Boolean) -> Unit = {}, + onRootControlsVisibleChange: (Boolean) -> Unit = {}, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -268,7 +277,7 @@ fun TodoListScreen( val selectedList = uiState.lists.firstOrNull { it.id == uiState.listId } val selectedListColorKey = selectedList?.color val usesTodayStyle = - uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST + uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.ANYTIME || uiState.mode == TodoListMode.LIST val titleColor = modeAccentColor( mode = uiState.mode, listColorKey = selectedListColorKey, @@ -282,7 +291,7 @@ fun TodoListScreen( listIconKey = selectedList?.iconKey, ) val showSectionedTimeline = - uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST + uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.ANYTIME || uiState.mode == TodoListMode.LIST val suppressInitialTodayTimeline = uiState.mode == TodoListMode.TODAY && !uiState.hasHydratedSnapshot && @@ -315,6 +324,17 @@ fun TodoListScreen( val timelineAnimationsEnabled = uiState.mode != TodoListMode.TODAY || timelineAnimationsReady val listState = rememberLazyListState() + val dockCollapsed = + listState.isScrollInProgress || listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 18 + LaunchedEffect(dockCollapsed) { + onRootDockCollapsedChange(dockCollapsed) + } + LaunchedEffect(Unit) { + onRootControlsVisibleChange(true) + } + DisposableEffect(Unit) { + onDispose { onRootControlsVisibleChange(true) } + } val density = LocalDensity.current val todayTitleScrollBehavior = rememberLazyListCollapsingTitleScrollBehavior( listState = listState, @@ -327,6 +347,9 @@ fun TodoListScreen( uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } + var lastHandledCreateTaskRequestKey by rememberSaveable { + mutableStateOf(createTaskRequestKey) + } var collapsedSectionKeys by rememberSaveable(uiState.mode, uiState.listId, highlightedTodoId) { mutableStateOf( if (isCollapsibleTimelineMode && highlightedTodoId.isNullOrBlank()) { @@ -345,6 +368,13 @@ fun TodoListScreen( val timelineDropTargetBounds = remember(uiState.mode) { mutableStateMapOf() } var pendingRescheduleDrop by remember(uiState.mode) { mutableStateOf(null) } + LaunchedEffect(createTaskRequestKey) { + if (createTaskRequestKey > lastHandledCreateTaskRequestKey) { + lastHandledCreateTaskRequestKey = createTaskRequestKey + quickAddDueEpochMs = null + showCreateTaskSheet = true + } + } var showListSettingsSheet by rememberSaveable { mutableStateOf(false) } var showDeleteListConfirmation by rememberSaveable { mutableStateOf(false) } var showSummarySheet by rememberSaveable(uiState.mode) { mutableStateOf(false) } @@ -363,12 +393,14 @@ fun TodoListScreen( uiState.items.firstOrNull { it.id == targetId || it.canonicalId == targetId } } } - val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = { todo, targetDate -> + val requestTaskReschedule: (TodoItem, LocalDate) -> Unit = + requestTaskReschedule@{ todo, targetDate -> draggedScheduledTodoId = null activeDropSectionKey = null activeTimelineDrag = null timelineDropTargetBounds.clear() - val currentDate = LocalDate.ofInstant(todo.due, zoneId) + val currentDue = todo.due ?: return@requestTaskReschedule + val currentDate = LocalDate.ofInstant(currentDue, zoneId) if (currentDate != targetDate) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) if (todo.isRecurring) { @@ -381,6 +413,7 @@ fun TodoListScreen( val canSummarizeCurrentMode = uiState.mode != TodoListMode.LIST && uiState.mode != TodoListMode.OVERDUE && + uiState.mode != TodoListMode.ANYTIME && uiState.aiSummaryEnabled val showTopBarActionButton = canSummarizeCurrentMode || uiState.mode == TodoListMode.LIST val fabPressed by fabInteractionSource.collectIsPressedAsState() @@ -467,7 +500,8 @@ fun TodoListScreen( fun canDropTodoInTimelineSection(todo: TodoItem, section: TodoSection): Boolean { val targetDate = section.targetDate ?: return false if (originSectionKeyFor(todo) == section.key) return false - return LocalDate.ofInstant(todo.due, zoneId) != targetDate + val due = todo.due ?: return false + return LocalDate.ofInstant(due, zoneId) != targetDate } fun timelineDropSectionKeyAt(position: Offset, todo: TodoItem): String? { @@ -566,20 +600,22 @@ fun TodoListScreen( } }, floatingActionButton = { - CreateTaskButton( - modifier = Modifier - .offset(y = fabOffsetY) - .graphicsLayer { - scaleX = fabScale - scaleY = fabScale + if (showCreateTaskButton) { + CreateTaskButton( + modifier = Modifier + .offset(y = fabOffsetY) + .graphicsLayer { + scaleX = fabScale + scaleY = fabScale + }, + interactionSource = fabInteractionSource, + backgroundColor = fabColor, + onClick = { + quickAddDueEpochMs = null + showCreateTaskSheet = true }, - interactionSource = fabInteractionSource, - backgroundColor = fabColor, - onClick = { - quickAddDueEpochMs = null - showCreateTaskSheet = true - }, - ) + ) + } }, ) { padding -> Box( @@ -890,6 +926,17 @@ fun TodoListScreen( ) } + if (showRootFeedDock && rootFeedTab != null && onRootFeedTabSelected != null) { + RootFeedDock( + activeTab = rootFeedTab, + collapsed = dockCollapsed, + onTabSelected = onRootFeedTabSelected, + modifier = Modifier + .align(Alignment.BottomStart) + .zIndex(8f), + ) + } + activeTimelineDrag?.let { drag -> TimelineTaskDragPreview( modifier = Modifier @@ -914,6 +961,7 @@ fun TodoListScreen( lists = uiState.lists, defaultListId = if (uiState.mode == TodoListMode.LIST) uiState.listId else null, defaultPriority = if (uiState.mode == TodoListMode.PRIORITY) "Medium" else null, + defaultScheduled = uiState.mode != TodoListMode.ANYTIME, initialDueEpochMs = quickAddDueEpochMs, onParseTaskTitleNlp = onParseTaskTitleNlp, onDismiss = { @@ -2089,7 +2137,7 @@ private fun TimelineTaskDragPreview( maxLines = 1, ) Text( - text = TODO_DUE_TIME_FORMATTER.format(todo.due), + text = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Anytime", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = colorScheme.onSurfaceVariant, @@ -2163,6 +2211,7 @@ private fun TimelineTaskRow( mode == TodoListMode.OVERDUE || mode == TodoListMode.SCHEDULED || mode == TodoListMode.PRIORITY || + mode == TodoListMode.ANYTIME || mode == TodoListMode.LIST ) ) { @@ -2266,7 +2315,9 @@ private fun shouldShowDateDivider( 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 currentDue = currentTodo.due ?: return false + val nextDue = nextTodoInSection.due ?: return false + return !currentDue.isSameLocalDayAs(nextDue, zoneId) } val nextVisibleTodo = sections @@ -2277,7 +2328,9 @@ private fun shouldShowDateDivider( .firstOrNull() ?: return false - return !currentTodo.due.isSameLocalDayAs(nextVisibleTodo.due, zoneId) + val currentDue = currentTodo.due ?: return false + val nextDue = nextVisibleTodo.due ?: return false + return !currentDue.isSameLocalDayAs(nextDue, zoneId) } private fun Instant.isSameLocalDayAs(other: Instant, zoneId: ZoneId): Boolean = @@ -2318,6 +2371,8 @@ private fun buildTimelineSections( includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) + TodoListMode.ANYTIME -> buildAnytimeSections(items) + TodoListMode.LIST -> buildScheduledSections( items = items, zoneId = zoneId, @@ -2335,13 +2390,14 @@ private fun buildOverdueSections( val now = Instant.now() val today = LocalDate.now(zoneId) val overdueByDate = items.asSequence() - .filter { todo -> todo.due.isBefore(now) } - .groupBy { todo -> LocalDate.ofInstant(todo.due, zoneId) } + .mapNotNull { todo -> todo.due?.let { due -> due to todo } } + .filter { (due, _) -> due.isBefore(now) } + .groupBy({ (due, _) -> LocalDate.ofInstant(due, zoneId) }, { (_, todo) -> todo }) val sections = mutableListOf() overdueByDate[today] - ?.sortedBy { it.due } + ?.sortedBy { it.due ?: Instant.MAX } ?.takeIf { it.isNotEmpty() } ?.let { todaysItems -> sections += TodoSection( @@ -2363,7 +2419,7 @@ private fun buildOverdueSections( sections += TodoSection( key = "day-$date", title = date.format(SCHEDULED_DAY_FORMATTER), - items = overdueByDate[date].orEmpty().sortedBy { it.due }, + items = overdueByDate[date].orEmpty().sortedBy { it.due ?: Instant.MAX }, quickAddDefaults = null, ) } @@ -2375,12 +2431,12 @@ private fun buildTodaySections( items: List, zoneId: ZoneId, ): List { - val sorted = items.sortedBy { it.due } + val sorted = items.filter { it.due != null }.sortedBy { it.due ?: Instant.MAX } val noon = LocalTime.NOON val eveningStartBoundary = LocalTime.of(18, 0) fun sectionOf(todo: TodoItem): TodaySectionSlot { - val dueTime = todo.due.atZone(zoneId).toLocalTime() + val dueTime = todo.due?.atZone(zoneId)?.toLocalTime() ?: LocalTime.NOON return when { // Requested boundaries: // Morning: 12:01 AM -> 12:00 PM (inclusive of 12:00 PM) @@ -2423,6 +2479,36 @@ private fun buildTodaySections( ) } +private fun buildAnytimeSections(items: List): List { + val anytimeItems = items.filter { it.due == null } + val priorityItems = anytimeItems + .filter { + it.pinned || it.priority.equals( + "High", + ignoreCase = true + ) || it.priority.equals("Medium", ignoreCase = true) + } + .sortedWith(compareByDescending { it.pinned }.thenBy { it.title.lowercase(Locale.getDefault()) }) + val laterItems = anytimeItems + .filterNot { it in priorityItems } + .sortedBy { it.title.lowercase(Locale.getDefault()) } + + return listOfNotNull( + priorityItems.takeIf { it.isNotEmpty() }?.let { + TodoSection( + key = "anytime-priority", + title = "Priority", + items = it, + ) + }, + TodoSection( + key = "anytime-open", + title = "Open", + items = laterItems, + ), + ) +} + private fun buildScheduledSections( items: List, zoneId: ZoneId, @@ -2431,11 +2517,13 @@ private fun buildScheduledSections( includeEmptyEarlierTarget: Boolean = false, ): List { val now = Instant.now() - val sorted = items.asSequence().filter { todo -> - if (futureOnly) !todo.due.isBefore(now) else true - }.sortedBy { it.due }.toList() + val sorted = items.asSequence().mapNotNull { todo -> + todo.due?.let { due -> due to todo } + }.filter { (due, _) -> + if (futureOnly) !due.isBefore(now) else true + }.sortedBy { (due, _) -> due }.map { (_, todo) -> todo }.toList() val groupedByDate = sorted.groupBy { todo -> - LocalDate.ofInstant(todo.due, zoneId) + LocalDate.ofInstant(todo.due ?: Instant.MAX, zoneId) } val today = LocalDate.now(zoneId) val horizonStart = today.plusDays(7) @@ -2471,7 +2559,8 @@ private fun buildScheduledSections( val earlierSection = if (!futureOnly) { val earlierItems = groupedByDate.asSequence().filter { (date, _) -> date < today } - .flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList() + .flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due ?: Instant.MAX } + .toList() if (earlierItems.isNotEmpty() || includeEmptyEarlierTarget) { TodoSection( key = "earlier", @@ -2509,7 +2598,7 @@ private fun buildScheduledSections( val restOfCurrentMonthItems = groupedByDate.asSequence().filter { (date, _) -> date >= horizonStart && YearMonth.from(date) == currentMonth - }.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList() + }.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due ?: Instant.MAX }.toList() val monthName = currentMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) sections += TodoSection( key = "rest-$currentMonth", @@ -2535,7 +2624,8 @@ private fun buildScheduledSections( while (targetMonth <= finalMonth) { val monthItems = groupedByDate.asSequence().filter { (date, _) -> date >= horizonStart && YearMonth.from(date) == targetMonth - }.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due }.toList() + }.flatMap { (_, dayItems) -> dayItems.asSequence() }.sortedBy { it.due ?: Instant.MAX } + .toList() sections += TodoSection( key = "month-$targetMonth", title = monthTitle(targetMonth, currentMonth.year), @@ -2599,6 +2689,7 @@ private fun emptyStateMessageForMode(mode: TodoListMode): String { TodoListMode.TODAY -> stringResource(R.string.todos_empty_today) TodoListMode.OVERDUE -> stringResource(R.string.todos_empty_overdue) TodoListMode.PRIORITY -> stringResource(R.string.todos_empty_priority) + TodoListMode.ANYTIME -> "No anytime tasks" TodoListMode.SCHEDULED -> stringResource(R.string.todos_empty_scheduled) TodoListMode.ALL -> stringResource(R.string.todos_empty_all) TodoListMode.LIST -> stringResource(R.string.todos_empty_list) @@ -2613,6 +2704,7 @@ private fun emptyStateIconForMode( TodoListMode.TODAY -> Icons.Rounded.WbSunny TodoListMode.OVERDUE -> Icons.Rounded.ErrorOutline TodoListMode.PRIORITY -> Icons.Rounded.Flag + TodoListMode.ANYTIME -> Icons.Rounded.Inventory TodoListMode.SCHEDULED -> Icons.Rounded.Schedule TodoListMode.ALL -> Icons.Rounded.Inbox TodoListMode.LIST -> listIconForKey(listIconKey) @@ -2885,9 +2977,9 @@ private fun SwipeTaskRow( animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), label = "swipeTaskTitleStrikeProgress", ) - val dueTimeText = TODO_DUE_TIME_FORMATTER.format(todo.due) - val dueDateTimeText = TODO_DUE_DATE_TIME_FORMATTER.format(todo.due) - val isOverdue = !todo.completed && todo.due.isBefore(Instant.now()) + val dueTimeText = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Anytime" + val dueDateTimeText = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) ?: "Anytime" + val isOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true val dueBodyText = if (showDueDateInSubtitle) dueDateTimeText else dueTimeText val dueSubtitleText = if (isOverdue) { stringResource(R.string.todos_due_overdue_text, dueBodyText) @@ -2924,6 +3016,7 @@ private fun SwipeTaskRow( TodoListMode.OVERDUE, TodoListMode.SCHEDULED, TodoListMode.PRIORITY, + TodoListMode.ANYTIME, TodoListMode.ALL, -> listMeta != null @@ -3223,8 +3316,8 @@ private fun TodayTodoRow( onDelete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val dueText = TODO_DUE_TIME_FORMATTER.format(todo.due) - val isDetailOverdue = !todo.completed && todo.due.isBefore(Instant.now()) + val dueText = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Anytime" + val isDetailOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true val detailDueText = if (isDetailOverdue) { stringResource(R.string.todos_due_overdue_text, dueText) } else { @@ -3297,7 +3390,7 @@ private fun TodoRow( onDelete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val due = TODO_DUE_DATE_TIME_FORMATTER.format(todo.due) + val due = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) ?: "Anytime" Card( colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant), @@ -3421,6 +3514,7 @@ private fun modeAccentColor( TodoListMode.SCHEDULED -> Color(0xFFF29F38) TodoListMode.ALL -> Color(0xFF5E6878) TodoListMode.PRIORITY -> Color(0xFFE65E52) + TodoListMode.ANYTIME -> Color(0xFF4D8F83) TodoListMode.LIST -> listAccentColor(listColorKey) } } 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 b3f6581a..93ff0a59 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 @@ -97,6 +97,7 @@ class TodoListViewModel @Inject constructor( TodoListMode.SCHEDULED -> "Scheduled" TodoListMode.ALL -> "All Tasks" TodoListMode.PRIORITY -> "Priority" + TodoListMode.ANYTIME -> "Anytime" TodoListMode.LIST -> listName ?: "List" }, aiSummaryEnabled = settingsRepository.isAiSummaryEnabledSnapshot(), @@ -119,7 +120,7 @@ class TodoListViewModel @Inject constructor( _uiState.update { it.copy(summaryError = "AI summary is disabled by admin") } return } - if (current.mode == TodoListMode.LIST || current.mode == TodoListMode.OVERDUE) { + if (current.mode == TodoListMode.LIST || current.mode == TodoListMode.OVERDUE || current.mode == TodoListMode.ANYTIME) { _uiState.update { it.copy(summaryError = "Summary is available only for Today, Scheduled, All, and Priority") } @@ -303,7 +304,8 @@ class TodoListViewModel @Inject constructor( } fun moveTask(todo: TodoItem, targetDate: LocalDate, scope: TaskRescheduleScope) { - val movedDue = movedDuePreservingTime(todo.due, targetDate) + val due = todo.due ?: return + val movedDue = movedDuePreservingTime(due, targetDate) val previousState = _uiState.value val mode = previousState.mode val currentListId = previousState.listId diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt index 209d6f09..1f3c4051 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt @@ -25,7 +25,6 @@ import androidx.glance.layout.Row import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxSize import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.size import androidx.glance.layout.width @@ -60,8 +59,8 @@ class TodayTasksWidget : GlanceAppWidget() { val dayEnd = today.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() val todayTasks = state.todos - .filter { !it.completed && it.dueEpochMs in dayStart until dayEnd } - .sortedBy { it.dueEpochMs } + .filter { task -> !task.completed && task.dueEpochMs?.let { it in dayStart until dayEnd } == true } + .sortedBy { it.dueEpochMs ?: Long.MAX_VALUE } .take(8) provideContent { @@ -142,7 +141,9 @@ private fun WidgetContent(tasks: List) { private fun TaskRow(task: CachedTodoRecord) { val timeFormatter = DateTimeFormatter.ofPattern("h:mm a") .withZone(ZoneId.systemDefault()) - val dueText = timeFormatter.format(Instant.ofEpochMilli(task.dueEpochMs)) + val dueText = task.dueEpochMs + ?.let { timeFormatter.format(Instant.ofEpochMilli(it)) } + ?: "Anytime" val priorityColor = when (task.priority.lowercase()) { "high" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFE53935)) "medium" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFFB8C00)) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt index 37a2a896..5e6b0744 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt @@ -55,6 +55,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SelectableDates import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TimePicker @@ -133,6 +135,7 @@ fun CreateTaskBottomSheet( editingTask: TodoItem? = null, defaultListId: String? = null, defaultPriority: String? = null, + defaultScheduled: Boolean = true, initialDueEpochMs: Long? = null, onParseTaskTitleNlp: (suspend ( title: String, @@ -180,6 +183,9 @@ fun CreateTaskBottomSheet( var dueEpochMs by rememberSaveable(editingTask?.id, initialDueEpochMs) { mutableStateOf(resolvedDueEpochMs) } + var scheduleEnabled by rememberSaveable(editingTask?.id, defaultScheduled) { + mutableStateOf(editingTask?.due != null || (editingTask == null && defaultScheduled)) + } LaunchedEffect(title, onParseTaskTitleNlp) { val nlpParser = onParseTaskTitleNlp ?: return@LaunchedEffect val inputTitle = title.trim() @@ -199,6 +205,9 @@ fun CreateTaskBottomSheet( if (cleanTitle != title) { title = cleanTitle } + if (!scheduleEnabled) { + scheduleEnabled = true + } if (parsedDueEpochMs != dueEpochMs) { dueEpochMs = parsedDueEpochMs } @@ -206,12 +215,21 @@ fun CreateTaskBottomSheet( var selectedRepeat by rememberSaveable(editingTask?.id) { mutableStateOf(repeatPresetFromRrule(editingTask?.rrule).name) } + LaunchedEffect(scheduleEnabled) { + if (!scheduleEnabled) { + selectedRepeat = RepeatPreset.NONE.name + } + } var dueDatePickerOpen by rememberSaveable { mutableStateOf(false) } var dueTimePickerOpen by rememberSaveable { mutableStateOf(false) } var sheetVisible by remember { mutableStateOf(false) } val selectedListName = lists.firstOrNull { it.id == selectedListId }?.name ?: "No list" - val repeatPreset = RepeatPreset.valueOf(selectedRepeat) + val repeatPreset = if (scheduleEnabled) { + RepeatPreset.valueOf(selectedRepeat) + } else { + RepeatPreset.NONE + } val canSubmit = title.isNotBlank() val colorScheme = MaterialTheme.colorScheme val isDarkTheme = colorScheme.background.luminance() < 0.5f @@ -247,14 +265,14 @@ fun CreateTaskBottomSheet( } fun submitTask() { - val due = Instant.ofEpochMilli(dueEpochMs) + val due = if (scheduleEnabled) Instant.ofEpochMilli(dueEpochMs) else null val payload = CreateTaskPayload( title = title.trim(), description = notes.trim().ifBlank { null }, priority = selectedPriority, due = due, - rrule = repeatPreset.rrule, + rrule = repeatPreset.rrule?.takeIf { scheduleEnabled }, listId = selectedListId, ) val editing = editingTask @@ -352,14 +370,31 @@ fun CreateTaskBottomSheet( SectionHeading("Schedule") GroupCard { - SplitDateTimeRow( - icon = Icons.Rounded.CalendarMonth, - title = "Due", - dateValue = dateOnlyFormatter.format(Instant.ofEpochMilli(dueEpochMs)), - timeValue = timeOnlyFormatter.format(Instant.ofEpochMilli(dueEpochMs)), - onDateClick = { dueDatePickerOpen = true }, - onTimeClick = { dueTimePickerOpen = true }, + ScheduleSwitchRow( + enabled = scheduleEnabled, + onEnabledChange = { enabled -> scheduleEnabled = enabled }, ) + AnimatedVisibility(visible = scheduleEnabled) { + Column { + RowDivider() + SplitDateTimeRow( + icon = Icons.Rounded.CalendarMonth, + title = "Due", + dateValue = dateOnlyFormatter.format( + Instant.ofEpochMilli( + dueEpochMs + ) + ), + timeValue = timeOnlyFormatter.format( + Instant.ofEpochMilli( + dueEpochMs + ) + ), + onDateClick = { dueDatePickerOpen = true }, + onTimeClick = { dueTimePickerOpen = true }, + ) + } + } } SectionHeading("Details") @@ -397,7 +432,9 @@ fun CreateTaskBottomSheet( icon = Icons.Rounded.Repeat, title = "Repeat", value = repeatPreset.label, - options = RepeatPreset.entries.toList(), + options = if (scheduleEnabled) RepeatPreset.entries.toList() else listOf( + RepeatPreset.NONE + ), optionLabel = { option -> option.label }, optionSwatchColor = { option -> repeatSwatchColor(option) }, isSelected = { option -> selectedRepeat == option.name }, @@ -758,6 +795,54 @@ private fun SplitDateTimeRow( } } +@Composable +private fun ScheduleSwitchRow( + enabled: Boolean, + onEnabledChange: (Boolean) -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onEnabledChange(!enabled) } + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Schedule, + contentDescription = null, + tint = colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + Spacer(modifier = Modifier.size(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Due date", + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + ) + Text( + text = if (enabled) "Scheduled" else "Anytime", + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + } + Switch( + checked = enabled, + onCheckedChange = onEnabledChange, + colors = SwitchDefaults.colors( + checkedThumbColor = colorScheme.onPrimary, + checkedTrackColor = colorScheme.primary, + uncheckedThumbColor = colorScheme.onSurfaceVariant, + uncheckedTrackColor = colorScheme.surfaceVariant, + ), + ) + } +} + @Composable private fun SheetRow( icon: ImageVector, diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt index ed1ac980..0379a593 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt @@ -37,7 +37,7 @@ class CacheMappersTest { assertEquals(todo.title, cached.title) assertEquals(todo.description, cached.description) assertEquals(todo.priority, cached.priority) - assertEquals(todo.due.toEpochMilli(), cached.dueEpochMs) + assertEquals(todo.due?.toEpochMilli(), cached.dueEpochMs) assertEquals(todo.rrule, cached.rrule) assertEquals(todo.instanceDateEpochMillis, cached.instanceDateEpochMs) assertEquals(todo.pinned, cached.pinned) @@ -56,7 +56,7 @@ class CacheMappersTest { assertEquals(cached.title, todo.title) assertEquals(cached.description, todo.description) assertEquals(cached.priority, todo.priority) - assertEquals(Instant.ofEpochMilli(cached.dueEpochMs), todo.due) + assertEquals(cached.dueEpochMs?.let(Instant::ofEpochMilli), todo.due) assertEquals(cached.rrule, todo.rrule) assertNotNull(todo.instanceDate) assertEquals(cached.instanceDateEpochMs, todo.instanceDateEpochMillis) @@ -133,7 +133,7 @@ class CacheMappersTest { assertEquals(item.originalTodoId, cached.originalTodoId) assertEquals(item.title, cached.title) assertEquals(item.priority, cached.priority) - assertEquals(item.due.toEpochMilli(), cached.dueEpochMs) + assertEquals(item.due?.toEpochMilli(), cached.dueEpochMs) assertEquals(item.completedAt?.toEpochMilli() ?: 0L, cached.completedAtEpochMs) assertEquals(item.listId, cached.listId) } diff --git a/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift b/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift index ca33e26b..a1bb0755 100644 --- a/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift @@ -35,8 +35,8 @@ final class CompletedRepository { title: item.title, description: item.description, priority: item.priority, - dueEpochMs: item.due.epochMilliseconds, - rrule: item.rrule, + dueEpochMs: item.due?.epochMilliseconds, + rrule: item.due == nil ? nil : item.rrule, instanceDateEpochMs: item.instanceDate?.epochMilliseconds, pinned: false, completed: false, @@ -94,9 +94,9 @@ final class CompletedRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due.epochMilliseconds, + dueEpochMs: payload.due?.epochMilliseconds, completedAtEpochMs: current.completedAtEpochMs, - rrule: payload.rrule, + rrule: payload.due == nil ? nil : payload.rrule, instanceDateEpochMs: current.instanceDateEpochMs, listId: normalizedListID, listName: state.lists.first(where: { $0.id == payload.listId })?.name, @@ -113,8 +113,8 @@ final class CompletedRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - due: payload.due.ISO8601Format(), - rrule: payload.rrule, + due: payload.due?.ISO8601Format(), + rrule: payload.due == nil ? nil : payload.rrule, listID: normalizedListID ) ) diff --git a/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift b/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift index 884b300b..a6d95adf 100644 --- a/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift +++ b/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift @@ -8,7 +8,7 @@ final class CachedTodoEntity { var title: String var itemDescription: String? var priority: String - var dueEpochMs: Int64 + var dueEpochMs: Int64? var rrule: String? var instanceDateEpochMs: Int64? var pinned: Bool @@ -60,7 +60,7 @@ final class CachedCompletedEntity { var title: String var itemDescription: String? var priority: String - var dueEpochMs: Int64 + var dueEpochMs: Int64? var completedAtEpochMs: Int64 var rrule: String? var instanceDateEpochMs: Int64? diff --git a/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift b/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift index 3910b900..ee622077 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift @@ -66,7 +66,7 @@ func todoSortPrecedes(_ lhs: TodoItem, _ rhs: TodoItem) -> Bool { return lhs.pinned && !rhs.pinned } if lhs.due != rhs.due { - return lhs.due < rhs.due + return (lhs.due ?? .distantFuture) < (rhs.due ?? .distantFuture) } let lhsKey = todoMergeKey(item: lhs) let rhsKey = todoMergeKey(item: rhs) @@ -81,7 +81,7 @@ func cachedTodoSortPrecedes(_ lhs: CachedTodoRecord, _ rhs: CachedTodoRecord) -> return lhs.pinned && !rhs.pinned } if lhs.dueEpochMs != rhs.dueEpochMs { - return lhs.dueEpochMs < rhs.dueEpochMs + return (lhs.dueEpochMs ?? Int64.max) < (rhs.dueEpochMs ?? Int64.max) } let lhsKey = todoMergeKey(record: lhs) let rhsKey = todoMergeKey(record: rhs) @@ -93,7 +93,7 @@ func cachedTodoSortPrecedes(_ lhs: CachedTodoRecord, _ rhs: CachedTodoRecord) -> func todoTimelineSortPrecedes(_ lhs: TodoItem, _ rhs: TodoItem) -> Bool { if lhs.due != rhs.due { - return lhs.due < rhs.due + return (lhs.due ?? .distantFuture) < (rhs.due ?? .distantFuture) } let lhsKey = todoMergeKey(item: lhs) let rhsKey = todoMergeKey(item: rhs) @@ -131,7 +131,7 @@ func mapTodoDTO(_ dto: TodoDTO) -> TodoItem { title: dto.title, description: dto.description, priority: dto.priority, - due: parseOptionalDate(dto.due) ?? .now, + due: parseOptionalDate(dto.due), rrule: dto.rrule, instanceDate: instanceDate, pinned: dto.pinned, @@ -148,7 +148,7 @@ func todoToCache(_ todo: TodoItem) -> CachedTodoRecord { title: todo.title, description: todo.description, priority: todo.priority, - dueEpochMs: Int64(todo.due.timeIntervalSince1970 * 1000.0), + dueEpochMs: todo.due.map { Int64($0.timeIntervalSince1970 * 1000.0) }, rrule: todo.rrule, instanceDateEpochMs: todo.instanceDateEpochMillis, pinned: todo.pinned, @@ -165,7 +165,7 @@ func todoFromCache(_ record: CachedTodoRecord) -> TodoItem { title: record.title, description: record.description, priority: record.priority, - due: Date(timeIntervalSince1970: TimeInterval(record.dueEpochMs) / 1000.0), + due: record.dueEpochMs.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) }, rrule: record.rrule, instanceDate: record.instanceDateEpochMs.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) }, pinned: record.pinned, @@ -232,7 +232,7 @@ func mapCompletedDTO(_ dto: CompletedTodoDTO) -> CompletedItem { title: dto.title, description: dto.description, priority: dto.priority, - due: parseOptionalDate(dto.due) ?? .now, + due: parseOptionalDate(dto.due), completedAt: parseOptionalDate(dto.completedAt), rrule: dto.rrule, instanceDate: parseOptionalDate(dto.instanceDate), @@ -249,7 +249,7 @@ func completedToCache(_ item: CompletedItem) -> CachedCompletedRecord { title: item.title, description: item.description, priority: item.priority, - dueEpochMs: Int64(item.due.timeIntervalSince1970 * 1000.0), + dueEpochMs: item.due.map { Int64($0.timeIntervalSince1970 * 1000.0) }, completedAtEpochMs: item.completedAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0, rrule: item.rrule, instanceDateEpochMs: item.instanceDate.map { Int64($0.timeIntervalSince1970 * 1000.0) }, @@ -266,7 +266,7 @@ func completedFromCache(_ record: CachedCompletedRecord) -> CompletedItem { title: record.title, description: record.description, priority: record.priority, - due: Date(timeIntervalSince1970: TimeInterval(record.dueEpochMs) / 1000.0), + due: record.dueEpochMs.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) }, completedAt: record.completedAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.completedAtEpochMs) / 1000.0) : nil, rrule: record.rrule, instanceDate: record.instanceDateEpochMs.map { Date(timeIntervalSince1970: TimeInterval($0) / 1000.0) }, diff --git a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift index 08f55c45..d5459736 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift @@ -394,8 +394,8 @@ final class SyncManager { title: mutation.title ?? "Untitled", description: mutation.description, priority: mutation.priority ?? "Low", - due: Date(epochMilliseconds: mutation.dueEpochMs ?? Date().epochMilliseconds).ISO8601Format(), - rrule: mutation.rrule, + due: mutation.dueEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() }, + rrule: mutation.dueEpochMs == nil ? nil : mutation.rrule, listID: resolvedListID ) ) diff --git a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift index 918ea6ae..a6ac9f1d 100644 --- a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift @@ -48,8 +48,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due.epochMilliseconds, - rrule: payload.rrule, + dueEpochMs: payload.due?.epochMilliseconds, + rrule: payload.due == nil ? nil : payload.rrule, listId: normalizedListID, pinned: false, completed: false, @@ -68,8 +68,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due.epochMilliseconds, - rrule: payload.rrule, + dueEpochMs: payload.due?.epochMilliseconds, + rrule: payload.due == nil ? nil : payload.rrule, instanceDateEpochMs: nil, pinned: false, completed: false, @@ -92,8 +92,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - due: payload.due.ISO8601Format(), - rrule: payload.rrule, + due: payload.due?.ISO8601Format(), + rrule: payload.due == nil ? nil : payload.rrule, listID: normalizedListID ) ) @@ -143,8 +143,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due.epochMilliseconds, - rrule: payload.rrule, + dueEpochMs: payload.due?.epochMilliseconds, + rrule: payload.due == nil ? nil : payload.rrule, instanceDateEpochMs: current.instanceDateEpochMs, pinned: current.pinned, completed: current.completed, @@ -162,8 +162,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due.epochMilliseconds, - rrule: payload.rrule, + dueEpochMs: payload.due?.epochMilliseconds, + rrule: payload.due == nil ? nil : payload.rrule, listId: normalizedListID, pinned: todo.pinned, completed: todo.completed, @@ -327,7 +327,7 @@ final class TodoRepository { title: todo.title, description: todo.description, priority: todo.priority, - dueEpochMs: todo.due.epochMilliseconds, + dueEpochMs: todo.due?.epochMilliseconds, completedAtEpochMs: now, rrule: todo.rrule, instanceDateEpochMs: todo.instanceDateEpochMilliseconds, @@ -537,6 +537,7 @@ final class TodoRepository { let now = Date() let todayTodos = timelineTodos.filter { isTodayTodo($0, now: now) } let scheduledTodos = timelineTodos.filter { isScheduledTodo($0, now: now) } + let anytimeTodos = timelineTodos.filter { $0.due == nil } let todoCountsByList = Dictionary(grouping: timelineTodos, by: \.listId).mapValues(\.count) let lists = orderListsLikeWeb(state.lists).map { list in listFromCache(list, todoCountOverride: todoCountsByList[list.id] ?? 0) @@ -547,6 +548,7 @@ final class TodoRepository { scheduledCount: scheduledTodos.count, allCount: timelineTodos.count, priorityCount: timelineTodos.filter { isPriorityTodo($0.priority) }.count, + anytimeCount: anytimeTodos.count, completedCount: state.completedItems.count, lists: lists ) @@ -568,6 +570,8 @@ final class TodoRepository { filtered = items case .priority: filtered = items.filter { isPriorityTodo($0.priority) } + case .anytime: + filtered = items.filter { $0.due == nil } case .list: filtered = items.filter { $0.listId == listId } } @@ -592,15 +596,18 @@ final class TodoRepository { guard let startOfTomorrow = calendar.date(byAdding: .day, value: 1, to: startOfToday) else { return false } - return todo.due >= startOfToday && todo.due < startOfTomorrow + guard let due = todo.due else { return false } + return due >= startOfToday && due < startOfTomorrow } private func isScheduledTodo(_ todo: TodoItem, now: Date = Date()) -> Bool { - todo.due >= now + guard let due = todo.due else { return false } + return due >= now } private func isOverdueTodo(_ todo: TodoItem, now: Date = Date()) -> Bool { - todo.due < now + guard let due = todo.due else { return false } + return due < now } private func isPriorityTodo(_ priority: String?) -> Bool { diff --git a/ios-swiftUI/Tday/Core/Model/ApiModels.swift b/ios-swiftUI/Tday/Core/Model/ApiModels.swift index 5303521b..10cb6307 100644 --- a/ios-swiftUI/Tday/Core/Model/ApiModels.swift +++ b/ios-swiftUI/Tday/Core/Model/ApiModels.swift @@ -122,7 +122,7 @@ struct CreateTodoRequest: Codable { let title: String let description: String? let priority: String - let due: String + let due: String? let rrule: String? let listID: String? } @@ -133,7 +133,7 @@ struct TodoDTO: Codable, Equatable { let description: String? let pinned: Bool let priority: String - let due: String + let due: String? let rrule: String? let timeZone: String? let instanceDate: String? @@ -292,7 +292,7 @@ struct ListTodoDTO: Codable, Equatable { let id: String let title: String let priority: String - let due: String + let due: String? let completed: Bool let order: Int } @@ -307,7 +307,7 @@ struct CompletedTodoDTO: Codable, Equatable { let title: String let description: String? let priority: String - let due: String + let due: String? let completedAt: String? let completedOnTime: Bool? let daysToComplete: Double? diff --git a/ios-swiftUI/Tday/Core/Model/DomainModels.swift b/ios-swiftUI/Tday/Core/Model/DomainModels.swift index 5a16b1b1..63fdccdd 100644 --- a/ios-swiftUI/Tday/Core/Model/DomainModels.swift +++ b/ios-swiftUI/Tday/Core/Model/DomainModels.swift @@ -7,6 +7,7 @@ enum TodoListMode: String, Codable, CaseIterable, Hashable { case scheduled = "SCHEDULED" case all = "ALL" case priority = "PRIORITY" + case anytime = "ANYTIME" case list = "LIST" var title: String { @@ -21,6 +22,8 @@ enum TodoListMode: String, Codable, CaseIterable, Hashable { return "All Tasks" case .priority: return "Priority" + case .anytime: + return "Anytime" case .list: return "List" } @@ -38,6 +41,8 @@ enum TodoListMode: String, Codable, CaseIterable, Hashable { return "all" case .priority: return "priority" + case .anytime: + return "anytime" case .list: return "list" } @@ -53,7 +58,7 @@ struct CreateTaskPayload: Equatable, Hashable, Codable { let title: String let description: String? let priority: String - let due: Date + let due: Date? let rrule: String? let listId: String? } @@ -64,7 +69,7 @@ struct TodoItem: Identifiable, Equatable, Hashable, Codable { let title: String let description: String? let priority: String - let due: Date + let due: Date? let rrule: String? let instanceDate: Date? let pinned: Bool @@ -90,7 +95,7 @@ extension TodoListMode { switch self { case .scheduled, .all, .priority, .list: return true - case .today, .overdue: + case .today, .overdue, .anytime: return false } } @@ -140,7 +145,8 @@ func movedTaskPayload( targetDay: Date, calendar: Calendar = .current ) -> CreateTaskPayload? { - guard let movedDue = movedDuePreservingTime(due: todo.due, targetDay: targetDay, calendar: calendar) else { + guard let due = todo.due, + let movedDue = movedDuePreservingTime(due: due, targetDay: targetDay, calendar: calendar) else { return nil } return CreateTaskPayload( @@ -225,6 +231,7 @@ struct DashboardSummary: Equatable, Hashable, Codable { let scheduledCount: Int let allCount: Int let priorityCount: Int + let anytimeCount: Int let completedCount: Int let lists: [ListSummary] } @@ -235,7 +242,7 @@ struct CompletedItem: Identifiable, Equatable, Hashable, Codable { let title: String let description: String? let priority: String - let due: Date + let due: Date? let completedAt: Date? let rrule: String? let instanceDate: Date? diff --git a/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift b/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift index dc4f00ac..9c4a132d 100644 --- a/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift +++ b/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift @@ -20,7 +20,7 @@ struct CachedTodoRecord: Identifiable, Equatable, Codable { let title: String let description: String? let priority: String - let dueEpochMs: Int64 + let dueEpochMs: Int64? let rrule: String? let instanceDateEpochMs: Int64? let pinned: Bool @@ -45,7 +45,7 @@ struct CachedCompletedRecord: Identifiable, Equatable, Codable { let title: String let description: String? let priority: String - let dueEpochMs: Int64 + let dueEpochMs: Int64? let completedAtEpochMs: Int64 let rrule: String? let instanceDateEpochMs: Int64? diff --git a/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift b/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift index 14cd5e49..a2f7f689 100644 --- a/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift +++ b/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift @@ -7,6 +7,7 @@ enum AppRoute: Hashable { case scheduledTodos case allTodos(highlightTodoId: String?) case priorityTodos + case anytimeTodos case listTodos(listId: String, listName: String) case completed case calendar @@ -30,6 +31,8 @@ enum AppRoute: Hashable { return "todos/all" case .priorityTodos: return "todos/priority" + case .anytimeTodos: + return "todos/anytime" case let .listTodos(listId, listName): return "todos/list/\(listId)/\(listName)" case .completed: @@ -84,6 +87,8 @@ enum AppRoute: Hashable { return .allTodos(highlightTodoId: highlightTodoId) case "priority": return .priorityTodos + case "anytime": + return .anytimeTodos case "list": let remaining = Array(components.dropFirst(2)) guard remaining.count >= 2 else { diff --git a/ios-swiftUI/Tday/Core/Notification/TaskReminderScheduler.swift b/ios-swiftUI/Tday/Core/Notification/TaskReminderScheduler.swift index dd3cc830..fb8d76e9 100644 --- a/ios-swiftUI/Tday/Core/Notification/TaskReminderScheduler.swift +++ b/ios-swiftUI/Tday/Core/Notification/TaskReminderScheduler.swift @@ -27,7 +27,10 @@ final class TaskReminderScheduler { } for task in tasks where !task.completed { - let triggerDate = task.due.addingTimeInterval(-offsetSeconds) + guard let due = task.due else { + continue + } + let triggerDate = due.addingTimeInterval(-offsetSeconds) guard triggerDate > Date(), !reminderPreferenceStore.hasNotified(taskID: task.id) else { continue } diff --git a/ios-swiftUI/Tday/Core/Widget/TodayTasksWidgetSnapshotStore.swift b/ios-swiftUI/Tday/Core/Widget/TodayTasksWidgetSnapshotStore.swift index 7b029128..874376ff 100644 --- a/ios-swiftUI/Tday/Core/Widget/TodayTasksWidgetSnapshotStore.swift +++ b/ios-swiftUI/Tday/Core/Widget/TodayTasksWidgetSnapshotStore.swift @@ -34,12 +34,19 @@ enum TodayTasksWidgetSnapshotStore { let dayEndEpochMs = Int64(dayEnd.timeIntervalSince1970 * 1_000) let todayTasks = state.todos - .filter { !$0.completed && $0.dueEpochMs >= dayStartEpochMs && $0.dueEpochMs < dayEndEpochMs } + .filter { + guard let dueEpochMs = $0.dueEpochMs else { + return false + } + return !$0.completed && dueEpochMs >= dayStartEpochMs && dueEpochMs < dayEndEpochMs + } .sorted { left, right in - if left.dueEpochMs == right.dueEpochMs { + let leftDue = left.dueEpochMs ?? Int64.max + let rightDue = right.dueEpochMs ?? Int64.max + if leftDue == rightDue { return left.title.localizedStandardCompare(right.title) == .orderedAscending } - return left.dueEpochMs < right.dueEpochMs + return leftDue < rightDue } return TodayTasksWidgetSnapshot( @@ -50,7 +57,7 @@ enum TodayTasksWidgetSnapshotStore { TodayTasksWidgetTaskSnapshot( id: $0.id, title: $0.title, - dueEpochMs: $0.dueEpochMs, + dueEpochMs: $0.dueEpochMs ?? dayStartEpochMs, priority: $0.priority ) } diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index 857ebaee..858f7b16 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -8,6 +8,10 @@ struct AppRootView: View { @State private var notificationDeepLinkRouter = NotificationDeepLinkRouter.shared @State private var hasLeftActiveScene = false @State private var isLaunchSplashHeld = false + @State private var rootFeedTab: RootFeedTab = .home + @State private var rootCreateTaskRequestID = 0 + @State private var rootDockCollapsed = false + @State private var rootControlsVisible = true @Environment(\.scenePhase) private var scenePhase init(container: AppContainer) { @@ -33,8 +37,38 @@ struct AppRootView: View { ) ) { TdayBackground { - HomeScreen(container: container) { route in - handleRoute(route) + ZStack(alignment: .bottom) { + switch rootFeedTab { + case .home: + HomeScreen( + container: container, + onRootFeedTabSelected: handleRootFeedTabSelection, + showsRootControls: false, + createTaskRequestID: rootCreateTaskRequestID, + onRootDockCollapsedChange: { rootDockCollapsed = $0 }, + onRootControlsVisibleChange: { rootControlsVisible = $0 } + ) { route in + handleRoute(route) + } + case .anytime: + TodoListScreen( + container: container, + mode: .anytime, + listId: nil, + listName: nil, + highlightedTodoId: nil, + rootFeedTab: .anytime, + onRootFeedTabSelected: handleRootFeedTabSelection, + showsRootControls: false, + createTaskRequestID: rootCreateTaskRequestID, + onRootDockCollapsedChange: { rootDockCollapsed = $0 }, + onRootControlsVisibleChange: { rootControlsVisible = $0 } + ) + } + + if appViewModel.authenticated, rootControlsVisible { + rootFloatingControls + } } } .blur(radius: showOnboardingOverlay ? 6 : 0) @@ -43,7 +77,10 @@ struct AppRootView: View { .navigationDestination(for: AppRoute.self) { route in switch route { case .home: - HomeScreen(container: container) { nextRoute in + HomeScreen( + container: container, + onRootFeedTabSelected: handleRootFeedTabSelection + ) { nextRoute in handleRoute(nextRoute) } case .todayTodos: @@ -56,6 +93,16 @@ struct AppRootView: View { TodoListScreen(container: container, mode: .all, listId: nil, listName: nil, highlightedTodoId: highlightTodoId) case .priorityTodos: TodoListScreen(container: container, mode: .priority, listId: nil, listName: nil, highlightedTodoId: nil) + case .anytimeTodos: + TodoListScreen( + container: container, + mode: .anytime, + listId: nil, + listName: nil, + highlightedTodoId: nil, + rootFeedTab: .anytime, + onRootFeedTabSelected: handleRootFeedTabSelection + ) case let .listTodos(listId, listName): TodoListScreen( container: container, @@ -191,7 +238,41 @@ struct AppRootView: View { } private func handleRoute(_ route: AppRoute) { - appViewModel.navigate(to: route) + switch route { + case .home: + rootFeedTab = .home + appViewModel.navigate(to: .home) + case .anytimeTodos: + rootFeedTab = .anytime + appViewModel.navigate(to: .home) + default: + appViewModel.navigate(to: route) + } + } + + private func handleRootFeedTabSelection(_ tab: RootFeedTab) { + rootFeedTab = tab + appViewModel.navigate(to: .home) + } + + private var rootFloatingControls: some View { + HStack(alignment: .bottom) { + RootFeedDock( + activeTab: rootFeedTab, + collapsed: rootDockCollapsed, + onSelect: handleRootFeedTabSelection + ) + .padding(.leading, 18) + .padding(.vertical, 8) + + Spacer(minLength: 12) + + TaskFloatingActionButton { + rootCreateTaskRequestID += 1 + } + .padding(.trailing, 18) + .padding(.vertical, 8) + } } private func handleDeepLink(_ url: URL) { diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index c9cd86cf..6bf44e47 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -38,7 +38,10 @@ private struct CalendarDateDropTargetFramePreferenceKey: PreferenceKey { } private func calendarTaskAlreadyDueOnDate(_ todo: TodoItem, _ date: Date) -> Bool { - Calendar.current.isDate(todo.due, inSameDayAs: date) + guard let due = todo.due else { + return false + } + return Calendar.current.isDate(due, inSameDayAs: date) } private enum CalendarTitleHandoff { @@ -169,12 +172,14 @@ struct CalendarScreen: View { } private var pendingItems: [TodoItem] { - viewModel.items.filter { isSelectedDay($0.due) }.sorted(by: { $0.due < $1.due }) + viewModel.items + .filter { todo in todo.due.map(isSelectedDay) ?? false } + .sorted(by: { ($0.due ?? .distantFuture) < ($1.due ?? .distantFuture) }) } private var pendingItemsByDay: [Date: [TodoItem]] { - Dictionary(grouping: viewModel.items) { todo in - Calendar.current.startOfDay(for: todo.due) + Dictionary(grouping: viewModel.items.filter { $0.due != nil }) { todo in + Calendar.current.startOfDay(for: todo.due ?? selectedDate) } } @@ -2522,7 +2527,7 @@ private struct CalendarTaskDragPreview: View { .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(todo.due.formatted(date: .omitted, time: .shortened)) + Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Anytime") .font(.tdayRounded(size: 12, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant) .lineLimit(1) @@ -2602,7 +2607,7 @@ private struct CalendarPendingTaskRow: View { strikeColor: colors.onSurface.opacity(0.65) ) - Text(todo.due.formatted(date: .omitted, time: .shortened)) + Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Anytime") .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant.opacity(0.8)) } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift index 2c99bb1d..6e0750e4 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarViewModel.swift @@ -76,8 +76,9 @@ 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 { + guard let due = todo.due, + !calendar.isDate(due, inSameDayAs: targetDay), + let movedDue = movedDuePreservingTime(due: due, targetDay: targetDay, calendar: calendar) else { return } @@ -106,7 +107,7 @@ final class CalendarViewModel { } private func hydrateFromCache() { - items = container.todoRepository.fetchTodosSnapshot(mode: .all) + items = container.todoRepository.fetchTodosSnapshot(mode: .all).filter { $0.due != nil } completedItems = container.completedRepository.fetchCompletedItemsSnapshot() lists = container.listRepository.fetchListsSnapshot() errorMessage = nil diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index ca801da5..a80af8f5 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -218,10 +218,10 @@ struct CompletedScreen: View { } let currentItem = sections[sectionIndex].items[itemIndex] - let currentDate = currentItem.completedAt ?? currentItem.due + let currentDate = currentItem.completedAt ?? currentItem.due ?? .distantPast let nextItemInSection = sections[sectionIndex].items.dropFirst(itemIndex + 1).first if let nextItemInSection { - let nextDate = nextItemInSection.completedAt ?? nextItemInSection.due + let nextDate = nextItemInSection.completedAt ?? nextItemInSection.due ?? .distantPast return !Calendar.current.isDate(currentDate, inSameDayAs: nextDate) } @@ -232,7 +232,7 @@ struct CompletedScreen: View { guard let nextVisibleItem else { return false } - let nextDate = nextVisibleItem.completedAt ?? nextVisibleItem.due + let nextDate = nextVisibleItem.completedAt ?? nextVisibleItem.due ?? .distantPast return !Calendar.current.isDate(currentDate, inSameDayAs: nextDate) } @@ -298,7 +298,7 @@ private struct CompletedTimelineRow: View { } var body: some View { - let completedDate = item.completedAt ?? item.due + let completedDate = item.completedAt ?? item.due ?? .distantPast let completedTimeText = completedDate.formatted(date: .omitted, time: .shortened) let showListIndicator = item.listName?.isEmpty == false let priorityIcon = priorityIndicatorSymbolName(item.priority) @@ -406,13 +406,13 @@ private struct CompletedTimelineRow: View { private func buildCompletedTimelineSections(items: [CompletedItem]) -> [TimelineSection] { let calendar = Calendar.current let grouped = Dictionary(grouping: items) { item in - calendar.startOfDay(for: item.completedAt ?? item.due) + calendar.startOfDay(for: item.completedAt ?? item.due ?? .distantPast) } return grouped.keys.sorted(by: >).map { date in let sectionItems = (grouped[date] ?? []).sorted { lhs, rhs in - let lhsCompletedAt = lhs.completedAt ?? lhs.due - let rhsCompletedAt = rhs.completedAt ?? rhs.due + let lhsCompletedAt = lhs.completedAt ?? lhs.due ?? .distantPast + let rhsCompletedAt = rhs.completedAt ?? rhs.due ?? .distantPast if lhsCompletedAt != rhsCompletedAt { return lhsCompletedAt > rhsCompletedAt } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index ee624827..91f8fe15 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -79,6 +79,11 @@ private struct CreateListSheetHeaderHeightKey: PreferenceKey { } struct HomeScreen: View { + let onRootFeedTabSelected: (RootFeedTab) -> Void + let showsRootControls: Bool + let createTaskRequestID: Int + let onRootDockCollapsedChange: (Bool) -> Void + let onRootControlsVisibleChange: (Bool) -> Void let onNavigate: (AppRoute) -> Void @State private var viewModel: HomeViewModel @@ -93,8 +98,22 @@ struct HomeScreen: View { @State private var showingCreateTask = false @State private var showingCreateList = false @State private var editingTodo: TodoItem? - - init(container: AppContainer, onNavigate: @escaping (AppRoute) -> Void) { + @State private var homeScrollOffset: CGFloat = 0 + + init( + container: AppContainer, + onRootFeedTabSelected: @escaping (RootFeedTab) -> Void = { _ in }, + showsRootControls: Bool = true, + createTaskRequestID: Int = 0, + onRootDockCollapsedChange: @escaping (Bool) -> Void = { _ in }, + onRootControlsVisibleChange: @escaping (Bool) -> Void = { _ in }, + onNavigate: @escaping (AppRoute) -> Void + ) { + self.onRootFeedTabSelected = onRootFeedTabSelected + self.showsRootControls = showsRootControls + self.createTaskRequestID = createTaskRequestID + self.onRootDockCollapsedChange = onRootDockCollapsedChange + self.onRootControlsVisibleChange = onRootControlsVisibleChange self.onNavigate = onNavigate _viewModel = State(initialValue: HomeViewModel(container: container)) } @@ -117,14 +136,16 @@ struct HomeScreen: View { (todo.description.map { homeSearchText($0).contains(normalizedSearchQuery) } ?? false) || (todo.listId.flatMap { listByID[$0]?.name }.map { homeSearchText($0).contains(normalizedSearchQuery) } ?? false) } - .sorted { $0.due < $1.due } + .sorted { + ($0.due ?? .distantFuture) < ($1.due ?? .distantFuture) + } .prefix(20) .map { $0 } } private var overdueCount: Int { let now = Date() - return viewModel.searchableTodos.count { $0.due < now } + return viewModel.searchableTodos.count { ($0.due ?? .distantFuture) < now } } private var showSearchResultsOverlay: Bool { @@ -152,6 +173,10 @@ struct HomeScreen: View { ) { ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: HomeMetrics.sectionSpacing) { + TimelineScrollOffsetObserver { homeScrollOffset = $0 } + .frame(height: 0) + .allowsHitTesting(false) + HomeTopBar( totalWidth: proxy.size.width - (HomeMetrics.screenPadding * 2), searchExpanded: $searchExpanded, @@ -250,9 +275,27 @@ struct HomeScreen: View { } .scrollBounceBehavior(.always, axes: .vertical) .safeAreaInset(edge: .bottom) { - TaskFloatingActionButtonDock { - closeSearch() - showingCreateTask = true + if showsRootControls { + HStack(alignment: .bottom) { + RootFeedDock( + activeTab: .home, + collapsed: homeScrollOffset > 18, + onSelect: onRootFeedTabSelected + ) + .padding(.leading, 18) + .padding(.vertical, 8) + + Spacer(minLength: 12) + + TaskFloatingActionButton { + closeSearch() + showingCreateTask = true + } + .padding(.trailing, 18) + .padding(.vertical, 8) + } + } else { + Color.clear.frame(height: 80) } } } @@ -297,6 +340,7 @@ struct HomeScreen: View { searchResultsFrame = frame } .onChange(of: searchExpanded) { _, expanded in + onRootControlsVisibleChange(!expanded) if expanded { searchFieldFocused = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.30) { @@ -309,6 +353,21 @@ struct HomeScreen: View { searchResultsFrame = .zero } } + .onChange(of: homeScrollOffset, initial: true) { _, offset in + onRootDockCollapsedChange(offset > 18) + } + .onChange(of: createTaskRequestID) { _, requestID in + guard requestID > 0 else { return } + closeSearch() + showingCreateTask = true + } + .onAppear { + onRootControlsVisibleChange(!searchExpanded) + onRootDockCollapsedChange(homeScrollOffset > 18) + } + .onDisappear { + onRootControlsVisibleChange(true) + } .sheet(isPresented: $showingCreateTask) { CreateTaskSheet( lists: viewModel.summary.lists, @@ -375,7 +434,11 @@ struct HomeScreen: View { } openingSearchResultID = todo.id closeSearch() - onNavigate(.allTodos(highlightTodoId: todo.id)) + if todo.due == nil { + onNavigate(.anytimeTodos) + } else { + onNavigate(.allTodos(highlightTodoId: todo.id)) + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { openingSearchResultID = nil } @@ -570,8 +633,8 @@ private struct HomeTodayTaskRow: View { } private var priorityIcon: String? { priorityIndicatorSymbolName(todo.priority) } - private var isOverdue: Bool { !todo.completed && todo.due < Date() } - private var dueText: String { todo.due.formatted(date: .omitted, time: .shortened) } + private var isOverdue: Bool { !todo.completed && (todo.due ?? .distantFuture) < Date() } + private var dueText: String { todo.due?.formatted(date: .omitted, time: .shortened) ?? "Anytime" } private var subtitleText: String { isOverdue ? "Overdue, \(dueText)" : "Due \(dueText)" } private var subtitleColor: Color { isOverdue ? colors.error : colors.onSurfaceVariant.opacity(0.8) } private var isCompleting: Bool { completionPhase != .active } @@ -1157,7 +1220,7 @@ private struct HomeSearchResultsOverlay: View { .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(Self.dueFormatter.string(from: todo.due)) + Text(todo.due.map(Self.dueFormatter.string(from:)) ?? "Anytime") .font(.tdayRounded(size: 12, weight: .bold)) .foregroundStyle(colors.onSurfaceVariant) .lineLimit(1) @@ -1202,7 +1265,11 @@ private struct HomeSearchResultsOverlay: View { todos.indices.contains(index + 1) else { return false } - return !Calendar.current.isDate(todos[index].due, inSameDayAs: todos[index + 1].due) + guard let currentDue = todos[index].due, + let nextDue = todos[index + 1].due else { + return false + } + return !Calendar.current.isDate(currentDue, inSameDayAs: nextDue) } } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift b/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift index 0f074c89..c61e73fb 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift @@ -7,7 +7,7 @@ final class HomeViewModel { private let container: AppContainer var isLoading = true - var summary = DashboardSummary(todayCount: 0, scheduledCount: 0, allCount: 0, priorityCount: 0, completedCount: 0, lists: []) + var summary = DashboardSummary(todayCount: 0, scheduledCount: 0, allCount: 0, priorityCount: 0, anytimeCount: 0, completedCount: 0, lists: []) var searchableTodos: [TodoItem] = [] var todayTodos: [TodoItem] = [] var errorMessage: String? diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 88343c0a..6d8bc2df 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -216,6 +216,12 @@ struct TimelineTopBarAction { struct TodoListScreen: View { let highlightedTodoId: String? let onListDeleted: () -> Void + let rootFeedTab: RootFeedTab? + let onRootFeedTabSelected: ((RootFeedTab) -> Void)? + let showsRootControls: Bool + let createTaskRequestID: Int + let onRootDockCollapsedChange: (Bool) -> Void + let onRootControlsVisibleChange: (Bool) -> Void @State private var viewModel: TodoListViewModel @Environment(\.tdayColors) private var colors @Environment(\.dismiss) private var dismiss @@ -241,10 +247,22 @@ struct TodoListScreen: View { listId: String?, listName: String?, highlightedTodoId: String?, + rootFeedTab: RootFeedTab? = nil, + onRootFeedTabSelected: ((RootFeedTab) -> Void)? = nil, + showsRootControls: Bool = true, + createTaskRequestID: Int = 0, + onRootDockCollapsedChange: @escaping (Bool) -> Void = { _ in }, + onRootControlsVisibleChange: @escaping (Bool) -> Void = { _ in }, onListDeleted: @escaping () -> Void = {} ) { self.highlightedTodoId = highlightedTodoId self.onListDeleted = onListDeleted + self.rootFeedTab = rootFeedTab + self.onRootFeedTabSelected = onRootFeedTabSelected + self.showsRootControls = showsRootControls + self.createTaskRequestID = createTaskRequestID + self.onRootDockCollapsedChange = onRootDockCollapsedChange + self.onRootControlsVisibleChange = onRootControlsVisibleChange _viewModel = State(initialValue: TodoListViewModel(container: container, mode: mode, listId: listId, listName: listName)) _collapsedSectionIDs = State(initialValue: mode == .priority || mode == .all || mode == .list ? ["earlier"] : []) } @@ -265,6 +283,7 @@ struct TodoListScreen: View { viewModel.mode == .overdue || viewModel.mode == .scheduled || viewModel.mode == .priority || + viewModel.mode == .anytime || viewModel.mode == .all || viewModel.mode == .list } @@ -298,6 +317,7 @@ struct TodoListScreen: View { private var canSummarizeCurrentMode: Bool { viewModel.mode != .list && viewModel.mode != .overdue && viewModel.aiSummaryEnabled + && viewModel.mode != .anytime } private var heroTopBarAction: TimelineTopBarAction? { @@ -404,7 +424,25 @@ struct TodoListScreen: View { handleItemsChanged() } .safeAreaInset(edge: .bottom) { - floatingActionButtonDock + if showsRootControls { + floatingActionButtonDock + } else { + Color.clear.frame(height: 80) + } + } + .onChange(of: timelineScrollOffset, initial: true) { _, offset in + onRootDockCollapsedChange(offset > 18) + } + .onChange(of: createTaskRequestID) { _, requestID in + guard requestID > 0 else { return } + showingCreateTask = true + } + .onAppear { + onRootControlsVisibleChange(true) + onRootDockCollapsedChange(timelineScrollOffset > 18) + } + .onDisappear { + onRootControlsVisibleChange(true) } .sheet(isPresented: $showingCreateTask) { createTaskSheetContent @@ -447,7 +485,7 @@ struct TodoListScreen: View { @ToolbarContentBuilder private var navigationToolbarContent: some ToolbarContent { if !usesHeroTimelineMode { - if viewModel.mode != .list && viewModel.mode != .overdue && viewModel.aiSummaryEnabled { + if canSummarizeCurrentMode { ToolbarItem(placement: .topBarTrailing) { Button(action: presentSummary) { Image(systemName: "sparkles") @@ -496,8 +534,24 @@ struct TodoListScreen: View { } private var floatingActionButtonDock: some View { - TaskFloatingActionButtonDock(fillColor: modeAccentColor) { - showingCreateTask = true + HStack(alignment: .bottom) { + if showsRootControls, let rootFeedTab, let onRootFeedTabSelected { + RootFeedDock( + activeTab: rootFeedTab, + collapsed: timelineScrollOffset > 18, + onSelect: onRootFeedTabSelected + ) + .padding(.leading, 18) + .padding(.vertical, 8) + } + + Spacer(minLength: 12) + + TaskFloatingActionButton(fillColor: modeAccentColor) { + showingCreateTask = true + } + .padding(.trailing, 18) + .padding(.vertical, 8) } } @@ -506,7 +560,8 @@ struct TodoListScreen: View { lists: viewModel.lists, titleText: "New task", submitText: "Create", - initialPayload: CreateTaskPayload(title: "", description: nil, priority: viewModel.mode == .priority ? "High" : "Low", due: Date().addingTimeInterval(60 * 60), rrule: nil, listId: viewModel.listId), + initialPayload: CreateTaskPayload(title: "", description: nil, priority: viewModel.mode == .priority ? "High" : "Low", due: viewModel.mode == .anytime ? nil : Date().addingTimeInterval(60 * 60), rrule: nil, listId: viewModel.listId), + defaultScheduled: viewModel.mode != .anytime, onParseTaskTitleNlp: { title, dueRef in await viewModel.parseTaskTitleNlp(text: title, referenceDueEpochMs: dueRef) }, @@ -610,7 +665,8 @@ struct TodoListScreen: View { return } TodoTaskDragSession.shared.handledDropSignature = dropSignature - guard !Calendar.current.isDate(todo.due, inSameDayAs: targetDay) else { + guard let due = todo.due, + !Calendar.current.isDate(due, inSameDayAs: targetDay) else { return } @@ -644,7 +700,10 @@ struct TodoListScreen: View { if sectionID(containing: todo) == section.id { return false } - return !Calendar.current.isDate(todo.due, inSameDayAs: targetDate) + guard let due = todo.due else { + return false + } + return !Calendar.current.isDate(due, inSameDayAs: targetDate) } private func setActiveDropSection(_ sectionId: String?) { @@ -1072,7 +1131,7 @@ struct TodoListScreen: View { } } HStack(spacing: 6) { - Text(todo.due.formatted(date: .abbreviated, time: .shortened)) + Text(todo.due?.formatted(date: .abbreviated, time: .shortened) ?? "Anytime") .font(.tdayRounded(size: 12, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant) } @@ -1140,7 +1199,7 @@ struct TodoListScreen: View { let showListIndicator = listMeta != nil && viewModel.mode != .list let priorityIcon = priorityIndicatorSymbolName(todo.priority) let subtitleText = minimalTimelineSubtitle(for: todo, in: section) - let isOverdueTask = !todo.completed && todo.due < Date() + let isOverdueTask = !todo.completed && (todo.due ?? .distantFuture) < Date() let subtitleColor = isOverdueTask ? colors.error : colors.onSurfaceVariant.opacity(0.8) let completionPhase = completionPhases[todo.id] let isCompleting = completionPhase != nil @@ -1408,7 +1467,11 @@ struct TodoListScreen: View { 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) + guard let currentDue = currentTodo.due, + let nextDue = nextTodoInSection.due else { + return false + } + return !Calendar.current.isDate(currentDue, inSameDayAs: nextDue) } let nextVisibleTodo = sections.dropFirst(sectionIndex + 1) @@ -1418,7 +1481,11 @@ struct TodoListScreen: View { guard let nextVisibleTodo else { return false } - return !Calendar.current.isDate(currentTodo.due, inSameDayAs: nextVisibleTodo.due) + guard let currentDue = currentTodo.due, + let nextDue = nextVisibleTodo.due else { + return false + } + return !Calendar.current.isDate(currentDue, inSameDayAs: nextDue) } private func timelineRowTransition() -> AnyTransition { @@ -1432,17 +1499,20 @@ struct TodoListScreen: View { } private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { - let timeText = todo.due.formatted(date: .omitted, time: .shortened) + guard let due = todo.due else { + return "Anytime" + } + let timeText = due.formatted(date: .omitted, time: .shortened) let dueBodyText = if section.id == "earlier" && (viewModel.mode == .all || viewModel.mode == .priority || viewModel.mode == .list) { - timelineDateTimeText(todo.due) + timelineDateTimeText(due) } else { timeText } switch viewModel.mode { case .today: - if !todo.completed && todo.due < Date() { + if !todo.completed && due < Date() { return "Overdue, \(dueBodyText)" } return "Due \(dueBodyText)" @@ -1451,17 +1521,19 @@ struct TodoListScreen: View { case .scheduled: return "Due \(dueBodyText)" case .all: - if !todo.completed && todo.due < Date() { + if !todo.completed && due < Date() { return "Overdue, \(dueBodyText)" } return "Due \(dueBodyText)" case .priority: - if !todo.completed && todo.due < Date() { + if !todo.completed && due < Date() { return "Overdue, \(dueBodyText)" } return "Due \(dueBodyText)" + case .anytime: + return "Anytime" case .list: - if !todo.completed && todo.due < Date() { + if !todo.completed && due < Date() { return "Overdue, \(dueBodyText)" } return "Due \(dueBodyText)" @@ -1876,7 +1948,7 @@ private struct TodoDragPreview: View { .font(.tdayRounded(size: 16, weight: .bold)) .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(todo.due.formatted(date: .omitted, time: .shortened)) + Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Anytime") .font(.tdayRounded(size: 12, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant) .lineLimit(1) @@ -2873,8 +2945,10 @@ private func buildSections( let calendar = Calendar.current switch mode { case .today: - let grouped = Dictionary(grouping: items) { item -> String in - let hour = calendar.component(.hour, from: item.due) + let grouped = Dictionary(grouping: items.compactMap { item -> TodoItem? in + item.due == nil ? nil : item + }) { item -> String in + let hour = calendar.component(.hour, from: item.due ?? .distantFuture) if hour < 12 { return "Morning" } if hour < 18 { return "Afternoon" } return "Tonight" @@ -2891,9 +2965,9 @@ private func buildSections( case .overdue: let now = Date() let startOfToday = calendar.startOfDay(for: now) - let overdueItems = items.filter { $0.due < now } + let overdueItems = items.filter { ($0.due ?? .distantFuture) < now } let grouped = Dictionary(grouping: overdueItems) { item in - calendar.startOfDay(for: item.due) + calendar.startOfDay(for: item.due ?? now) } var sections: [TodoTimelineSection] = [] @@ -2928,8 +3002,8 @@ private func buildSections( return sections case .scheduled: let startOfToday = calendar.startOfDay(for: Date()) - let grouped = Dictionary(grouping: items.filter { $0.due >= startOfToday }) { item in - calendar.startOfDay(for: item.due) + let grouped = Dictionary(grouping: items.filter { ($0.due ?? .distantPast) >= startOfToday }) { item in + calendar.startOfDay(for: item.due ?? startOfToday) } return grouped.keys.sorted().map { date in TodoTimelineSection( @@ -2957,6 +3031,8 @@ private func buildSections( placesEarlierBeforeToday: true, includeEmptyEarlierTarget: includeEmptyEarlierTarget ) + case .anytime: + return buildAnytimeTimelineSections(items: items) case .list: return buildFutureTimelineSections( items: items, @@ -2967,6 +3043,64 @@ private func buildSections( } } +private func buildAnytimeTimelineSections(items: [TodoItem]) -> [TodoTimelineSection] { + let anytimeItems = items.filter { $0.due == nil } + let priorityItems = anytimeItems + .filter { $0.pinned || $0.priority.caseInsensitiveCompare("High") == .orderedSame || $0.priority.caseInsensitiveCompare("Medium") == .orderedSame } + .sorted(by: anytimeTodoSortPrecedes) + let openItems = anytimeItems + .filter { item in !priorityItems.contains(where: { $0.id == item.id }) } + .sorted(by: anytimeTodoSortPrecedes) + + var sections: [TodoTimelineSection] = [] + if !priorityItems.isEmpty { + sections.append( + TodoTimelineSection( + id: "anytime-priority", + title: "Priority", + items: priorityItems, + isCollapsible: false, + targetDate: nil + ) + ) + } + if !openItems.isEmpty || sections.isEmpty { + sections.append( + TodoTimelineSection( + id: "anytime-open", + title: "Open", + items: openItems, + isCollapsible: false, + targetDate: nil + ) + ) + } + return sections +} + +private func anytimeTodoSortPrecedes(_ lhs: TodoItem, _ rhs: TodoItem) -> Bool { + if lhs.pinned != rhs.pinned { + return lhs.pinned && !rhs.pinned + } + let lhsPriority = anytimePriorityRank(lhs.priority) + let rhsPriority = anytimePriorityRank(rhs.priority) + if lhsPriority != rhsPriority { + return lhsPriority > rhsPriority + } + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending +} + +private func anytimePriorityRank(_ priority: String) -> Int { + switch priority.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "high", "urgent", "important": + return 3 + case "medium": + return 2 + default: + return 1 + } +} + private func scheduledSectionTitle(for date: Date, calendar: Calendar) -> String { if calendar.isDateInToday(date) { return "Today" @@ -2985,8 +3119,9 @@ private func buildFutureTimelineSections( ) -> [TodoTimelineSection] { let now = Date() let today = calendar.startOfDay(for: now) - let groupedByDate = Dictionary(grouping: items.sorted(by: todoTimelineSortPrecedes)) { item in - calendar.startOfDay(for: item.due) + let datedItems = items.filter { $0.due != nil } + let groupedByDate = Dictionary(grouping: datedItems.sorted(by: todoTimelineSortPrecedes)) { item in + calendar.startOfDay(for: item.due ?? today) } let currentYear = calendar.component(.year, from: today) let currentMonth = calendar.component(.month, from: today) @@ -3197,6 +3332,8 @@ private func emptyTimelineMessage(for mode: TodoListMode) -> String { return "No tasks yet" case .priority: return "No priority tasks" + case .anytime: + return "No anytime tasks" case .list: return "No tasks in this list" } @@ -3214,6 +3351,8 @@ private func emptyTimelineSystemImage(for mode: TodoListMode, listIconKey: Strin return "tray.fill" case .priority: return "flag.fill" + case .anytime: + return "tray.full.fill" case .list: return todoListSymbolName(for: listIconKey) } @@ -3231,6 +3370,8 @@ private func todoModeAccentColor(_ mode: TodoListMode, listColorKey: String?) -> return todoHexColor(0x5E6878) case .priority: return todoHexColor(0xE65E52) + case .anytime: + return todoHexColor(0x4D8F83) case .list: return todoListAccentColor(for: listColorKey) } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift index 2bed7cba..3e8b9ed1 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift @@ -57,7 +57,7 @@ final class TodoListViewModel { summaryError = "AI summary is disabled by admin" return } - guard mode != .list && mode != .overdue else { + guard mode != .list && mode != .overdue && mode != .anytime else { summaryError = "Summary is available for Today, Scheduled, All, and Priority" return } @@ -107,11 +107,12 @@ final class TodoListViewModel { func moveTask(_ todo: TodoItem, toDay targetDay: Date, scope: TaskRescheduleScope) async { let calendar = Calendar.current - guard !calendar.isDate(todo.due, inSameDayAs: targetDay) else { + guard let due = todo.due, + !calendar.isDate(due, inSameDayAs: targetDay) else { return } - guard let movedDue = movedDuePreservingTime(due: todo.due, targetDay: targetDay, calendar: calendar) else { + guard let movedDue = movedDuePreservingTime(due: due, targetDay: targetDay, calendar: calendar) else { return } diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index da702fe1..de6b1600 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -28,6 +28,7 @@ struct CreateTaskSheet: View { let titleText: String let submitText: String let initialPayload: CreateTaskPayload? + let defaultScheduled: Bool let onParseTaskTitleNlp: ((String, Int64) async -> TodoTitleNlpResponse?)? let onDismiss: () -> Void let onSubmit: (CreateTaskPayload) async -> Void @@ -40,6 +41,7 @@ struct CreateTaskSheet: View { @State private var priority = "Low" @State private var selectedListID: String? @State private var dueDate = Date().addingTimeInterval(60 * 60) + @State private var scheduleEnabled = true @State private var repeatRule: String? @State private var isSubmitting = false @State private var parserTask: Task? @@ -66,7 +68,10 @@ struct CreateTaskSheet: View { } private var selectedRepeatLabel: String { - repeatOptions.first(where: { $0.value == repeatRule })?.label ?? "No repeat" + guard scheduleEnabled else { + return "No repeat" + } + return repeatOptions.first(where: { $0.value == repeatRule })?.label ?? "No repeat" } private var maximumSheetHeight: CGFloat { @@ -86,6 +91,7 @@ struct CreateTaskSheet: View { titleText: String, submitText: String, initialPayload: CreateTaskPayload?, + defaultScheduled: Bool = true, onParseTaskTitleNlp: ((String, Int64) async -> TodoTitleNlpResponse?)?, onDismiss: @escaping () -> Void, onSubmit: @escaping (CreateTaskPayload) async -> Void @@ -94,6 +100,7 @@ struct CreateTaskSheet: View { self.titleText = titleText self.submitText = submitText self.initialPayload = initialPayload + self.defaultScheduled = defaultScheduled self.onParseTaskTitleNlp = onParseTaskTitleNlp self.onDismiss = onDismiss self.onSubmit = onSubmit @@ -148,11 +155,20 @@ struct CreateTaskSheet: View { CreateTaskSheetSectionTitle(text: "Schedule") CreateTaskSheetGroupCard { - CreateTaskSheetDueRow( - dueDate: $dueDate, - onDateTap: { activeSelector = .date }, - onTimeTap: { activeSelector = .time } + CreateTaskSheetScheduleToggleRow( + isOn: $scheduleEnabled ) + + if scheduleEnabled { + CreateTaskSheetDivider() + + CreateTaskSheetDueRow( + dueDate: $dueDate, + onDateTap: { activeSelector = .date }, + onTimeTap: { activeSelector = .time } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } } CreateTaskSheetSectionTitle(text: "Details") @@ -179,7 +195,11 @@ struct CreateTaskSheet: View { iconName: "repeat", title: "Repeat", value: selectedRepeatLabel, - onTap: { activeSelector = .recurrence } + isEnabled: scheduleEnabled, + onTap: { + guard scheduleEnabled else { return } + activeSelector = .recurrence + } ) } } @@ -214,6 +234,14 @@ struct CreateTaskSheet: View { .onChange(of: title) { _, _ in scheduleNlpParse() } + .onChange(of: scheduleEnabled) { _, isEnabled in + if !isEnabled { + repeatRule = nil + if activeSelector == .date || activeSelector == .time || activeSelector == .recurrence { + activeSelector = nil + } + } + } .onPreferenceChange(CreateTaskSheetHeaderHeightKey.self) { height in headerHeight = max(height, 1) } @@ -224,14 +252,22 @@ struct CreateTaskSheet: View { private func hydrateFromInitialPayload() { guard let initialPayload else { + scheduleEnabled = defaultScheduled + repeatRule = defaultScheduled ? repeatRule : nil return } title = initialPayload.title notes = initialPayload.description ?? "" priority = initialPayload.priority selectedListID = initialPayload.listId - dueDate = initialPayload.due - repeatRule = initialPayload.rrule + if let due = initialPayload.due { + dueDate = due + scheduleEnabled = true + repeatRule = initialPayload.rrule + } else { + scheduleEnabled = false + repeatRule = nil + } } private func scheduleNlpParse() { @@ -251,6 +287,7 @@ struct CreateTaskSheet: View { title = parsed.cleanTitle if let dueEpochMs = parsed.dueEpochMs { dueDate = Date(epochMilliseconds: dueEpochMs) + scheduleEnabled = true } } } @@ -262,8 +299,8 @@ struct CreateTaskSheet: View { title: title.trimmingCharacters(in: .whitespacesAndNewlines), description: notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : notes.trimmingCharacters(in: .whitespacesAndNewlines), priority: priority, - due: dueDate, - rrule: repeatRule, + due: scheduleEnabled ? dueDate : nil, + rrule: scheduleEnabled ? repeatRule : nil, listId: selectedListID ) await onSubmit(payload) @@ -483,6 +520,37 @@ private struct CreateTaskSheetGroupCard: View { } } +private struct CreateTaskSheetScheduleToggleRow: View { + @Binding var isOn: Bool + + @Environment(\.tdayColors) private var colors + + var body: some View { + Toggle(isOn: $isOn.animation(.spring(response: 0.28, dampingFraction: 0.9))) { + HStack(spacing: 10) { + Image(systemName: isOn ? "calendar.badge.clock" : "tray.full") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .frame(width: 22, height: 22) + + VStack(alignment: .leading, spacing: 2) { + Text("Schedule") + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurface) + Text(isOn ? "Task has a due date" : "Anytime task") + .font(.tdayRounded(size: 12, weight: .bold)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) + } + } + } + .toggleStyle(.switch) + .tint(colors.primary) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .frame(minHeight: 72) + } +} + private struct CreateTaskSheetDueRow: View { @Binding var dueDate: Date let onDateTap: () -> Void @@ -644,6 +712,7 @@ private struct CreateTaskSheetSelectorTriggerRow: View { let iconName: String let title: String let value: String + var isEnabled = true let onTap: () -> Void @Environment(\.tdayColors) private var colors @@ -653,25 +722,25 @@ private struct CreateTaskSheetSelectorTriggerRow: View { HStack(spacing: 14) { Image(systemName: iconName) .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(colors.onSurfaceVariant) + .foregroundStyle(colors.onSurfaceVariant.opacity(isEnabled ? 1 : 0.42)) .frame(width: 22, height: 22) Text(title) .font(.tdayRounded(size: 18, weight: .heavy)) - .foregroundStyle(colors.onSurface) + .foregroundStyle(colors.onSurface.opacity(isEnabled ? 1 : 0.5)) Spacer(minLength: 8) HStack(spacing: 4) { Text(value) .font(.tdayRounded(size: 14, weight: .heavy)) - .foregroundStyle(colors.onSurfaceVariant) + .foregroundStyle(colors.onSurfaceVariant.opacity(isEnabled ? 1 : 0.48)) .lineLimit(1) .minimumScaleFactor(0.78) Image(systemName: "chevron.down") .font(.system(size: 12, weight: .heavy)) - .foregroundStyle(colors.onSurfaceVariant.opacity(0.72)) + .foregroundStyle(colors.onSurfaceVariant.opacity(isEnabled ? 0.72 : 0.3)) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -680,6 +749,7 @@ private struct CreateTaskSheetSelectorTriggerRow: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .disabled(!isEnabled) } } diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 1f300118..8abb6060 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -56,6 +56,7 @@ 861A548A6A3DDE0A78D35D83 /* TdayApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70A4E591B572A2A9980EC6B7 /* TdayApp.swift */; }; 8D227AE36B26809A15603F5E /* SecureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = E867FA22FEB66EDCE837CF6D /* SecureStore.swift */; }; 8F2E99D406CD695C364FE1DF /* UserFacingError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A7EC85EB5D9686D401249C6 /* UserFacingError.swift */; }; + A9F10102A9F10102A9F10102 /* RootFeedDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9F10101A9F10101A9F10101 /* RootFeedDock.swift */; }; 9146B23E1A1F6A3A71DE7A5B /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC2B21F3E1A43EAEECA86EE8 /* Colors.swift */; }; 91660A57CFA826E39338BD33 /* CalendarScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A90A443440754855071C9D /* CalendarScreen.swift */; }; 957E83469CA6C09174B1B374 /* LoginCredentialCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C31F2E171A73F2774E89A1 /* LoginCredentialCoordinator.swift */; }; @@ -102,6 +103,7 @@ 0380EFC9ADD5303B83B5BD91 /* ProbeDecryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProbeDecryptor.swift; sourceTree = ""; }; 055D0F0501B287B70456AF4B /* TodoRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoRepository.swift; sourceTree = ""; }; 0DE4A047CF237AD9F605D02E /* TaskFloatingActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFloatingActionButton.swift; sourceTree = ""; }; + A9F10101A9F10101A9F10101 /* RootFeedDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootFeedDock.swift; sourceTree = ""; }; 133161412A6BD2A0C04F745A /* SnackbarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackbarManager.swift; sourceTree = ""; }; 15B9BC191869EBEA7E8340E6 /* NavigationBackHistoryTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBackHistoryTitle.swift; sourceTree = ""; }; 19F61DADBD9B8EFC300698C4 /* SystemCredentialLoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemCredentialLoginTests.swift; sourceTree = ""; }; @@ -553,6 +555,7 @@ AE3D896A638869384B4CF1EF /* ErrorRetryView.swift */, 15B9BC191869EBEA7E8340E6 /* NavigationBackHistoryTitle.swift */, AC35A6A50C3BFA68468FEDF9 /* OfflineBanner.swift */, + A9F10101A9F10101A9F10101 /* RootFeedDock.swift */, 133161412A6BD2A0C04F745A /* SnackbarManager.swift */, 0DE4A047CF237AD9F605D02E /* TaskFloatingActionButton.swift */, 8A7EC85EB5D9686D401249C6 /* UserFacingError.swift */, @@ -734,6 +737,7 @@ 1AC138341BE3A2841CF05908 /* StringHelpers.swift in Sources */, 222184A155C5A7B9F178007B /* SwiftDataModels.swift in Sources */, 2C1549AFC2F29218C879306C /* SwipeActions.swift in Sources */, + A9F10102A9F10102A9F10102 /* RootFeedDock.swift in Sources */, 58EB6EF803693EB982E331E4 /* SyncAndRefreshUseCase.swift in Sources */, 02DCC3220DC138F342784CA4 /* SyncManager.swift in Sources */, 5D93F6903E05BD9902C43A51 /* SystemCredentialService.swift in Sources */, diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt index 14d589b9..967fe1a4 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt @@ -14,7 +14,7 @@ data class CompletedTodoDto( val title: String, val description: String? = null, val priority: String = "Low", - val due: String, + val due: String? = null, val completedAt: String? = null, val completedOnTime: Boolean? = null, val daysToComplete: Double? = null, diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/ListModels.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/ListModels.kt index c12cec6f..ba6d4b4d 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/ListModels.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/ListModels.kt @@ -63,7 +63,7 @@ data class ListTodoDto( val id: String, val title: String, val priority: String, - val due: String, + val due: String? = null, val completed: Boolean, val order: Int, ) diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/TodoModels.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/TodoModels.kt index f83e2627..b5777d94 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/TodoModels.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/TodoModels.kt @@ -47,7 +47,7 @@ data class CreateTodoRequest( val title: String, val description: String? = null, val priority: String = "Low", - val due: String, + val due: String? = null, val rrule: String? = null, val listID: String? = null, ) @@ -59,7 +59,7 @@ data class TodoDto( val description: String? = null, val pinned: Boolean = false, val priority: String = "Low", - val due: String, + val due: String? = null, val rrule: String? = null, val timeZone: String? = null, val instanceDate: String? = null, diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/validation/ContractValidators.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/validation/ContractValidators.kt index a485f1d0..9af2458f 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/validation/ContractValidators.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/validation/ContractValidators.kt @@ -9,8 +9,8 @@ object ContractValidators { if (request.title.isBlank()) { errors += "title cannot be blank" } - if (request.due.isBlank()) { - errors += "due is required" + if (!request.rrule.isNullOrBlank() && request.due.isNullOrBlank()) { + errors += "due is required for recurring tasks" } return errors } diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt index bab26ac4..d26ae4ae 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt @@ -12,8 +12,8 @@ object CompletedTodos : Table("CompletedTodo") { val description = text("description").nullable() val priority = pgEnum("priority", "\"Priority\"") val completedAt = datetime("completedAt") - val due = datetime("due") - val completedOnTime = bool("completedOnTime") + val due = datetime("due").nullable() + val completedOnTime = bool("completedOnTime").nullable() val daysToComplete = decimal("daysToComplete", 10, 2) val rrule = text("rrule").nullable() val userID = varchar("userID", 30).references(Users.id).index() diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Todos.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Todos.kt index 49cf7476..6e796df1 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Todos.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Todos.kt @@ -15,7 +15,7 @@ object Todos : Table("todos") { val pinned = bool("pinned").default(false) val order = integer("order").autoIncrement() val priority = pgEnum("priority", "\"Priority\"") - val due = datetime("due") + val due = datetime("due").nullable() val exdates = registerColumn>("exdates", TimestampArrayColumnType()) val rrule = text("rrule").nullable() val timeZone = varchar("timeZone", 64).default("UTC") diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt index 0365a7f7..66bbab4d 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt @@ -43,15 +43,20 @@ fun Route.completedTodoRoutes() { body.title?.let { fields["title"] = it } body.description?.let { fields["description"] = it } body.priority?.let { fields["priority"] = it } - body.due?.let { due -> - val parsed = parseTodoDateTime(due) - ?: return@withAuth Either.Left( - AppError.BadRequest("due must be a valid ISO-8601 datetime"), - ) - fields["due"] = parsed + val due = body.due + if (due != null) { + if (due.isBlank()) { + fields["due"] = null + } else { + val parsed = parseTodoDateTime(due) + ?: return@withAuth Either.Left( + AppError.BadRequest("due must be a valid ISO-8601 datetime"), + ) + fields["due"] = parsed + } } - body.rrule?.let { fields["rrule"] = it } - body.listID?.let { fields["listID"] = it } + body.rrule?.let { fields["rrule"] = it.takeIf { value -> value.isNotBlank() } } + body.listID?.let { fields["listID"] = it.takeIf { value -> value.isNotBlank() } } if (fields.isEmpty()) { return@withAuth completedTodoService.deleteById(user.id, body.id) .map { count -> diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/TodoRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/TodoRoutes.kt index 20b8de8a..f1744b33 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/TodoRoutes.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/TodoRoutes.kt @@ -24,6 +24,7 @@ private const val MSG = "message" private const val TODOS = "todos" private const val SUMMARY = "summary" private const val ERR_INVALID_DUE = "due must be a valid ISO-8601 datetime" +private const val ERR_RECURRING_REQUIRES_DUE = "due is required for recurring tasks" private const val ERR_INVALID_INSTANCE_DATE = "instanceDate must be a valid ISO-8601 datetime" fun Route.todoRoutes() { @@ -50,8 +51,14 @@ private fun Route.todoCreateRoute(todoService: TodoService) { val body = call.receive() validateCreateTodo.validateOrFail(body).bind() val due = parseTodoDateTime(body.due) - ?: raise(AppError.BadRequest(ERR_INVALID_DUE)) - val todo = todoService.create(user.id, body.title, body.description, body.priority, due, body.rrule, body.listID).bind() + if (!body.due.isNullOrBlank() && due == null) { + raise(AppError.BadRequest(ERR_INVALID_DUE)) + } + val rrule = body.rrule?.takeIf { it.isNotBlank() } + if (rrule != null && due == null) { + raise(AppError.BadRequest(ERR_RECURRING_REQUIRES_DUE)) + } + val todo = todoService.create(user.id, body.title, body.description, body.priority, due, rrule, body.listID).bind() CreateTodoResponse(message = "todo created", todo = todo) } } @@ -92,13 +99,28 @@ private fun Route.todoPatchRoute(todoService: TodoService) { body.priority?.let { fields["priority"] = it } body.pinned?.let { fields["pinned"] = it } body.completed?.let { fields["completed"] = it } - body.due?.let { - val parsed = parseTodoDateTime(it) + val requestedRrule = body.rrule?.takeIf { it.isNotBlank() } + if (body.dateChanged == true && body.due.isNullOrBlank() && requestedRrule != null) { + raise(AppError.BadRequest(ERR_RECURRING_REQUIRES_DUE)) + } + if (body.dateChanged == true) { + if (body.due.isNullOrBlank()) { + fields["due"] = null + fields["rrule"] = null + } else { + val parsed = parseTodoDateTime(body.due) + ?: raise(AppError.BadRequest(ERR_INVALID_DUE)) + fields["due"] = parsed + } + } else if (!body.due.isNullOrBlank()) { + val parsed = parseTodoDateTime(body.due) ?: raise(AppError.BadRequest(ERR_INVALID_DUE)) fields["due"] = parsed } - body.rrule?.let { fields["rrule"] = it } - body.listID?.let { fields["listID"] = it } + if (body.rruleChanged == true || body.rrule != null) { + fields["rrule"] = requestedRrule + } + body.listID?.let { fields["listID"] = it.takeIf { value -> value.isNotBlank() } } todoService.update(user.id, body.id, fields).bind() mapOf(MSG to "Todo updated") } @@ -261,10 +283,11 @@ private fun Route.todoUtilityRoutes( } } -internal fun parseTodoDateTime(value: String): LocalDateTime? { - return runCatching { LocalDateTime.parse(value) }.getOrNull() +internal fun parseTodoDateTime(value: String?): LocalDateTime? { + val normalized = value?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return runCatching { LocalDateTime.parse(normalized) }.getOrNull() ?: runCatching { - OffsetDateTime.parse(value) + OffsetDateTime.parse(normalized) .withOffsetSameInstant(ZoneOffset.UTC) .toLocalDateTime() }.getOrNull() diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt index 6d12046e..c35f1cf0 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt @@ -68,8 +68,8 @@ class CompletedTodoServiceImpl( stmt[CompletedTodos.description] = fieldEncryption.encryptIfSensitive("description", it as? String) } fields["priority"]?.let { stmt[CompletedTodos.priority] = Priority.valueOf(it as String) } - fields["due"]?.let { stmt[CompletedTodos.due] = it as LocalDateTime } - fields["rrule"]?.let { stmt[CompletedTodos.rrule] = it as? String } + if (fields.containsKey("due")) stmt[CompletedTodos.due] = fields["due"] as? LocalDateTime + if (fields.containsKey("rrule")) stmt[CompletedTodos.rrule] = fields["rrule"] as? String fields["listID"]?.let { listId -> stmt[CompletedTodos.listID] = listId as? String stmt[CompletedTodos.listName] = list?.get(Lists.name) @@ -88,7 +88,7 @@ class CompletedTodoServiceImpl( description = fieldEncryption.decryptIfEncrypted(this[CompletedTodos.description]), priority = this[CompletedTodos.priority].name, completedAt = this[CompletedTodos.completedAt].toString(), - due = this[CompletedTodos.due].toString(), + due = this[CompletedTodos.due]?.toString(), completedOnTime = this[CompletedTodos.completedOnTime], daysToComplete = this[CompletedTodos.daysToComplete].toDouble(), rrule = this[CompletedTodos.rrule], diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt index 369487a3..7615861b 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt @@ -60,7 +60,7 @@ class ListServiceImpl(private val cache: CacheService) : ListService { id = row[Todos.id], title = row[Todos.title], priority = row[Todos.priority].name, - due = row[Todos.due].toString(), + due = row[Todos.due]?.toString(), completed = row[Todos.completed], order = row[Todos.order], ) diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt index 94fdf546..f3fdc309 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt @@ -27,7 +27,7 @@ import java.time.ZoneId import java.time.ZoneOffset interface TodoService { - suspend fun create(userId: String, title: String, description: String?, priority: String, due: LocalDateTime, rrule: String?, listID: String?): Either + suspend fun create(userId: String, title: String, description: String?, priority: String, due: LocalDateTime?, rrule: String?, listID: String?): Either suspend fun getByDateRange(userId: String, start: Long, end: Long, timeZone: String): Either> suspend fun getTimeline(userId: String, timeZone: String, recurringFutureDays: Int): Either> suspend fun update(userId: String, id: String, fields: Map): Either @@ -48,7 +48,7 @@ class TodoServiceImpl( override suspend fun create( userId: String, title: String, description: String?, - priority: String, due: LocalDateTime, + priority: String, due: LocalDateTime?, rrule: String?, listID: String?, ): Either { val id = CuidGenerator.newCuid() @@ -72,7 +72,7 @@ class TodoServiceImpl( cache.invalidateTodoCaches(userId) return TodoResponse( id = id, title = title, description = description, - priority = priority, due = due.toString(), + priority = priority, due = due?.toString(), rrule = rrule, timeZone = "UTC", completed = false, pinned = false, order = 0, listID = listID, userID = userId, createdAt = now.toString(), updatedAt = now.toString(), @@ -87,7 +87,7 @@ class TodoServiceImpl( val todos = newSuspendedTransaction(Dispatchers.IO) { val oneOff = Todos.selectAll().where { (Todos.userID eq userId) and Todos.rrule.isNull() and - (Todos.completed eq false) and (Todos.due greaterEq dateRangeStart) and + (Todos.completed eq false) and Todos.due.isNotNull() and (Todos.due greaterEq dateRangeStart) and (Todos.due lessEq dateRangeEnd) }.orderBy(Todos.createdAt, SortOrder.DESC).map { it.toTodoResponse() } @@ -129,9 +129,9 @@ class TodoServiceImpl( fields["priority"]?.let { stmt[Todos.priority] = Priority.valueOf(it as String) } fields["pinned"]?.let { stmt[Todos.pinned] = it as Boolean } fields["completed"]?.let { stmt[Todos.completed] = it as Boolean } - fields["due"]?.let { stmt[Todos.due] = it as LocalDateTime } - fields["rrule"]?.let { stmt[Todos.rrule] = it as? String } - fields["listID"]?.let { stmt[Todos.listID] = it as? String } + if (fields.containsKey("due")) stmt[Todos.due] = fields["due"] as? LocalDateTime + if (fields.containsKey("rrule")) stmt[Todos.rrule] = fields["rrule"] as? String + if (fields.containsKey("listID")) stmt[Todos.listID] = fields["listID"] as? String stmt[Todos.updatedAt] = LocalDateTime.now() } } @@ -182,7 +182,7 @@ class TodoServiceImpl( it[CompletedTodos.priority] = todo[Todos.priority] it[CompletedTodos.completedAt] = now it[CompletedTodos.due] = todoDue - it[CompletedTodos.completedOnTime] = !now.isAfter(todoDue) + it[CompletedTodos.completedOnTime] = todoDue?.let { due -> !now.isAfter(due) } it[CompletedTodos.daysToComplete] = BigDecimal.valueOf(daysToComplete).setScale(2, RoundingMode.HALF_UP) it[CompletedTodos.rrule] = todo[Todos.rrule] it[CompletedTodos.userID] = userId @@ -278,7 +278,7 @@ class TodoServiceImpl( val todos = newSuspendedTransaction(Dispatchers.IO) { Todos.selectAll().where { (Todos.userID eq userId) and (Todos.completed eq false) and - Todos.rrule.isNull() and (Todos.due less now) + Todos.rrule.isNull() and Todos.due.isNotNull() and (Todos.due less now) }.orderBy(Todos.due, SortOrder.ASC).map { it.toTodoResponse() } } return todos.right() @@ -360,7 +360,7 @@ class TodoServiceImpl( pinned = this[Todos.pinned], order = this[Todos.order], priority = this[Todos.priority].name, - due = this[Todos.due].toString(), + due = this[Todos.due]?.toString(), rrule = this[Todos.rrule], timeZone = this[Todos.timeZone], completed = this[Todos.completed], diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/plugins/RateLimitingTest.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/plugins/RateLimitingTest.kt index 5ffccee3..f6f24331 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/plugins/RateLimitingTest.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/plugins/RateLimitingTest.kt @@ -261,7 +261,7 @@ class RateLimitingTest { title: String, description: String?, priority: String, - due: LocalDateTime, + due: LocalDateTime?, rrule: String?, listID: String?, ): Either = unsupported() diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/TodoRoutesTest.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/TodoRoutesTest.kt index 390fc99a..03b50a76 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/TodoRoutesTest.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/TodoRoutesTest.kt @@ -13,6 +13,8 @@ import com.ohmz.tday.services.TodoNlpService import com.ohmz.tday.services.TodoService import com.ohmz.tday.services.TodoSummaryService import com.ohmz.tday.shared.model.CreateTodoRequest +import com.ohmz.tday.shared.model.UpdateTodoRequest +import io.ktor.client.request.patch import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText @@ -33,6 +35,7 @@ import org.koin.ktor.plugin.Koin import java.time.LocalDateTime import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue class TodoRoutesTest { private val json = Json { ignoreUnknownKeys = true } @@ -63,6 +66,62 @@ class TodoRoutesTest { assertEquals(LocalDateTime.of(2026, 3, 27, 15, 42, 0), todoService.lastCreateDue) } + @Test + fun `create todo allows missing due date`() = testApplication { + val todoService = RecordingTodoService() + + application { + configureTodoRoutesTestApp(todoService) + } + + val response = client.post("/api/todo") { + contentType(ContentType.Application.Json) + setBody( + json.encodeToString( + CreateTodoRequest( + title = "Anytime task", + description = null, + priority = "Low", + ), + ), + ) + } + + assertEquals(HttpStatusCode.OK, response.status) + assertNull(todoService.lastCreateDue) + } + + @Test + fun `create todo requires due date for recurring tasks`() = testApplication { + val todoService = RecordingTodoService() + + application { + configureTodoRoutesTestApp(todoService) + } + + val response = client.post("/api/todo") { + contentType(ContentType.Application.Json) + setBody( + json.encodeToString( + CreateTodoRequest( + title = "Repeating task", + description = null, + priority = "Low", + rrule = "RRULE:FREQ=DAILY;INTERVAL=1", + ), + ), + ) + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + val payload = json.parseToJsonElement(response.bodyAsText()).jsonObject + assertEquals( + "due is required for recurring tasks", + payload.getValue("message").jsonPrimitive.content, + ) + assertNull(todoService.lastCreateDue) + } + @Test fun `create todo returns bad request when timestamp is invalid`() = testApplication { val todoService = RecordingTodoService() @@ -94,6 +153,66 @@ class TodoRoutesTest { assertNull(todoService.lastCreateDue) } + @Test + fun `patch todo clears due and repeat when dateChanged true and due is missing`() = testApplication { + val todoService = RecordingTodoService() + + application { + configureTodoRoutesTestApp(todoService) + } + + val response = client.patch("/api/todo") { + contentType(ContentType.Application.Json) + setBody( + json.encodeToString( + UpdateTodoRequest( + id = "todo_123", + dateChanged = true, + ), + ), + ) + } + + assertEquals(HttpStatusCode.OK, response.status) + val fields = todoService.lastUpdateFields ?: error("expected update fields") + assertTrue(fields.containsKey("due")) + assertNull(fields["due"]) + assertTrue(fields.containsKey("rrule")) + assertNull(fields["rrule"]) + } + + @Test + fun `patch todo rejects repeat rule when due is cleared`() = testApplication { + val todoService = RecordingTodoService() + + application { + configureTodoRoutesTestApp(todoService) + } + + val response = client.patch("/api/todo") { + contentType(ContentType.Application.Json) + setBody( + json.encodeToString( + UpdateTodoRequest( + id = "todo_123", + due = null, + dateChanged = true, + rrule = "RRULE:FREQ=DAILY;INTERVAL=1", + rruleChanged = true, + ), + ), + ) + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + val payload = json.parseToJsonElement(response.bodyAsText()).jsonObject + assertEquals( + "due is required for recurring tasks", + payload.getValue("message").jsonPrimitive.content, + ) + assertNull(todoService.lastUpdateFields) + } + private fun Application.configureTodoRoutesTestApp( todoService: TodoService, ) { @@ -132,13 +251,14 @@ class TodoRoutesTest { private class RecordingTodoService : TodoService { var lastCreateDue: LocalDateTime? = null + var lastUpdateFields: Map? = null override suspend fun create( userId: String, title: String, description: String?, priority: String, - due: LocalDateTime, + due: LocalDateTime?, rrule: String?, listID: String?, ): Either { @@ -148,7 +268,7 @@ class TodoRoutesTest { title = title, description = description, priority = priority, - due = due.toString(), + due = due?.toString(), listID = listID, completed = false, pinned = false, @@ -168,7 +288,10 @@ class TodoRoutesTest { recurringFutureDays: Int, ) = emptyList().right() - override suspend fun update(userId: String, id: String, fields: Map) = Unit.right() + override suspend fun update(userId: String, id: String, fields: Map): Either { + lastUpdateFields = fields + return Unit.right() + } override suspend fun delete(userId: String, id: String) = 1.right() diff --git a/tday-web/src/features/calendar/query/get-calendar-todo.ts b/tday-web/src/features/calendar/query/get-calendar-todo.ts index 794948b9..2a15a470 100644 --- a/tday-web/src/features/calendar/query/get-calendar-todo.ts +++ b/tday-web/src/features/calendar/query/get-calendar-todo.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api-client"; -import { TodoItemType } from "@/types"; +import { TodoApiItemType, TodoItemType } from "@/types"; import parseApiDateTime from "@/lib/date/parseApiDateTime"; export const useCalendarTodo = (calendarRange: { start: Date; end: Date }) => { @@ -19,14 +19,14 @@ export const useCalendarTodo = (calendarRange: { start: Date; end: Date }) => { const data = await api.GET({ url: `/api/todo?start=${calendarRange.start.getTime()}&end=${calendarRange.end.getTime()}`, }); - const { todos }: { todos: TodoItemType[] } = data; + const { todos }: { todos: TodoApiItemType[] } = data; if (!todos) { throw new Error( data.message || `bad server response: Did not recieve todo`, ); } - const todoWithFormattedDates = todos.map((todo) => { + const todoWithFormattedDates = todos.filter((todo) => todo.due != null).map((todo) => { // id needs to be todo id + instance date, so that ghost todos of the same parent can have unique ids const todoInstanceDate = todo.instanceDate ? parseApiDateTime(todo.instanceDate) @@ -36,7 +36,7 @@ export const useCalendarTodo = (calendarRange: { start: Date; end: Date }) => { return { ...todo, id: todoId, - due: parseApiDateTime(todo.due), + due: parseApiDateTime(todo.due!), instanceDate: todoInstanceDate, listID: todo.listID ?? null, instances: diff --git a/tday-web/src/features/completed/query/get-completedTodo.ts b/tday-web/src/features/completed/query/get-completedTodo.ts index abbd4061..2b6eeb70 100644 --- a/tday-web/src/features/completed/query/get-completedTodo.ts +++ b/tday-web/src/features/completed/query/get-completedTodo.ts @@ -1,7 +1,7 @@ import { CompletedTodoItemType } from "@/types"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api-client"; -import parseApiDateTime from "@/lib/date/parseApiDateTime"; +import parseApiDateTime, { parseOptionalApiDateTime } from "@/lib/date/parseApiDateTime"; export const useCompletedTodo = () => { const { @@ -33,7 +33,7 @@ export const useCompletedTodo = () => { id: todoId, instanceDate: todoInstanceDate, createdAt: parseApiDateTime(todo.createdAt), - due: parseApiDateTime(todo.due), + due: parseOptionalApiDateTime(todo.due), completedAt: parseApiDateTime(todo.completedAt), }; }); diff --git a/tday-web/src/features/list/query/get-list-todos.ts b/tday-web/src/features/list/query/get-list-todos.ts index e43c4597..15a7c1cb 100644 --- a/tday-web/src/features/list/query/get-list-todos.ts +++ b/tday-web/src/features/list/query/get-list-todos.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api-client"; -import { TodoItemType } from "@/types"; +import { TodoApiItemType, TodoItemType } from "@/types"; import { endOfToday, startOfToday } from "date-fns"; import parseApiDateTime from "@/lib/date/parseApiDateTime"; @@ -21,7 +21,7 @@ export const useList = ({ id }: { id: string }) => { signal, }); - const todoWithFormattedDates = todos.map((todo: TodoItemType) => { + const todoWithFormattedDates = (todos as TodoApiItemType[]).filter((todo) => todo.due != null).map((todo) => { // id needs to be todo id + instance date, so that ghost todos of the same parent can have unique ids const todoInstanceDate = todo.instanceDate ? parseApiDateTime(todo.instanceDate) @@ -32,7 +32,7 @@ export const useList = ({ id }: { id: string }) => { ...todo, id: todoId, createdAt: parseApiDateTime(todo.createdAt), - due: parseApiDateTime(todo.due), + due: parseApiDateTime(todo.due!), instanceDate: todoInstanceDate, listID: todo.listID ?? null, }; diff --git a/tday-web/src/features/todayTodos/query/get-todo-timeline.ts b/tday-web/src/features/todayTodos/query/get-todo-timeline.ts index 07d0df37..9ea25fb6 100644 --- a/tday-web/src/features/todayTodos/query/get-todo-timeline.ts +++ b/tday-web/src/features/todayTodos/query/get-todo-timeline.ts @@ -1,4 +1,4 @@ -import { TodoItemType } from "@/types"; +import { TodoApiItemType, TodoItemType } from "@/types"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api-client"; import parseApiDateTime from "@/lib/date/parseApiDateTime"; @@ -7,13 +7,13 @@ const getTodoTimeline = async () => { const data = await api.GET({ url: "/api/todo?timeline=true", }); - const { todos }: { todos: TodoItemType[] } = data; + const { todos }: { todos: TodoApiItemType[] } = data; if (!todos) { throw new Error(data.message || "bad server response: Did not recieve todo"); } - return todos.map((todo) => { + return todos.filter((todo) => todo.due != null).map((todo) => { const todoInstanceDate = todo.instanceDate ? parseApiDateTime(todo.instanceDate) : null; @@ -24,7 +24,7 @@ const getTodoTimeline = async () => { ...todo, id: todoId, createdAt: parseApiDateTime(todo.createdAt), - due: parseApiDateTime(todo.due), + due: parseApiDateTime(todo.due!), instanceDate: todoInstanceDate, }; }); diff --git a/tday-web/src/features/todayTodos/query/get-todo.ts b/tday-web/src/features/todayTodos/query/get-todo.ts index 43d6c1ab..3564f2db 100644 --- a/tday-web/src/features/todayTodos/query/get-todo.ts +++ b/tday-web/src/features/todayTodos/query/get-todo.ts @@ -1,4 +1,4 @@ -import { TodoItemType } from "@/types"; +import { TodoApiItemType, TodoItemType } from "@/types"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/lib/api-client"; import { startOfToday, endOfToday } from "date-fns"; @@ -8,13 +8,13 @@ const getTodo = async () => { const data = await api.GET({ url: `/api/todo?start=${startOfToday().getTime()}&end=${endOfToday().getTime()}`, }); - const { todos }: { todos: TodoItemType[] } = data; + const { todos }: { todos: TodoApiItemType[] } = data; if (!todos) { throw new Error( data.message || `bad server response: Did not recieve todo`, ); } - const todoWithFormattedDates = todos.map((todo) => { + const todoWithFormattedDates = todos.filter((todo) => todo.due != null).map((todo) => { // id needs to be todo id + instance date, so that ghost todos of the same parent can have unique ids const todoInstanceDate = todo.instanceDate ? parseApiDateTime(todo.instanceDate) @@ -25,7 +25,7 @@ const getTodo = async () => { ...todo, id: todoId, createdAt: parseApiDateTime(todo.createdAt), - due: parseApiDateTime(todo.due), + due: parseApiDateTime(todo.due!), instanceDate: todoInstanceDate, listID: todo.listID ?? null, }; diff --git a/tday-web/src/lib/date/parseApiDateTime.ts b/tday-web/src/lib/date/parseApiDateTime.ts index e6d2e36d..f20b6e78 100644 --- a/tday-web/src/lib/date/parseApiDateTime.ts +++ b/tday-web/src/lib/date/parseApiDateTime.ts @@ -49,3 +49,12 @@ export default function parseApiDateTime(value: string | Date): Date { return new Date(value); } + +export function parseOptionalApiDateTime( + value: string | Date | null | undefined, +): Date | null { + if (value == null || value === "") { + return null; + } + return parseApiDateTime(value); +} diff --git a/tday-web/src/types/index.ts b/tday-web/src/types/index.ts index 24ab2ff6..68b2e491 100644 --- a/tday-web/src/types/index.ts +++ b/tday-web/src/types/index.ts @@ -51,6 +51,10 @@ export interface recurringTodoItemType extends TodoItemType { instances: overridingInstance[]; } +export interface TodoApiItemType extends Omit { + due: Date | null; +} + export interface CompletedTodoItemType { id: string; originalTodoID: string | null; @@ -59,7 +63,7 @@ export interface CompletedTodoItemType { createdAt: Date; completedAt: Date; priority: "Low" | "Medium" | "High"; - due: Date; + due: Date | null; userID: string; rrule: string | null; instanceDate: Date | null; diff --git a/tday-web/vitest.config.ts b/tday-web/vitest.config.ts index 20c6886a..366812a4 100644 --- a/tday-web/vitest.config.ts +++ b/tday-web/vitest.config.ts @@ -11,5 +11,6 @@ export default defineConfig({ }, test: { include: ["tests/**/*.test.{ts,tsx}"], + setupFiles: ["tests/setup/web-storage.ts"], }, }); From 3aa884b03466ac15bc8e8d84e20b4812dea7a44c Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Thu, 28 May 2026 23:10:08 -0400 Subject: [PATCH 02/11] feat(ux): implement `RootFeedDock` navigation and unified "Anytime" view Introduce a persistent docking navigation component ("RootFeedDock") to toggle between "Home" and "Anytime" views, featuring a spring-animated sliding selector and scroll-responsive collapsing behavior. - **Navigation & UI**: - Implement `RootFeedDock` in both Android (Compose) and iOS (SwiftUI) with interactive state management, including a "tap-to-expand" behavior when collapsed. - Unified the "Anytime" view to use a new hero header style with dynamic daytime/nighttime icons (Sun/Moon) based on the local hour. - Standardized dock collapse thresholds to `44.dp` / `44pt` across platforms to improve consistency during list scrolling. - **Android (Compose)**: - Created `RootFeedDock.kt` and `RootCreateTaskButton` for the main navigation interface. - Refactored `HomeScreen` and `TodoListScreen` to support the new dock logic and remove legacy cascade animations for list items to improve performance. - Updated `TdayApp.kt` navigation graph to handle the `Anytime` route via the root dock state rather than independent screen transitions. - Updated `TdaySegmentedSlider` to handle text overflow with ellipses. - **iOS (SwiftUI)**: - Created `RootFeedDock.swift` using `ultraThinMaterial` and `GeometryReader` for a native, fluid feel. - Refactored `AppRootView` and `AppViewModel` to manage a synchronized navigation path that treats "Home" and "Anytime" as top-level root states. - Updated `TodoListScreen` to support `usesRootFeedHeader` logic, hiding standard navigation bars when the hero header is present. - **Backend & Tooling**: - **Database**: Added a migration to allow unscheduled todos by dropping `NOT NULL` constraints on `due` dates in `todos` and `completedtodo` tables. - **Web**: Added a memory-based storage mock for `localStorage` and `sessionStorage` in test environments. --- .../java/com/ohmz/tday/compose/TdayApp.kt | 32 +- .../tday/compose/feature/home/HomeScreen.kt | 185 +------- .../compose/feature/todos/TodoListScreen.kt | 301 ++++++++----- .../tday/compose/ui/component/RootFeedDock.kt | 396 ++++++++++++++++++ .../ui/component/TdaySegmentedSlider.kt | 4 + ios-swiftUI/Tday/Core/UI/RootFeedDock.swift | 145 +++++++ .../Tday/Feature/App/AppRootView.swift | 99 ++++- .../Tday/Feature/App/AppViewModel.swift | 2 +- .../Tday/Feature/Home/HomeScreen.swift | 22 +- .../Tday/Feature/Todos/TodoListScreen.swift | 175 +++++--- .../migration/V7__allow_unscheduled_todos.sql | 8 + tday-web/tests/setup/web-storage.ts | 93 ++++ 12 files changed, 1086 insertions(+), 376 deletions(-) create mode 100644 android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt create mode 100644 ios-swiftUI/Tday/Core/UI/RootFeedDock.swift create mode 100644 tday-backend/src/main/resources/db/migration/V7__allow_unscheduled_todos.sql create mode 100644 tday-web/tests/setup/web-storage.ts 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 31d51e61..71d586c0 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 @@ -402,6 +402,7 @@ fun TdayApp( onTaskDeleted = ::showTaskDeletedToast, showRootFeedDock = false, showCreateTaskButton = false, + usesRootFeedHeader = true, createTaskRequestKey = rootCreateTaskRequestKey, onRootDockCollapsedChange = { rootDockCollapsed = it @@ -549,27 +550,14 @@ fun TdayApp( route = AppRoute.AnytimeTodos.route, deepLinks = listOf(navDeepLink { uriPattern = "tday://anytime" }), ) { - TodosRoute( - mode = TodoListMode.ANYTIME, - onBack = { - navController.navigate(AppRoute.Home.route) { - popUpTo(AppRoute.Home.route) { inclusive = false } - launchSingleTop = true - } - }, - onTaskDeleted = ::showTaskDeletedToast, - rootFeedTab = RootFeedTab.ANYTIME, - onRootFeedTabSelected = { tab -> - when (tab) { - RootFeedTab.HOME -> navController.navigate(AppRoute.Home.route) { - popUpTo(AppRoute.Home.route) { inclusive = false } - launchSingleTop = true - } - - RootFeedTab.ANYTIME -> Unit - } - }, - ) + LaunchedEffect(Unit) { + rootFeedTab = RootFeedTab.ANYTIME + navController.navigate(AppRoute.Home.route) { + popUpTo(AppRoute.Home.route) { inclusive = false } + launchSingleTop = true + } + } + Box(modifier = Modifier.fillMaxSize()) } composable( @@ -938,6 +926,7 @@ private fun TodosRoute( onRootFeedTabSelected: ((RootFeedTab) -> Unit)? = null, showRootFeedDock: Boolean = true, showCreateTaskButton: Boolean = true, + usesRootFeedHeader: Boolean = false, createTaskRequestKey: Int = 0, onRootDockCollapsedChange: (Boolean) -> Unit = {}, onRootControlsVisibleChange: (Boolean) -> Unit = {}, @@ -987,6 +976,7 @@ private fun TodosRoute( onRootFeedTabSelected = onRootFeedTabSelected, showRootFeedDock = showRootFeedDock, showCreateTaskButton = showCreateTaskButton, + usesRootFeedHeader = usesRootFeedHeader, createTaskRequestKey = createTaskRequestKey, onRootDockCollapsedChange = onRootDockCollapsedChange, onRootControlsVisibleChange = onRootControlsVisibleChange, 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 e0c7fdcd..226e16f7 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 @@ -284,12 +284,6 @@ fun HomeScreen( var listColor by rememberSaveable { mutableStateOf(DEFAULT_LIST_COLOR) } var listIconKey by rememberSaveable { mutableStateOf(DEFAULT_LIST_ICON_KEY) } var showCreateList by rememberSaveable { mutableStateOf(false) } - var hasCapturedInitialListSnapshot by rememberSaveable { mutableStateOf(false) } - var hasShownListDataOnce by rememberSaveable { mutableStateOf(false) } - var lastListStructureSignature by rememberSaveable { mutableStateOf("") } - var lastListIdsSignature by rememberSaveable { mutableStateOf("") } - var visibleListStage by rememberSaveable { mutableIntStateOf(0) } - var animateListCascade by rememberSaveable { mutableStateOf(false) } var searchResultOpening by rememberSaveable { mutableStateOf(false) } val searchResultScope = rememberCoroutineScope() val closeSearch = { @@ -344,22 +338,6 @@ fun HomeScreen( closeSearch() } } - val listStructureSignature = remember(uiState.summary.lists) { - uiState.summary.lists.joinToString(separator = "|") { list -> - buildString { - append(list.id) - append(':') - append(list.name) - append(':') - append(list.color.orEmpty()) - append(':') - append(list.iconKey.orEmpty()) - } - } - } - val listIdsSignature = remember(uiState.summary.lists) { - uiState.summary.lists.joinToString(separator = "|") { it.id } - } val listById = remember(uiState.summary.lists) { uiState.summary.lists.associateBy { it.id } } val normalizedSearchQuery = remember(searchQuery) { searchQuery.trim().lowercase(Locale.getDefault()) } val overdueCount = remember(uiState.searchableTodos) { @@ -390,8 +368,14 @@ fun HomeScreen( val showSearchResultsOverlay = searchExpanded && searchQuery.isNotBlank() val density = LocalDensity.current val listState = rememberLazyListState() + val hasScrollableContent = + listState.canScrollForward || listState.canScrollBackward + val dockCollapseThresholdPx = with(density) { RootFeedDockCollapseThreshold.roundToPx() } + val hasScrolledPastDockCollapseThreshold = + listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > dockCollapseThresholdPx val dockCollapsed = - listState.isScrollInProgress || listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 18 + hasScrollableContent && hasScrolledPastDockCollapseThreshold LaunchedEffect(dockCollapsed) { onRootDockCollapsedChange(dockCollapsed) } @@ -410,69 +394,6 @@ fun HomeScreen( } } - LaunchedEffect(listStructureSignature) { - val lists = uiState.summary.lists - val targetFinalStage = if (lists.isEmpty()) 0 else lists.size + 1 - if (!hasCapturedInitialListSnapshot) { - visibleListStage = targetFinalStage - animateListCascade = false - hasCapturedInitialListSnapshot = true - hasShownListDataOnce = lists.isNotEmpty() - lastListStructureSignature = listStructureSignature - lastListIdsSignature = listIdsSignature - return@LaunchedEffect - } - - if (listStructureSignature == lastListStructureSignature) { - visibleListStage = targetFinalStage - animateListCascade = false - return@LaunchedEffect - } - - val previousListIds = lastListIdsSignature - .split('|') - .filter { it.isNotBlank() } - val currentListIds = lists.map { it.id } - val isDeletionOnly = previousListIds.isNotEmpty() && - currentListIds.size < previousListIds.size && - currentListIds.all { it in previousListIds } - val isMetadataOnlyChange = previousListIds == currentListIds - - lastListStructureSignature = listStructureSignature - lastListIdsSignature = listIdsSignature - if (lists.isEmpty()) { - visibleListStage = 0 - animateListCascade = false - return@LaunchedEffect - } - - if (!hasShownListDataOnce) { - visibleListStage = targetFinalStage - animateListCascade = false - hasShownListDataOnce = true - return@LaunchedEffect - } - - if (isDeletionOnly || isMetadataOnlyChange) { - visibleListStage = targetFinalStage - animateListCascade = false - return@LaunchedEffect - } - - animateListCascade = true - visibleListStage = 0 - delay(70) - visibleListStage = 1 - delay(75) - lists.forEachIndexed { index, _ -> - visibleListStage = index + 2 - delay(60) - } - // Stop wrapping rows with entry animation once the cascade has completed. - // This prevents rows from re-animating when they are recomposed during scroll. - visibleListStage = targetFinalStage - animateListCascade = false - } LaunchedEffect(showSearchResultsOverlay) { if (!showSearchResultsOverlay) { searchResultsBounds = null @@ -649,57 +570,23 @@ fun HomeScreen( if (uiState.summary.lists.isNotEmpty()) { item { - if (visibleListStage >= 1) { - if (animateListCascade) { - TopDownCascadeReveal { - MyListsHeader() - } - } else { - MyListsHeader() - } - } + MyListsHeader() } itemsIndexed( items = uiState.summary.lists, key = { _, list -> list.id }, contentType = { _, _ -> "list_row" }, - ) { index, list -> - if (visibleListStage >= index + 2) { - val listRowPlacementModifier = Modifier.animateItem( - fadeInSpec = tween(durationMillis = 180), - placementSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMediumLow, - ), - fadeOutSpec = tween(durationMillis = 130), - ) - if (animateListCascade) { - TopDownCascadeReveal(modifier = listRowPlacementModifier) { - ListRow( - name = list.name, - colorKey = list.color, - iconKey = list.iconKey, - count = list.todoCount, - onClick = { - closeSearch() - onOpenList(list.id, capitalizeFirstListLetter(list.name)) - }, - ) - } - } else { - ListRow( - modifier = listRowPlacementModifier, - name = list.name, - colorKey = list.color, - iconKey = list.iconKey, - count = list.todoCount, - onClick = { - closeSearch() - onOpenList(list.id, capitalizeFirstListLetter(list.name)) - }, - ) - } - } + ) { _, list -> + ListRow( + name = list.name, + colorKey = list.color, + iconKey = list.iconKey, + count = list.todoCount, + onClick = { + closeSearch() + onOpenList(list.id, capitalizeFirstListLetter(list.name)) + }, + ) } } @@ -2122,39 +2009,6 @@ private fun calendarTileColor(colorScheme: ColorScheme): Color { return Color(0xFF9A89D2) } -@Composable -private fun TopDownCascadeReveal( - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - var revealed by remember { mutableStateOf(false) } - val alpha by animateFloatAsState( - targetValue = if (revealed) 1f else 0f, - animationSpec = tween(durationMillis = 320), - label = "listCascadeAlpha", - ) - val offsetY by animateDpAsState( - targetValue = if (revealed) 0.dp else (-14).dp, - animationSpec = tween(durationMillis = 320), - label = "listCascadeOffsetY", - ) - - LaunchedEffect(Unit) { - revealed = true - } - - Box( - modifier = modifier - .fillMaxWidth() - .graphicsLayer { - this.alpha = alpha - translationY = offsetY.toPx() - }, - ) { - content() - } -} - @Composable private fun CategoryCard( modifier: Modifier, @@ -2501,6 +2355,7 @@ private const val CREATE_LIST_SHEET_NORMAL_HEIGHT_FRACTION = 0.70f private const val CREATE_LIST_SHEET_KEYBOARD_HEIGHT_FRACTION = 0.80f private const val CREATE_LIST_SHEET_MOTION_MS = 320 private const val SEARCH_RESULT_SEARCH_CLOSE_DELAY_MS = 260L +private val RootFeedDockCollapseThreshold = 44.dp private fun performGentleHaptic(view: android.view.View) { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) 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 59554550..b6c215dd 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 @@ -105,6 +105,7 @@ import androidx.compose.material.icons.rounded.Medication import androidx.compose.material.icons.rounded.Mood import androidx.compose.material.icons.rounded.MoreHoriz import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.NightsStay import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.Payments import androidx.compose.material.icons.rounded.Pets @@ -233,6 +234,7 @@ private val TimelineDateGroupSpacing = 6.dp private val TimelineSectionTopSpacing = 6.dp private val TimelineHeaderBodySpacing = 2.dp private val TimelineCollapsedSectionSpacing = 4.dp +private val RootFeedDockCollapseThreshold = 44.dp private fun timelineTaskBottomSpacing( itemIndex: Int, @@ -267,6 +269,7 @@ fun TodoListScreen( onRootFeedTabSelected: ((RootFeedTab) -> Unit)? = null, showRootFeedDock: Boolean = true, showCreateTaskButton: Boolean = true, + usesRootFeedHeader: Boolean = false, createTaskRequestKey: Int = 0, onRootDockCollapsedChange: (Boolean) -> Unit = {}, onRootControlsVisibleChange: (Boolean) -> Unit = {}, @@ -278,6 +281,9 @@ fun TodoListScreen( val selectedListColorKey = selectedList?.color val usesTodayStyle = uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.ANYTIME || uiState.mode == TodoListMode.LIST + val isAnytimeScreen = uiState.mode == TodoListMode.ANYTIME || uiState.title.trim() == "Anytime" + val usesRootFeedChrome = + usesRootFeedHeader || isAnytimeScreen val titleColor = modeAccentColor( mode = uiState.mode, listColorKey = selectedListColorKey, @@ -324,8 +330,16 @@ fun TodoListScreen( val timelineAnimationsEnabled = uiState.mode != TodoListMode.TODAY || timelineAnimationsReady val listState = rememberLazyListState() + val hasScrollableContent = + listState.canScrollForward || listState.canScrollBackward + val dockCollapseThresholdPx = with(LocalDensity.current) { + RootFeedDockCollapseThreshold.roundToPx() + } + val hasScrolledPastDockCollapseThreshold = + listState.firstVisibleItemIndex > 0 || + listState.firstVisibleItemScrollOffset > dockCollapseThresholdPx val dockCollapsed = - listState.isScrollInProgress || listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 18 + hasScrollableContent && hasScrolledPastDockCollapseThreshold LaunchedEffect(dockCollapsed) { onRootDockCollapsedChange(dockCollapsed) } @@ -545,58 +559,63 @@ fun TodoListScreen( Scaffold( containerColor = colorScheme.background, topBar = { - if (usesTodayStyle) { - TodayTopBar( - onBack = onBack, - collapseProgress = todayTitleScrollBehavior.collapseProgress, - title = uiState.title, - titleColor = titleColor, - showActionButton = showTopBarActionButton, - actionIcon = if (canSummarizeCurrentMode) { - Icons.Rounded.AutoAwesome - } else { - Icons.Rounded.MoreHoriz - }, - actionContentDescription = if (canSummarizeCurrentMode) { - stringResource(R.string.todos_summarize) - } else { - stringResource(R.string.action_more_options) - }, - onAction = { - if (canSummarizeCurrentMode) { - showSummarySheet = true - } else if (selectedList != null) { - listSettingsTargetId = selectedList.id - listSettingsName = selectedList.name - listSettingsColor = normalizedListColorKey(selectedList.color) - listSettingsIconKey = selectedList.iconKey - ?.takeIf { isSupportedListIconKey(it) } - ?: DEFAULT_LIST_ICON_KEY - listSettingsColorTouched = false - listSettingsIconTouched = false - showListSettingsSheet = true - } - }, - ) - } else { - TopAppBar( - title = { - Text( - text = uiState.title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.ExtraBold, - color = titleColor, - ) - }, - navigationIcon = { - TodayHeaderButton( - onClick = onBack, - icon = Icons.Rounded.ChevronLeft, - contentDescription = stringResource(R.string.action_back), - isBackButton = true, - ) - }, - ) + when { + usesRootFeedChrome -> Unit + usesTodayStyle -> { + TodayTopBar( + onBack = onBack, + collapseProgress = todayTitleScrollBehavior.collapseProgress, + title = uiState.title, + titleColor = titleColor, + showActionButton = showTopBarActionButton, + actionIcon = if (canSummarizeCurrentMode) { + Icons.Rounded.AutoAwesome + } else { + Icons.Rounded.MoreHoriz + }, + actionContentDescription = if (canSummarizeCurrentMode) { + stringResource(R.string.todos_summarize) + } else { + stringResource(R.string.action_more_options) + }, + onAction = { + if (canSummarizeCurrentMode) { + showSummarySheet = true + } else if (selectedList != null) { + listSettingsTargetId = selectedList.id + listSettingsName = selectedList.name + listSettingsColor = normalizedListColorKey(selectedList.color) + listSettingsIconKey = selectedList.iconKey + ?.takeIf { isSupportedListIconKey(it) } + ?: DEFAULT_LIST_ICON_KEY + listSettingsColorTouched = false + listSettingsIconTouched = false + showListSettingsSheet = true + } + }, + ) + } + + else -> { + TopAppBar( + title = { + Text( + text = uiState.title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + color = titleColor, + ) + }, + navigationIcon = { + TodayHeaderButton( + onClick = onBack, + icon = Icons.Rounded.ChevronLeft, + contentDescription = stringResource(R.string.action_back), + isBackButton = true, + ) + }, + ) + } } }, floatingActionButton = { @@ -641,15 +660,24 @@ fun TodoListScreen( }, ), state = listState, - contentPadding = if (usesTodayStyle) { - PaddingValues(horizontal = 18.dp, vertical = 2.dp) - } else { - PaddingValues(horizontal = 16.dp, vertical = 12.dp) + contentPadding = when { + usesRootFeedChrome -> PaddingValues(18.dp) + usesTodayStyle -> PaddingValues(horizontal = 18.dp, vertical = 2.dp) + else -> PaddingValues(horizontal = 16.dp, vertical = 12.dp) }, verticalArrangement = Arrangement.spacedBy( if (showSectionedTimeline) 0.dp else timelineItemSpacing, ), ) { + if (usesRootFeedChrome) { + item( + key = "root-feed-title", + contentType = "root-feed-title", + ) { + RootFeedTitleRow(title = uiState.title) + } + } + if (!showSectionedTimeline && uiState.items.isEmpty() && uiState.isLoading) { item { Card( @@ -689,62 +717,64 @@ fun TodoListScreen( canDropTodoInTimelineSection(todo, section) } == true - item( - key = "timeline-header-${section.key}", - contentType = "timeline-header", - ) { - var headerModifier: Modifier = Modifier - if (timelineAnimationsEnabled) { - headerModifier = headerModifier.animateItem( - fadeInSpec = null, - placementSpec = tween( - durationMillis = 320, - easing = FastOutSlowInEasing, - ), - fadeOutSpec = null, - ) - } - TimelineSectionHeader( - modifier = headerModifier - .fillMaxWidth() - .heightIn(min = 1.dp) - .timelineInAppDropTarget( - targetId = "header-${section.key}", - section = section, - enabled = isDropEligibleSection, - dropTargets = timelineDropTargetBounds, + if (!usesRootFeedChrome) { + item( + key = "timeline-header-${section.key}", + contentType = "timeline-header", + ) { + var headerModifier: Modifier = Modifier + if (timelineAnimationsEnabled) { + headerModifier = headerModifier.animateItem( + fadeInSpec = null, + placementSpec = tween( + durationMillis = 320, + easing = FastOutSlowInEasing, + ), + fadeOutSpec = null, ) - .padding(top = if (sectionIndex == 0) 0.dp else TimelineSectionTopSpacing), - section = section, - useMinimalStyle = usesTodayStyle, - isCollapsed = isCollapsed, - isDropTarget = isActiveDropSection && isDropEligibleSection, - bottomSpacing = if (isCollapsed) { - TimelineCollapsedSectionSpacing - } else { - timelineHeaderBodySpacing - }, - onHeaderClick = if (sectionCanCollapse) { - { - collapsedSectionKeys = - if (isCollapsed) { - collapsedSectionKeys - section.key - } else { - collapsedSectionKeys + section.key - } - } - } else { - null - }, - onTapForQuickAdd = section.quickAddDefaults - ?.takeUnless { sectionModeCanCollapse } - ?.let { dueEpochMs -> + } + TimelineSectionHeader( + modifier = headerModifier + .fillMaxWidth() + .heightIn(min = 1.dp) + .timelineInAppDropTarget( + targetId = "header-${section.key}", + section = section, + enabled = isDropEligibleSection, + dropTargets = timelineDropTargetBounds, + ) + .padding(top = if (sectionIndex == 0) 0.dp else TimelineSectionTopSpacing), + section = section, + useMinimalStyle = usesTodayStyle, + isCollapsed = isCollapsed, + isDropTarget = isActiveDropSection && isDropEligibleSection, + bottomSpacing = if (isCollapsed) { + TimelineCollapsedSectionSpacing + } else { + timelineHeaderBodySpacing + }, + onHeaderClick = if (sectionCanCollapse) { { - quickAddDueEpochMs = dueEpochMs - showCreateTaskSheet = true + collapsedSectionKeys = + if (isCollapsed) { + collapsedSectionKeys - section.key + } else { + collapsedSectionKeys + section.key + } } + } else { + null }, - ) + onTapForQuickAdd = section.quickAddDefaults + ?.takeUnless { sectionModeCanCollapse } + ?.let { dueEpochMs -> + { + quickAddDueEpochMs = dueEpochMs + showCreateTaskSheet = true + } + }, + ) + } } if (canRescheduleTasks && isActiveDropSection && isDropEligibleSection && section.targetDate != null) { @@ -1229,6 +1259,59 @@ private fun ListDeleteConfirmationDialog( } } +@Composable +private fun RootFeedTitleRow( + title: String, +) { + val colorScheme = MaterialTheme.colorScheme + val isDaytime = rememberTodoRootIsDaytime() + val titleIcon = if (isDaytime) Icons.Rounded.WbSunny else Icons.Rounded.NightsStay + val titleIconTint = if (isDaytime) Color(0xFFF4C542) else Color(0xFFA8B8E8) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(start = 2.dp), + contentAlignment = Alignment.CenterStart, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = titleIcon, + contentDescription = null, + tint = titleIconTint, + modifier = Modifier.size(26.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onBackground, + maxLines = 1, + ) + } + } +} + +@Composable +private fun rememberTodoRootIsDaytime(): Boolean { + var hour by remember { mutableStateOf(LocalTime.now().hour) } + + LaunchedEffect(Unit) { + while (true) { + val now = LocalTime.now() + val millisToNextMinute = ((60 - now.second) * 1000L) - (now.nano / 1_000_000L) + delay(millisToNextMinute.coerceAtLeast(500L)) + hour = LocalTime.now().hour + } + } + + return hour in 6 until 18 +} + @Composable private fun TodayTopBar( onBack: () -> Unit, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt new file mode 100644 index 00000000..f3c6cfa9 --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt @@ -0,0 +1,396 @@ +package com.ohmz.tday.compose.ui.component + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +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.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat +import com.ohmz.tday.compose.R +import com.ohmz.tday.compose.ui.theme.TdayDimens +import kotlinx.coroutines.delay + +enum class RootFeedTab { + HOME, + ANYTIME, +} + +private val RootFeedTabs = listOf(RootFeedTab.HOME, RootFeedTab.ANYTIME) +private val RootFeedSliderAccent = Color(0xFF7D67B6) +private val RootFeedDockCollapsedWidth = 112.dp +private val RootFeedDockHeight = 58.dp +private val RootFeedDockInnerPadding = 5.dp +private val RootFeedDockTabWidth = RootFeedDockCollapsedWidth - (RootFeedDockInnerPadding * 2) +private val RootFeedDockExpandedWidth = + (RootFeedDockTabWidth * RootFeedTabs.size) + (RootFeedDockInnerPadding * 2) +private val RootFeedDockShape = RoundedCornerShape(22.dp) +private val RootFeedDockSelectorShape = RoundedCornerShape(18.dp) + +private fun RootFeedTab.label(): String { + return when (this) { + RootFeedTab.HOME -> "Home" + RootFeedTab.ANYTIME -> "Anytime" + } +} + +@Composable +fun RootCreateTaskButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + backgroundColor: Color = Color(0xFF6EA8E1), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val view = LocalView.current + + Card( + modifier = modifier, + onClick = { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + onClick() + }, + interactionSource = interactionSource, + shape = CircleShape, + border = BorderStroke(1.dp, backgroundColor.copy(alpha = 0.72f)), + colors = CardDefaults.cardColors(containerColor = backgroundColor), + elevation = CardDefaults.cardElevation( + defaultElevation = TdayDimens.FabElevation, + pressedElevation = TdayDimens.FabPressedElevation, + ), + ) { + Box( + modifier = Modifier.size(TdayDimens.FabSize), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(R.string.action_create_task), + tint = Color.White, + modifier = Modifier.size(40.dp), + ) + } + } +} + +@Composable +fun RootFeedDock( + activeTab: RootFeedTab, + collapsed: Boolean, + onTabSelected: (RootFeedTab) -> Unit, + modifier: Modifier = Modifier, +) { + var expandedByTap by remember { mutableStateOf(false) } + val expanded = !collapsed || expandedByTap + val view = LocalView.current + val expansionProgress by animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockExpansion", + ) + val colorScheme = MaterialTheme.colorScheme + val isDarkTheme = colorScheme.background.luminance() < 0.5f + val trackColor = colorScheme.surfaceVariant.copy(alpha = if (isDarkTheme) 0.76f else 0.68f) + val trackBorderColor = if (isDarkTheme) { + colorScheme.onSurfaceVariant.copy(alpha = 0.12f) + } else { + colorScheme.surface.copy(alpha = 0.72f) + } + val selectorContainerColor = if (isDarkTheme) { + colorScheme.background.copy(alpha = 0.9f) + } else { + colorScheme.surface.copy(alpha = 0.98f) + } + val selectorBorderColor = if (isDarkTheme) { + colorScheme.onSurfaceVariant.copy(alpha = 0.24f) + } else { + colorScheme.onSurface.copy(alpha = 0.1f) + } + val activeIndex = RootFeedTabs.indexOf(activeTab).coerceAtLeast(0) + val interactionSources = remember { + List(RootFeedTabs.size) { MutableInteractionSource() } + } + val pressedStates = interactionSources.map { source -> + source.collectIsPressedAsState() + } + val dockWidth = lerp( + RootFeedDockCollapsedWidth, + RootFeedDockExpandedWidth, + expansionProgress, + ) + + LaunchedEffect(collapsed) { + if (!collapsed) { + expandedByTap = false + } + } + LaunchedEffect(expandedByTap) { + if (expandedByTap) { + delay(2400) + expandedByTap = false + } + } + + Box( + modifier = modifier + .navigationBarsPadding() + .padding(start = 18.dp, bottom = 18.dp) + .width(dockWidth) + .height(RootFeedDockHeight) + .clip(RootFeedDockShape) + .background(trackColor, RootFeedDockShape) + .border( + width = 1.dp, + color = trackBorderColor, + shape = RootFeedDockShape, + ) + .padding(RootFeedDockInnerPadding) + .selectableGroup(), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val tabWidthTarget = if (maxWidth < RootFeedDockTabWidth) { + maxWidth + } else { + RootFeedDockTabWidth + } + val selectorWidthTarget = tabWidthTarget + val selectorOffsetTarget = if (activeIndex == 0) { + 0.dp + } else { + maxWidth - selectorWidthTarget + } + val selectorWidth by animateDpAsState( + targetValue = selectorWidthTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockSelectorWidth", + ) + val selectorOffset by animateDpAsState( + targetValue = selectorOffsetTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockSelectorOffset", + ) + val activePressed = pressedStates.getOrNull(activeIndex)?.value == true + val selectorScale by animateFloatAsState( + targetValue = if (activePressed) 0.985f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockSelectorPressScale", + ) + + Box( + modifier = Modifier + .offset(x = selectorOffset) + .width(selectorWidth) + .fillMaxHeight() + .padding(2.dp) + .graphicsLayer { + scaleX = selectorScale + scaleY = selectorScale + } + .shadow( + elevation = 12.dp, + shape = RootFeedDockSelectorShape, + ambientColor = RootFeedSliderAccent.copy(alpha = 0.16f), + spotColor = Color.Black.copy(alpha = 0.14f), + ) + .clip(RootFeedDockSelectorShape) + .background(selectorContainerColor, RootFeedDockSelectorShape) + .background( + RootFeedSliderAccent.copy(alpha = if (isDarkTheme) 0.04f else 0.06f), + RootFeedDockSelectorShape, + ) + .border( + width = 1.dp, + color = selectorBorderColor, + shape = RootFeedDockSelectorShape, + ) + ) + + RootFeedTabs.forEachIndexed { index, tab -> + val selected = tab == activeTab + val interactionSource = interactionSources[index] + val tabPressed = pressedStates[index].value + val tabOffsetTarget = if (selected) { + selectorOffsetTarget + } else { + val expandedOffset = tabWidthTarget * index + val hiddenOffset = if (index < activeIndex) { + -tabWidthTarget + } else { + maxWidth + } + lerp(hiddenOffset, expandedOffset, expansionProgress) + } + val tabAlphaTarget = if (selected) { + 1f + } else { + expansionProgress + } + val tabOffset by animateDpAsState( + targetValue = tabOffsetTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockTabOffset", + ) + val tabWidth by animateDpAsState( + targetValue = tabWidthTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockTabWidth", + ) + val tabAlpha by animateFloatAsState( + targetValue = tabAlphaTarget, + animationSpec = spring( + dampingRatio = 0.88f, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockTabAlpha", + ) + val contentScale by animateFloatAsState( + targetValue = if (tabPressed) 0.98f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ), + label = "rootFeedDockContentPressScale", + ) + val contentColor = if (selected) { + colorScheme.onSurface + } else { + colorScheme.onSurfaceVariant.copy(alpha = 0.82f) + } + val animatedContentColor by animateColorAsState( + targetValue = contentColor, + animationSpec = tween(durationMillis = 180), + label = "rootFeedDockContentColor", + ) + + Box( + modifier = Modifier + .offset(x = tabOffset) + .width(tabWidth) + .fillMaxHeight() + .graphicsLayer { alpha = tabAlpha } + .clip(RootFeedDockSelectorShape) + .selectable( + selected = selected, + onClick = { + if (!expanded && selected) { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + expandedByTap = true + } else { + if (!selected) { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + } + onTabSelected(tab) + } + }, + role = Role.RadioButton, + interactionSource = interactionSource, + indication = null, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = tab.label(), + style = MaterialTheme.typography.titleSmall, + fontWeight = if (selected) FontWeight.Black else FontWeight.ExtraBold, + color = animatedContentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, + modifier = Modifier.graphicsLayer { + scaleX = contentScale + scaleY = contentScale + }, + ) + } + } + + if (!expanded) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + expandedByTap = true + }, + ) + } + } + } +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt index d8bb9b84..8462d22f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySegmentedSlider.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalView import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat @@ -245,6 +246,9 @@ fun TdaySegmentedSlider( style = MaterialTheme.typography.titleSmall, fontWeight = if (selected) FontWeight.Black else FontWeight.ExtraBold, color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, modifier = Modifier.graphicsLayer { scaleX = contentScale scaleY = contentScale diff --git a/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift b/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift new file mode 100644 index 00000000..b02e931f --- /dev/null +++ b/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift @@ -0,0 +1,145 @@ +import SwiftUI + +enum RootFeedTab: Hashable { + case home + case anytime + + var title: String { + switch self { + case .home: + return "Home" + case .anytime: + return "Anytime" + } + } + + var systemImage: String { + switch self { + case .home: + return "house.fill" + case .anytime: + return "tray.full.fill" + } + } +} + +struct RootFeedDock: View { + let activeTab: RootFeedTab + let collapsed: Bool + let onSelect: (RootFeedTab) -> Void + + @Environment(\.tdayColors) private var colors + @State private var tapExpanded = false + + private let tabs: [RootFeedTab] = [.home, .anytime] + private let accentColor = Color(red: 125.0 / 255.0, green: 103.0 / 255.0, blue: 182.0 / 255.0) + private let animation = Animation.interactiveSpring(response: 0.36, dampingFraction: 0.88, blendDuration: 0.04) + + private var isExpanded: Bool { + !collapsed || tapExpanded + } + + private var activeIndex: Int { + tabs.firstIndex(of: activeTab) ?? 0 + } + + var body: some View { + GeometryReader { proxy in + let dockWidth = proxy.size.width + let innerWidth = Swift.max(0, dockWidth - (RootFeedDockMetrics.innerPadding * 2)) + let innerHeight = RootFeedDockMetrics.height - (RootFeedDockMetrics.innerPadding * 2) + let tabWidth = Swift.min(innerWidth, RootFeedDockMetrics.tabWidth) + let selectorWidth = tabWidth + let selectorX = activeIndex == 0 ? 0 : innerWidth - selectorWidth + + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: RootFeedDockMetrics.cornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + .background( + RoundedRectangle(cornerRadius: RootFeedDockMetrics.cornerRadius, style: .continuous) + .fill(colors.surfaceVariant.opacity(colors.isDark ? 0.76 : 0.68)) + ) + .overlay( + RoundedRectangle(cornerRadius: RootFeedDockMetrics.cornerRadius, style: .continuous) + .stroke(colors.isDark ? colors.onSurfaceVariant.opacity(0.12) : colors.surface.opacity(0.72), lineWidth: 1) + ) + + ZStack(alignment: .topLeading) { + RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous) + .fill(colors.isDark ? colors.background.opacity(0.90) : colors.surface.opacity(0.98)) + .overlay( + RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous) + .fill(accentColor.opacity(colors.isDark ? 0.04 : 0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous) + .stroke(colors.isDark ? colors.onSurfaceVariant.opacity(0.24) : colors.onSurface.opacity(0.10), lineWidth: 1) + ) + .shadow(color: accentColor.opacity(0.16), radius: 12, x: 0, y: 7) + .shadow(color: Color.black.opacity(0.12), radius: 9, x: 0, y: 5) + .frame(width: selectorWidth, height: innerHeight) + .offset(x: selectorX) + + ForEach(Array(tabs.enumerated()), id: \.element) { index, tab in + let selected = tab == activeTab + let expandedX = CGFloat(index) * tabWidth + let hiddenX = index < activeIndex ? -tabWidth : innerWidth + let tabX = selected ? selectorX : (isExpanded ? expandedX : hiddenX) + + Button { + if !isExpanded && selected { + tapExpanded = true + } else { + onSelect(tab) + } + } label: { + Text(tab.title) + .font(.tdayRounded(size: 13, weight: selected ? .black : .bold)) + .foregroundStyle(selected ? colors.onSurface : colors.onSurfaceVariant.opacity(0.82)) + .lineLimit(1) + .minimumScaleFactor(0.82) + .frame(width: tabWidth, height: innerHeight) + .contentShape(RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(!isExpanded && !selected) + .opacity(selected ? 1 : (isExpanded ? 1 : 0)) + .frame(width: tabWidth, height: innerHeight) + .offset(x: tabX) + } + } + .padding(RootFeedDockMetrics.innerPadding) + } + .clipShape(RoundedRectangle(cornerRadius: RootFeedDockMetrics.cornerRadius, style: .continuous)) + } + .frame(width: isExpanded ? RootFeedDockMetrics.expandedWidth : RootFeedDockMetrics.collapsedWidth) + .frame(height: RootFeedDockMetrics.height) + .animation(animation, value: isExpanded) + .animation(animation, value: activeTab) + .contentShape(RoundedRectangle(cornerRadius: RootFeedDockMetrics.cornerRadius, style: .continuous)) + .onChange(of: collapsed) { _, newValue in + if !newValue { + tapExpanded = false + } + } + .onChange(of: tapExpanded) { _, expanded in + guard expanded else { return } + Task { @MainActor in + try? await Task.sleep(nanoseconds: 2_400_000_000) + if tapExpanded { + tapExpanded = false + } + } + } + } +} + +private enum RootFeedDockMetrics { + static let collapsedWidth: CGFloat = 112 + static let height: CGFloat = 52 + static let innerPadding: CGFloat = 5 + static let tabWidth: CGFloat = collapsedWidth - (innerPadding * 2) + static let expandedWidth: CGFloat = (tabWidth * 2) + (innerPadding * 2) + static let cornerRadius: CGFloat = 22 + static let selectorCornerRadius: CGFloat = 18 +} diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index 858f7b16..36990377 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -31,10 +31,7 @@ struct AppRootView: View { let showOnboardingOverlay = !appViewModel.authenticated && appViewModel.versionCheckResult == .compatible NavigationStack( - path: Binding( - get: { appViewModel.navigationPath }, - set: { appViewModel.navigationPath = $0 } - ) + path: rootNavigationPath ) { TdayBackground { ZStack(alignment: .bottom) { @@ -60,6 +57,7 @@ struct AppRootView: View { rootFeedTab: .anytime, onRootFeedTabSelected: handleRootFeedTabSelection, showsRootControls: false, + usesRootFeedHeader: true, createTaskRequestID: rootCreateTaskRequestID, onRootDockCollapsedChange: { rootDockCollapsed = $0 }, onRootControlsVisibleChange: { rootControlsVisible = $0 } @@ -74,6 +72,8 @@ struct AppRootView: View { .blur(radius: showOnboardingOverlay ? 6 : 0) .scaleEffect(showOnboardingOverlay ? 0.992 : 1) .animation(.easeInOut(duration: 0.22), value: showOnboardingOverlay) + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) .navigationDestination(for: AppRoute.self) { route in switch route { case .home: @@ -94,15 +94,12 @@ struct AppRootView: View { case .priorityTodos: TodoListScreen(container: container, mode: .priority, listId: nil, listName: nil, highlightedTodoId: nil) case .anytimeTodos: - TodoListScreen( - container: container, - mode: .anytime, - listId: nil, - listName: nil, - highlightedTodoId: nil, - rootFeedTab: .anytime, - onRootFeedTabSelected: handleRootFeedTabSelection - ) + Color.clear + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .onAppear { + selectRootFeedTab(.anytime) + } case let .listTodos(listId, listName): TodoListScreen( container: container, @@ -124,6 +121,9 @@ struct AppRootView: View { LatestReleaseScreen(viewModel: appViewModel) } } + .onChange(of: appViewModel.navigationPath) { _, path in + normalizeRootNavigationPath(path) + } .overlay(alignment: .top) { OfflineBanner( visible: appViewModel.authenticated && appViewModel.isOffline, @@ -240,19 +240,65 @@ struct AppRootView: View { private func handleRoute(_ route: AppRoute) { switch route { case .home: - rootFeedTab = .home - appViewModel.navigate(to: .home) + selectRootFeedTab(.home) case .anytimeTodos: - rootFeedTab = .anytime - appViewModel.navigate(to: .home) + selectRootFeedTab(.anytime) default: appViewModel.navigate(to: route) } } private func handleRootFeedTabSelection(_ tab: RootFeedTab) { + selectRootFeedTab(tab) + } + + private func selectRootFeedTab(_ tab: RootFeedTab) { rootFeedTab = tab - appViewModel.navigate(to: .home) + appViewModel.navigationPath = [] + } + + private var rootNavigationPath: Binding<[AppRoute]> { + Binding( + get: { sanitizedNavigationPath(appViewModel.navigationPath) }, + set: { newPath in + setNavigationPath(newPath) + } + ) + } + + private func setNavigationPath(_ newPath: [AppRoute]) { + if let rootTab = rootFeedTabRoute(in: newPath) { + selectRootFeedTab(rootTab) + return + } + + appViewModel.navigationPath = newPath + } + + private func sanitizedNavigationPath(_ path: [AppRoute]) -> [AppRoute] { + path.filter { route in + !route.isRootFeedRoute + } + } + + private func rootFeedTabRoute(in path: [AppRoute]) -> RootFeedTab? { + for route in path.reversed() { + if let tab = route.rootFeedTab { + return tab + } + } + + return nil + } + + private func normalizeRootNavigationPath(_ path: [AppRoute]) { + guard let rootTab = rootFeedTabRoute(in: path) else { + return + } + + DispatchQueue.main.async { + selectRootFeedTab(rootTab) + } } private var rootFloatingControls: some View { @@ -291,6 +337,23 @@ struct AppRootView: View { } } +private extension AppRoute { + var rootFeedTab: RootFeedTab? { + switch self { + case .home: + return .home + case .anytimeTodos: + return .anytime + default: + return nil + } + } + + var isRootFeedRoute: Bool { + rootFeedTab != nil + } +} + struct AppLaunchSplashView: View { @Binding var isHeld: Bool @Environment(\.colorScheme) private var colorScheme diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index 239a9ec0..28cb6213 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -323,7 +323,7 @@ final class AppViewModel { func navigate(to route: AppRoute) { switch route { - case .home: + case .home, .anytimeTodos: navigationPath = [] default: navigationPath.append(route) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 91f8fe15..f57db084 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -12,6 +12,7 @@ private enum HomeMetrics { static let tileInnerPadding: CGFloat = 12 static let todayCardHeight: CGFloat = 70 static let listRowHeight: CGFloat = 70 + static let rootDockCollapseThreshold: CGFloat = 44 static let listContainerColorWeight: CGFloat = 0.66 static let tileWatermarkSize: CGFloat = 116 static let tileWatermarkTrailingInset: CGFloat = 22 @@ -152,6 +153,10 @@ struct HomeScreen: View { searchExpanded && !normalizedSearchQuery.isEmpty } + private var shouldCollapseRootDock: Bool { + max(homeScrollOffset, 0) > HomeMetrics.rootDockCollapseThreshold + } + var body: some View { GeometryReader { proxy in let fallbackSearchBarFrame = CGRect( @@ -279,7 +284,7 @@ struct HomeScreen: View { HStack(alignment: .bottom) { RootFeedDock( activeTab: .home, - collapsed: homeScrollOffset > 18, + collapsed: shouldCollapseRootDock, onSelect: onRootFeedTabSelected ) .padding(.leading, 18) @@ -354,7 +359,7 @@ struct HomeScreen: View { } } .onChange(of: homeScrollOffset, initial: true) { _, offset in - onRootDockCollapsedChange(offset > 18) + onRootDockCollapsedChange(max(offset, 0) > HomeMetrics.rootDockCollapseThreshold) } .onChange(of: createTaskRequestID) { _, requestID in guard requestID > 0 else { return } @@ -363,7 +368,7 @@ struct HomeScreen: View { } .onAppear { onRootControlsVisibleChange(!searchExpanded) - onRootDockCollapsedChange(homeScrollOffset > 18) + onRootDockCollapsedChange(shouldCollapseRootDock) } .onDisappear { onRootControlsVisibleChange(true) @@ -1039,10 +1044,6 @@ private struct HomeListsSection: View { let displayName: (String) -> String let onOpenList: (ListSummary, String) -> Void - private var listIDs: [String] { - lists.map(\.id) - } - var body: some View { VStack(alignment: .leading, spacing: HomeMetrics.sectionSpacing) { HomeListsHeader() @@ -1057,15 +1058,8 @@ private struct HomeListsSection: View { ) { onOpenList(list, name) } - .transition( - .asymmetric( - insertion: .opacity.combined(with: .move(edge: .top)), - removal: .opacity.combined(with: .scale(scale: 0.98, anchor: .top)) - ) - ) } } - .animation(.spring(response: 0.34, dampingFraction: 0.88), value: listIDs) } } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 6d8bc2df..aa422d03 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -54,6 +54,8 @@ enum TodoTimelineMetrics { static let sectionTopSpacing: CGFloat = 6 static let sectionHeaderBottomPadding: CGFloat = 2 static let titleCollapseDistance: CGFloat = 64 + static let rootFeedTitleTopInset: CGFloat = 32 + static let rootDockCollapseThreshold: CGFloat = 44 static let topBarRowHeight: CGFloat = 56 static let topBarButtonFrame: CGFloat = 56 static let topBarButtonIconSize: CGFloat = 24 @@ -83,6 +85,11 @@ enum TodoTimelineMetrics { private let todoDropPlaceholderAnimation = Animation.spring(response: 0.28, dampingFraction: 0.88, blendDuration: 0.02) +private func isTodoRootDaytime(_ date: Date) -> Bool { + let hour = Calendar.current.component(.hour, from: date) + return (6..<18).contains(hour) +} + struct TimelinePinnedSectionHeaderBackground: ViewModifier { @Environment(\.tdayColors) private var colors @@ -213,12 +220,40 @@ struct TimelineTopBarAction { } } +private struct RootFeedTitleRow: View { + let title: String + + @Environment(\.tdayColors) private var colors + + var body: some View { + let daytime = isTodoRootDaytime(Date()) + let iconColor = daytime + ? Color(red: 244.0 / 255.0, green: 197.0 / 255.0, blue: 66.0 / 255.0) + : Color(red: 168.0 / 255.0, green: 184.0 / 255.0, blue: 232.0 / 255.0) + + HStack(spacing: 8) { + Image(systemName: daytime ? "sun.max.fill" : "moon.stars.fill") + .font(.system(size: 26, weight: .regular)) + .foregroundStyle(iconColor) + + Text(title) + .font(.tdayRounded(size: 32, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 2) + .frame(height: 56) + } +} + struct TodoListScreen: View { let highlightedTodoId: String? let onListDeleted: () -> Void let rootFeedTab: RootFeedTab? let onRootFeedTabSelected: ((RootFeedTab) -> Void)? let showsRootControls: Bool + let usesRootFeedHeader: Bool let createTaskRequestID: Int let onRootDockCollapsedChange: (Bool) -> Void let onRootControlsVisibleChange: (Bool) -> Void @@ -250,6 +285,7 @@ struct TodoListScreen: View { rootFeedTab: RootFeedTab? = nil, onRootFeedTabSelected: ((RootFeedTab) -> Void)? = nil, showsRootControls: Bool = true, + usesRootFeedHeader: Bool = false, createTaskRequestID: Int = 0, onRootDockCollapsedChange: @escaping (Bool) -> Void = { _ in }, onRootControlsVisibleChange: @escaping (Bool) -> Void = { _ in }, @@ -260,6 +296,7 @@ struct TodoListScreen: View { self.rootFeedTab = rootFeedTab self.onRootFeedTabSelected = onRootFeedTabSelected self.showsRootControls = showsRootControls + self.usesRootFeedHeader = usesRootFeedHeader self.createTaskRequestID = createTaskRequestID self.onRootDockCollapsedChange = onRootDockCollapsedChange self.onRootControlsVisibleChange = onRootControlsVisibleChange @@ -292,6 +329,10 @@ struct TodoListScreen: View { isTodayMode || isMinimalTimelineMode } + private var showsTimelineNavigationTopBar: Bool { + usesHeroTimelineMode && !usesRootFeedHeader + } + private var modeAccentColor: Color { todoModeAccentColor(viewModel.mode, listColorKey: viewModel.lists.first(where: { $0.id == viewModel.listId })?.color) } @@ -309,6 +350,10 @@ struct TodoListScreen: View { return min(max(timelineScrollOffset / distance, 0), 1) } + private var shouldCollapseRootDock: Bool { + max(timelineScrollOffset, 0) > TodoTimelineMetrics.rootDockCollapseThreshold + } + private var timelineItemAnimationKey: String { let itemIDs = viewModel.items.map(\.id).joined(separator: "|") let completingIDs = completionPhases.keys.sorted().joined(separator: "|") @@ -411,9 +456,10 @@ struct TodoListScreen: View { inlineTitleColor: colors.onSurface, backgroundColor: colors.background ) - .navigationTitle(usesHeroTimelineMode ? "" : viewModel.title) + .navigationTitle((usesRootFeedHeader || usesHeroTimelineMode) ? "" : viewModel.title) + .navigationBarBackButtonHidden(usesRootFeedHeader) .navigationBarTitleDisplayMode(.inline) - .toolbar(usesHeroTimelineMode ? .hidden : .visible, for: .navigationBar) + .toolbar((usesRootFeedHeader || usesHeroTimelineMode) ? .hidden : .visible, for: .navigationBar) .toolbar { navigationToolbarContent } @@ -431,7 +477,7 @@ struct TodoListScreen: View { } } .onChange(of: timelineScrollOffset, initial: true) { _, offset in - onRootDockCollapsedChange(offset > 18) + onRootDockCollapsedChange(max(offset, 0) > TodoTimelineMetrics.rootDockCollapseThreshold) } .onChange(of: createTaskRequestID) { _, requestID in guard requestID > 0 else { return } @@ -439,7 +485,7 @@ struct TodoListScreen: View { } .onAppear { onRootControlsVisibleChange(true) - onRootDockCollapsedChange(timelineScrollOffset > 18) + onRootDockCollapsedChange(shouldCollapseRootDock) } .onDisappear { onRootControlsVisibleChange(true) @@ -506,7 +552,7 @@ struct TodoListScreen: View { @ViewBuilder private var timelineTopInset: some View { - if usesHeroTimelineMode { + if showsTimelineNavigationTopBar { TimelineTopBar( title: viewModel.title, accentColor: modeAccentColor, @@ -517,28 +563,59 @@ struct TodoListScreen: View { } } - private var timelineHeroTitleRow: some View { + private var timelineHeroTitleCollapseProgress: CGFloat { + usesRootFeedHeader ? 0 : titleCollapseProgress + } + + private var timelineHeroTitleRowBase: some View { TimelineExpandedTitleRow( title: viewModel.title, accentColor: modeAccentColor, - collapseProgress: titleCollapseProgress + collapseProgress: timelineHeroTitleCollapseProgress ) .background { TimelineScrollOffsetObserver { timelineScrollOffset = $0 } .frame(width: 0, height: 0) } - .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) .listRowBackground(colors.background) .listRowSeparator(.hidden) } + private var rootFeedTitleRow: some View { + RootFeedTitleRow(title: viewModel.title) + .background { + TimelineScrollOffsetObserver { timelineScrollOffset = $0 } + .frame(width: 0, height: 0) + } + .listRowInsets( + EdgeInsets( + top: TodoTimelineMetrics.rootFeedTitleTopInset, + leading: TodoTimelineMetrics.horizontalPadding, + bottom: 0, + trailing: TodoTimelineMetrics.horizontalPadding + ) + ) + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + } + + @ViewBuilder + private var timelineHeroTitleRow: some View { + if usesRootFeedHeader { + rootFeedTitleRow + } else { + timelineHeroTitleRowBase + .onVerticalScrollSnap(collapseDistance: TodoTimelineMetrics.titleCollapseDistance) + } + } + private var floatingActionButtonDock: some View { HStack(alignment: .bottom) { if showsRootControls, let rootFeedTab, let onRootFeedTabSelected { RootFeedDock( activeTab: rootFeedTab, - collapsed: timelineScrollOffset > 18, + collapsed: shouldCollapseRootDock, onSelect: onRootFeedTabSelected ) .padding(.leading, 18) @@ -1380,46 +1457,48 @@ struct TodoListScreen: View { } } } header: { - TimelineSectionHeader( - title: section.title, - isActiveDropTarget: isActiveDropSection, - isCollapsible: canCollapseSection, - isCollapsed: isCollapsed, - onTap: canCollapseSection ? { - toggleTimelineSection(section) - } : nil - ) - .id(timelineSectionScrollID(section.id)) - .padding(.top, isFirstSection ? 0 : TodoTimelineMetrics.sectionTopSpacing) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - .todoInAppDropTargetFrame( - targetID: "minimal-header-\(section.id)", - section: section, - enabled: viewModel.mode.supportsTaskReschedule && isDropEligibleSection - ) - .timelinePinnedSectionHeaderBackground() - .scheduledTodoDropTarget( - section: section, - draggedTodo: draggedTodo, - resolveTodo: resolveTodoForDrop, - onMove: { todo, targetDate in - requestReschedule(todo, to: targetDate) - }, - canMoveTodo: canDropTodo, - onSectionChange: { sectionId in - setActiveDropSection(sectionId) - } - ) - .listRowInsets( - EdgeInsets( - top: 0, - leading: 0, - bottom: 0, - trailing: 0 + if !usesRootFeedHeader { + TimelineSectionHeader( + title: section.title, + isActiveDropTarget: isActiveDropSection, + isCollapsible: canCollapseSection, + isCollapsed: isCollapsed, + onTap: canCollapseSection ? { + toggleTimelineSection(section) + } : nil ) - ) - .listRowSeparator(.hidden) + .id(timelineSectionScrollID(section.id)) + .padding(.top, isFirstSection ? 0 : TodoTimelineMetrics.sectionTopSpacing) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .todoInAppDropTargetFrame( + targetID: "minimal-header-\(section.id)", + section: section, + enabled: viewModel.mode.supportsTaskReschedule && isDropEligibleSection + ) + .timelinePinnedSectionHeaderBackground() + .scheduledTodoDropTarget( + section: section, + draggedTodo: draggedTodo, + resolveTodo: resolveTodoForDrop, + onMove: { todo, targetDate in + requestReschedule(todo, to: targetDate) + }, + canMoveTodo: canDropTodo, + onSectionChange: { sectionId in + setActiveDropSection(sectionId) + } + ) + .listRowInsets( + EdgeInsets( + top: 0, + leading: 0, + bottom: 0, + trailing: 0 + ) + ) + .listRowSeparator(.hidden) + } } } diff --git a/tday-backend/src/main/resources/db/migration/V7__allow_unscheduled_todos.sql b/tday-backend/src/main/resources/db/migration/V7__allow_unscheduled_todos.sql new file mode 100644 index 00000000..154d8f35 --- /dev/null +++ b/tday-backend/src/main/resources/db/migration/V7__allow_unscheduled_todos.sql @@ -0,0 +1,8 @@ +ALTER TABLE todos + ALTER COLUMN due DROP NOT NULL; + +ALTER TABLE completedtodo + ALTER COLUMN due DROP NOT NULL; + +ALTER TABLE completedtodo + ALTER COLUMN "completedOnTime" DROP NOT NULL; diff --git a/tday-web/tests/setup/web-storage.ts b/tday-web/tests/setup/web-storage.ts new file mode 100644 index 00000000..33b03597 --- /dev/null +++ b/tday-web/tests/setup/web-storage.ts @@ -0,0 +1,93 @@ +function createMemoryStorage(): Storage { + const values = new Map(); + const storage: Partial = {}; + + function syncKeyProperty(key: string, value: string | null) { + if (value == null) { + delete (storage as Record)[key]; + return; + } + Object.defineProperty(storage, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }); + } + + Object.defineProperties(storage, { + length: { + configurable: true, + enumerable: false, + get() { + return values.size; + }, + }, + clear: { + configurable: true, + enumerable: false, + value() { + for (const key of values.keys()) { + syncKeyProperty(key, null); + } + values.clear(); + }, + }, + getItem: { + configurable: true, + enumerable: false, + value(key: string) { + return values.get(key) ?? null; + }, + }, + key: { + configurable: true, + enumerable: false, + value(index: number) { + return Array.from(values.keys())[index] ?? null; + }, + }, + removeItem: { + configurable: true, + enumerable: false, + value(key: string) { + values.delete(key); + syncKeyProperty(key, null); + }, + }, + setItem: { + configurable: true, + enumerable: false, + value(key: string, value: string) { + values.set(key, value); + syncKeyProperty(key, value); + }, + }, + }); + + return storage as Storage; +} + +function installStorage(name: "localStorage" | "sessionStorage") { + if (typeof window === "undefined") { + return; + } + + const candidate = window[name]; + if ( + typeof candidate?.getItem === "function" && + typeof candidate?.setItem === "function" && + typeof candidate?.removeItem === "function" && + typeof candidate?.clear === "function" + ) { + return; + } + + Object.defineProperty(window, name, { + configurable: true, + value: createMemoryStorage(), + }); +} + +installStorage("localStorage"); +installStorage("sessionStorage"); From 48f811b309d1218312c8c936784836f79e6ed6c7 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 03:12:40 -0400 Subject: [PATCH 03/11] feat(floater): implement "Floater" tasks and lists for unscheduled items Introduce "Floater" tasks (previously "Anytime" tasks) as a dedicated feature with its own database tables, API endpoints, and UI management logic across Android, iOS, and the Backend. Floaters are distinct from scheduled todos in that they do not require a due date. - **Backend & Shared Logic**: - Created `Floaters`, `FloaterLists`, `CompletedFloaters` tables and corresponding services/routes. - Updated `Todos` and `CompletedTodos` schema to strictly require a `due` date, effectively migrating unscheduled tasks to the new Floater system. - Added validation logic and DTOs for Floater CRUD operations. - **Android (Compose)**: - Renamed `Anytime` mode to `Floater` across `TodoListMode`, `RootFeedTab`, and `AppRoute`. - Implemented `FloaterListRepository` and updated `SyncManager` to handle Floater-specific offline caching and synchronization. - Enhanced `TodoListScreen` with a new search header, "My Lists" section for floaters, and specialized sectioning (High/Medium/Low priority). - Updated `CreateTaskBottomSheet` to toggle schedule controls based on the current mode. - **iOS (SwiftUI)**: - Introduced `FloaterListRepository` and updated `AppContainer` to manage Floater data. - Refactored `AppRootView` and `RootFeedDock` to transition from "Anytime" to "Floater" terminology and navigation. - Updated `SwiftData` models to include `CachedFloaterEntity`, `CachedFloaterListEntity`, and `CachedCompletedFloaterEntity`. - Implemented a new search interface in `TodoListScreen` specifically for the Floater root view. - **Data Persistence**: - Performed destructive migrations (Android) and schema updates (iOS/Backend) to support the separated Floater tables. - Updated `SyncManager` on both platforms to resolve local IDs and merge remote snapshots for Floaters and Floater Lists. --- .../java/com/ohmz/tday/compose/TdayApp.kt | 56 +- .../compose/core/data/OfflineSyncModels.kt | 48 ++ .../compose/core/data/cache/CacheMappers.kt | 162 ++++ .../core/data/cache/OfflineCacheManager.kt | 22 +- .../ohmz/tday/compose/core/data/db/Daos.kt | 45 ++ .../core/data/db/DatabaseMigrations.kt | 184 ----- .../compose/core/data/db/DatabaseModule.kt | 11 +- .../tday/compose/core/data/db/Entities.kt | 46 ++ .../compose/core/data/db/EntityMappers.kt | 71 ++ .../tday/compose/core/data/db/TdayDatabase.kt | 8 +- .../core/data/list/FloaterListRepository.kt | 342 +++++++++ .../compose/core/data/list/ListRepository.kt | 1 - .../compose/core/data/sync/SyncManager.kt | 525 ++++++++++++- .../compose/core/data/todo/TodoRepository.kt | 406 +++++++++- .../ohmz/tday/compose/core/model/ApiModels.kt | 22 + .../tday/compose/core/model/DomainModels.kt | 6 +- .../tday/compose/core/navigation/AppRoute.kt | 7 +- .../compose/core/network/TdayApiService.kt | 95 ++- .../feature/calendar/CalendarScreen.kt | 6 +- .../tday/compose/feature/home/HomeScreen.kt | 17 +- .../compose/feature/home/HomeViewModel.kt | 2 +- .../compose/feature/todos/TodoListScreen.kt | 708 ++++++++++++++++-- .../feature/todos/TodoListViewModel.kt | 190 +++-- .../feature/widget/TodayTasksWidget.kt | 2 +- .../ui/component/CreateTaskBottomSheet.kt | 92 +-- .../tday/compose/ui/component/RootFeedDock.kt | 6 +- .../core/data/cache/CacheMappersTest.kt | 1 + ios-swiftUI/Tday/Core/Data/AppContainer.swift | 5 + .../Core/Data/Cache/OfflineCacheManager.swift | 56 +- .../Core/Data/Database/SwiftDataModels.swift | 71 ++ .../Data/List/FloaterListRepository.swift | 358 +++++++++ .../Tday/Core/Data/List/ListRepository.swift | 9 +- .../Tday/Core/Data/Sync/CacheMappers.swift | 172 +++++ .../Tday/Core/Data/Sync/SyncManager.swift | 579 +++++++++++++- .../Tday/Core/Data/Todo/TodoRepository.swift | 349 ++++++++- ios-swiftUI/Tday/Core/Model/ApiModels.swift | 185 ++++- .../Tday/Core/Model/DomainModels.swift | 14 +- .../Tday/Core/Model/OfflineSyncModels.swift | 48 ++ .../Tday/Core/Navigation/AppRoute.swift | 24 +- .../Tday/Core/Network/TdayAPIService.swift | 64 ++ ios-swiftUI/Tday/Core/UI/RootFeedDock.swift | 10 +- .../Tday/Feature/App/AppRootView.swift | 35 +- .../Tday/Feature/App/AppViewModel.swift | 2 +- .../Feature/Calendar/CalendarScreen.swift | 4 +- .../Tday/Feature/Home/HomeScreen.swift | 12 +- .../Tday/Feature/Home/HomeViewModel.swift | 2 +- .../Tday/Feature/Todos/TodoListScreen.swift | 649 ++++++++++++++-- .../Feature/Todos/TodoListViewModel.swift | 77 +- .../Tday/UI/Component/CreateTaskSheet.swift | 70 +- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 + .../ohmz/tday/shared/model/CompletedModels.kt | 4 +- .../ohmz/tday/shared/model/FloaterModels.kt | 109 +++ .../com/ohmz/tday/shared/model/ListModels.kt | 65 ++ .../com/ohmz/tday/shared/model/TodoModels.kt | 4 +- .../shared/validation/ContractValidators.kt | 22 +- .../com/ohmz/tday/config/DatabaseConfig.kt | 4 +- .../ohmz/tday/db/tables/CompletedFloaters.kt | 21 + .../com/ohmz/tday/db/tables/CompletedTodos.kt | 4 +- .../com/ohmz/tday/db/tables/FloaterLists.kt | 17 + .../com/ohmz/tday/db/tables/Floaters.kt | 25 + .../kotlin/com/ohmz/tday/db/tables/Todos.kt | 2 +- .../main/kotlin/com/ohmz/tday/di/AppModule.kt | 3 + .../com/ohmz/tday/domain/Validations.kt | 20 + .../request/CompletedFloaterRequests.kt | 4 + .../models/request/FloaterListRequests.kt | 5 + .../tday/models/request/FloaterRequests.kt | 9 + .../models/response/FloaterListResponses.kt | 7 + .../tday/models/response/FloaterResponses.kt | 5 + .../kotlin/com/ohmz/tday/plugins/Routing.kt | 3 + .../tday/routes/CompletedFloaterRoutes.kt | 65 ++ .../ohmz/tday/routes/CompletedTodoRoutes.kt | 2 +- .../com/ohmz/tday/routes/FloaterListRoutes.kt | 101 +++ .../com/ohmz/tday/routes/FloaterRoutes.kt | 115 +++ .../kotlin/com/ohmz/tday/routes/TodoRoutes.kt | 16 +- .../com/ohmz/tday/services/AdminService.kt | 2 + .../tday/services/CompletedFloaterService.kt | 99 +++ .../tday/services/CompletedTodoService.kt | 4 +- .../ohmz/tday/services/FloaterListService.kt | 188 +++++ .../com/ohmz/tday/services/FloaterService.kt | 229 ++++++ .../com/ohmz/tday/services/ListService.kt | 1 - .../com/ohmz/tday/services/MemoryCache.kt | 14 + .../com/ohmz/tday/services/TodoService.kt | 12 +- .../migration/V7__allow_unscheduled_todos.sql | 8 - .../com/ohmz/tday/plugins/RateLimitingTest.kt | 2 +- .../com/ohmz/tday/routes/FloaterRoutesTest.kt | 267 +++++++ .../com/ohmz/tday/routes/TodoRoutesTest.kt | 55 +- 86 files changed, 6750 insertions(+), 643 deletions(-) delete mode 100644 android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt create mode 100644 android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt create mode 100644 ios-swiftUI/Tday/Core/Data/List/FloaterListRepository.swift create mode 100644 shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/FloaterModels.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedFloaters.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/FloaterLists.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Floaters.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/models/request/CompletedFloaterRequests.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/models/request/FloaterListRequests.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/models/request/FloaterRequests.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/models/response/FloaterListResponses.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/models/response/FloaterResponses.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedFloaterRoutes.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/routes/FloaterListRoutes.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/routes/FloaterRoutes.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedFloaterService.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/services/FloaterListService.kt create mode 100644 tday-backend/src/main/kotlin/com/ohmz/tday/services/FloaterService.kt delete mode 100644 tday-backend/src/main/resources/db/migration/V7__allow_unscheduled_todos.sql create mode 100644 tday-backend/src/test/kotlin/com/ohmz/tday/routes/FloaterRoutesTest.kt 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 71d586c0..56af2099 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 @@ -335,8 +335,8 @@ fun TdayApp( onOpenPriority = { navController.navigate(AppRoute.PriorityTodos.route) }, onOpenCompleted = { navController.navigate(AppRoute.Completed.route) }, onOpenCalendar = { navController.navigate(AppRoute.Calendar.route) }, - onOpenAnytime = { - rootFeedTab = RootFeedTab.ANYTIME + onOpenFloater = { + rootFeedTab = RootFeedTab.FLOATER }, onOpenSettings = { navController.navigate(AppRoute.Settings.route) }, onOpenTaskFromSearch = { todoId -> @@ -395,11 +395,22 @@ fun TdayApp( ) } - RootFeedTab.ANYTIME -> { + RootFeedTab.FLOATER -> { TodosRoute( - mode = TodoListMode.ANYTIME, + mode = TodoListMode.FLOATER, onBack = { rootFeedTab = RootFeedTab.HOME }, onTaskDeleted = ::showTaskDeletedToast, + onOpenFloaterList = { id, name -> + navController.navigate( + AppRoute.FloaterListTodos.create( + id, + name + ) + ) + }, + onOpenSettings = { + navController.navigate(AppRoute.Settings.route) + }, showRootFeedDock = false, showCreateTaskButton = false, usesRootFeedHeader = true, @@ -445,7 +456,7 @@ fun TdayApp( onOpenPriority = {}, onOpenCompleted = {}, onOpenCalendar = {}, - onOpenAnytime = {}, + onOpenFloater = {}, onOpenSettings = {}, onOpenTaskFromSearch = {}, onOpenList = { _, _ -> }, @@ -547,11 +558,11 @@ fun TdayApp( } composable( - route = AppRoute.AnytimeTodos.route, - deepLinks = listOf(navDeepLink { uriPattern = "tday://anytime" }), + route = AppRoute.FloaterTodos.route, + deepLinks = listOf(navDeepLink { uriPattern = "tday://floater" }), ) { LaunchedEffect(Unit) { - rootFeedTab = RootFeedTab.ANYTIME + rootFeedTab = RootFeedTab.FLOATER navController.navigate(AppRoute.Home.route) { popUpTo(AppRoute.Home.route) { inclusive = false } launchSingleTop = true @@ -661,6 +672,28 @@ fun TdayApp( ) } + composable( + route = AppRoute.FloaterListTodos.route, + arguments = listOf( + navArgument("listId") { type = NavType.StringType }, + navArgument("listName") { type = NavType.StringType }, + ), + deepLinks = listOf( + navDeepLink { uriPattern = "tday://floater/list/{listId}/{listName}" }, + ), + ) { entry -> + val listId = entry.arguments?.getString("listId").orEmpty() + val listName = Uri.decode(entry.arguments?.getString("listName").orEmpty()) + TodosRoute( + mode = TodoListMode.FLOATER, + listId = listId, + listName = listName, + onBack = { navController.popBackStack() }, + onTaskDeleted = ::showTaskDeletedToast, + usesRootFeedHeader = true, + ) + } + composable( route = AppRoute.Completed.route, deepLinks = listOf(navDeepLink { uriPattern = "tday://completed" }), @@ -919,6 +952,8 @@ private fun TodosRoute( onBack: () -> Unit, onTaskDeleted: () -> Unit, onListDeleted: () -> Unit = {}, + onOpenFloaterList: (String, String) -> Unit = { _, _ -> }, + onOpenSettings: () -> Unit = {}, highlightTodoId: String? = null, listId: String? = null, listName: String? = null, @@ -972,6 +1007,9 @@ private fun TodosRoute( onOptimisticDelete = onListDeleted, ) }, + onOpenFloaterList = onOpenFloaterList, + onOpenSettings = onOpenSettings, + onCreateList = viewModel::createList, rootFeedTab = rootFeedTab, onRootFeedTabSelected = onRootFeedTabSelected, showRootFeedDock = showRootFeedDock, @@ -1184,7 +1222,7 @@ private val UnauthenticatedHomeUiState = HomeUiState( scheduledCount = 0, allCount = 0, priorityCount = 0, - anytimeCount = 0, + floaterCount = 0, completedCount = 0, lists = listOf( ListSummary( diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt index 14784d49..7462108a 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/OfflineSyncModels.kt @@ -7,8 +7,11 @@ data class OfflineSyncState( val lastSuccessfulSyncEpochMs: Long = 0L, val lastSyncAttemptEpochMs: Long = 0L, val todos: List = emptyList(), + val floaters: List = emptyList(), val completedItems: List = emptyList(), + val completedFloaters: List = emptyList(), val lists: List = emptyList(), + val floaterLists: List = emptyList(), val pendingMutations: List = emptyList(), val aiSummaryEnabled: Boolean = true, ) @@ -29,6 +32,19 @@ data class CachedTodoRecord( val updatedAtEpochMs: Long = 0L, ) +@Serializable +data class CachedFloaterRecord( + val id: String, + val canonicalId: String, + val title: String, + val description: String? = null, + val priority: String = "Low", + val pinned: Boolean = false, + val completed: Boolean = false, + val listId: String? = null, + val updatedAtEpochMs: Long = 0L, +) + @Serializable data class CachedListRecord( val id: String, @@ -40,6 +56,17 @@ data class CachedListRecord( val createdAtEpochMs: Long = 0L, ) +@Serializable +data class CachedFloaterListRecord( + val id: String, + val name: String, + val color: String? = null, + val iconKey: String? = null, + val todoCount: Int = 0, + val updatedAtEpochMs: Long = 0L, + val createdAtEpochMs: Long = 0L, +) + @Serializable data class CachedCompletedRecord( val id: String, @@ -56,6 +83,19 @@ data class CachedCompletedRecord( val listColor: String? = null, ) +@Serializable +data class CachedCompletedFloaterRecord( + val id: String, + val originalFloaterId: String? = null, + val title: String, + val description: String? = null, + val priority: String, + val completedAtEpochMs: Long = 0L, + val listId: String? = null, + val listName: String? = null, + val listColor: String? = null, +) + @Serializable data class PendingMutationRecord( val mutationId: String, @@ -81,12 +121,20 @@ enum class MutationKind { CREATE_LIST, UPDATE_LIST, DELETE_LIST, + CREATE_FLOATER_LIST, + UPDATE_FLOATER_LIST, + DELETE_FLOATER_LIST, CREATE_TODO, UPDATE_TODO, DELETE_TODO, + CREATE_FLOATER, + UPDATE_FLOATER, + DELETE_FLOATER, SET_PINNED, SET_PRIORITY, COMPLETE_TODO, COMPLETE_TODO_INSTANCE, UNCOMPLETE_TODO, + COMPLETE_FLOATER, + UNCOMPLETE_FLOATER, } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt index 9bc0e616..48ee907e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/CacheMappers.kt @@ -1,10 +1,16 @@ package com.ohmz.tday.compose.core.data.cache +import com.ohmz.tday.compose.core.data.CachedCompletedFloaterRecord import com.ohmz.tday.compose.core.data.CachedCompletedRecord +import com.ohmz.tday.compose.core.data.CachedFloaterListRecord +import com.ohmz.tday.compose.core.data.CachedFloaterRecord import com.ohmz.tday.compose.core.data.CachedListRecord import com.ohmz.tday.compose.core.data.CachedTodoRecord +import com.ohmz.tday.compose.core.model.CompletedFloaterDto import com.ohmz.tday.compose.core.model.CompletedItem import com.ohmz.tday.compose.core.model.CompletedTodoDto +import com.ohmz.tday.compose.core.model.FloaterDto +import com.ohmz.tday.compose.core.model.FloaterListDto import com.ohmz.tday.compose.core.model.ListDto import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoDto @@ -15,8 +21,11 @@ import java.time.OffsetDateTime import java.time.ZoneOffset internal const val LOCAL_TODO_PREFIX = "local-todo-" +internal const val LOCAL_FLOATER_PREFIX = "local-floater-" internal const val LOCAL_LIST_PREFIX = "local-list-" +internal const val LOCAL_FLOATER_LIST_PREFIX = "local-floater-list-" internal const val LOCAL_COMPLETED_PREFIX = "local-completed-" +internal const val LOCAL_COMPLETED_FLOATER_PREFIX = "local-completed-floater-" internal fun todoToCache(todo: TodoItem): CachedTodoRecord { return CachedTodoRecord( @@ -56,6 +65,41 @@ internal fun todoFromCache(cache: CachedTodoRecord): TodoItem { ) } +internal fun floaterToCache(floater: TodoItem): CachedFloaterRecord { + return CachedFloaterRecord( + id = floater.id, + canonicalId = floater.canonicalId, + title = floater.title, + description = floater.description, + priority = floater.priority, + pinned = floater.pinned, + completed = floater.completed, + listId = floater.listId, + updatedAtEpochMs = floater.updatedAt?.toEpochMilli() ?: 0L, + ) +} + +internal fun floaterFromCache(cache: CachedFloaterRecord): TodoItem { + return TodoItem( + id = cache.id, + canonicalId = cache.canonicalId, + title = cache.title, + description = cache.description, + priority = cache.priority, + due = null, + rrule = null, + instanceDate = null, + pinned = cache.pinned, + completed = cache.completed, + listId = cache.listId, + updatedAt = if (cache.updatedAtEpochMs > 0L) { + Instant.ofEpochMilli(cache.updatedAtEpochMs) + } else { + null + }, + ) +} + internal fun listToCache(list: ListSummary): CachedListRecord { return CachedListRecord( id = list.id, @@ -78,6 +122,16 @@ internal fun orderListsLikeWeb(lists: List): List): List { + if (lists.none { it.createdAtEpochMs > 0L }) return lists + return lists.withIndex() + .sortedWith( + compareByDescending> { it.value.createdAtEpochMs } + .thenBy { it.index }, + ) + .map { it.value } +} + internal fun listFromCache( cache: CachedListRecord, todoCountOverride: Int, @@ -101,6 +155,33 @@ internal fun listFromCache( ) } +internal fun floaterListToCache(list: ListSummary): CachedFloaterListRecord { + return CachedFloaterListRecord( + id = list.id, + name = list.name, + color = list.color, + iconKey = list.iconKey, + todoCount = list.todoCount, + updatedAtEpochMs = list.updatedAt?.toEpochMilli() ?: 0L, + createdAtEpochMs = list.createdAt?.toEpochMilli() ?: 0L, + ) +} + +internal fun floaterListFromCache( + cache: CachedFloaterListRecord, + todoCountOverride: Int, +): ListSummary { + return ListSummary( + id = cache.id, + name = cache.name, + color = cache.color, + iconKey = cache.iconKey, + todoCount = todoCountOverride, + updatedAt = if (cache.updatedAtEpochMs > 0L) Instant.ofEpochMilli(cache.updatedAtEpochMs) else null, + createdAt = if (cache.createdAtEpochMs > 0L) Instant.ofEpochMilli(cache.createdAtEpochMs) else null, + ) +} + internal fun completedToCache(item: CompletedItem): CachedCompletedRecord { return CachedCompletedRecord( id = item.id, @@ -139,6 +220,41 @@ internal fun completedFromCache(cache: CachedCompletedRecord): CompletedItem { ) } +internal fun completedFloaterToCache(item: CompletedItem): CachedCompletedFloaterRecord { + return CachedCompletedFloaterRecord( + id = item.id, + originalFloaterId = item.originalTodoId, + title = item.title, + description = item.description, + priority = item.priority, + completedAtEpochMs = item.completedAt?.toEpochMilli() ?: 0L, + listId = item.listId, + listName = item.listName, + listColor = item.listColor, + ) +} + +internal fun completedFloaterFromCache(cache: CachedCompletedFloaterRecord): CompletedItem { + return CompletedItem( + id = cache.id, + originalTodoId = cache.originalFloaterId, + title = cache.title, + description = cache.description, + priority = cache.priority, + due = null, + completedAt = if (cache.completedAtEpochMs > 0L) { + Instant.ofEpochMilli(cache.completedAtEpochMs) + } else { + null + }, + rrule = null, + instanceDate = null, + listId = cache.listId, + listName = cache.listName, + listColor = cache.listColor, + ) +} + internal fun mapTodoDto(dto: TodoDto): TodoItem { val canonicalId = dto.id.substringBefore(':') val suffixInstance = dto.id.substringAfter(':', "") @@ -162,6 +278,23 @@ internal fun mapTodoDto(dto: TodoDto): TodoItem { ) } +internal fun mapFloaterDto(dto: FloaterDto): TodoItem { + return TodoItem( + id = dto.id, + canonicalId = dto.id, + title = dto.title, + description = dto.description, + priority = dto.priority, + due = null, + rrule = null, + instanceDate = null, + pinned = dto.pinned, + completed = dto.completed, + listId = dto.listID, + updatedAt = parseOptionalInstant(dto.updatedAt), + ) +} + internal fun mapCompletedDto(dto: CompletedTodoDto): CompletedItem { return CompletedItem( id = dto.id, @@ -179,6 +312,23 @@ internal fun mapCompletedDto(dto: CompletedTodoDto): CompletedItem { ) } +internal fun mapCompletedFloaterDto(dto: CompletedFloaterDto): CompletedItem { + return CompletedItem( + id = dto.id, + originalTodoId = dto.originalFloaterID, + title = dto.title, + description = dto.description, + priority = dto.priority, + due = null, + completedAt = parseOptionalInstant(dto.completedAt), + rrule = null, + instanceDate = null, + listId = dto.listID, + listName = dto.listName, + listColor = dto.listColor, + ) +} + internal fun mapListDto(dto: ListDto, iconFallback: String? = null): ListSummary { return ListSummary( id = dto.id, @@ -191,6 +341,18 @@ internal fun mapListDto(dto: ListDto, iconFallback: String? = null): ListSummary ) } +internal fun mapFloaterListDto(dto: FloaterListDto, iconFallback: String? = null): ListSummary { + return ListSummary( + id = dto.id, + name = dto.name, + color = dto.color, + iconKey = dto.iconKey ?: iconFallback, + todoCount = dto.todoCount, + updatedAt = parseOptionalInstant(dto.updatedAt), + createdAt = parseOptionalInstant(dto.createdAt), + ) +} + internal fun matchesCompletedRecord( record: CachedCompletedRecord, itemId: String, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt index df291e25..4144fb45 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt @@ -14,7 +14,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerializationException -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import java.net.CookieManager import javax.inject.Inject @@ -29,8 +28,11 @@ class OfflineCacheManager @Inject constructor( private val cookieManager: CookieManager, ) { private val todoDao = database.todoDao() + private val floaterDao = database.floaterDao() private val listDao = database.listDao() + private val floaterListDao = database.floaterListDao() private val completedDao = database.completedDao() + private val completedFloaterDao = database.completedFloaterDao() private val mutationDao = database.mutationDao() private val syncMetadataDao = database.syncMetadataDao() @@ -78,8 +80,11 @@ class OfflineCacheManager @Inject constructor( fun loadOfflineState(): OfflineSyncState { ensureMigrated() val todos = todoDao.getAll().map { it.toRecord() } + val floaters = floaterDao.getAll().map { it.toRecord() } val lists = listDao.getAll().map { it.toRecord() } + val floaterLists = floaterListDao.getAll().map { it.toRecord() } val completed = completedDao.getAll().map { it.toRecord() } + val completedFloaters = completedFloaterDao.getAll().map { it.toRecord() } val mutations = mutationDao.getAll().map { it.toRecord() } val metadata = syncMetadataDao.get() @@ -87,8 +92,11 @@ class OfflineCacheManager @Inject constructor( lastSuccessfulSyncEpochMs = metadata?.lastSuccessfulSyncEpochMs ?: 0L, lastSyncAttemptEpochMs = metadata?.lastSyncAttemptEpochMs ?: 0L, todos = todos, + floaters = floaters, completedItems = completed, + completedFloaters = completedFloaters, lists = lists, + floaterLists = floaterLists, pendingMutations = mutations, aiSummaryEnabled = metadata?.aiSummaryEnabled ?: true, ) @@ -117,8 +125,11 @@ class OfflineCacheManager @Inject constructor( fun hasCachedData(): Boolean { ensureMigrated() if (todoDao.count() > 0) return true + if (floaterDao.count() > 0) return true if (listDao.count() > 0) return true + if (floaterListDao.count() > 0) return true if (completedDao.count() > 0) return true + if (completedFloaterDao.count() > 0) return true return mutationDao.count() > 0 } @@ -159,10 +170,16 @@ class OfflineCacheManager @Inject constructor( database.runInTransaction { todoDao.deleteAll() todoDao.insertAll(state.todos.map { it.toEntity() }) + floaterDao.deleteAll() + floaterDao.insertAll(state.floaters.map { it.toEntity() }) listDao.deleteAll() listDao.insertAll(state.lists.map { it.toEntity() }) + floaterListDao.deleteAll() + floaterListDao.insertAll(state.floaterLists.map { it.toEntity() }) completedDao.deleteAll() completedDao.insertAll(state.completedItems.map { it.toEntity() }) + completedFloaterDao.deleteAll() + completedFloaterDao.insertAll(state.completedFloaters.map { it.toEntity() }) mutationDao.deleteAll() mutationDao.insertAll(state.pendingMutations.map { it.toEntity() }) syncMetadataDao.upsert( @@ -180,8 +197,11 @@ class OfflineCacheManager @Inject constructor( next: OfflineSyncState, ): Boolean { return previous.todos != next.todos || + previous.floaters != next.floaters || previous.completedItems != next.completedItems || + previous.completedFloaters != next.completedFloaters || previous.lists != next.lists || + previous.floaterLists != next.floaterLists || previous.aiSummaryEnabled != next.aiSummaryEnabled } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Daos.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Daos.kt index 999de023..d685329f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Daos.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Daos.kt @@ -24,6 +24,21 @@ interface TodoDao { fun deleteAll() } +@Dao +interface FloaterDao { + @Query("SELECT * FROM cached_floaters") + fun getAll(): List + + @Query("SELECT COUNT(*) FROM cached_floaters") + fun count(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(floaters: List) + + @Query("DELETE FROM cached_floaters") + fun deleteAll() +} + @Dao interface ListDao { @Query("SELECT * FROM cached_lists") @@ -42,6 +57,21 @@ interface ListDao { fun deleteAll() } +@Dao +interface FloaterListDao { + @Query("SELECT * FROM cached_floater_lists") + fun getAll(): List + + @Query("SELECT COUNT(*) FROM cached_floater_lists") + fun count(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(lists: List) + + @Query("DELETE FROM cached_floater_lists") + fun deleteAll() +} + @Dao interface CompletedDao { @Query("SELECT * FROM cached_completed") @@ -60,6 +90,21 @@ interface CompletedDao { fun deleteAll() } +@Dao +interface CompletedFloaterDao { + @Query("SELECT * FROM cached_completed_floaters") + fun getAll(): List + + @Query("SELECT COUNT(*) FROM cached_completed_floaters") + fun count(): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertAll(items: List) + + @Query("DELETE FROM cached_completed_floaters") + fun deleteAll() +} + @Dao interface MutationDao { @Query("SELECT * FROM pending_mutations ORDER BY timestampEpochMs ASC") diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt deleted file mode 100644 index 224b1bd7..00000000 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseMigrations.kt +++ /dev/null @@ -1,184 +0,0 @@ -package com.ohmz.tday.compose.core.data.db - -import androidx.room.migration.Migration -import androidx.sqlite.db.SupportSQLiteDatabase - -/** - * Drops `dtstartEpochMs` from cached todos, completed rows, and pending mutations. - */ -val MIGRATION_1_2 = object : Migration(1, 2) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `cached_todos_new` ( - `id` TEXT NOT NULL PRIMARY KEY, - `canonicalId` TEXT NOT NULL, - `title` TEXT NOT NULL, - `description` TEXT, - `priority` TEXT NOT NULL, - `dueEpochMs` INTEGER NOT NULL, - `rrule` TEXT, - `instanceDateEpochMs` INTEGER, - `pinned` INTEGER NOT NULL, - `completed` INTEGER NOT NULL, - `listId` TEXT, - `updatedAtEpochMs` INTEGER NOT NULL - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO `cached_todos_new` (`id`,`canonicalId`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`instanceDateEpochMs`,`pinned`,`completed`,`listId`,`updatedAtEpochMs`) - SELECT `id`,`canonicalId`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`instanceDateEpochMs`,`pinned`,`completed`,`listId`,`updatedAtEpochMs` FROM `cached_todos` - """.trimIndent(), - ) - db.execSQL("DROP TABLE `cached_todos`") - db.execSQL("ALTER TABLE `cached_todos_new` RENAME TO `cached_todos`") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_listId` ON `cached_todos` (`listId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_dueEpochMs` ON `cached_todos` (`dueEpochMs`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_completed` ON `cached_todos` (`completed`)") - - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `cached_completed_new` ( - `id` TEXT NOT NULL PRIMARY KEY, - `originalTodoId` TEXT, - `title` TEXT NOT NULL, - `description` TEXT, - `priority` TEXT NOT NULL, - `dueEpochMs` INTEGER NOT NULL, - `completedAtEpochMs` INTEGER NOT NULL, - `rrule` TEXT, - `instanceDateEpochMs` INTEGER, - `listName` TEXT, - `listColor` TEXT - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO `cached_completed_new` (`id`,`originalTodoId`,`title`,`description`,`priority`,`dueEpochMs`,`completedAtEpochMs`,`rrule`,`instanceDateEpochMs`,`listName`,`listColor`) - SELECT `id`,`originalTodoId`,`title`,`description`,`priority`,`dueEpochMs`,`completedAtEpochMs`,`rrule`,`instanceDateEpochMs`,`listName`,`listColor` FROM `cached_completed` - """.trimIndent(), - ) - db.execSQL("DROP TABLE `cached_completed`") - db.execSQL("ALTER TABLE `cached_completed_new` RENAME TO `cached_completed`") - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_cached_completed_completedAtEpochMs` ON `cached_completed` (`completedAtEpochMs`)", - ) - - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `pending_mutations_new` ( - `mutationId` TEXT NOT NULL PRIMARY KEY, - `kind` TEXT NOT NULL, - `targetId` TEXT, - `timestampEpochMs` INTEGER NOT NULL, - `title` TEXT, - `description` TEXT, - `priority` TEXT, - `dueEpochMs` INTEGER, - `rrule` TEXT, - `listId` TEXT, - `pinned` INTEGER, - `completed` INTEGER, - `instanceDateEpochMs` INTEGER, - `name` TEXT, - `color` TEXT, - `iconKey` TEXT - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO `pending_mutations_new` (`mutationId`,`kind`,`targetId`,`timestampEpochMs`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`listId`,`pinned`,`completed`,`instanceDateEpochMs`,`name`,`color`,`iconKey`) - SELECT `mutationId`,`kind`,`targetId`,`timestampEpochMs`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`listId`,`pinned`,`completed`,`instanceDateEpochMs`,`name`,`color`,`iconKey` FROM `pending_mutations` - """.trimIndent(), - ) - db.execSQL("DROP TABLE `pending_mutations`") - db.execSQL("ALTER TABLE `pending_mutations_new` RENAME TO `pending_mutations`") - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_pending_mutations_timestampEpochMs` ON `pending_mutations` (`timestampEpochMs`)", - ) - } -} - -val MIGRATION_2_3 = object : Migration(2, 3) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "ALTER TABLE `cached_lists` ADD COLUMN `createdAtEpochMs` INTEGER NOT NULL DEFAULT 0", - ) - } -} - -val MIGRATION_3_4 = object : Migration(3, 4) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - "ALTER TABLE `cached_completed` ADD COLUMN `listId` TEXT", - ) - } -} - -val MIGRATION_4_5 = object : Migration(4, 5) { - override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `cached_todos_new` ( - `id` TEXT NOT NULL PRIMARY KEY, - `canonicalId` TEXT NOT NULL, - `title` TEXT NOT NULL, - `description` TEXT, - `priority` TEXT NOT NULL, - `dueEpochMs` INTEGER, - `rrule` TEXT, - `instanceDateEpochMs` INTEGER, - `pinned` INTEGER NOT NULL, - `completed` INTEGER NOT NULL, - `listId` TEXT, - `updatedAtEpochMs` INTEGER NOT NULL - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO `cached_todos_new` (`id`,`canonicalId`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`instanceDateEpochMs`,`pinned`,`completed`,`listId`,`updatedAtEpochMs`) - SELECT `id`,`canonicalId`,`title`,`description`,`priority`,`dueEpochMs`,`rrule`,`instanceDateEpochMs`,`pinned`,`completed`,`listId`,`updatedAtEpochMs` FROM `cached_todos` - """.trimIndent(), - ) - db.execSQL("DROP TABLE `cached_todos`") - db.execSQL("ALTER TABLE `cached_todos_new` RENAME TO `cached_todos`") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_listId` ON `cached_todos` (`listId`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_dueEpochMs` ON `cached_todos` (`dueEpochMs`)") - db.execSQL("CREATE INDEX IF NOT EXISTS `index_cached_todos_completed` ON `cached_todos` (`completed`)") - - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS `cached_completed_new` ( - `id` TEXT NOT NULL PRIMARY KEY, - `originalTodoId` TEXT, - `title` TEXT NOT NULL, - `description` TEXT, - `priority` TEXT NOT NULL, - `dueEpochMs` INTEGER, - `completedAtEpochMs` INTEGER NOT NULL, - `rrule` TEXT, - `instanceDateEpochMs` INTEGER, - `listId` TEXT, - `listName` TEXT, - `listColor` TEXT - ) - """.trimIndent(), - ) - db.execSQL( - """ - INSERT INTO `cached_completed_new` (`id`,`originalTodoId`,`title`,`description`,`priority`,`dueEpochMs`,`completedAtEpochMs`,`rrule`,`instanceDateEpochMs`,`listId`,`listName`,`listColor`) - SELECT `id`,`originalTodoId`,`title`,`description`,`priority`,`dueEpochMs`,`completedAtEpochMs`,`rrule`,`instanceDateEpochMs`,`listId`,`listName`,`listColor` FROM `cached_completed` - """.trimIndent(), - ) - db.execSQL("DROP TABLE `cached_completed`") - db.execSQL("ALTER TABLE `cached_completed_new` RENAME TO `cached_completed`") - db.execSQL( - "CREATE INDEX IF NOT EXISTS `index_cached_completed_completedAtEpochMs` ON `cached_completed` (`completedAtEpochMs`)", - ) - } -} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt index 66dc066c..1a4bcb5d 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/DatabaseModule.kt @@ -21,7 +21,7 @@ object DatabaseModule { TdayDatabase::class.java, "tday_offline_cache.db", ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5) + .fallbackToDestructiveMigration() .allowMainThreadQueries() .build() } @@ -29,12 +29,21 @@ object DatabaseModule { @Provides fun provideTodoDao(db: TdayDatabase): TodoDao = db.todoDao() + @Provides + fun provideFloaterDao(db: TdayDatabase): FloaterDao = db.floaterDao() + @Provides fun provideListDao(db: TdayDatabase): ListDao = db.listDao() + @Provides + fun provideFloaterListDao(db: TdayDatabase): FloaterListDao = db.floaterListDao() + @Provides fun provideCompletedDao(db: TdayDatabase): CompletedDao = db.completedDao() + @Provides + fun provideCompletedFloaterDao(db: TdayDatabase): CompletedFloaterDao = db.completedFloaterDao() + @Provides fun provideMutationDao(db: TdayDatabase): MutationDao = db.mutationDao() diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt index f17f0196..01824215 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/Entities.kt @@ -27,6 +27,25 @@ data class CachedTodoEntity( val updatedAtEpochMs: Long, ) +@Entity( + tableName = "cached_floaters", + indices = [ + Index("listId"), + Index("completed"), + ], +) +data class CachedFloaterEntity( + @PrimaryKey val id: String, + val canonicalId: String, + val title: String, + val description: String?, + val priority: String, + val pinned: Boolean, + val completed: Boolean, + val listId: String?, + val updatedAtEpochMs: Long, +) + @Entity(tableName = "cached_lists") data class CachedListEntity( @PrimaryKey val id: String, @@ -38,6 +57,17 @@ data class CachedListEntity( val createdAtEpochMs: Long, ) +@Entity(tableName = "cached_floater_lists") +data class CachedFloaterListEntity( + @PrimaryKey val id: String, + val name: String, + val color: String?, + val iconKey: String?, + val todoCount: Int, + val updatedAtEpochMs: Long, + val createdAtEpochMs: Long, +) + @Entity( tableName = "cached_completed", indices = [Index("completedAtEpochMs")], @@ -57,6 +87,22 @@ data class CachedCompletedEntity( val listColor: String?, ) +@Entity( + tableName = "cached_completed_floaters", + indices = [Index("completedAtEpochMs")], +) +data class CachedCompletedFloaterEntity( + @PrimaryKey val id: String, + val originalFloaterId: String?, + val title: String, + val description: String?, + val priority: String, + val completedAtEpochMs: Long, + val listId: String?, + val listName: String?, + val listColor: String?, +) + @Entity( tableName = "pending_mutations", indices = [Index("timestampEpochMs")], diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt index f05916d3..cb351e53 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/EntityMappers.kt @@ -1,6 +1,9 @@ package com.ohmz.tday.compose.core.data.db +import com.ohmz.tday.compose.core.data.CachedCompletedFloaterRecord import com.ohmz.tday.compose.core.data.CachedCompletedRecord +import com.ohmz.tday.compose.core.data.CachedFloaterListRecord +import com.ohmz.tday.compose.core.data.CachedFloaterRecord import com.ohmz.tday.compose.core.data.CachedListRecord import com.ohmz.tday.compose.core.data.CachedTodoRecord import com.ohmz.tday.compose.core.data.MutationKind @@ -36,6 +39,30 @@ fun CachedTodoEntity.toRecord() = CachedTodoRecord( updatedAtEpochMs = updatedAtEpochMs, ) +fun CachedFloaterRecord.toEntity() = CachedFloaterEntity( + id = id, + canonicalId = canonicalId, + title = title, + description = description, + priority = priority, + pinned = pinned, + completed = completed, + listId = listId, + updatedAtEpochMs = updatedAtEpochMs, +) + +fun CachedFloaterEntity.toRecord() = CachedFloaterRecord( + id = id, + canonicalId = canonicalId, + title = title, + description = description, + priority = priority, + pinned = pinned, + completed = completed, + listId = listId, + updatedAtEpochMs = updatedAtEpochMs, +) + fun CachedListRecord.toEntity() = CachedListEntity( id = id, name = name, @@ -56,6 +83,26 @@ fun CachedListEntity.toRecord() = CachedListRecord( createdAtEpochMs = createdAtEpochMs, ) +fun CachedFloaterListRecord.toEntity() = CachedFloaterListEntity( + id = id, + name = name, + color = color, + iconKey = iconKey, + todoCount = todoCount, + updatedAtEpochMs = updatedAtEpochMs, + createdAtEpochMs = createdAtEpochMs, +) + +fun CachedFloaterListEntity.toRecord() = CachedFloaterListRecord( + id = id, + name = name, + color = color, + iconKey = iconKey, + todoCount = todoCount, + updatedAtEpochMs = updatedAtEpochMs, + createdAtEpochMs = createdAtEpochMs, +) + fun CachedCompletedRecord.toEntity() = CachedCompletedEntity( id = id, originalTodoId = originalTodoId, @@ -86,6 +133,30 @@ fun CachedCompletedEntity.toRecord() = CachedCompletedRecord( listColor = listColor, ) +fun CachedCompletedFloaterRecord.toEntity() = CachedCompletedFloaterEntity( + id = id, + originalFloaterId = originalFloaterId, + title = title, + description = description, + priority = priority, + completedAtEpochMs = completedAtEpochMs, + listId = listId, + listName = listName, + listColor = listColor, +) + +fun CachedCompletedFloaterEntity.toRecord() = CachedCompletedFloaterRecord( + id = id, + originalFloaterId = originalFloaterId, + title = title, + description = description, + priority = priority, + completedAtEpochMs = completedAtEpochMs, + listId = listId, + listName = listName, + listColor = listColor, +) + fun PendingMutationRecord.toEntity() = PendingMutationEntity( mutationId = mutationId, kind = kind.name, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt index 6802ff73..3c2356a0 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/TdayDatabase.kt @@ -6,18 +6,24 @@ import androidx.room.RoomDatabase @Database( entities = [ CachedTodoEntity::class, + CachedFloaterEntity::class, CachedListEntity::class, + CachedFloaterListEntity::class, CachedCompletedEntity::class, + CachedCompletedFloaterEntity::class, PendingMutationEntity::class, SyncMetadataEntity::class, ], - version = 5, + version = 7, exportSchema = false, ) abstract class TdayDatabase : RoomDatabase() { abstract fun todoDao(): TodoDao + abstract fun floaterDao(): FloaterDao abstract fun listDao(): ListDao + abstract fun floaterListDao(): FloaterListDao abstract fun completedDao(): CompletedDao + abstract fun completedFloaterDao(): CompletedFloaterDao abstract fun mutationDao(): MutationDao abstract fun syncMetadataDao(): SyncMetadataDao } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt new file mode 100644 index 00000000..8f7171fa --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt @@ -0,0 +1,342 @@ +package com.ohmz.tday.compose.core.data.list + +import android.util.Log +import com.ohmz.tday.compose.core.data.CachedFloaterListRecord +import com.ohmz.tday.compose.core.data.MutationKind +import com.ohmz.tday.compose.core.data.OfflineSyncState +import com.ohmz.tday.compose.core.data.PendingMutationRecord +import com.ohmz.tday.compose.core.data.SecureConfigStore +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_LIST_PREFIX +import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager +import com.ohmz.tday.compose.core.data.cache.floaterListFromCache +import com.ohmz.tday.compose.core.data.cache.orderFloaterListsLikeWeb +import com.ohmz.tday.compose.core.data.cache.parseOptionalInstant +import com.ohmz.tday.compose.core.data.isLikelyUnrecoverableMutationError +import com.ohmz.tday.compose.core.data.requireApiBody +import com.ohmz.tday.compose.core.data.sync.SyncManager +import com.ohmz.tday.compose.core.model.CreateFloaterListRequest +import com.ohmz.tday.compose.core.model.DeleteFloaterListRequest +import com.ohmz.tday.compose.core.model.ListSummary +import com.ohmz.tday.compose.core.model.UpdateFloaterListRequest +import com.ohmz.tday.compose.core.model.capitalizeFirstListLetter +import com.ohmz.tday.compose.core.network.TdayApiService +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class FloaterListRepository @Inject constructor( + private val api: TdayApiService, + private val cacheManager: OfflineCacheManager, + private val secureConfigStore: SecureConfigStore, + private val syncManager: SyncManager, +) { + suspend fun fetchLists(): List = + buildListsForState(cacheManager.loadOfflineState()) + + fun fetchListsSnapshot(): List = + buildListsForState(cacheManager.loadOfflineState()) + + suspend fun createList(name: String, color: String? = null, iconKey: String? = null) { + val normalizedName = capitalizeFirstListLetter(name).trim() + if (normalizedName.isBlank()) return + + val localListId = "$LOCAL_FLOATER_LIST_PREFIX${UUID.randomUUID()}" + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + cacheManager.updateOfflineState { state -> + val newList = CachedFloaterListRecord( + id = localListId, + name = normalizedName, + color = color, + iconKey = iconKey, + todoCount = 0, + createdAtEpochMs = timestampMs, + updatedAtEpochMs = timestampMs, + ) + state.copy( + floaterLists = state.floaterLists + newList, + pendingMutations = state.pendingMutations + PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.CREATE_FLOATER_LIST, + targetId = localListId, + timestampEpochMs = timestampMs, + name = normalizedName, + color = color, + iconKey = iconKey, + ), + ) + } + + runCatching { + requireApiBody( + api.createFloaterList( + CreateFloaterListRequest( + name = normalizedName, + color = color, + iconKey = iconKey, + ), + ), + "Could not create floater list", + ).list + }.onSuccess { createdList -> + if (createdList == null) return@onSuccess + val createdAt = + parseOptionalInstant(createdList.createdAt)?.toEpochMilli() ?: timestampMs + val updatedAt = + parseOptionalInstant(createdList.updatedAt)?.toEpochMilli() ?: timestampMs + cacheManager.updateOfflineState { state -> + val remapped = replaceLocalFloaterListId( + state = state, + localListId = localListId, + serverListId = createdList.id, + ) + val todoCount = + remapped.floaters.count { !it.completed && it.listId == createdList.id } + remapped.copy( + floaterLists = remapped.floaterLists.map { list -> + if (list.id == createdList.id) { + list.copy( + name = createdList.name, + color = createdList.color, + iconKey = createdList.iconKey ?: list.iconKey, + todoCount = todoCount, + updatedAtEpochMs = updatedAt, + createdAtEpochMs = createdAt, + ) + } else { + list + } + }, + pendingMutations = remapped.pendingMutations.filterNot { it.mutationId == mutationId }, + ) + } + } + } + + suspend fun updateList( + listId: String, + name: String, + color: String? = null, + iconKey: String? = null + ) { + val trimmedName = capitalizeFirstListLetter(name).trim() + if (listId.isBlank()) return + require(trimmedName.isNotBlank()) { "List name is required" } + + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + if (listId.startsWith(LOCAL_FLOATER_LIST_PREFIX)) { + cacheManager.updateOfflineState { state -> + state.copy( + floaterLists = state.floaterLists.map { list -> + if (list.id == listId) { + list.copy( + name = trimmedName, + color = color ?: list.color, + iconKey = iconKey ?: list.iconKey, + updatedAtEpochMs = timestampMs, + ) + } else { + list + } + }, + pendingMutations = state.pendingMutations.map { mutation -> + if (mutation.kind == MutationKind.CREATE_FLOATER_LIST && mutation.targetId == listId) { + mutation.copy( + name = trimmedName, + color = color ?: mutation.color, + iconKey = iconKey ?: mutation.iconKey, + timestampEpochMs = timestampMs, + ) + } else { + mutation + } + }, + ) + } + iconKey?.takeIf { it.isNotBlank() }?.let { + secureConfigStore.saveListIcon(listId, it) + } + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.UPDATE_FLOATER_LIST, + targetId = listId, + timestampEpochMs = timestampMs, + name = trimmedName, + color = color, + iconKey = iconKey, + ) + cacheManager.updateOfflineState { state -> + state.copy( + floaterLists = state.floaterLists.map { list -> + if (list.id == listId) { + list.copy( + name = trimmedName, + color = color ?: list.color, + iconKey = iconKey ?: list.iconKey, + updatedAtEpochMs = timestampMs, + ) + } else { + list + } + }, + pendingMutations = state.pendingMutations + .filterNot { it.kind == MutationKind.UPDATE_FLOATER_LIST && it.targetId == listId } + pendingMutation, + ) + } + + val immediateError = runCatching { + requireApiBody( + api.patchFloaterListByBody( + UpdateFloaterListRequest( + id = listId, + name = trimmedName, + color = color, + iconKey = iconKey, + ), + ), + "Could not update floater list", + ) + }.exceptionOrNull() + + if (immediateError != null && isLikelyUnrecoverableMutationError( + immediateError, + pendingMutation + ) + ) { + throw immediateError + } + + iconKey?.takeIf { it.isNotBlank() }?.let { + secureConfigStore.saveListIcon(listId, it) + } + + if (immediateError == null) { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } else { + Log.w( + LOG_TAG, + "updateFloaterList deferred listId=$listId reason=${immediateError.message}" + ) + } + } + + suspend fun deleteList( + listId: String, + onOptimisticDelete: () -> Unit = {}, + ) { + val normalizedListId = listId.trim() + if (normalizedListId.isBlank()) return + + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.DELETE_FLOATER_LIST, + targetId = normalizedListId, + timestampEpochMs = timestampMs, + ) + val isLocalOnly = normalizedListId.startsWith(LOCAL_FLOATER_LIST_PREFIX) + + cacheManager.updateOfflineState { state -> + val deletedFloaterIds = state.floaters + .filter { it.listId == normalizedListId } + .map { it.canonicalId } + .toSet() + val prunedMutations = state.pendingMutations.filterNot { mutation -> + mutation.targetId == normalizedListId || + mutation.listId == normalizedListId || + deletedFloaterIds.contains(mutation.targetId) + } + + state.copy( + floaterLists = state.floaterLists.filterNot { it.id == normalizedListId }, + floaters = state.floaters.filterNot { it.listId == normalizedListId }, + completedFloaters = state.completedFloaters.filterNot { completed -> + completed.listId == normalizedListId || + completed.originalFloaterId?.let(deletedFloaterIds::contains) == true + }, + pendingMutations = if (isLocalOnly) prunedMutations else prunedMutations + pendingMutation, + ) + } + + onOptimisticDelete() + + if (isLocalOnly) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val immediateError = runCatching { + requireApiBody( + api.deleteFloaterListByBody(DeleteFloaterListRequest(id = normalizedListId)), + "Could not delete floater list", + ) + }.exceptionOrNull() + + if (immediateError != null && isLikelyUnrecoverableMutationError( + immediateError, + pendingMutation + ) + ) { + throw immediateError + } + + if (immediateError == null) { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } else { + Log.w( + LOG_TAG, + "deleteFloaterList deferred listId=$normalizedListId reason=${immediateError.message}" + ) + } + } + + private fun buildListsForState(state: OfflineSyncState): List { + val todoCountsByList = state.floaters + .asSequence() + .filterNot { it.completed } + .groupingBy { it.listId } + .eachCount() + return orderFloaterListsLikeWeb(state.floaterLists).map { + floaterListFromCache(cache = it, todoCountOverride = todoCountsByList[it.id] ?: 0) + } + } + + private fun replaceLocalFloaterListId( + state: OfflineSyncState, + localListId: String, + serverListId: String, + ): OfflineSyncState { + return state.copy( + floaterLists = state.floaterLists.map { + if (it.id == localListId) it.copy(id = serverListId) else it + }, + floaters = state.floaters.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, + completedFloaters = state.completedFloaters.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, + pendingMutations = state.pendingMutations.map { + it.copy( + targetId = if (it.targetId == localListId) serverListId else it.targetId, + listId = if (it.listId == localListId) serverListId else it.listId, + ) + }, + ) + } + + private companion object { + const val LOG_TAG = "FloaterListRepository" + } +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt index f8d4c20c..ced37323 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt @@ -263,7 +263,6 @@ class ListRepository @Inject constructor( .filter { it.listId == normalizedListId } .map { it.canonicalId } .toSet() - val prunedMutations = state.pendingMutations.filterNot { mutation -> mutation.targetId == normalizedListId || mutation.listId == normalizedListId || 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 a5a5df10..c2200507 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 @@ -3,20 +3,31 @@ package com.ohmz.tday.compose.core.data.sync import android.content.Context import android.util.Log import androidx.glance.appwidget.updateAll +import com.ohmz.tday.compose.core.data.CachedFloaterListRecord +import com.ohmz.tday.compose.core.data.CachedFloaterRecord import com.ohmz.tday.compose.core.data.CachedListRecord import com.ohmz.tday.compose.core.data.CachedTodoRecord import com.ohmz.tday.compose.core.data.MutationKind import com.ohmz.tday.compose.core.data.OfflineSyncState import com.ohmz.tday.compose.core.data.PendingMutationRecord import com.ohmz.tday.compose.core.data.SecureConfigStore +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_LIST_PREFIX +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_PREFIX import com.ohmz.tday.compose.core.data.cache.LOCAL_LIST_PREFIX import com.ohmz.tday.compose.core.data.cache.LOCAL_TODO_PREFIX import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager +import com.ohmz.tday.compose.core.data.cache.completedFloaterToCache import com.ohmz.tday.compose.core.data.cache.completedToCache +import com.ohmz.tday.compose.core.data.cache.floaterListToCache +import com.ohmz.tday.compose.core.data.cache.floaterToCache import com.ohmz.tday.compose.core.data.cache.listToCache import com.ohmz.tday.compose.core.data.cache.mapCompletedDto +import com.ohmz.tday.compose.core.data.cache.mapCompletedFloaterDto +import com.ohmz.tday.compose.core.data.cache.mapFloaterDto +import com.ohmz.tday.compose.core.data.cache.mapFloaterListDto import com.ohmz.tday.compose.core.data.cache.mapListDto import com.ohmz.tday.compose.core.data.cache.mapTodoDto +import com.ohmz.tday.compose.core.data.cache.orderFloaterListsLikeWeb import com.ohmz.tday.compose.core.data.cache.orderListsLikeWeb import com.ohmz.tday.compose.core.data.cache.todoMergeKey import com.ohmz.tday.compose.core.data.cache.todoToCache @@ -24,16 +35,24 @@ import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue import com.ohmz.tday.compose.core.data.isLikelyUnrecoverableMutationError import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.model.CompletedItem +import com.ohmz.tday.compose.core.model.CreateFloaterListRequest +import com.ohmz.tday.compose.core.model.CreateFloaterRequest import com.ohmz.tday.compose.core.model.CreateListRequest import com.ohmz.tday.compose.core.model.CreateTodoRequest +import com.ohmz.tday.compose.core.model.DeleteFloaterListRequest +import com.ohmz.tday.compose.core.model.DeleteFloaterRequest import com.ohmz.tday.compose.core.model.DeleteListRequest import com.ohmz.tday.compose.core.model.DeleteTodoRequest +import com.ohmz.tday.compose.core.model.FloaterCompleteRequest +import com.ohmz.tday.compose.core.model.FloaterUncompleteRequest import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoCompleteRequest import com.ohmz.tday.compose.core.model.TodoInstanceUpdateRequest import com.ohmz.tday.compose.core.model.TodoItem import com.ohmz.tday.compose.core.model.TodoPrioritizeRequest import com.ohmz.tday.compose.core.model.TodoUncompleteRequest +import com.ohmz.tday.compose.core.model.UpdateFloaterListRequest +import com.ohmz.tday.compose.core.model.UpdateFloaterRequest import com.ohmz.tday.compose.core.model.UpdateListRequest import com.ohmz.tday.compose.core.model.UpdateTodoRequest import com.ohmz.tday.compose.core.network.TdayApiService @@ -188,6 +207,20 @@ class SyncManager @Inject constructor( ).completedTodos.map(::mapCompletedDto) } + val floaters = async { + requireApiBody( + api.getFloaters(), + "Could not load floaters", + ).floaters.map(::mapFloaterDto) + } + + val completedFloaters = async { + requireApiBody( + api.getCompletedFloaters(), + "Could not load completed floaters", + ).completedFloaters.map(::mapCompletedFloaterDto) + } + val lists = async { requireApiBody( api.getLists(), @@ -195,6 +228,18 @@ class SyncManager @Inject constructor( ).lists.map { mapListDto(it, iconFallback = secureConfigStore.getListIcon(it.id)) } } + val floaterLists = async { + requireApiBody( + api.getFloaterLists(), + "Could not load floater lists", + ).lists.map { + mapFloaterListDto( + it, + iconFallback = secureConfigStore.getListIcon(it.id) + ) + } + } + val aiSummaryEnabled = async { runCatching { requireApiBody( @@ -208,8 +253,11 @@ class SyncManager @Inject constructor( RemoteSnapshot( todos = todos.await(), + floaters = floaters.await(), completedItems = completed.await(), + completedFloaters = completedFloaters.await(), lists = lists.await(), + floaterLists = floaterLists.await(), aiSummaryEnabled = aiSummaryEnabled.await(), ) } @@ -224,13 +272,14 @@ class SyncManager @Inject constructor( val pending = initialState.pendingMutations.sortedBy { it.timestampEpochMs }.toMutableList() val resolvedTodoIds = mutableMapOf() val resolvedListIds = mutableMapOf() + val resolvedFloaterListIds = mutableMapOf() val remaining = mutableListOf() for (mutation in pending) { val resolvedTargetId = resolveTargetId( targetId = mutation.targetId, todoIdMap = resolvedTodoIds, - listIdMap = resolvedListIds, + listIdMap = resolvedListIds + resolvedFloaterListIds, ) val success = runCatching { @@ -285,6 +334,57 @@ class SyncManager @Inject constructor( true } + MutationKind.CREATE_FLOATER_LIST -> { + val localListId = mutation.targetId ?: return@runCatching false + if (!localListId.startsWith(LOCAL_FLOATER_LIST_PREFIX)) return@runCatching true + val localListExists = state.floaterLists.any { it.id == localListId } + if (!localListExists) return@runCatching true + val response = requireApiBody( + api.createFloaterList( + CreateFloaterListRequest( + name = mutation.name?.trim().orEmpty(), + color = mutation.color, + iconKey = mutation.iconKey, + ), + ), + "Could not create floater list", + ) + val serverListId = response.list?.id ?: return@runCatching false + resolvedFloaterListIds[localListId] = serverListId + state = replaceLocalFloaterListId(state, localListId, serverListId) + true + } + + MutationKind.UPDATE_FLOATER_LIST -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_LIST_PREFIX)) return@runCatching false + val remoteUpdatedAt = + remoteSnapshot.floaterListUpdatedAtById[targetId] ?: 0L + if (remoteUpdatedAt > mutation.timestampEpochMs) return@runCatching true + requireApiBody( + api.patchFloaterListByBody( + UpdateFloaterListRequest( + id = targetId, + name = mutation.name, + color = mutation.color, + iconKey = mutation.iconKey, + ), + ), + "Could not update floater list", + ) + true + } + + MutationKind.DELETE_FLOATER_LIST -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_LIST_PREFIX)) return@runCatching true + requireApiBody( + api.deleteFloaterListByBody(DeleteFloaterListRequest(id = targetId)), + "Could not delete floater list", + ) + true + } + MutationKind.CREATE_TODO -> { val localTodoId = mutation.targetId ?: return@runCatching false if (!localTodoId.startsWith(LOCAL_TODO_PREFIX)) return@runCatching true @@ -304,7 +404,7 @@ class SyncManager @Inject constructor( priority = mutation.priority ?: "Low", due = mutation.dueEpochMs?.let { Instant.ofEpochMilli(it).toString() - }, + } ?: return@runCatching false, rrule = mutation.rrule?.takeIf { mutation.dueEpochMs != null }, listID = resolvedListId, ), @@ -422,6 +522,88 @@ class SyncManager @Inject constructor( true } + MutationKind.CREATE_FLOATER -> { + val localFloaterId = mutation.targetId ?: return@runCatching false + if (!localFloaterId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching true + val localFloaterExists = + state.floaters.any { it.canonicalId == localFloaterId } + if (!localFloaterExists) return@runCatching true + val resolvedListId = mutation.listId?.let { + resolvedFloaterListIds[it] ?: it + } + if (resolvedListId != null && resolvedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { + return@runCatching false + } + val created = requireApiBody( + api.createFloater( + CreateFloaterRequest( + title = mutation.title?.trim().orEmpty(), + description = mutation.description, + priority = mutation.priority ?: "Low", + listID = resolvedListId, + ), + ), + "Could not create floater", + ).floater ?: return@runCatching false + val createdFloater = mapFloaterDto(created) + resolvedTodoIds[localFloaterId] = createdFloater.canonicalId + state = + replaceLocalFloaterId(state, localFloaterId, createdFloater.canonicalId) + true + } + + MutationKind.UPDATE_FLOATER -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching false + val remoteUpdatedAt = + remoteSnapshot.floaterUpdatedAtByCanonical[targetId] ?: 0L + if (remoteUpdatedAt > mutation.timestampEpochMs) return@runCatching true + val resolvedListId = + mutation.listId?.let { resolvedFloaterListIds[it] ?: it } + if (!resolvedListId.isNullOrBlank() && resolvedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { + return@runCatching false + } + val remoteFloater = + remoteSnapshot.floaters.firstOrNull { it.canonicalId == targetId } + val listIdForApi = resolvedListId + ?: if (!remoteFloater?.listId.isNullOrBlank()) "" else null + requireApiBody( + api.patchFloaterByBody( + UpdateFloaterRequest( + id = targetId, + title = mutation.title, + description = mutation.description + ?: if (remoteFloater?.description != null) "" else null, + pinned = mutation.pinned, + priority = mutation.priority, + completed = mutation.completed, + listID = listIdForApi, + ), + ), + "Could not update floater", + ) + true + } + + MutationKind.DELETE_FLOATER -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching true + val remoteUpdatedAt = + remoteSnapshot.floaterUpdatedAtByCanonical[targetId] ?: 0L + if (remoteUpdatedAt > mutation.timestampEpochMs) return@runCatching true + requireApiBody( + api.deleteFloaterByBody(DeleteFloaterRequest(id = targetId)), + "Could not delete floater", + ) + true + } + MutationKind.SET_PINNED -> { val targetId = resolvedTargetId ?: return@runCatching false if (targetId.startsWith(LOCAL_TODO_PREFIX)) return@runCatching false @@ -511,6 +693,29 @@ class SyncManager @Inject constructor( ) true } + + MutationKind.COMPLETE_FLOATER -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching false + val remoteUpdatedAt = + remoteSnapshot.floaterUpdatedAtByCanonical[targetId] ?: 0L + if (remoteUpdatedAt > mutation.timestampEpochMs) return@runCatching true + requireApiBody( + api.completeFloaterByBody(FloaterCompleteRequest(id = targetId)), + "Could not complete floater", + ) + true + } + + MutationKind.UNCOMPLETE_FLOATER -> { + val targetId = resolvedTargetId ?: return@runCatching false + if (targetId.startsWith(LOCAL_FLOATER_PREFIX)) return@runCatching false + requireApiBody( + api.uncompleteFloaterByBody(FloaterUncompleteRequest(id = targetId)), + "Could not restore floater", + ) + true + } } }.getOrElse { error -> if (isLikelyConnectivityIssue(error)) { @@ -551,19 +756,35 @@ class SyncManager @Inject constructor( .filter { it.kind == MutationKind.DELETE_LIST } .mapNotNull { it.targetId } .toSet() + val pendingDeletedFloaterListIds = localState.pendingMutations + .filter { it.kind == MutationKind.DELETE_FLOATER_LIST } + .mapNotNull { it.targetId } + .toSet() val remoteTodos = remote.todos .filterNot { it.listId != null && pendingDeletedListIds.contains(it.listId) } .map(::todoToCache) val remoteLists = remote.lists.map(::listToCache) + val remoteFloaterLists = remote.floaterLists.map(::floaterListToCache) val remoteCompleted = remote.completedItems .filterNot { it.listId != null && pendingDeletedListIds.contains(it.listId) } .map(::completedToCache) .toMutableList() + val remoteFloaters = remote.floaters + .filterNot { it.listId != null && pendingDeletedFloaterListIds.contains(it.listId) } + .map(::floaterToCache) + val remoteCompletedFloaters = remote.completedFloaters + .filterNot { it.listId != null && pendingDeletedFloaterListIds.contains(it.listId) } + .map(::completedFloaterToCache) + .toMutableList() val pendingTodoCanonicalIds = localState.pendingMutations .filter { it.kind.affectsTodo() } .mapNotNull { it.targetId } .toSet() + val pendingFloaterCanonicalIds = localState.pendingMutations + .filter { it.kind.affectsFloater() } + .mapNotNull { it.targetId } + .toSet() val pendingListIds = localState.pendingMutations .filter { it.kind == MutationKind.CREATE_LIST || @@ -572,6 +793,14 @@ class SyncManager @Inject constructor( } .mapNotNull { it.targetId } .toSet() + val pendingFloaterListIds = localState.pendingMutations + .filter { + it.kind == MutationKind.CREATE_FLOATER_LIST || + it.kind == MutationKind.UPDATE_FLOATER_LIST || + it.kind == MutationKind.DELETE_FLOATER_LIST + } + .mapNotNull { it.targetId } + .toSet() val pendingDeleteAllCanonicals = localState.pendingMutations .filter { it.kind == MutationKind.DELETE_TODO && it.instanceDateEpochMs == null } .mapNotNull { it.targetId } @@ -584,6 +813,10 @@ class SyncManager @Inject constructor( } } .toSet() + val pendingDeletedFloaterIds = localState.pendingMutations + .filter { it.kind == MutationKind.DELETE_FLOATER } + .mapNotNull { it.targetId } + .toSet() val localTodoByKey = localState.todos.associateBy(::todoMergeKey) val remoteTodoByKey = remoteTodos.associateBy(::todoMergeKey) @@ -633,6 +866,56 @@ class SyncManager @Inject constructor( } } + val localFloaterById = localState.floaters.associateBy { it.canonicalId } + val remoteFloaterById = remoteFloaters.associateBy { it.canonicalId } + val mergedFloaters = mutableListOf() + val allFloaterIds = LinkedHashSet().apply { + addAll(remoteFloaterById.keys) + addAll(localFloaterById.keys) + } + + allFloaterIds.forEach { canonicalId -> + val localFloater = localFloaterById[canonicalId] + val remoteFloater = remoteFloaterById[canonicalId] + + if (remoteFloater != null && pendingDeletedFloaterIds.contains(remoteFloater.canonicalId)) { + return@forEach + } + if (remoteFloater == null && localFloater != null) { + val hasPendingLocalMutation = + pendingFloaterCanonicalIds.contains(localFloater.canonicalId) + val isUnsyncedLocalFloater = + localFloater.canonicalId.startsWith(LOCAL_FLOATER_PREFIX) + if (!hasPendingLocalMutation && !isUnsyncedLocalFloater) return@forEach + } + + val merged = when { + localFloater != null && remoteFloater != null -> { + if (pendingFloaterCanonicalIds.contains(localFloater.canonicalId) || + localFloater.updatedAtEpochMs > remoteFloater.updatedAtEpochMs + ) { + localFloater + } else { + remoteFloater + } + } + + localFloater != null -> localFloater + remoteFloater != null -> remoteFloater + else -> null + } + if (merged != null) mergedFloaters.add(merged) + } + + pendingFloaterCanonicalIds.forEach { canonicalId -> + val localCompletedForFloater = + localState.completedFloaters.filter { it.originalFloaterId == canonicalId } + if (localCompletedForFloater.isNotEmpty()) { + remoteCompletedFloaters.removeAll { it.originalFloaterId == canonicalId } + remoteCompletedFloaters.addAll(localCompletedForFloater) + } + } + val localListById = localState.lists.associateBy { it.id } val remoteListById = remoteLists.associateBy { it.id } val mergedLists = mutableListOf() @@ -673,6 +956,47 @@ class SyncManager @Inject constructor( if (merged != null) mergedLists.add(merged) } + val localFloaterListById = localState.floaterLists.associateBy { it.id } + val remoteFloaterListById = remoteFloaterLists.associateBy { it.id } + val mergedFloaterLists = mutableListOf() + val allFloaterListIds = LinkedHashSet().apply { + addAll(remoteFloaterListById.keys) + addAll(localFloaterListById.keys) + } + + allFloaterListIds.forEach { listId -> + val localList = localFloaterListById[listId] + val remoteList = remoteFloaterListById[listId] + + if (remoteList != null && pendingDeletedFloaterListIds.contains(remoteList.id)) { + return@forEach + } + + if (remoteList == null && localList != null) { + val hasPendingLocalMutation = pendingFloaterListIds.contains(localList.id) + val isUnsyncedLocalList = localList.id.startsWith(LOCAL_FLOATER_LIST_PREFIX) + if (!hasPendingLocalMutation && !isUnsyncedLocalList) return@forEach + } + + val merged = when { + localList != null && remoteList != null -> { + if ( + pendingFloaterListIds.contains(listId) || + localList.updatedAtEpochMs > remoteList.updatedAtEpochMs + ) { + localList + } else { + remoteList + } + } + + localList != null -> localList + remoteList != null -> remoteList + else -> null + } + if (merged != null) mergedFloaterLists.add(merged) + } + val todoCountByList = mergedTodos .asSequence() .filterNot { it.completed } @@ -683,11 +1007,24 @@ class SyncManager @Inject constructor( it.copy(todoCount = todoCountByList[it.id] ?: 0) }, ) + val floaterCountByList = mergedFloaters + .asSequence() + .filterNot { it.completed } + .groupingBy { it.listId } + .eachCount() + val normalizedFloaterLists = orderFloaterListsLikeWeb( + mergedFloaterLists.map { + it.copy(todoCount = floaterCountByList[it.id] ?: 0) + }, + ) val dataMergedState = localState.copy( todos = mergedTodos, + floaters = mergedFloaters, completedItems = remoteCompleted, + completedFloaters = remoteCompletedFloaters, lists = normalizedLists, + floaterLists = normalizedFloaterLists, aiSummaryEnabled = remote.aiSummaryEnabled, ) val localWinsMutations = buildLocalWinsMutations( @@ -713,6 +1050,10 @@ class SyncManager @Inject constructor( .filter { it.kind.affectsTodo() } .mapNotNull { it.targetId } .toSet() + val pendingFloaterCanonicalIds = existingPending + .filter { it.kind.affectsFloater() } + .mapNotNull { it.targetId } + .toSet() val pendingListIds = existingPending .filter { it.kind == MutationKind.CREATE_LIST || @@ -721,17 +1062,35 @@ class SyncManager @Inject constructor( } .mapNotNull { it.targetId } .toSet() + val pendingFloaterListIds = existingPending + .filter { + it.kind == MutationKind.CREATE_FLOATER_LIST || + it.kind == MutationKind.UPDATE_FLOATER_LIST || + it.kind == MutationKind.DELETE_FLOATER_LIST + } + .mapNotNull { it.targetId } + .toSet() val pendingLocalListCreates = existingPending .filter { it.kind == MutationKind.CREATE_LIST } .mapNotNull { it.targetId } .toSet() + val pendingLocalFloaterListCreates = existingPending + .filter { it.kind == MutationKind.CREATE_FLOATER_LIST } + .mapNotNull { it.targetId } + .toSet() val remoteTodoByKey = remote.todos .map(::todoToCache) .associateBy(::todoMergeKey) + val remoteFloaterById = remote.floaters + .map(::floaterToCache) + .associateBy { it.canonicalId } val remoteListById = remote.lists .map(::listToCache) .associateBy { it.id } + val remoteFloaterListById = remote.floaterLists + .map(::floaterListToCache) + .associateBy { it.id } val generated = mutableListOf() @@ -793,6 +1152,51 @@ class SyncManager @Inject constructor( generated.add(mutation) } + mergedState.floaters.forEach { localFloater -> + if (localFloater.canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) return@forEach + if (pendingFloaterCanonicalIds.contains(localFloater.canonicalId)) return@forEach + + val remoteFloater = remoteFloaterById[localFloater.canonicalId] ?: return@forEach + if (!hasFloaterMeaningfulDifferences( + local = localFloater, + remote = remoteFloater + ) + ) return@forEach + val localUpdatedAt = localFloater.updatedAtEpochMs + val remoteUpdatedAt = remoteFloater.updatedAtEpochMs + if (localUpdatedAt <= 0L || localUpdatedAt <= remoteUpdatedAt) return@forEach + + val mutation = if (localFloater.completed != remoteFloater.completed) { + PendingMutationRecord( + mutationId = UUID.randomUUID().toString(), + kind = if (localFloater.completed) MutationKind.COMPLETE_FLOATER else MutationKind.UNCOMPLETE_FLOATER, + targetId = localFloater.canonicalId, + timestampEpochMs = localUpdatedAt, + ) + } else { + val localListId = localFloater.listId + if (!localListId.isNullOrBlank() && + localListId.startsWith(LOCAL_FLOATER_LIST_PREFIX) && + !pendingLocalFloaterListCreates.contains(localListId) + ) { + return@forEach + } + PendingMutationRecord( + mutationId = UUID.randomUUID().toString(), + kind = MutationKind.UPDATE_FLOATER, + targetId = localFloater.canonicalId, + timestampEpochMs = localUpdatedAt, + title = localFloater.title, + description = localFloater.description, + priority = localFloater.priority, + pinned = localFloater.pinned, + completed = localFloater.completed, + listId = localFloater.listId, + ) + } + generated.add(mutation) + } + mergedState.lists.forEach { localList -> if (localList.id.startsWith(LOCAL_LIST_PREFIX)) return@forEach if (pendingListIds.contains(localList.id)) return@forEach @@ -816,6 +1220,33 @@ class SyncManager @Inject constructor( ) } + mergedState.floaterLists.forEach { localList -> + if (localList.id.startsWith(LOCAL_FLOATER_LIST_PREFIX)) return@forEach + if (pendingFloaterListIds.contains(localList.id)) return@forEach + + val remoteList = remoteFloaterListById[localList.id] ?: return@forEach + if (!hasFloaterListMeaningfulDifferences( + local = localList, + remote = remoteList + ) + ) return@forEach + val localUpdatedAt = localList.updatedAtEpochMs + val remoteUpdatedAt = remoteList.updatedAtEpochMs + if (localUpdatedAt <= 0L || localUpdatedAt <= remoteUpdatedAt) return@forEach + + generated.add( + PendingMutationRecord( + mutationId = UUID.randomUUID().toString(), + kind = MutationKind.UPDATE_FLOATER_LIST, + targetId = localList.id, + timestampEpochMs = localUpdatedAt, + name = localList.name, + color = localList.color, + iconKey = localList.iconKey, + ), + ) + } + return generated } @@ -843,6 +1274,18 @@ class SyncManager @Inject constructor( local.listId != remote.listId } + private fun hasFloaterMeaningfulDifferences( + local: CachedFloaterRecord, + remote: CachedFloaterRecord, + ): Boolean { + return local.title != remote.title || + local.description != remote.description || + local.priority != remote.priority || + local.pinned != remote.pinned || + local.completed != remote.completed || + local.listId != remote.listId + } + private fun hasListMeaningfulDifferences( local: CachedListRecord, remote: CachedListRecord, @@ -852,6 +1295,15 @@ class SyncManager @Inject constructor( local.iconKey != remote.iconKey } + private fun hasFloaterListMeaningfulDifferences( + local: CachedFloaterListRecord, + remote: CachedFloaterListRecord, + ): Boolean { + return local.name != remote.name || + local.color != remote.color || + local.iconKey != remote.iconKey + } + private fun mergePendingMutations( existing: List, generated: List, @@ -891,6 +1343,14 @@ class SyncManager @Inject constructor( this == MutationKind.UNCOMPLETE_TODO } + private fun MutationKind.affectsFloater(): Boolean { + return this == MutationKind.CREATE_FLOATER || + this == MutationKind.UPDATE_FLOATER || + this == MutationKind.DELETE_FLOATER || + this == MutationKind.COMPLETE_FLOATER || + this == MutationKind.UNCOMPLETE_FLOATER + } + private fun replaceLocalListId( state: OfflineSyncState, localListId: String, @@ -915,6 +1375,30 @@ class SyncManager @Inject constructor( ) } + private fun replaceLocalFloaterListId( + state: OfflineSyncState, + localListId: String, + serverListId: String, + ): OfflineSyncState { + return state.copy( + floaterLists = state.floaterLists.map { + if (it.id == localListId) it.copy(id = serverListId) else it + }, + floaters = state.floaters.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, + completedFloaters = state.completedFloaters.map { + if (it.listId == localListId) it.copy(listId = serverListId) else it + }, + pendingMutations = state.pendingMutations.map { + it.copy( + targetId = if (it.targetId == localListId) serverListId else it.targetId, + listId = if (it.listId == localListId) serverListId else it.listId, + ) + }, + ) + } + private fun replaceLocalTodoId( state: OfflineSyncState, localTodoId: String, @@ -937,6 +1421,28 @@ class SyncManager @Inject constructor( ) } + private fun replaceLocalFloaterId( + state: OfflineSyncState, + localFloaterId: String, + serverFloaterId: String, + ): OfflineSyncState { + return state.copy( + floaters = state.floaters.map { + if (it.canonicalId == localFloaterId) { + it.copy( + id = if (it.id == localFloaterId) serverFloaterId else it.id, + canonicalId = serverFloaterId, + ) + } else { + it + } + }, + pendingMutations = state.pendingMutations.map { + if (it.targetId == localFloaterId) it.copy(targetId = serverFloaterId) else it + }, + ) + } + private fun resolveTargetId( targetId: String?, todoIdMap: Map, @@ -955,8 +1461,11 @@ class SyncManager @Inject constructor( private data class RemoteSnapshot( val todos: List, + val floaters: List, val completedItems: List, + val completedFloaters: List, val lists: List, + val floaterLists: List, val aiSummaryEnabled: Boolean, ) { val todoUpdatedAtByCanonical: Map = todos @@ -970,6 +1479,18 @@ class SyncManager @Inject constructor( .mapValues { (_, entries) -> entries.maxOfOrNull { it.updatedAt?.toEpochMilli() ?: 0L } ?: 0L } + + val floaterListUpdatedAtById: Map = floaterLists + .groupBy { it.id } + .mapValues { (_, entries) -> + entries.maxOfOrNull { it.updatedAt?.toEpochMilli() ?: 0L } ?: 0L + } + + val floaterUpdatedAtByCanonical: Map = floaters + .groupBy { it.canonicalId } + .mapValues { (_, entries) -> + entries.maxOfOrNull { it.updatedAt?.toEpochMilli() ?: 0L } ?: 0L + } } companion object { 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 a5ecba06..50695148 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 @@ -1,25 +1,34 @@ package com.ohmz.tday.compose.core.data.todo import android.util.Log +import com.ohmz.tday.compose.core.data.CachedFloaterRecord import com.ohmz.tday.compose.core.data.CachedTodoRecord import com.ohmz.tday.compose.core.data.MutationKind import com.ohmz.tday.compose.core.data.OfflineSyncState import com.ohmz.tday.compose.core.data.PendingMutationRecord +import com.ohmz.tday.compose.core.data.cache.LOCAL_COMPLETED_FLOATER_PREFIX +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_LIST_PREFIX +import com.ohmz.tday.compose.core.data.cache.LOCAL_FLOATER_PREFIX import com.ohmz.tday.compose.core.data.cache.LOCAL_LIST_PREFIX import com.ohmz.tday.compose.core.data.cache.LOCAL_TODO_PREFIX import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager -import com.ohmz.tday.compose.core.data.cache.completedFromCache +import com.ohmz.tday.compose.core.data.cache.floaterFromCache +import com.ohmz.tday.compose.core.data.cache.floaterToCache import com.ohmz.tday.compose.core.data.cache.listFromCache +import com.ohmz.tday.compose.core.data.cache.mapFloaterDto import com.ohmz.tday.compose.core.data.cache.mapTodoDto import com.ohmz.tday.compose.core.data.cache.orderListsLikeWeb import com.ohmz.tday.compose.core.data.cache.todoFromCache import com.ohmz.tday.compose.core.data.isLikelyUnrecoverableMutationError import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.data.sync.SyncManager +import com.ohmz.tday.compose.core.model.CreateFloaterRequest import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.CreateTodoRequest import com.ohmz.tday.compose.core.model.DashboardSummary +import com.ohmz.tday.compose.core.model.DeleteFloaterRequest import com.ohmz.tday.compose.core.model.DeleteTodoRequest +import com.ohmz.tday.compose.core.model.FloaterCompleteRequest import com.ohmz.tday.compose.core.model.TodoCompleteRequest import com.ohmz.tday.compose.core.model.TodoInstanceDeleteRequest import com.ohmz.tday.compose.core.model.TodoInstanceUpdateRequest @@ -29,6 +38,7 @@ import com.ohmz.tday.compose.core.model.TodoSummaryRequest import com.ohmz.tday.compose.core.model.TodoSummaryResponse import com.ohmz.tday.compose.core.model.TodoTitleNlpRequest import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse +import com.ohmz.tday.compose.core.model.UpdateFloaterRequest import com.ohmz.tday.compose.core.model.UpdateTodoRequest import com.ohmz.tday.compose.core.network.TdayApiService import java.time.Instant @@ -93,8 +103,8 @@ class TodoRepository @Inject constructor( "High" -> "High" else -> "Low" } - val normalizedDue = payload.due - val normalizedRrule = payload.rrule?.takeIf { normalizedDue != null && it.isNotBlank() } + val normalizedDue = payload.due ?: ZonedDateTime.now(zoneId).plusHours(1).toInstant() + val normalizedRrule = payload.rrule?.takeIf { it.isNotBlank() } val normalizedDescription = payload.description?.trim()?.ifBlank { null } val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } @@ -109,7 +119,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue?.toEpochMilli(), + dueEpochMs = normalizedDue.toEpochMilli(), rrule = normalizedRrule, instanceDateEpochMs = null, pinned = false, @@ -127,14 +137,17 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue?.toEpochMilli(), + dueEpochMs = normalizedDue.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, ), ) } - if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith(LOCAL_LIST_PREFIX)) { + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return } @@ -146,7 +159,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - due = normalizedDue?.toString(), + due = normalizedDue.toString(), rrule = normalizedRrule, listID = normalizedListId, ), @@ -176,6 +189,91 @@ class TodoRepository @Inject constructor( }.onFailure { /* pending mutation will be retried by background sync */ } } + suspend fun createFloater(payload: CreateTaskPayload) { + val trimmedTitle = payload.title.trim() + if (trimmedTitle.isBlank()) return + + val normalizedPriority = when (payload.priority.trim()) { + "Medium" -> "Medium" + "High" -> "High" + else -> "Low" + } + val normalizedDescription = payload.description?.trim()?.ifBlank { null } + val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } + val localFloaterId = "$LOCAL_FLOATER_PREFIX${UUID.randomUUID()}" + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + + cacheManager.updateOfflineState { state -> + val newFloater = CachedFloaterRecord( + id = localFloaterId, + canonicalId = localFloaterId, + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + pinned = false, + completed = false, + listId = normalizedListId, + updatedAtEpochMs = timestampMs, + ) + state.copy( + floaters = state.floaters + newFloater, + pendingMutations = state.pendingMutations + PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.CREATE_FLOATER, + targetId = localFloaterId, + timestampEpochMs = timestampMs, + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + ), + ) + } + + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + runCatching { + requireApiBody( + api.createFloater( + CreateFloaterRequest( + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listID = normalizedListId, + ), + ), + "Could not create floater", + ).floater + }.onSuccess { createdDto -> + if (createdDto == null) return@onSuccess + val createdFloater = mapFloaterDto(createdDto) + cacheManager.updateOfflineState { state -> + val remapped = replaceLocalFloaterId( + state = state, + localFloaterId = localFloaterId, + serverFloaterId = createdFloater.canonicalId, + ) + remapped.copy( + floaters = remapped.floaters.map { + if (it.canonicalId == createdFloater.canonicalId) { + floaterToCache(createdFloater) + } else { + it + } + }, + pendingMutations = remapped.pendingMutations.filterNot { it.mutationId == mutationId }, + ) + } + }.onFailure { /* pending mutation will be retried by background sync */ } + } + suspend fun updateTodo(todo: TodoItem, payload: CreateTaskPayload) { val canonicalId = todo.canonicalId if (canonicalId.isBlank()) return @@ -188,9 +286,10 @@ class TodoRepository @Inject constructor( "High" -> "High" else -> "Low" } - val normalizedDue = payload.due + val normalizedDue = + payload.due ?: todo.due ?: ZonedDateTime.now(zoneId).plusHours(1).toInstant() val normalizedDescription = payload.description?.trim()?.ifBlank { null } - val normalizedRrule = payload.rrule?.takeIf { normalizedDue != null && it.isNotBlank() } + val normalizedRrule = payload.rrule?.takeIf { it.isNotBlank() } val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } val instanceDateEpochMs = todo.instanceDateEpochMillis val timestampMs = System.currentTimeMillis() @@ -203,7 +302,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue?.toEpochMilli(), + dueEpochMs = normalizedDue.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, instanceDateEpochMs = instanceDateEpochMs, @@ -220,7 +319,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue?.toEpochMilli(), + dueEpochMs = normalizedDue.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, updatedAtEpochMs = timestampMs, @@ -235,7 +334,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue?.toEpochMilli(), + dueEpochMs = normalizedDue.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, timestampEpochMs = timestampMs, @@ -260,7 +359,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = normalizedDescription, priority = normalizedPriority, - dueEpochMs = normalizedDue?.toEpochMilli(), + dueEpochMs = normalizedDue.toEpochMilli(), rrule = normalizedRrule, listId = normalizedListId, updatedAtEpochMs = timestampMs, @@ -278,7 +377,10 @@ class TodoRepository @Inject constructor( ) } - if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith(LOCAL_LIST_PREFIX)) { + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( + LOCAL_FLOATER_LIST_PREFIX + ) + ) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return } @@ -297,7 +399,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = descriptionForApi, priority = normalizedPriority, - due = normalizedDue?.toString(), + due = normalizedDue.toString(), ), ), "Could not update recurring task instance", @@ -310,7 +412,7 @@ class TodoRepository @Inject constructor( title = trimmedTitle, description = descriptionForApi, priority = normalizedPriority, - due = normalizedDue?.toString(), + due = normalizedDue.toString(), rrule = rruleForApi, listID = listIdForApi, dateChanged = true, @@ -338,6 +440,130 @@ class TodoRepository @Inject constructor( } } + suspend fun updateFloater(floater: TodoItem, payload: CreateTaskPayload) { + val canonicalId = floater.canonicalId + if (canonicalId.isBlank()) return + val trimmedTitle = payload.title.trim() + if (trimmedTitle.isBlank()) return + + val normalizedPriority = when (payload.priority.trim()) { + "Medium" -> "Medium" + "High" -> "High" + else -> "Low" + } + val normalizedDescription = payload.description?.trim()?.ifBlank { null } + val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + val pendingMutation = PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.UPDATE_FLOATER, + targetId = canonicalId, + timestampEpochMs = timestampMs, + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + ) + + if (canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) { + cacheManager.updateOfflineState { state -> + state.copy( + floaters = state.floaters.map { cached -> + if (cached.canonicalId == canonicalId) { + cached.copy( + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + updatedAtEpochMs = timestampMs, + ) + } else { + cached + } + }, + pendingMutations = state.pendingMutations.map { mutation -> + if (mutation.kind == MutationKind.CREATE_FLOATER && mutation.targetId == canonicalId) { + mutation.copy( + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + timestampEpochMs = timestampMs, + ) + } else { + mutation + } + }, + ) + } + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + cacheManager.updateOfflineState { state -> + state.copy( + floaters = state.floaters.map { cached -> + if (cached.canonicalId == canonicalId) { + cached.copy( + title = trimmedTitle, + description = normalizedDescription, + priority = normalizedPriority, + listId = normalizedListId, + updatedAtEpochMs = timestampMs, + ) + } else { + cached + } + }, + pendingMutations = state.pendingMutations + .filterNot { it.kind == MutationKind.UPDATE_FLOATER && it.targetId == canonicalId } + pendingMutation, + ) + } + + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith(LOCAL_LIST_PREFIX)) { + syncManager.syncCachedData(force = true, replayPendingMutations = true) + return + } + + val descriptionForApi = + normalizedDescription ?: if (floater.description != null) "" else null + val listIdForApi = normalizedListId ?: if (!floater.listId.isNullOrBlank()) "" else null + val immediateError = runCatching { + requireApiBody( + api.patchFloaterByBody( + UpdateFloaterRequest( + id = canonicalId, + title = trimmedTitle, + description = descriptionForApi, + priority = normalizedPriority, + listID = listIdForApi, + ), + ), + "Could not update floater", + ) + }.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, + "updateFloater deferred floater=$canonicalId reason=${immediateError.message}" + ) + } + } + suspend fun moveTodo(todo: TodoItem, due: Instant) { val canonicalId = todo.canonicalId if (canonicalId.isBlank()) return @@ -523,6 +749,53 @@ class TodoRepository @Inject constructor( } } + suspend fun deleteFloater(floater: TodoItem) { + val timestampMs = System.currentTimeMillis() + val canonicalId = floater.canonicalId + val mutationId = UUID.randomUUID().toString() + + cacheManager.updateOfflineState { state -> + val isLocalOnly = canonicalId.startsWith(LOCAL_FLOATER_PREFIX) + val prunedFloaters = state.floaters.filterNot { it.canonicalId == canonicalId } + val prunedCompleted = + state.completedFloaters.filterNot { it.originalFloaterId == canonicalId } + + if (isLocalOnly) { + state.copy( + floaters = prunedFloaters, + completedFloaters = prunedCompleted, + pendingMutations = state.pendingMutations.filterNot { it.targetId == canonicalId }, + ) + } else { + state.copy( + floaters = prunedFloaters, + completedFloaters = prunedCompleted, + pendingMutations = state.pendingMutations + .filterNot { it.kind == MutationKind.DELETE_FLOATER && it.targetId == canonicalId } + + PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.DELETE_FLOATER, + targetId = canonicalId, + timestampEpochMs = timestampMs, + ), + ) + } + } + + if (canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) return + + runCatching { + requireApiBody( + api.deleteFloaterByBody(DeleteFloaterRequest(id = canonicalId)), + "Could not delete floater", + ) + }.onSuccess { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } + } + suspend fun completeTodo(todo: TodoItem) { val timestampMs = System.currentTimeMillis() val mutationId = UUID.randomUUID().toString() @@ -608,6 +881,58 @@ class TodoRepository @Inject constructor( } } + suspend fun completeFloater(floater: TodoItem) { + val timestampMs = System.currentTimeMillis() + val mutationId = UUID.randomUUID().toString() + cacheManager.updateOfflineState { state -> + val updatedFloaters = state.floaters.map { + if (it.canonicalId == floater.canonicalId) { + it.copy(completed = true, updatedAtEpochMs = timestampMs) + } else { + it + } + } + val completedId = "$LOCAL_COMPLETED_FLOATER_PREFIX${UUID.randomUUID()}" + val listMeta = + floater.listId?.let { listId -> state.floaterLists.firstOrNull { it.id == listId } } + val completedItem = com.ohmz.tday.compose.core.data.CachedCompletedFloaterRecord( + id = completedId, + originalFloaterId = floater.canonicalId, + title = floater.title, + description = floater.description, + priority = floater.priority, + completedAtEpochMs = timestampMs, + listId = floater.listId, + listName = listMeta?.name, + listColor = listMeta?.color, + ) + + state.copy( + floaters = updatedFloaters, + completedFloaters = state.completedFloaters + completedItem, + pendingMutations = state.pendingMutations + PendingMutationRecord( + mutationId = mutationId, + kind = MutationKind.COMPLETE_FLOATER, + targetId = floater.canonicalId, + timestampEpochMs = timestampMs, + ), + ) + } + + if (floater.canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) return + + runCatching { + requireApiBody( + api.completeFloaterByBody(FloaterCompleteRequest(id = floater.canonicalId)), + "Could not complete floater", + ) + }.onSuccess { + cacheManager.updateOfflineState { state -> + state.copy(pendingMutations = state.pendingMutations.filterNot { it.mutationId == mutationId }) + } + } + } + suspend fun summarizeTodos( mode: TodoListMode, listId: String? = null, @@ -620,7 +945,7 @@ class TodoRepository @Inject constructor( TodoListMode.SCHEDULED -> "scheduled" TodoListMode.ALL -> "all" TodoListMode.PRIORITY -> "priority" - TodoListMode.ANYTIME -> throw IllegalStateException( + TodoListMode.FLOATER -> throw IllegalStateException( "Summary is available only for Today, Scheduled, All, and Priority screens", ) TodoListMode.LIST -> throw IllegalStateException( @@ -667,11 +992,14 @@ class TodoRepository @Inject constructor( .map(::todoFromCache) .filterNot { it.completed } .toList() + val activeFloaters = state.floaters + .asSequence() + .map(::floaterFromCache) + .filterNot { it.completed } + .toList() val todayTodos = timelineTodos.filter(::isTodayTodo) val now = Instant.now() val scheduledTodos = timelineTodos.filter { isScheduledTodo(it, now) } - val anytimeTodos = timelineTodos.filter { it.due == null } - val completedTodos = state.completedItems.map(::completedFromCache) val todoCountsByList = timelineTodos .groupingBy { it.listId } .eachCount() @@ -685,8 +1013,8 @@ class TodoRepository @Inject constructor( scheduledCount = scheduledTodos.size, allCount = timelineTodos.size, priorityCount = timelineTodos.count { isPriorityTodo(it.priority) }, - anytimeCount = anytimeTodos.size, - completedCount = completedTodos.size, + floaterCount = activeFloaters.size, + completedCount = state.completedItems.size, lists = lists, ) } @@ -701,6 +1029,11 @@ class TodoRepository @Inject constructor( .map(::todoFromCache) .toList() val activeTodos = allTodos.filterNot { it.completed } + val activeFloaters = state.floaters + .asSequence() + .map(::floaterFromCache) + .filterNot { it.completed } + .toList() val now = Instant.now() return when (mode) { @@ -709,7 +1042,10 @@ class TodoRepository @Inject constructor( TodoListMode.ALL -> activeTodos TodoListMode.SCHEDULED -> activeTodos.filter { isScheduledTodo(it, now) } TodoListMode.PRIORITY -> activeTodos.filter { isPriorityTodo(it.priority) } - TodoListMode.ANYTIME -> activeTodos.filter { it.due == null } + TodoListMode.FLOATER -> { + if (listId.isNullOrBlank()) activeFloaters + else activeFloaters.filter { it.listId == listId } + } TodoListMode.LIST -> { if (listId.isNullOrBlank()) emptyList() else activeTodos.filter { it.listId == listId } @@ -784,6 +1120,32 @@ class TodoRepository @Inject constructor( ) } + private fun replaceLocalFloaterId( + state: OfflineSyncState, + localFloaterId: String, + serverFloaterId: String, + ): OfflineSyncState { + return state.copy( + floaters = state.floaters.map { + if (it.canonicalId == localFloaterId) { + it.copy( + id = if (it.id == localFloaterId) serverFloaterId else it.id, + canonicalId = serverFloaterId, + ) + } else { + it + } + }, + pendingMutations = state.pendingMutations.map { + if (it.targetId == localFloaterId) { + it.copy(targetId = serverFloaterId) + } else { + it + } + }, + ) + } + private companion object { const val LOG_TAG = "TodoRepository" } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/ApiModels.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/ApiModels.kt index 05602434..44cb4046 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/ApiModels.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/model/ApiModels.kt @@ -17,6 +17,16 @@ typealias TodoDto = com.ohmz.tday.shared.model.TodoDto typealias CreateTodoResponse = com.ohmz.tday.shared.model.CreateTodoResponse typealias UpdateTodoRequest = com.ohmz.tday.shared.model.UpdateTodoRequest typealias DeleteTodoRequest = com.ohmz.tday.shared.model.DeleteTodoRequest +typealias FloatersResponse = com.ohmz.tday.shared.model.FloatersResponse +typealias FloaterDto = com.ohmz.tday.shared.model.FloaterDto +typealias CreateFloaterRequest = com.ohmz.tday.shared.model.CreateFloaterRequest +typealias CreateFloaterResponse = com.ohmz.tday.shared.model.CreateFloaterResponse +typealias UpdateFloaterRequest = com.ohmz.tday.shared.model.UpdateFloaterRequest +typealias DeleteFloaterRequest = com.ohmz.tday.shared.model.DeleteFloaterRequest +typealias FloaterCompleteRequest = com.ohmz.tday.shared.model.FloaterCompleteRequest +typealias FloaterUncompleteRequest = com.ohmz.tday.shared.model.FloaterUncompleteRequest +typealias FloaterPrioritizeRequest = com.ohmz.tday.shared.model.FloaterPrioritizeRequest +typealias FloaterReorderRequest = com.ohmz.tday.shared.model.FloaterReorderRequest typealias TodoInstanceUpdateRequest = com.ohmz.tday.shared.model.TodoInstancePatchRequest typealias TodoInstanceDeleteRequest = com.ohmz.tday.shared.model.TodoInstanceDeleteRequest typealias TodoCompleteRequest = com.ohmz.tday.shared.model.TodoCompleteRequest @@ -30,10 +40,22 @@ typealias ListDetailResponse = com.ohmz.tday.shared.model.ListDetailResponse typealias UpdateListRequest = com.ohmz.tday.shared.model.UpdateListRequest typealias DeleteListRequest = com.ohmz.tday.shared.model.DeleteListRequest typealias DeleteListResponse = com.ohmz.tday.shared.model.DeleteListResponse +typealias FloaterListsResponse = com.ohmz.tday.shared.model.FloaterListsResponse +typealias CreateFloaterListRequest = com.ohmz.tday.shared.model.CreateFloaterListRequest +typealias FloaterListDto = com.ohmz.tday.shared.model.FloaterListDto +typealias CreateFloaterListResponse = com.ohmz.tday.shared.model.CreateFloaterListResponse +typealias FloaterListDetailResponse = com.ohmz.tday.shared.model.FloaterListDetailResponse +typealias UpdateFloaterListRequest = com.ohmz.tday.shared.model.UpdateFloaterListRequest +typealias DeleteFloaterListRequest = com.ohmz.tday.shared.model.DeleteFloaterListRequest +typealias DeleteFloaterListResponse = com.ohmz.tday.shared.model.DeleteFloaterListResponse typealias CompletedTodosResponse = com.ohmz.tday.shared.model.CompletedTodosResponse typealias CompletedTodoDto = com.ohmz.tday.shared.model.CompletedTodoDto typealias UpdateCompletedTodoRequest = com.ohmz.tday.shared.model.UpdateCompletedTodoRequest typealias DeleteCompletedTodoRequest = com.ohmz.tday.shared.model.DeleteCompletedTodoRequest +typealias CompletedFloatersResponse = com.ohmz.tday.shared.model.CompletedFloatersResponse +typealias CompletedFloaterDto = com.ohmz.tday.shared.model.CompletedFloaterDto +typealias UpdateCompletedFloaterRequest = com.ohmz.tday.shared.model.UpdateCompletedFloaterRequest +typealias DeleteCompletedFloaterRequest = com.ohmz.tday.shared.model.DeleteCompletedFloaterRequest typealias PreferencesResponse = com.ohmz.tday.shared.model.PreferencesResponse typealias PreferencesDto = com.ohmz.tday.shared.model.PreferencesDto 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 9633753b..0fe24dbc 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 @@ -12,7 +12,7 @@ enum class TodoListMode { SCHEDULED, ALL, PRIORITY, - ANYTIME, + FLOATER, LIST, } @@ -59,7 +59,7 @@ fun TodoListMode.supportsTaskReschedule(): Boolean { TodoListMode.LIST, -> true - TodoListMode.ANYTIME, + TodoListMode.FLOATER, TodoListMode.TODAY, TodoListMode.OVERDUE, -> false @@ -146,7 +146,7 @@ data class DashboardSummary( val scheduledCount: Int, val allCount: Int, val priorityCount: Int, - val anytimeCount: Int, + val floaterCount: Int, val completedCount: Int, val lists: List, ) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt index c6e4249f..3b46648f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/navigation/AppRoute.kt @@ -7,7 +7,7 @@ sealed class AppRoute(val route: String) { data object ServerSetup : AppRoute("server-setup") data object Login : AppRoute("login") data object Home : AppRoute("home") - data object AnytimeTodos : AppRoute("anytime") + data object FloaterTodos : AppRoute("floater") data object TodayTodos : AppRoute("todos/today") data object OverdueTodos : AppRoute("todos/overdue") data object ScheduledTodos : AppRoute("todos/scheduled") @@ -26,6 +26,11 @@ sealed class AppRoute(val route: String) { return "todos/list/$listId/${Uri.encode(listName)}" } } + data object FloaterListTodos : AppRoute("floater/list/{listId}/{listName}") { + fun create(listId: String, listName: String): String { + return "floater/list/$listId/${Uri.encode(listName)}" + } + } data object Completed : AppRoute("completed") data object Calendar : AppRoute("calendar") diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt index 504a54ce..02b8b963 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/TdayApiService.kt @@ -3,7 +3,12 @@ package com.ohmz.tday.compose.core.network import com.ohmz.tday.compose.core.model.AdminSettingsResponse import com.ohmz.tday.compose.core.model.AppSettingsResponse import com.ohmz.tday.compose.core.model.ChangePasswordRequest +import com.ohmz.tday.compose.core.model.CompletedFloatersResponse import com.ohmz.tday.compose.core.model.CompletedTodosResponse +import com.ohmz.tday.compose.core.model.CreateFloaterListRequest +import com.ohmz.tday.compose.core.model.CreateFloaterListResponse +import com.ohmz.tday.compose.core.model.CreateFloaterRequest +import com.ohmz.tday.compose.core.model.CreateFloaterResponse import com.ohmz.tday.compose.core.model.CreateListRequest import com.ohmz.tday.compose.core.model.CreateListResponse import com.ohmz.tday.compose.core.model.CreateTodoRequest @@ -11,10 +16,21 @@ import com.ohmz.tday.compose.core.model.CreateTodoResponse import com.ohmz.tday.compose.core.model.CredentialKeyResponse import com.ohmz.tday.compose.core.model.CredentialsCallbackRequest import com.ohmz.tday.compose.core.model.CsrfResponse +import com.ohmz.tday.compose.core.model.DeleteCompletedFloaterRequest import com.ohmz.tday.compose.core.model.DeleteCompletedTodoRequest -import com.ohmz.tday.compose.core.model.DeleteListResponse +import com.ohmz.tday.compose.core.model.DeleteFloaterListRequest +import com.ohmz.tday.compose.core.model.DeleteFloaterListResponse +import com.ohmz.tday.compose.core.model.DeleteFloaterRequest import com.ohmz.tday.compose.core.model.DeleteListRequest +import com.ohmz.tday.compose.core.model.DeleteListResponse import com.ohmz.tday.compose.core.model.DeleteTodoRequest +import com.ohmz.tday.compose.core.model.FloaterCompleteRequest +import com.ohmz.tday.compose.core.model.FloaterListDetailResponse +import com.ohmz.tday.compose.core.model.FloaterListsResponse +import com.ohmz.tday.compose.core.model.FloaterPrioritizeRequest +import com.ohmz.tday.compose.core.model.FloaterReorderRequest +import com.ohmz.tday.compose.core.model.FloaterUncompleteRequest +import com.ohmz.tday.compose.core.model.FloatersResponse import com.ohmz.tday.compose.core.model.ListDetailResponse import com.ohmz.tday.compose.core.model.ListsResponse import com.ohmz.tday.compose.core.model.MessageResponse @@ -35,7 +51,10 @@ import com.ohmz.tday.compose.core.model.TodoTitleNlpResponse import com.ohmz.tday.compose.core.model.TodoUncompleteRequest import com.ohmz.tday.compose.core.model.TodosResponse import com.ohmz.tday.compose.core.model.UpdateAdminSettingsRequest +import com.ohmz.tday.compose.core.model.UpdateCompletedFloaterRequest import com.ohmz.tday.compose.core.model.UpdateCompletedTodoRequest +import com.ohmz.tday.compose.core.model.UpdateFloaterListRequest +import com.ohmz.tday.compose.core.model.UpdateFloaterRequest import com.ohmz.tday.compose.core.model.UpdateListRequest import com.ohmz.tday.compose.core.model.UpdateProfileRequest import com.ohmz.tday.compose.core.model.UpdateTodoRequest @@ -119,6 +138,44 @@ interface TdayApiService { @Body payload: CreateTodoRequest, ): Response + @GET("/api/floater") + suspend fun getFloaters(): Response + + @POST("/api/floater") + suspend fun createFloater( + @Body payload: CreateFloaterRequest, + ): Response + + @PATCH("/api/floater") + suspend fun patchFloaterByBody( + @Body payload: UpdateFloaterRequest, + ): Response + + @HTTP(method = "DELETE", path = "/api/floater", hasBody = true) + suspend fun deleteFloaterByBody( + @Body payload: DeleteFloaterRequest, + ): Response + + @PATCH("/api/floater/complete") + suspend fun completeFloaterByBody( + @Body payload: FloaterCompleteRequest, + ): Response + + @PATCH("/api/floater/uncomplete") + suspend fun uncompleteFloaterByBody( + @Body payload: FloaterUncompleteRequest, + ): Response + + @PATCH("/api/floater/prioritize") + suspend fun prioritizeFloaterByBody( + @Body payload: FloaterPrioritizeRequest, + ): Response + + @PATCH("/api/floater/reorder") + suspend fun reorderFloater( + @Body payload: FloaterReorderRequest, + ): Response + @PATCH("/api/todo") suspend fun patchTodoByBody( @Body payload: UpdateTodoRequest, @@ -168,6 +225,9 @@ interface TdayApiService { @GET("/api/completedTodo") suspend fun getCompletedTodos(): Response + @GET("/api/completedFloater") + suspend fun getCompletedFloaters(): Response + @PATCH("/api/completedTodo") suspend fun patchCompletedTodoByBody( @Body payload: UpdateCompletedTodoRequest, @@ -178,6 +238,16 @@ interface TdayApiService { @Body payload: DeleteCompletedTodoRequest, ): Response + @PATCH("/api/completedFloater") + suspend fun patchCompletedFloaterByBody( + @Body payload: UpdateCompletedFloaterRequest, + ): Response + + @HTTP(method = "DELETE", path = "/api/completedFloater", hasBody = true) + suspend fun deleteCompletedFloaterByBody( + @Body payload: DeleteCompletedFloaterRequest, + ): Response + @GET("/api/list") suspend fun getLists(): Response @@ -203,6 +273,29 @@ interface TdayApiService { @Body payload: DeleteListRequest, ): Response + @GET("/api/floaterList") + suspend fun getFloaterLists(): Response + + @GET("/api/floaterList/{id}") + suspend fun getFloaterListTodos( + @Path("id") listId: String, + ): Response + + @POST("/api/floaterList") + suspend fun createFloaterList( + @Body payload: CreateFloaterListRequest, + ): Response + + @PATCH("/api/floaterList") + suspend fun patchFloaterListByBody( + @Body payload: UpdateFloaterListRequest, + ): Response + + @HTTP(method = "DELETE", path = "/api/floaterList", hasBody = true) + suspend fun deleteFloaterListByBody( + @Body payload: DeleteFloaterListRequest, + ): Response + @GET("/api/preferences") suspend fun getPreferences(): Response 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 2332d72b..8c6d1c09 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 @@ -2062,7 +2062,7 @@ private fun CalendarTaskDragPreview( maxLines = 1, ) Text( - text = todo.due?.let(CalendarTaskDragDueTimeFormatter::format) ?: "Anytime", + text = todo.due?.let(CalendarTaskDragDueTimeFormatter::format) ?: "Floater", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = colorScheme.onSurfaceVariant, @@ -2150,7 +2150,7 @@ private fun CalendarTodoRow( ) val dueText = todo.due ?.let { DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(it) } - ?: "Anytime" + ?: "Floater" val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val showListIndicator = listMeta != null val priorityIcon = priorityIconFor(todo.priority) @@ -2443,7 +2443,7 @@ private fun CalendarCompletedTodoRow( ) val dueText = item.due ?.let { DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(it) } - ?: "Anytime" + ?: "Floater" val listMeta = item.resolveListSummary(lists) val listIndicatorColor = listMeta?.color?.let(::listAccentColor) ?: item.listColor?.let(::listAccentColor) 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 226e16f7..31c94791 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 @@ -236,7 +236,7 @@ fun HomeScreen( onOpenPriority: () -> Unit, onOpenCompleted: () -> Unit, onOpenCalendar: () -> Unit, - onOpenAnytime: () -> Unit, + onOpenFloater: () -> Unit, onOpenSettings: () -> Unit, onOpenTaskFromSearch: (todoId: String) -> Unit, onOpenList: (listId: String, listName: String) -> Unit, @@ -647,12 +647,7 @@ fun HomeScreen( .semantics(mergeDescendants = true) {} .heightIn(min = 48.dp) .clickable { - if (todo.due == null) { - closeSearch() - onOpenAnytime() - } else { - openTaskFromSearch(todo.id) - } + openTaskFromSearch(todo.id) } .padding(horizontal = 12.dp, vertical = 9.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -675,7 +670,7 @@ fun HomeScreen( ) Text( text = todo.due?.let(dueFormatter::format) - ?: "Anytime", + .orEmpty(), style = MaterialTheme.typography.bodySmall, color = colorScheme.onSurfaceVariant, maxLines = 1, @@ -713,9 +708,9 @@ fun HomeScreen( activeTab = RootFeedTab.HOME, collapsed = dockCollapsed, onTabSelected = { tab -> - if (tab == RootFeedTab.ANYTIME) { + if (tab == RootFeedTab.FLOATER) { closeSearch() - onOpenAnytime() + onOpenFloater() } }, modifier = Modifier @@ -1691,7 +1686,7 @@ private fun HomeTodayTaskRow( label = "homeTodayTitleStrikeProgress", ) val actionRevealProgress = swipeRevealState.revealProgress(animatedOffsetX) - val dueText = todo.due?.let(HOME_TODAY_DUE_FORMATTER::format) ?: "Anytime" + val dueText = todo.due?.let(HOME_TODAY_DUE_FORMATTER::format).orEmpty() val rowShape = RoundedCornerShape(16.dp) val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val listIndicatorColor = listColorAccent(listMeta?.color) 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 931903ed..ae746492 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 @@ -31,7 +31,7 @@ data class HomeUiState( scheduledCount = 0, allCount = 0, priorityCount = 0, - anytimeCount = 0, + floaterCount = 0, completedCount = 0, lists = emptyList(), ), 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 b6c215dd..c14f7f3b 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,5 +1,6 @@ package com.ohmz.tday.compose.feature.todos +import androidx.activity.compose.BackHandler import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing @@ -21,6 +22,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState 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.PaddingValues import androidx.compose.foundation.layout.Row @@ -36,6 +38,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -51,6 +54,7 @@ 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.automirrored.rounded.PlaylistAdd import androidx.compose.material.icons.rounded.AcUnit import androidx.compose.material.icons.rounded.AccountBalance import androidx.compose.material.icons.rounded.AccountBalanceWallet @@ -114,6 +118,7 @@ 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.Search import androidx.compose.material.icons.rounded.ShoppingBasket import androidx.compose.material.icons.rounded.ShoppingCart import androidx.compose.material.icons.rounded.SportsBaseball @@ -160,10 +165,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester 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.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector @@ -177,6 +185,7 @@ 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.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -186,6 +195,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -214,6 +224,7 @@ import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.component.RootFeedDock import com.ohmz.tday.compose.ui.component.RootFeedTab +import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -228,6 +239,7 @@ import java.time.format.TextStyle import java.util.Locale import kotlin.math.abs import kotlin.math.roundToInt +import androidx.compose.ui.graphics.lerp as lerpColor private val TimelineSameDateTaskSpacing = 2.dp private val TimelineDateGroupSpacing = 6.dp @@ -265,6 +277,9 @@ fun TodoListScreen( onDelete: (todo: TodoItem) -> Unit, onUpdateListSettings: (listId: String, name: String, color: String?, iconKey: String?) -> Unit, onDeleteList: (listId: String) -> Unit, + onOpenFloaterList: (listId: String, listName: String) -> Unit = { _, _ -> }, + onOpenSettings: () -> Unit = {}, + onCreateList: (name: String, color: String?, iconKey: String?) -> Unit = { _, _, _ -> }, rootFeedTab: RootFeedTab? = null, onRootFeedTabSelected: ((RootFeedTab) -> Unit)? = null, showRootFeedDock: Boolean = true, @@ -280,10 +295,12 @@ fun TodoListScreen( val selectedList = uiState.lists.firstOrNull { it.id == uiState.listId } val selectedListColorKey = selectedList?.color val usesTodayStyle = - uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.ANYTIME || uiState.mode == TodoListMode.LIST - val isAnytimeScreen = uiState.mode == TodoListMode.ANYTIME || uiState.title.trim() == "Anytime" + uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.FLOATER || uiState.mode == TodoListMode.LIST + val isFloaterScreen = uiState.mode == TodoListMode.FLOATER || uiState.title.trim() == "Floater" + val isRootFloaterScreen = + uiState.mode == TodoListMode.FLOATER && uiState.listId.isNullOrBlank() val usesRootFeedChrome = - usesRootFeedHeader || isAnytimeScreen + usesRootFeedHeader || isFloaterScreen val titleColor = modeAccentColor( mode = uiState.mode, listColorKey = selectedListColorKey, @@ -297,7 +314,7 @@ fun TodoListScreen( listIconKey = selectedList?.iconKey, ) val showSectionedTimeline = - uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.ANYTIME || uiState.mode == TodoListMode.LIST + uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.FLOATER || uiState.mode == TodoListMode.LIST val suppressInitialTodayTimeline = uiState.mode == TodoListMode.TODAY && !uiState.hasHydratedSnapshot && @@ -310,6 +327,22 @@ fun TodoListScreen( items = uiState.items, ) } + val floaterListRows = remember(uiState.mode, uiState.listId, uiState.items, uiState.lists) { + if (uiState.mode == TodoListMode.FLOATER && uiState.listId.isNullOrBlank()) { + val floaterCountsByList = uiState.items + .asSequence() + .mapNotNull { it.listId } + .groupingBy { it } + .eachCount() + uiState.lists.mapNotNull { list -> + val count = floaterCountsByList[list.id] ?: return@mapNotNull null + list to count + } + } else { + emptyList() + } + } + val floaterListById = remember(uiState.lists) { uiState.lists.associateBy { it.id } } var timelineAnimationsReady by remember(uiState.mode, uiState.listId) { mutableStateOf(uiState.mode != TodoListMode.TODAY) } @@ -330,6 +363,7 @@ fun TodoListScreen( val timelineAnimationsEnabled = uiState.mode != TodoListMode.TODAY || timelineAnimationsReady val listState = rememberLazyListState() + val screenScope = rememberCoroutineScope() val hasScrollableContent = listState.canScrollForward || listState.canScrollBackward val dockCollapseThresholdPx = with(LocalDensity.current) { @@ -361,6 +395,46 @@ fun TodoListScreen( uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } + var rootFloaterSearchExpanded by rememberSaveable { mutableStateOf(false) } + var rootFloaterSearchQuery by rememberSaveable { mutableStateOf("") } + val normalizedRootFloaterSearchQuery = remember(rootFloaterSearchQuery) { + rootFloaterSearchQuery.trim().lowercase(Locale.getDefault()) + } + val rootFloaterSearchResults = remember( + isRootFloaterScreen, + normalizedRootFloaterSearchQuery, + uiState.items, + floaterListById, + ) { + if (!isRootFloaterScreen || normalizedRootFloaterSearchQuery.isBlank()) { + emptyList() + } else { + uiState.items + .asSequence() + .filter { todo -> + todo.title.lowercase(Locale.getDefault()) + .contains(normalizedRootFloaterSearchQuery) || + (todo.description?.lowercase(Locale.getDefault()) + ?.contains(normalizedRootFloaterSearchQuery) == true) || + (todo.listId?.let { floaterListById[it]?.name } + ?.lowercase(Locale.getDefault()) + ?.contains(normalizedRootFloaterSearchQuery) == true) + } + .sortedWith( + compareByDescending { it.pinned } + .thenBy { floaterPriorityRank(it.priority) } + .thenBy { it.title.lowercase(Locale.getDefault()) }, + ) + .take(20) + .toList() + } + } + val showRootFloaterSearchResults = + isRootFloaterScreen && rootFloaterSearchExpanded && rootFloaterSearchQuery.isNotBlank() + val closeRootFloaterSearch = { + rootFloaterSearchExpanded = false + rootFloaterSearchQuery = "" + } var lastHandledCreateTaskRequestKey by rememberSaveable { mutableStateOf(createTaskRequestKey) } @@ -385,11 +459,24 @@ fun TodoListScreen( LaunchedEffect(createTaskRequestKey) { if (createTaskRequestKey > lastHandledCreateTaskRequestKey) { lastHandledCreateTaskRequestKey = createTaskRequestKey + closeRootFloaterSearch() quickAddDueEpochMs = null showCreateTaskSheet = true } } + BackHandler(enabled = rootFloaterSearchExpanded) { + closeRootFloaterSearch() + } + LaunchedEffect(isRootFloaterScreen, rootFloaterSearchExpanded) { + if (!isRootFloaterScreen) { + closeRootFloaterSearch() + onRootControlsVisibleChange(true) + } else { + onRootControlsVisibleChange(!rootFloaterSearchExpanded) + } + } var showListSettingsSheet by rememberSaveable { mutableStateOf(false) } + var showCreateListSheet by rememberSaveable { mutableStateOf(false) } var showDeleteListConfirmation by rememberSaveable { mutableStateOf(false) } var showSummarySheet by rememberSaveable(uiState.mode) { mutableStateOf(false) } var listSettingsTargetId by rememberSaveable { mutableStateOf(null) } @@ -398,6 +485,9 @@ fun TodoListScreen( var listSettingsIconKey by rememberSaveable { mutableStateOf(DEFAULT_LIST_ICON_KEY) } var listSettingsColorTouched by rememberSaveable { mutableStateOf(false) } var listSettingsIconTouched by rememberSaveable { mutableStateOf(false) } + var createListName by rememberSaveable { mutableStateOf("") } + var createListColor by rememberSaveable { mutableStateOf(DEFAULT_LIST_COLOR_KEY) } + var createListIconKey by rememberSaveable { mutableStateOf(DEFAULT_LIST_ICON_KEY) } val fabInteractionSource = remember { MutableInteractionSource() } val editTargetTodo = remember(editTargetTodoId, uiState.items) { editTargetTodoId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } } @@ -427,7 +517,7 @@ fun TodoListScreen( val canSummarizeCurrentMode = uiState.mode != TodoListMode.LIST && uiState.mode != TodoListMode.OVERDUE && - uiState.mode != TodoListMode.ANYTIME && + uiState.mode != TodoListMode.FLOATER && uiState.aiSummaryEnabled val showTopBarActionButton = canSummarizeCurrentMode || uiState.mode == TodoListMode.LIST val fabPressed by fabInteractionSource.collectIsPressedAsState() @@ -456,6 +546,20 @@ fun TodoListScreen( } return null } + fun rootFloaterTodoListTarget(todoId: String): Pair? { + var itemIndex = 1 // Root Floater header row. + timelineSections.forEach { section -> + val todoIndex = section.items.indexOfFirst { item -> + item.id == todoId || item.canonicalId == todoId + } + if (todoIndex >= 0) { + val todo = section.items[todoIndex] + return itemIndex + todoIndex to "timeline-todo-${section.key}-${todo.id}" + } + itemIndex += section.items.size + } + return null + } LaunchedEffect(showSummarySheet, canSummarizeCurrentMode) { if (showSummarySheet && canSummarizeCurrentMode) { onSummarize() @@ -486,6 +590,30 @@ fun TodoListScreen( } } } + fun openRootFloaterSearchResult(todo: TodoItem) { + closeRootFloaterSearch() + val target = rootFloaterTodoListTarget(todo.id) ?: return + screenScope.launch { + delay(SEARCH_RESULT_NAV_SETTLE_DELAY_MS) + val viewportHeight = + listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset + val estimatedRowHeight = + with(density) { SEARCH_RESULT_ESTIMATED_ROW_HEIGHT_DP.dp.toPx().toInt() } + val centeredScrollOffset = + -((viewportHeight - estimatedRowHeight).coerceAtLeast(0) / 2) + listState.animateSearchResultScrollToItem( + targetIndex = target.first, + targetKey = target.second, + centeredScrollOffset = centeredScrollOffset, + estimatedItemSizePx = estimatedRowHeight, + ) + flashTodoId = todo.id + delay(2300) + if (flashTodoId == todo.id || flashTodoId == todo.canonicalId) { + flashTodoId = null + } + } + } LaunchedEffect(uiState.mode) { if (uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST) { collapsedSectionKeys = collapsedSectionKeys + "earlier" @@ -644,7 +772,9 @@ fun TodoListScreen( timelineDragContainerOrigin = coordinates.positionInRoot() }, ) { - Box( + TdayPullToRefreshBox( + isRefreshing = uiState.isLoading, + onRefresh = onRefresh, modifier = Modifier .fillMaxSize() .padding(padding), @@ -674,7 +804,40 @@ fun TodoListScreen( key = "root-feed-title", contentType = "root-feed-title", ) { - RootFeedTitleRow(title = uiState.title) + if (isRootFloaterScreen) { + RootFeedSearchHeaderRow( + title = uiState.title, + searchExpanded = rootFloaterSearchExpanded, + searchQuery = rootFloaterSearchQuery, + onSearchQueryChange = { rootFloaterSearchQuery = it }, + onSearchExpandedChange = { rootFloaterSearchExpanded = it }, + onSearchClose = closeRootFloaterSearch, + onCreateList = { + closeRootFloaterSearch() + showCreateListSheet = true + }, + onOpenSettings = { + closeRootFloaterSearch() + onOpenSettings() + }, + ) + } else { + RootFeedTitleRow(title = uiState.title) + } + } + } + + if (showRootFloaterSearchResults) { + item( + key = "root-floater-search-results", + contentType = "root-floater-search-results", + ) { + RootFloaterSearchResultsCard( + results = rootFloaterSearchResults, + listsById = floaterListById, + onOpenTodo = ::openRootFloaterSearchResult, + modifier = Modifier.padding(bottom = 10.dp), + ) } } @@ -933,6 +1096,36 @@ fun TodoListScreen( } } + if (floaterListRows.isNotEmpty()) { + item( + key = "floater-my-lists-header", + contentType = "floater-list-header", + ) { + FloaterMyListsHeader( + modifier = Modifier.padding(top = 4.dp, bottom = 10.dp), + ) + } + items( + items = floaterListRows, + key = { (list, _) -> "floater-list-${list.id}" }, + contentType = { "floater-list-row" }, + ) { (list, count) -> + FloaterListRow( + modifier = Modifier.padding(bottom = 10.dp), + name = list.name, + colorKey = list.color, + iconKey = list.iconKey, + count = count, + onClick = { + onOpenFloaterList( + list.id, + capitalizeFirstListLetter(list.name), + ) + }, + ) + } + } + uiState.errorMessage?.let { message -> item { com.ohmz.tday.compose.core.ui.ErrorRetryCard( @@ -989,11 +1182,12 @@ fun TodoListScreen( if (showCreateTaskSheet) { CreateTaskBottomSheet( lists = uiState.lists, - defaultListId = if (uiState.mode == TodoListMode.LIST) uiState.listId else null, + defaultListId = if (uiState.mode == TodoListMode.LIST || uiState.mode == TodoListMode.FLOATER) uiState.listId else null, defaultPriority = if (uiState.mode == TodoListMode.PRIORITY) "Medium" else null, - defaultScheduled = uiState.mode != TodoListMode.ANYTIME, + defaultScheduled = uiState.mode != TodoListMode.FLOATER, + showScheduleControls = uiState.mode != TodoListMode.FLOATER, initialDueEpochMs = quickAddDueEpochMs, - onParseTaskTitleNlp = onParseTaskTitleNlp, + onParseTaskTitleNlp = if (uiState.mode == TodoListMode.FLOATER) null else onParseTaskTitleNlp, onDismiss = { showCreateTaskSheet = false quickAddDueEpochMs = null @@ -1087,7 +1281,8 @@ fun TodoListScreen( CreateTaskBottomSheet( lists = uiState.lists, editingTask = todo, - onParseTaskTitleNlp = onParseTaskTitleNlp, + showScheduleControls = uiState.mode != TodoListMode.FLOATER, + onParseTaskTitleNlp = if (uiState.mode == TodoListMode.FLOATER) null else onParseTaskTitleNlp, onDismiss = { editTargetTodoId = null }, onCreateTask = { _ -> }, onUpdateTask = { target, payload -> @@ -1097,6 +1292,31 @@ fun TodoListScreen( ) } + if (showCreateListSheet && isRootFloaterScreen) { + ListSettingsBottomSheet( + title = stringResource(R.string.home_new_list), + listName = createListName, + onListNameChange = { createListName = capitalizeFirstListLetter(it) }, + listColor = createListColor, + onListColorChange = { createListColor = it }, + listIconKey = createListIconKey, + onListIconChange = { createListIconKey = it }, + showDelete = false, + onDismiss = { showCreateListSheet = false }, + onSave = { + val normalizedName = capitalizeFirstListLetter(createListName).trim() + if (normalizedName.isNotBlank()) { + onCreateList(normalizedName, createListColor, createListIconKey) + createListName = "" + createListColor = DEFAULT_LIST_COLOR_KEY + createListIconKey = DEFAULT_LIST_ICON_KEY + showCreateListSheet = false + } + }, + onDelete = {}, + ) + } + val selectedListId = listSettingsTargetId ?: uiState.listId if ( showListSettingsSheet && @@ -1104,6 +1324,7 @@ fun TodoListScreen( !selectedListId.isNullOrBlank() ) { ListSettingsBottomSheet( + title = stringResource(R.string.todos_list_settings), listName = listSettingsName, onListNameChange = { listSettingsName = capitalizeFirstListLetter(it) }, listColor = listSettingsColor, @@ -1296,6 +1517,381 @@ private fun RootFeedTitleRow( } } +@Composable +private fun RootFeedSearchHeaderRow( + title: String, + searchExpanded: Boolean, + searchQuery: String, + onSearchQueryChange: (String) -> Unit, + onSearchExpandedChange: (Boolean) -> Unit, + onSearchClose: () -> Unit, + onCreateList: () -> Unit, + onOpenSettings: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val isDaytime = rememberTodoRootIsDaytime() + val titleIcon = if (isDaytime) Icons.Rounded.WbSunny else Icons.Rounded.NightsStay + val titleIconTint = if (isDaytime) Color(0xFFF4C542) else Color(0xFFA8B8E8) + + LaunchedEffect(searchExpanded) { + if (searchExpanded) { + delay(300) + focusRequester.requestFocus() + keyboardController?.show() + } else { + focusManager.clearFocus(force = true) + keyboardController?.hide() + } + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + ) { + val buttonSize = 56.dp + val buttonGap = 8.dp + val expandedSearchWidth = maxWidth.coerceAtLeast(buttonSize) + val collapsedSearchOffset = -((buttonSize * 2) + (buttonGap * 2)) + val animatedSearchWidth by animateDpAsState( + targetValue = if (searchExpanded) expandedSearchWidth else buttonSize, + label = "rootFeedSearchHeaderWidth", + ) + val animatedSearchOffset by animateDpAsState( + targetValue = if (searchExpanded) 0.dp else collapsedSearchOffset, + label = "rootFeedSearchHeaderOffset", + ) + val actionsAlpha by animateFloatAsState( + targetValue = if (searchExpanded) 0f else 1f, + label = "rootFeedSearchHeaderActionsAlpha", + ) + val searchContentAlpha by animateFloatAsState( + targetValue = if (searchExpanded) 1f else 0f, + label = "rootFeedSearchHeaderContentAlpha", + ) + + Row( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 2.dp) + .graphicsLayer { alpha = actionsAlpha }, + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = titleIcon, + contentDescription = null, + tint = titleIconTint, + modifier = Modifier.size(26.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + modifier = Modifier + .align(Alignment.CenterEnd) + .graphicsLayer { alpha = actionsAlpha }, + horizontalArrangement = Arrangement.spacedBy(buttonGap), + verticalAlignment = Alignment.CenterVertically, + ) { + TodayHeaderButton( + onClick = onCreateList, + icon = Icons.AutoMirrored.Rounded.PlaylistAdd, + contentDescription = stringResource(R.string.action_create_list), + ) + TodayHeaderButton( + onClick = onOpenSettings, + icon = Icons.Rounded.MoreHoriz, + contentDescription = stringResource(R.string.action_more), + ) + } + + Card( + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = animatedSearchOffset) + .width(animatedSearchWidth) + .height(buttonSize) + .zIndex(2f), + shape = CircleShape, + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.38f)), + colors = CardDefaults.cardColors(containerColor = colorScheme.background), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = true), + ) { + if (!searchExpanded) onSearchExpandedChange(true) + }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = stringResource(R.string.action_search), + tint = colorScheme.onSurface, + modifier = Modifier + .size(30.dp) + .graphicsLayer { alpha = if (searchExpanded) 0f else 1f }, + ) + + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp) + .graphicsLayer { alpha = searchContentAlpha }, + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + BasicTextField( + value = searchQuery, + onValueChange = onSearchQueryChange, + enabled = searchExpanded, + singleLine = true, + textStyle = MaterialTheme.typography.titleMedium.copy( + color = colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + ), + cursorBrush = SolidColor(colorScheme.primary), + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester), + decorationBox = { innerTextField -> + Box(contentAlignment = Alignment.CenterStart) { + if (searchQuery.isBlank()) { + Text( + text = stringResource(R.string.home_search_placeholder), + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + innerTextField() + } + }, + ) + IconButton(onClick = onSearchClose) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.action_close), + tint = colorScheme.onSurfaceVariant.copy(alpha = 0.78f), + ) + } + } + } + } + } +} + +@Composable +private fun RootFloaterSearchResultsCard( + results: List, + listsById: Map, + onOpenTodo: (TodoItem) -> Unit, + modifier: Modifier = Modifier, +) { + val colorScheme = MaterialTheme.colorScheme + Card( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(22.dp), + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.2f)), + colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + if (results.isEmpty()) { + Text( + text = stringResource(R.string.home_search_no_results), + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + style = MaterialTheme.typography.bodyMedium, + color = colorScheme.onSurfaceVariant, + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + items( + items = results, + key = { todo -> todo.id }, + ) { todo -> + val listMeta = todo.listId?.let { listsById[it] } + Row( + modifier = Modifier + .fillMaxWidth() + .semantics(mergeDescendants = true) {} + .heightIn(min = 48.dp) + .clickable { onOpenTodo(todo) } + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = listIconForKey(listMeta?.iconKey), + contentDescription = null, + tint = listAccentColor(listMeta?.color).copy(alpha = 0.92f), + modifier = Modifier.size(17.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = todo.title, + style = MaterialTheme.typography.titleSmall, + color = colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.ExtraBold, + ) + Text( + text = listMeta?.name ?: todo.priority, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } + } +} + +@Composable +private fun FloaterMyListsHeader( + modifier: Modifier = Modifier, +) { + Text( + text = "My Lists", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + modifier = modifier, + ) +} + +@Composable +private fun FloaterListRow( + modifier: Modifier = Modifier, + name: String, + colorKey: String?, + iconKey: String?, + count: Int, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val view = LocalView.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val animatedScale by animateFloatAsState( + targetValue = if (isPressed) 0.98f else 1f, + label = "floaterListRowScale", + ) + val animatedOffsetY by animateDpAsState( + targetValue = if (isPressed) 2.dp else 0.dp, + label = "floaterListRowOffsetY", + ) + val animatedElevation by animateDpAsState( + targetValue = if (isPressed) 2.dp else 8.dp, + label = "floaterListRowElevation", + ) + val accent = listAccentColor(colorKey) + val icon = listIconForKey(iconKey) + val containerColor = + lerpColor(colorScheme.surfaceVariant, accent, FLOATER_LIST_CONTAINER_COLOR_WEIGHT) + val displayName = capitalizeFirstListLetter(name) + + Card( + modifier = modifier + .fillMaxWidth() + .height(70.dp) + .semantics(mergeDescendants = true) {} + .offset(y = animatedOffsetY) + .graphicsLayer { + scaleX = animatedScale + scaleY = animatedScale + }, + onClick = { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + onClick() + }, + interactionSource = interactionSource, + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation( + defaultElevation = animatedElevation, + pressedElevation = animatedElevation, + ), + ) { + Box(modifier = Modifier.fillMaxSize()) { + Icon( + imageVector = icon, + contentDescription = null, + tint = lerpColor(containerColor, Color.White, 0.34f).copy(alpha = 0.42f), + modifier = Modifier + .align(Alignment.CenterEnd) + .offset(x = 14.dp, y = 8.dp) + .size(82.dp), + ) + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(24.dp), + ) + Text( + text = displayName, + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + text = count.toString(), + color = Color.White, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } +} + @Composable private fun rememberTodoRootIsDaytime(): Boolean { var hour by remember { mutableStateOf(LocalTime.now().hour) } @@ -1600,12 +2196,14 @@ private fun CreateTaskButton( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ListSettingsBottomSheet( + title: String, listName: String, onListNameChange: (String) -> Unit, listColor: String, onListColorChange: (String) -> Unit, listIconKey: String, onListIconChange: (String) -> Unit, + showDelete: Boolean = true, onDismiss: () -> Unit, onSave: () -> Unit, onDelete: () -> Unit, @@ -1663,7 +2261,7 @@ private fun ListSettingsBottomSheet( ) Text( - text = stringResource(R.string.todos_list_settings), + text = title, style = MaterialTheme.typography.headlineSmall, color = colorScheme.onBackground, fontWeight = FontWeight.ExtraBold, @@ -1900,7 +2498,9 @@ private fun ListSettingsBottomSheet( } Spacer(Modifier.height(2.dp)) - ListSettingsDeleteButton(onClick = onDelete) + if (showDelete) { + ListSettingsDeleteButton(onClick = onDelete) + } } } } @@ -2220,7 +2820,7 @@ private fun TimelineTaskDragPreview( maxLines = 1, ) Text( - text = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Anytime", + text = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Floater", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = colorScheme.onSurfaceVariant, @@ -2294,7 +2894,7 @@ private fun TimelineTaskRow( mode == TodoListMode.OVERDUE || mode == TodoListMode.SCHEDULED || mode == TodoListMode.PRIORITY || - mode == TodoListMode.ANYTIME || + mode == TodoListMode.FLOATER || mode == TodoListMode.LIST ) ) { @@ -2454,7 +3054,7 @@ private fun buildTimelineSections( includeEmptyEarlierTarget = includeEmptyEarlierTarget, ) - TodoListMode.ANYTIME -> buildAnytimeSections(items) + TodoListMode.FLOATER -> buildFloaterSections(items) TodoListMode.LIST -> buildScheduledSections( items = items, @@ -2562,36 +3162,53 @@ private fun buildTodaySections( ) } -private fun buildAnytimeSections(items: List): List { - val anytimeItems = items.filter { it.due == null } - val priorityItems = anytimeItems - .filter { - it.pinned || it.priority.equals( - "High", - ignoreCase = true - ) || it.priority.equals("Medium", ignoreCase = true) - } - .sortedWith(compareByDescending { it.pinned }.thenBy { it.title.lowercase(Locale.getDefault()) }) - val laterItems = anytimeItems - .filterNot { it in priorityItems } - .sortedBy { it.title.lowercase(Locale.getDefault()) } +private fun buildFloaterSections(items: List): List { + val floaterItems = items + .sortedWith( + compareByDescending { it.pinned } + .thenBy { it.title.lowercase(Locale.getDefault()) }, + ) return listOfNotNull( - priorityItems.takeIf { it.isNotEmpty() }?.let { + floaterItems.filter { it.priority.equals("High", ignoreCase = true) } + .takeIf { it.isNotEmpty() } + ?.let { + TodoSection( + key = "floater-high", + title = "High", + items = it, + ) + }, + floaterItems.filter { it.priority.equals("Medium", ignoreCase = true) } + .takeIf { it.isNotEmpty() } + ?.let { + TodoSection( + key = "floater-medium", + title = "Medium", + items = it, + ) + }, + floaterItems.filterNot { + it.priority.equals("High", ignoreCase = true) || + it.priority.equals("Medium", ignoreCase = true) + }.takeIf { it.isNotEmpty() }?.let { TodoSection( - key = "anytime-priority", - title = "Priority", + key = "floater-low", + title = "Low", items = it, ) }, - TodoSection( - key = "anytime-open", - title = "Open", - items = laterItems, - ), ) } +private fun floaterPriorityRank(priority: String): Int { + return when { + priority.equals("High", ignoreCase = true) -> 0 + priority.equals("Medium", ignoreCase = true) -> 1 + else -> 2 + } +} + private fun buildScheduledSections( items: List, zoneId: ZoneId, @@ -2772,7 +3389,7 @@ private fun emptyStateMessageForMode(mode: TodoListMode): String { TodoListMode.TODAY -> stringResource(R.string.todos_empty_today) TodoListMode.OVERDUE -> stringResource(R.string.todos_empty_overdue) TodoListMode.PRIORITY -> stringResource(R.string.todos_empty_priority) - TodoListMode.ANYTIME -> "No anytime tasks" + TodoListMode.FLOATER -> "No floater tasks" TodoListMode.SCHEDULED -> stringResource(R.string.todos_empty_scheduled) TodoListMode.ALL -> stringResource(R.string.todos_empty_all) TodoListMode.LIST -> stringResource(R.string.todos_empty_list) @@ -2787,7 +3404,7 @@ private fun emptyStateIconForMode( TodoListMode.TODAY -> Icons.Rounded.WbSunny TodoListMode.OVERDUE -> Icons.Rounded.ErrorOutline TodoListMode.PRIORITY -> Icons.Rounded.Flag - TodoListMode.ANYTIME -> Icons.Rounded.Inventory + TodoListMode.FLOATER -> Icons.Rounded.Inventory TodoListMode.SCHEDULED -> Icons.Rounded.Schedule TodoListMode.ALL -> Icons.Rounded.Inbox TodoListMode.LIST -> listIconForKey(listIconKey) @@ -3060,8 +3677,8 @@ private fun SwipeTaskRow( animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), label = "swipeTaskTitleStrikeProgress", ) - val dueTimeText = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Anytime" - val dueDateTimeText = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) ?: "Anytime" + val dueTimeText = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Floater" + val dueDateTimeText = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) ?: "Floater" val isOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true val dueBodyText = if (showDueDateInSubtitle) dueDateTimeText else dueTimeText val dueSubtitleText = if (isOverdue) { @@ -3099,7 +3716,7 @@ private fun SwipeTaskRow( TodoListMode.OVERDUE, TodoListMode.SCHEDULED, TodoListMode.PRIORITY, - TodoListMode.ANYTIME, + TodoListMode.FLOATER, TodoListMode.ALL, -> listMeta != null @@ -3399,7 +4016,7 @@ private fun TodayTodoRow( onDelete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val dueText = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Anytime" + val dueText = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Floater" val isDetailOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true val detailDueText = if (isDetailOverdue) { stringResource(R.string.todos_due_overdue_text, dueText) @@ -3473,7 +4090,7 @@ private fun TodoRow( onDelete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val due = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) ?: "Anytime" + val due = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) ?: "Floater" Card( colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant), @@ -3597,7 +4214,7 @@ private fun modeAccentColor( TodoListMode.SCHEDULED -> Color(0xFFF29F38) TodoListMode.ALL -> Color(0xFF5E6878) TodoListMode.PRIORITY -> Color(0xFFE65E52) - TodoListMode.ANYTIME -> Color(0xFF4D8F83) + TodoListMode.FLOATER -> Color(0xFF4D8F83) TodoListMode.LIST -> listAccentColor(listColorKey) } } @@ -3650,6 +4267,7 @@ private data class ListSettingsIconOption( private const val DEFAULT_LIST_COLOR_KEY = "PINK" private const val DEFAULT_LIST_ICON_KEY = "inbox" +private const val FLOATER_LIST_CONTAINER_COLOR_WEIGHT = 0.66f private val LIST_SETTINGS_COLOR_KEYS = listOf( "PINK", 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 93ff0a59..fc7b0c44 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 @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager import com.ohmz.tday.compose.core.data.isLikelyConnectivityIssue +import com.ohmz.tday.compose.core.data.list.FloaterListRepository import com.ohmz.tday.compose.core.data.list.ListRepository import com.ohmz.tday.compose.core.data.settings.SettingsRepository import com.ohmz.tday.compose.core.data.sync.SyncManager @@ -52,6 +53,7 @@ data class TodoListUiState( class TodoListViewModel @Inject constructor( private val todoRepository: TodoRepository, private val listRepository: ListRepository, + private val floaterListRepository: FloaterListRepository, private val settingsRepository: SettingsRepository, private val syncManager: SyncManager, private val cacheManager: OfflineCacheManager, @@ -97,7 +99,7 @@ class TodoListViewModel @Inject constructor( TodoListMode.SCHEDULED -> "Scheduled" TodoListMode.ALL -> "All Tasks" TodoListMode.PRIORITY -> "Priority" - TodoListMode.ANYTIME -> "Anytime" + TodoListMode.FLOATER -> listName ?: "Floater" TodoListMode.LIST -> listName ?: "List" }, aiSummaryEnabled = settingsRepository.isAiSummaryEnabledSnapshot(), @@ -120,7 +122,7 @@ class TodoListViewModel @Inject constructor( _uiState.update { it.copy(summaryError = "AI summary is disabled by admin") } return } - if (current.mode == TodoListMode.LIST || current.mode == TodoListMode.OVERDUE || current.mode == TodoListMode.ANYTIME) { + if (current.mode == TodoListMode.LIST || current.mode == TodoListMode.OVERDUE || current.mode == TodoListMode.FLOATER) { _uiState.update { it.copy(summaryError = "Summary is available only for Today, Scheduled, All, and Priority") } @@ -187,7 +189,7 @@ class TodoListViewModel @Inject constructor( private fun hydrateFromCache(mode: TodoListMode, listId: String?) { runCatching { val todos = todoRepository.fetchTodosSnapshot(mode = mode, listId = listId) - val lists = listRepository.fetchListsSnapshot() + val lists = fetchListsSnapshotForMode(mode) val aiSummaryEnabled = settingsRepository.isAiSummaryEnabledSnapshot() Triple(todos, lists, aiSummaryEnabled) }.onSuccess { (todos, lists, aiSummaryEnabled) -> @@ -233,7 +235,7 @@ class TodoListViewModel @Inject constructor( .onFailure { /* fall back to local cache */ } } val todos = todoRepository.fetchTodos(mode = mode, listId = listId) - val lists = listRepository.fetchLists() + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -269,16 +271,20 @@ class TodoListViewModel @Inject constructor( fun addTask(payload: CreateTaskPayload) { if (payload.title.isBlank()) return val mode = _uiState.value.mode - val listId = payload.listId + val currentListId = _uiState.value.listId viewModelScope.launch { runCatching { - todoRepository.createTodo(payload) + if (mode == TodoListMode.FLOATER) { + todoRepository.createFloater(payload) + } else { + todoRepository.createTodo(payload) + } }.onSuccess { - rescheduleReminders() + if (mode != TodoListMode.FLOATER) rescheduleReminders() runCatching { - val todos = todoRepository.fetchTodosCached(mode = mode, listId = listId) - val lists = listRepository.fetchLists() + val todos = todoRepository.fetchTodosCached(mode = mode, listId = currentListId) + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -330,7 +336,7 @@ class TodoListViewModel @Inject constructor( rescheduleReminders() runCatching { val todos = todoRepository.fetchTodosCached(mode = mode, listId = currentListId) - val lists = listRepository.fetchLists() + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -362,12 +368,12 @@ class TodoListViewModel @Inject constructor( "High" -> "High" else -> "Low" } - val normalizedDue = payload.due val normalizedDescription = payload.description?.trim()?.ifBlank { null } val normalizedListId = payload.listId?.takeIf { it.isNotBlank() } val previousState = _uiState.value val mode = previousState.mode + val normalizedDue = if (mode == TodoListMode.FLOATER) null else payload.due val currentListId = previousState.listId val updatedTodo = visibleTodo.copy( title = normalizedTitle, @@ -382,7 +388,7 @@ class TodoListViewModel @Inject constructor( val optimisticItems = current.items .map { item -> if (item.id == visibleTodo.id) updatedTodo else item } .filterNot { item -> - current.mode == TodoListMode.LIST && + (current.mode == TodoListMode.LIST || current.mode == TodoListMode.FLOATER) && !current.listId.isNullOrBlank() && item.id == visibleTodo.id && item.listId != current.listId @@ -392,22 +398,24 @@ class TodoListViewModel @Inject constructor( viewModelScope.launch { runCatching { - todoRepository.updateTodo( - todo = repositoryTodo, - payload = CreateTaskPayload( - title = normalizedTitle, - description = normalizedDescription, - priority = normalizedPriority, - due = normalizedDue, - rrule = payload.rrule, - listId = normalizedListId, - ), + val normalizedPayload = CreateTaskPayload( + title = normalizedTitle, + description = normalizedDescription, + priority = normalizedPriority, + due = normalizedDue, + rrule = if (mode == TodoListMode.FLOATER) null else payload.rrule, + listId = normalizedListId, ) + if (mode == TodoListMode.FLOATER) { + todoRepository.updateFloater(repositoryTodo, normalizedPayload) + } else { + todoRepository.updateTodo(repositoryTodo, normalizedPayload) + } }.onSuccess { - rescheduleReminders() + if (mode != TodoListMode.FLOATER) rescheduleReminders() runCatching { val todos = todoRepository.fetchTodosCached(mode = mode, listId = currentListId) - val lists = listRepository.fetchLists() + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -436,9 +444,13 @@ class TodoListViewModel @Inject constructor( } viewModelScope.launch { runCatching { - todoRepository.completeTodo(todo) + if (_uiState.value.mode == TodoListMode.FLOATER) { + todoRepository.completeFloater(todo) + } else { + todoRepository.completeTodo(todo) + } }.onSuccess { - rescheduleReminders() + if (_uiState.value.mode != TodoListMode.FLOATER) rescheduleReminders() refreshInternal(forceSync = false, showLoading = false) }.onFailure { error -> _uiState.update { @@ -463,13 +475,17 @@ class TodoListViewModel @Inject constructor( } viewModelScope.launch { runCatching { - todoRepository.deleteTodo(todo) + if (mode == TodoListMode.FLOATER) { + todoRepository.deleteFloater(todo) + } else { + todoRepository.deleteTodo(todo) + } }.onSuccess { onDeleted?.invoke() - rescheduleReminders() + if (mode != TodoListMode.FLOATER) rescheduleReminders() runCatching { val todos = todoRepository.fetchTodosCached(mode = mode, listId = listId) - val lists = listRepository.fetchLists() + val lists = fetchListsForMode(mode) todos to lists }.onSuccess { (todos, lists) -> _uiState.update { current -> @@ -516,7 +532,14 @@ class TodoListViewModel @Inject constructor( val previousState = currentState _uiState.update { current -> current.copy( - title = if (current.mode == TodoListMode.LIST) trimmedName else current.title, + title = if ( + current.mode == TodoListMode.LIST || + (current.mode == TodoListMode.FLOATER && !current.listId.isNullOrBlank()) + ) { + trimmedName + } else { + current.title + }, lists = current.lists.map { list -> if (list.id == resolvedListId) { list.copy( @@ -534,12 +557,21 @@ class TodoListViewModel @Inject constructor( viewModelScope.launch { runCatching { - listRepository.updateList( - listId = resolvedListId, - name = trimmedName, - color = color, - iconKey = iconKey, - ) + if (currentState.mode == TodoListMode.FLOATER) { + floaterListRepository.updateList( + listId = resolvedListId, + name = trimmedName, + color = color, + iconKey = iconKey, + ) + } else { + listRepository.updateList( + listId = resolvedListId, + name = trimmedName, + color = color, + iconKey = iconKey, + ) + } }.onSuccess { Log.d(TAG, "updateListSettings persisted listId=$resolvedListId") }.onFailure { error -> @@ -551,6 +583,39 @@ class TodoListViewModel @Inject constructor( } } + fun createList(name: String, color: String? = null, iconKey: String? = null) { + val trimmedName = capitalizeFirstListLetter(name).trim() + if (trimmedName.isBlank()) return + + val currentMode = _uiState.value.mode + viewModelScope.launch { + runCatching { + if (currentMode == TodoListMode.FLOATER) { + floaterListRepository.createList( + name = trimmedName, + color = color, + iconKey = iconKey, + ) + } else { + listRepository.createList( + name = trimmedName, + color = color, + iconKey = iconKey, + ) + } + }.onSuccess { + hydrateFromCache( + mode = _uiState.value.mode, + listId = _uiState.value.listId, + ) + }.onFailure { error -> + _uiState.update { + it.copy(errorMessage = error.userFacingMessage("Could not create list.")) + } + } + } + } + fun deleteList( listId: String, onOptimisticDelete: () -> Unit, @@ -564,21 +629,30 @@ class TodoListViewModel @Inject constructor( viewModelScope.launch { runCatching { - listRepository.deleteList( - listId = resolvedListId, - onOptimisticDelete = { - _uiState.update { current -> - current.copy( - lists = current.lists.filterNot { it.id == resolvedListId }, - items = current.items.filterNot { it.listId == resolvedListId }, - errorMessage = null, - ) - } - onOptimisticDelete() - }, - ) + val optimisticDelete = { + _uiState.update { current -> + current.copy( + lists = current.lists.filterNot { it.id == resolvedListId }, + items = current.items.filterNot { it.listId == resolvedListId }, + errorMessage = null, + ) + } + onOptimisticDelete() + } + + if (currentState.mode == TodoListMode.FLOATER) { + floaterListRepository.deleteList( + listId = resolvedListId, + onOptimisticDelete = optimisticDelete, + ) + } else { + listRepository.deleteList( + listId = resolvedListId, + onOptimisticDelete = optimisticDelete, + ) + } }.onSuccess { - rescheduleReminders() + if (currentState.mode != TodoListMode.FLOATER) rescheduleReminders() }.onFailure { error -> Log.e(TAG, "deleteList failed listId=$resolvedListId", error) _uiState.update { @@ -598,6 +672,22 @@ class TodoListViewModel @Inject constructor( } } + private suspend fun fetchListsForMode(mode: TodoListMode): List { + return if (mode == TodoListMode.FLOATER) { + floaterListRepository.fetchLists() + } else { + listRepository.fetchLists() + } + } + + private fun fetchListsSnapshotForMode(mode: TodoListMode): List { + return if (mode == TodoListMode.FLOATER) { + floaterListRepository.fetchListsSnapshot() + } else { + listRepository.fetchListsSnapshot() + } + } + private companion object { const val TAG = "TodoListViewModel" } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt index 1f3c4051..ae1ee0c4 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt @@ -143,7 +143,7 @@ private fun TaskRow(task: CachedTodoRecord) { .withZone(ZoneId.systemDefault()) val dueText = task.dueEpochMs ?.let { timeFormatter.format(Instant.ofEpochMilli(it)) } - ?: "Anytime" + ?: "Floater" val priorityColor = when (task.priority.lowercase()) { "high" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFE53935)) "medium" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFFB8C00)) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt index 5e6b0744..a2a265fa 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt @@ -136,6 +136,7 @@ fun CreateTaskBottomSheet( defaultListId: String? = null, defaultPriority: String? = null, defaultScheduled: Boolean = true, + showScheduleControls: Boolean = true, initialDueEpochMs: Long? = null, onParseTaskTitleNlp: (suspend ( title: String, @@ -184,7 +185,7 @@ fun CreateTaskBottomSheet( mutableStateOf(resolvedDueEpochMs) } var scheduleEnabled by rememberSaveable(editingTask?.id, defaultScheduled) { - mutableStateOf(editingTask?.due != null || (editingTask == null && defaultScheduled)) + mutableStateOf(showScheduleControls && (editingTask?.due != null || (editingTask == null && defaultScheduled))) } LaunchedEffect(title, onParseTaskTitleNlp) { val nlpParser = onParseTaskTitleNlp ?: return@LaunchedEffect @@ -205,7 +206,7 @@ fun CreateTaskBottomSheet( if (cleanTitle != title) { title = cleanTitle } - if (!scheduleEnabled) { + if (showScheduleControls && !scheduleEnabled) { scheduleEnabled = true } if (parsedDueEpochMs != dueEpochMs) { @@ -225,7 +226,7 @@ fun CreateTaskBottomSheet( var sheetVisible by remember { mutableStateOf(false) } val selectedListName = lists.firstOrNull { it.id == selectedListId }?.name ?: "No list" - val repeatPreset = if (scheduleEnabled) { + val repeatPreset = if (scheduleEnabled && showScheduleControls) { RepeatPreset.valueOf(selectedRepeat) } else { RepeatPreset.NONE @@ -265,14 +266,15 @@ fun CreateTaskBottomSheet( } fun submitTask() { - val due = if (scheduleEnabled) Instant.ofEpochMilli(dueEpochMs) else null + val due = + if (scheduleEnabled && showScheduleControls) Instant.ofEpochMilli(dueEpochMs) else null val payload = CreateTaskPayload( title = title.trim(), description = notes.trim().ifBlank { null }, priority = selectedPriority, due = due, - rrule = repeatPreset.rrule?.takeIf { scheduleEnabled }, + rrule = repeatPreset.rrule?.takeIf { scheduleEnabled && showScheduleControls }, listId = selectedListId, ) val editing = editingTask @@ -368,31 +370,33 @@ fun CreateTaskBottomSheet( onKeyboardDone = dismissKeyboard, ) - SectionHeading("Schedule") - GroupCard { - ScheduleSwitchRow( - enabled = scheduleEnabled, - onEnabledChange = { enabled -> scheduleEnabled = enabled }, - ) - AnimatedVisibility(visible = scheduleEnabled) { - Column { - RowDivider() - SplitDateTimeRow( - icon = Icons.Rounded.CalendarMonth, - title = "Due", - dateValue = dateOnlyFormatter.format( - Instant.ofEpochMilli( - dueEpochMs - ) - ), - timeValue = timeOnlyFormatter.format( - Instant.ofEpochMilli( - dueEpochMs - ) - ), - onDateClick = { dueDatePickerOpen = true }, - onTimeClick = { dueTimePickerOpen = true }, + if (showScheduleControls) { + SectionHeading("Schedule") + GroupCard { + ScheduleSwitchRow( + enabled = scheduleEnabled, + onEnabledChange = { enabled -> scheduleEnabled = enabled }, ) + AnimatedVisibility(visible = scheduleEnabled) { + Column { + RowDivider() + SplitDateTimeRow( + icon = Icons.Rounded.CalendarMonth, + title = "Due", + dateValue = dateOnlyFormatter.format( + Instant.ofEpochMilli( + dueEpochMs + ) + ), + timeValue = timeOnlyFormatter.format( + Instant.ofEpochMilli( + dueEpochMs + ) + ), + onDateClick = { dueDatePickerOpen = true }, + onTimeClick = { dueTimePickerOpen = true }, + ) + } } } } @@ -427,19 +431,21 @@ fun CreateTaskBottomSheet( isSelected = { option -> selectedPriority == option }, onOptionSelected = { option -> selectedPriority = option }, ) - RowDivider() - SheetDropdownRow( - icon = Icons.Rounded.Repeat, - title = "Repeat", - value = repeatPreset.label, - options = if (scheduleEnabled) RepeatPreset.entries.toList() else listOf( - RepeatPreset.NONE - ), - optionLabel = { option -> option.label }, - optionSwatchColor = { option -> repeatSwatchColor(option) }, - isSelected = { option -> selectedRepeat == option.name }, - onOptionSelected = { option -> selectedRepeat = option.name }, - ) + if (showScheduleControls) { + RowDivider() + SheetDropdownRow( + icon = Icons.Rounded.Repeat, + title = "Repeat", + value = repeatPreset.label, + options = if (scheduleEnabled) RepeatPreset.entries.toList() else listOf( + RepeatPreset.NONE + ), + optionLabel = { option -> option.label }, + optionSwatchColor = { option -> repeatSwatchColor(option) }, + isSelected = { option -> selectedRepeat == option.name }, + onOptionSelected = { option -> selectedRepeat = option.name }, + ) + } } Spacer(modifier = Modifier.height(4.dp)) @@ -824,7 +830,7 @@ private fun ScheduleSwitchRow( fontWeight = FontWeight.ExtraBold, ) Text( - text = if (enabled) "Scheduled" else "Anytime", + text = if (enabled) "Scheduled" else "Floater", style = MaterialTheme.typography.bodySmall, color = colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt index f3c6cfa9..ea63e94f 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt @@ -61,10 +61,10 @@ import kotlinx.coroutines.delay enum class RootFeedTab { HOME, - ANYTIME, + FLOATER, } -private val RootFeedTabs = listOf(RootFeedTab.HOME, RootFeedTab.ANYTIME) +private val RootFeedTabs = listOf(RootFeedTab.HOME, RootFeedTab.FLOATER) private val RootFeedSliderAccent = Color(0xFF7D67B6) private val RootFeedDockCollapsedWidth = 112.dp private val RootFeedDockHeight = 58.dp @@ -78,7 +78,7 @@ private val RootFeedDockSelectorShape = RoundedCornerShape(18.dp) private fun RootFeedTab.label(): String { return when (this) { RootFeedTab.HOME -> "Home" - RootFeedTab.ANYTIME -> "Anytime" + RootFeedTab.FLOATER -> "Floater" } } diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt index 0379a593..4a78cd53 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/cache/CacheMappersTest.kt @@ -410,6 +410,7 @@ class CacheMappersTest { priority = "High", due = dueInstant.toString(), completedAt = completedInstant.toString(), + completedOnTime = true, listID = "list-1", ) diff --git a/ios-swiftUI/Tday/Core/Data/AppContainer.swift b/ios-swiftUI/Tday/Core/Data/AppContainer.swift index 1797bb85..d85bdb48 100644 --- a/ios-swiftUI/Tday/Core/Data/AppContainer.swift +++ b/ios-swiftUI/Tday/Core/Data/AppContainer.swift @@ -22,6 +22,7 @@ final class AppContainer { let syncManager: SyncManager let todoRepository: TodoRepository let listRepository: ListRepository + let floaterListRepository: FloaterListRepository let completedRepository: CompletedRepository let settingsRepository: SettingsRepository let realtimeClient: RealtimeClient @@ -46,8 +47,11 @@ final class AppContainer { apiService = TdayAPIService(configuration: networkConfiguration) modelContainer = try! ModelContainer( for: CachedTodoEntity.self, + CachedFloaterEntity.self, CachedListEntity.self, + CachedFloaterListEntity.self, CachedCompletedEntity.self, + CachedCompletedFloaterEntity.self, PendingMutationEntity.self, SyncMetadataEntity.self ) @@ -70,6 +74,7 @@ final class AppContainer { syncManager = SyncManager(api: apiService, cacheManager: cacheManager) todoRepository = TodoRepository(api: apiService, cacheManager: cacheManager, syncManager: syncManager) listRepository = ListRepository(api: apiService, cacheManager: cacheManager, syncManager: syncManager) + floaterListRepository = FloaterListRepository(api: apiService, cacheManager: cacheManager, syncManager: syncManager) completedRepository = CompletedRepository(api: apiService, cacheManager: cacheManager, syncManager: syncManager) settingsRepository = SettingsRepository(api: apiService, cacheManager: cacheManager) realtimeClient = RealtimeClient(configuration: networkConfiguration) diff --git a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift index b0be1654..c901e693 100644 --- a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift @@ -34,8 +34,11 @@ final class OfflineCacheManager { func loadOfflineState() -> OfflineSyncState { let todos = (try? modelContext.fetch(FetchDescriptor())) ?? [] + let floaters = (try? modelContext.fetch(FetchDescriptor())) ?? [] let lists = (try? modelContext.fetch(FetchDescriptor())) ?? [] + let floaterLists = (try? modelContext.fetch(FetchDescriptor())) ?? [] let completed = (try? modelContext.fetch(FetchDescriptor())) ?? [] + let completedFloaters = (try? modelContext.fetch(FetchDescriptor())) ?? [] let mutations = (try? modelContext.fetch(FetchDescriptor())) ?? [] let metadata = (try? modelContext.fetch(FetchDescriptor()))?.first @@ -51,6 +54,18 @@ final class OfflineCacheManager { ) } + let floaterListRecords = floaterLists.map { + CachedFloaterListRecord( + id: $0.id, + name: $0.name, + color: $0.color, + iconKey: $0.iconKey, + todoCount: $0.todoCount, + updatedAtEpochMs: $0.updatedAtEpochMs, + createdAtEpochMs: $0.createdAtEpochMs ?? 0 + ) + } + return OfflineSyncState( lastSuccessfulSyncEpochMs: metadata?.lastSuccessfulSyncEpochMs ?? 0, lastSyncAttemptEpochMs: metadata?.lastSyncAttemptEpochMs ?? 0, @@ -70,6 +85,19 @@ final class OfflineCacheManager { updatedAtEpochMs: $0.updatedAtEpochMs ) }, + floaters: floaters.map { + CachedFloaterRecord( + id: $0.id, + canonicalId: $0.canonicalId, + title: $0.title, + description: $0.itemDescription, + priority: $0.priority, + pinned: $0.pinned, + completed: $0.completed, + listId: $0.listId, + updatedAtEpochMs: $0.updatedAtEpochMs + ) + }, completedItems: completed.map { CachedCompletedRecord( id: $0.id, @@ -86,7 +114,21 @@ final class OfflineCacheManager { listColor: $0.listColor ) }, + completedFloaters: completedFloaters.map { + CachedCompletedFloaterRecord( + id: $0.id, + originalFloaterId: $0.originalFloaterId, + title: $0.title, + description: $0.itemDescription, + priority: $0.priority, + completedAtEpochMs: $0.completedAtEpochMs, + listId: $0.listId, + listName: $0.listName, + listColor: $0.listColor + ) + }, lists: orderListsLikeWeb(listRecords), + floaterLists: orderFloaterListsLikeWeb(floaterListRecords), pendingMutations: mutations.map { PendingMutationRecord( mutationId: $0.mutationId, @@ -117,14 +159,20 @@ final class OfflineCacheManager { } replaceAll(CachedTodoEntity.self) + replaceAll(CachedFloaterEntity.self) replaceAll(CachedListEntity.self) + replaceAll(CachedFloaterListEntity.self) replaceAll(CachedCompletedEntity.self) + replaceAll(CachedCompletedFloaterEntity.self) replaceAll(PendingMutationEntity.self) replaceAll(SyncMetadataEntity.self) state.todos.forEach { modelContext.insert(CachedTodoEntity(from: $0)) } + state.floaters.forEach { modelContext.insert(CachedFloaterEntity(from: $0)) } state.lists.forEach { modelContext.insert(CachedListEntity(from: $0)) } + state.floaterLists.forEach { modelContext.insert(CachedFloaterListEntity(from: $0)) } state.completedItems.forEach { modelContext.insert(CachedCompletedEntity(from: $0)) } + state.completedFloaters.forEach { modelContext.insert(CachedCompletedFloaterEntity(from: $0)) } state.pendingMutations.forEach { modelContext.insert(PendingMutationEntity(from: $0)) } modelContext.insert( SyncMetadataEntity( @@ -150,7 +198,13 @@ final class OfflineCacheManager { func hasCachedData() -> Bool { let state = loadOfflineState() - return !state.todos.isEmpty || !state.completedItems.isEmpty || !state.lists.isEmpty || !state.pendingMutations.isEmpty + return !state.todos.isEmpty || + !state.floaters.isEmpty || + !state.completedItems.isEmpty || + !state.completedFloaters.isEmpty || + !state.lists.isEmpty || + !state.floaterLists.isEmpty || + !state.pendingMutations.isEmpty } func clearAllLocalData() { diff --git a/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift b/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift index a6d95adf..8ac14fd9 100644 --- a/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift +++ b/ios-swiftUI/Tday/Core/Data/Database/SwiftDataModels.swift @@ -32,6 +32,31 @@ final class CachedTodoEntity { } } +@Model +final class CachedFloaterEntity { + @Attribute(.unique) var id: String + var canonicalId: String + var title: String + var itemDescription: String? + var priority: String + var pinned: Bool + var completed: Bool + var listId: String? + var updatedAtEpochMs: Int64 + + init(from record: CachedFloaterRecord) { + id = record.id + canonicalId = record.canonicalId + title = record.title + itemDescription = record.description + priority = record.priority + pinned = record.pinned + completed = record.completed + listId = record.listId + updatedAtEpochMs = record.updatedAtEpochMs + } +} + @Model final class CachedListEntity { @Attribute(.unique) var id: String @@ -53,6 +78,27 @@ final class CachedListEntity { } } +@Model +final class CachedFloaterListEntity { + @Attribute(.unique) var id: String + var name: String + var color: String? + var iconKey: String? + var todoCount: Int + var updatedAtEpochMs: Int64 + var createdAtEpochMs: Int64? + + init(from record: CachedFloaterListRecord) { + id = record.id + name = record.name + color = record.color + iconKey = record.iconKey + todoCount = record.todoCount + updatedAtEpochMs = record.updatedAtEpochMs + createdAtEpochMs = record.createdAtEpochMs + } +} + @Model final class CachedCompletedEntity { @Attribute(.unique) var id: String @@ -84,6 +130,31 @@ final class CachedCompletedEntity { } } +@Model +final class CachedCompletedFloaterEntity { + @Attribute(.unique) var id: String + var originalFloaterId: String? + var title: String + var itemDescription: String? + var priority: String + var completedAtEpochMs: Int64 + var listId: String? + var listName: String? + var listColor: String? + + init(from record: CachedCompletedFloaterRecord) { + id = record.id + originalFloaterId = record.originalFloaterId + title = record.title + itemDescription = record.description + priority = record.priority + completedAtEpochMs = record.completedAtEpochMs + listId = record.listId + listName = record.listName + listColor = record.listColor + } +} + @Model final class PendingMutationEntity { @Attribute(.unique) var mutationId: String diff --git a/ios-swiftUI/Tday/Core/Data/List/FloaterListRepository.swift b/ios-swiftUI/Tday/Core/Data/List/FloaterListRepository.swift new file mode 100644 index 00000000..0d4eeb6b --- /dev/null +++ b/ios-swiftUI/Tday/Core/Data/List/FloaterListRepository.swift @@ -0,0 +1,358 @@ +import Foundation + +@MainActor +final class FloaterListRepository { + private let api: TdayAPIService + private let cacheManager: OfflineCacheManager + private let syncManager: SyncManager + + init(api: TdayAPIService, cacheManager: OfflineCacheManager, syncManager: SyncManager) { + self.api = api + self.cacheManager = cacheManager + self.syncManager = syncManager + } + + func fetchLists() -> [ListSummary] { + buildLists(from: cacheManager.loadOfflineState()) + } + + func fetchListsSnapshot() -> [ListSummary] { + buildLists(from: cacheManager.loadOfflineState()) + } + + func createList(name: String, color: String? = nil, iconKey: String? = nil) async throws { + let normalizedName = capitalizeFirstListLetter(name).trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedName.isEmpty else { + return + } + + let now = Date().epochMilliseconds + let localListID = LOCAL_FLOATER_LIST_PREFIX + UUID().uuidString.lowercased() + let mutationID = UUID().uuidString + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + nextState.floaterLists.append( + CachedFloaterListRecord( + id: localListID, + name: normalizedName, + color: color, + iconKey: iconKey, + todoCount: 0, + updatedAtEpochMs: now, + createdAtEpochMs: now + ) + ) + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: mutationID, + kind: .createFloaterList, + targetId: localListID, + timestampEpochMs: now, + title: nil, + description: nil, + priority: nil, + dueEpochMs: nil, + rrule: nil, + listId: nil, + pinned: nil, + completed: nil, + instanceDateEpochMs: nil, + name: normalizedName, + color: color, + iconKey: iconKey + ) + ) + return nextState + } + + do { + let response = try await api.createFloaterList( + payload: CreateFloaterListRequest(name: normalizedName, color: color, iconKey: iconKey) + ) + guard let createdList = response.list else { + return + } + let createdAt = parseOptionalDate(createdList.createdAt)?.epochMilliseconds ?? now + let updatedAt = parseOptionalDate(createdList.updatedAt)?.epochMilliseconds ?? now + _ = try await cacheManager.updateOfflineState { state in + var nextState = self.replaceLocalFloaterListID( + state, + localListID: localListID, + serverListID: createdList.id + ) + let todoCount = nextState.floaters.filter { !$0.completed && $0.listId == createdList.id }.count + nextState.floaterLists = nextState.floaterLists.map { list in + guard list.id == createdList.id else { + return list + } + return CachedFloaterListRecord( + id: createdList.id, + name: createdList.name, + color: createdList.color, + iconKey: createdList.iconKey ?? list.iconKey, + todoCount: todoCount, + updatedAtEpochMs: updatedAt, + createdAtEpochMs: createdAt + ) + } + nextState.pendingMutations.removeAll { $0.mutationId == mutationID } + return nextState + } + } catch { + // Keep the pending CREATE_FLOATER_LIST mutation so background sync can retry it. + } + } + + func updateList(listId: String, name: String, color: String? = nil, iconKey: String? = nil) async throws { + let normalizedName = capitalizeFirstListLetter(name).trimmingCharacters(in: .whitespacesAndNewlines) + guard !listId.isEmpty, !normalizedName.isEmpty else { + return + } + + let now = Date().epochMilliseconds + if listId.hasPrefix(LOCAL_FLOATER_LIST_PREFIX) { + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + nextState.floaterLists = state.floaterLists.map { list in + guard list.id == listId else { + return list + } + return CachedFloaterListRecord( + id: list.id, + name: normalizedName, + color: color ?? list.color, + iconKey: iconKey ?? list.iconKey, + todoCount: list.todoCount, + updatedAtEpochMs: now, + createdAtEpochMs: list.createdAtEpochMs + ) + } + nextState.pendingMutations = state.pendingMutations.compactMap { mutation in + if mutation.kind == .updateFloaterList && mutation.targetId == listId { + return nil + } + guard mutation.kind == .createFloaterList, mutation.targetId == listId 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: mutation.dueEpochMs, + rrule: mutation.rrule, + listId: mutation.listId, + pinned: mutation.pinned, + completed: mutation.completed, + instanceDateEpochMs: mutation.instanceDateEpochMs, + name: normalizedName, + color: color ?? mutation.color, + iconKey: iconKey ?? mutation.iconKey + ) + } + return nextState + } + _ = await syncManager.syncCachedData(force: true, replayPendingMutations: true) + return + } + + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + nextState.floaterLists = state.floaterLists.map { list in + guard list.id == listId else { return list } + return CachedFloaterListRecord( + id: list.id, + name: normalizedName, + color: color ?? list.color, + iconKey: iconKey ?? list.iconKey, + todoCount: list.todoCount, + updatedAtEpochMs: now, + createdAtEpochMs: list.createdAtEpochMs + ) + } + nextState.pendingMutations.removeAll { $0.kind == .updateFloaterList && $0.targetId == listId } + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: UUID().uuidString, + kind: .updateFloaterList, + targetId: listId, + timestampEpochMs: now, + title: nil, + description: nil, + priority: nil, + dueEpochMs: nil, + rrule: nil, + listId: nil, + pinned: nil, + completed: nil, + instanceDateEpochMs: nil, + name: normalizedName, + color: color, + iconKey: iconKey + ) + ) + return nextState + } + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) + if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { + throw error + } + } + + func deleteList(listId: String, onOptimisticDelete: () -> Void = {}) async throws { + let normalizedListID = listId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedListID.isEmpty else { + return + } + + let now = Date().epochMilliseconds + let mutationID = UUID().uuidString + let isLocalOnly = normalizedListID.hasPrefix(LOCAL_FLOATER_LIST_PREFIX) + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + let deletedFloaterIDs = Set(state.floaters.filter { $0.listId == normalizedListID }.map(\.canonicalId)) + + nextState.floaterLists.removeAll { $0.id == normalizedListID } + nextState.floaters.removeAll { $0.listId == normalizedListID } + nextState.completedFloaters.removeAll { completed in + completed.listId == normalizedListID || + completed.originalFloaterId.map { deletedFloaterIDs.contains($0) } == true + } + nextState.pendingMutations.removeAll { mutation in + mutation.targetId == normalizedListID || + mutation.listId == normalizedListID || + mutation.targetId.map { deletedFloaterIDs.contains($0) } == true + } + if !isLocalOnly { + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: mutationID, + kind: .deleteFloaterList, + targetId: normalizedListID, + timestampEpochMs: now, + title: nil, + description: nil, + priority: nil, + dueEpochMs: nil, + rrule: nil, + listId: nil, + pinned: nil, + completed: nil, + instanceDateEpochMs: nil, + name: nil, + color: nil, + iconKey: nil + ) + ) + } + return nextState + } + + onOptimisticDelete() + + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) + if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { + throw error + } + } + + private func buildLists(from state: OfflineSyncState) -> [ListSummary] { + let floaterCounts = Dictionary(grouping: state.floaters.filter { !$0.completed }, by: { $0.listId }) + .mapValues(\.count) + return orderFloaterListsLikeWeb(state.floaterLists) + .map { floaterListFromCache($0, todoCountOverride: floaterCounts[$0.id] ?? 0) } + } + + private func replaceLocalFloaterListID( + _ state: OfflineSyncState, + localListID: String, + serverListID: String + ) -> OfflineSyncState { + var nextState = state + nextState.floaters = state.floaters.map { floater in + guard floater.listId == localListID else { + return floater + } + return CachedFloaterRecord( + id: floater.id, + canonicalId: floater.canonicalId, + title: floater.title, + description: floater.description, + priority: floater.priority, + pinned: floater.pinned, + completed: floater.completed, + listId: serverListID, + updatedAtEpochMs: floater.updatedAtEpochMs + ) + } + nextState.completedFloaters = state.completedFloaters.map { completed in + guard completed.listId == localListID else { + return completed + } + return CachedCompletedFloaterRecord( + id: completed.id, + originalFloaterId: completed.originalFloaterId, + title: completed.title, + description: completed.description, + priority: completed.priority, + completedAtEpochMs: completed.completedAtEpochMs, + listId: serverListID, + listName: completed.listName, + listColor: completed.listColor + ) + } + nextState.floaterLists = state.floaterLists.map { list in + guard list.id == localListID else { + return list + } + return CachedFloaterListRecord( + id: serverListID, + name: list.name, + color: list.color, + iconKey: list.iconKey, + todoCount: list.todoCount, + updatedAtEpochMs: list.updatedAtEpochMs, + createdAtEpochMs: list.createdAtEpochMs + ) + } + nextState.pendingMutations = state.pendingMutations.map { mutation in + PendingMutationRecord( + mutationId: mutation.mutationId, + kind: mutation.kind, + targetId: mutation.targetId == localListID ? serverListID : mutation.targetId, + timestampEpochMs: mutation.timestampEpochMs, + title: mutation.title, + description: mutation.description, + priority: mutation.priority, + dueEpochMs: mutation.dueEpochMs, + rrule: mutation.rrule, + listId: mutation.listId == localListID ? serverListID : mutation.listId, + pinned: mutation.pinned, + completed: mutation.completed, + instanceDateEpochMs: mutation.instanceDateEpochMs, + name: mutation.name, + color: mutation.color, + iconKey: mutation.iconKey + ) + } + return nextState + } + + private func capitalizeFirstListLetter(_ value: String) -> String { + guard let firstLetterIndex = value.firstIndex(where: { $0.isLetter }) else { + return value + } + let current = value[firstLetterIndex] + let capitalized = String(current).capitalized + guard String(current) != capitalized else { + return value + } + + var result = value + result.replaceSubrange(firstLetterIndex...firstLetterIndex, with: capitalized) + return result + } +} diff --git a/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift b/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift index d2162ee1..67ea62fd 100644 --- a/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift @@ -75,7 +75,7 @@ final class ListRepository { let updatedAt = parseOptionalDate(createdList.updatedAt)?.epochMilliseconds ?? now _ = try await cacheManager.updateOfflineState { state in var nextState = self.replaceLocalListID(state, localListID: localListID, serverListID: createdList.id) - let todoCount = nextState.todos.filter { !$0.completed && $0.listId == createdList.id }.count + let todoCount = nextState.todos.filter { !$0.completed && $0.dueEpochMs != nil && $0.listId == createdList.id }.count nextState.lists = nextState.lists.map { list in guard list.id == createdList.id else { return list @@ -257,10 +257,10 @@ final class ListRepository { } private func buildLists(from state: OfflineSyncState) -> [ListSummary] { - let todoCounts = Dictionary(grouping: state.todos.filter { !$0.completed }, by: { $0.listId }) + let scheduledCounts = Dictionary(grouping: state.todos.filter { !$0.completed && $0.dueEpochMs != nil }, by: { $0.listId }) .mapValues(\.count) return orderListsLikeWeb(state.lists) - .map { listFromCache($0, todoCountOverride: todoCounts[$0.id] ?? 0) } + .map { listFromCache($0, todoCountOverride: scheduledCounts[$0.id] ?? 0) } } private func replaceLocalListID(_ state: OfflineSyncState, localListID: String, serverListID: String) -> OfflineSyncState { @@ -286,6 +286,7 @@ final class ListRepository { updatedAtEpochMs: todo.updatedAtEpochMs ) }, + floaters: state.floaters, completedItems: state.completedItems.map { completed in guard completed.listId == localListID else { return completed @@ -305,6 +306,7 @@ final class ListRepository { listColor: completed.listColor ) }, + completedFloaters: state.completedFloaters, lists: state.lists.map { list in guard list.id == localListID else { return list @@ -319,6 +321,7 @@ final class ListRepository { createdAtEpochMs: list.createdAtEpochMs ) }, + floaterLists: state.floaterLists, pendingMutations: state.pendingMutations.map { mutation in PendingMutationRecord( mutationId: mutation.mutationId, diff --git a/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift b/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift index ee622077..28388f16 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/CacheMappers.swift @@ -91,6 +91,32 @@ func cachedTodoSortPrecedes(_ lhs: CachedTodoRecord, _ rhs: CachedTodoRecord) -> return lhs.id < rhs.id } +func cachedFloaterSortPrecedes(_ lhs: CachedFloaterRecord, _ rhs: CachedFloaterRecord) -> Bool { + if lhs.pinned != rhs.pinned { + return lhs.pinned && !rhs.pinned + } + let lhsRank = floaterPriorityRank(lhs.priority) + let rhsRank = floaterPriorityRank(rhs.priority) + if lhsRank != rhsRank { + return lhsRank < rhsRank + } + if lhs.title.localizedCaseInsensitiveCompare(rhs.title) != .orderedSame { + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + return lhs.id < rhs.id +} + +private func floaterPriorityRank(_ priority: String) -> Int { + switch priority.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "high", "urgent", "important": + return 0 + case "medium": + return 1 + default: + return 2 + } +} + func todoTimelineSortPrecedes(_ lhs: TodoItem, _ rhs: TodoItem) -> Bool { if lhs.due != rhs.due { return (lhs.due ?? .distantFuture) < (rhs.due ?? .distantFuture) @@ -141,6 +167,23 @@ func mapTodoDTO(_ dto: TodoDTO) -> TodoItem { ) } +func mapFloaterDTO(_ dto: FloaterDTO) -> TodoItem { + TodoItem( + id: dto.id, + canonicalId: dto.id, + title: dto.title, + description: dto.description, + priority: dto.priority, + due: nil, + rrule: nil, + instanceDate: nil, + pinned: dto.pinned, + completed: dto.completed, + listId: dto.listID, + updatedAt: parseOptionalDate(dto.updatedAt) + ) +} + func todoToCache(_ todo: TodoItem) -> CachedTodoRecord { CachedTodoRecord( id: todo.id, @@ -175,6 +218,37 @@ func todoFromCache(_ record: CachedTodoRecord) -> TodoItem { ) } +func floaterToCache(_ floater: TodoItem) -> CachedFloaterRecord { + CachedFloaterRecord( + id: floater.id, + canonicalId: floater.canonicalId, + title: floater.title, + description: floater.description, + priority: floater.priority, + pinned: floater.pinned, + completed: floater.completed, + listId: floater.listId, + updatedAtEpochMs: floater.updatedAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0 + ) +} + +func floaterFromCache(_ record: CachedFloaterRecord) -> TodoItem { + TodoItem( + id: record.id, + canonicalId: record.canonicalId, + title: record.title, + description: record.description, + priority: record.priority, + due: nil, + rrule: nil, + instanceDate: nil, + pinned: record.pinned, + completed: record.completed, + listId: record.listId, + updatedAt: record.updatedAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.updatedAtEpochMs) / 1000.0) : nil + ) +} + func mapListDTO(_ dto: ListDTO, iconFallback: String? = nil) -> ListSummary { ListSummary( id: dto.id, @@ -187,6 +261,18 @@ func mapListDTO(_ dto: ListDTO, iconFallback: String? = nil) -> ListSummary { ) } +func mapFloaterListDTO(_ dto: FloaterListDTO, iconFallback: String? = nil) -> ListSummary { + ListSummary( + id: dto.id, + name: dto.name, + color: dto.color, + iconKey: dto.iconKey ?? iconFallback, + todoCount: dto.todoCount, + updatedAt: parseOptionalDate(dto.updatedAt), + createdAt: parseOptionalDate(dto.createdAt) + ) +} + func listToCache(_ list: ListSummary) -> CachedListRecord { CachedListRecord( id: list.id, @@ -199,6 +285,18 @@ func listToCache(_ list: ListSummary) -> CachedListRecord { ) } +func floaterListToCache(_ list: ListSummary) -> CachedFloaterListRecord { + CachedFloaterListRecord( + id: list.id, + name: list.name, + color: list.color, + iconKey: list.iconKey, + todoCount: list.todoCount, + updatedAtEpochMs: list.updatedAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0, + createdAtEpochMs: list.createdAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0 + ) +} + func orderListsLikeWeb(_ lists: [CachedListRecord]) -> [CachedListRecord] { guard lists.contains(where: { $0.createdAtEpochMs > 0 }) else { return lists @@ -213,6 +311,20 @@ func orderListsLikeWeb(_ lists: [CachedListRecord]) -> [CachedListRecord] { .map(\.element) } +func orderFloaterListsLikeWeb(_ lists: [CachedFloaterListRecord]) -> [CachedFloaterListRecord] { + guard lists.contains(where: { $0.createdAtEpochMs > 0 }) else { + return lists + } + return lists.enumerated() + .sorted { lhs, rhs in + if lhs.element.createdAtEpochMs != rhs.element.createdAtEpochMs { + return lhs.element.createdAtEpochMs > rhs.element.createdAtEpochMs + } + return lhs.offset < rhs.offset + } + .map(\.element) +} + func listFromCache(_ record: CachedListRecord, todoCountOverride: Int? = nil) -> ListSummary { ListSummary( id: record.id, @@ -225,6 +337,18 @@ func listFromCache(_ record: CachedListRecord, todoCountOverride: Int? = nil) -> ) } +func floaterListFromCache(_ record: CachedFloaterListRecord, todoCountOverride: Int? = nil) -> ListSummary { + ListSummary( + id: record.id, + name: record.name, + color: record.color, + iconKey: record.iconKey, + todoCount: todoCountOverride ?? record.todoCount, + updatedAt: record.updatedAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.updatedAtEpochMs) / 1000.0) : nil, + createdAt: record.createdAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.createdAtEpochMs) / 1000.0) : nil + ) +} + func mapCompletedDTO(_ dto: CompletedTodoDTO) -> CompletedItem { CompletedItem( id: dto.id, @@ -242,6 +366,23 @@ func mapCompletedDTO(_ dto: CompletedTodoDTO) -> CompletedItem { ) } +func mapCompletedFloaterDTO(_ dto: CompletedFloaterDTO) -> CompletedItem { + CompletedItem( + id: dto.id, + originalTodoId: dto.originalFloaterID, + title: dto.title, + description: dto.description, + priority: dto.priority, + due: nil, + completedAt: parseOptionalDate(dto.completedAt), + rrule: nil, + instanceDate: nil, + listId: dto.listID, + listName: dto.listName, + listColor: dto.listColor + ) +} + func completedToCache(_ item: CompletedItem) -> CachedCompletedRecord { CachedCompletedRecord( id: item.id, @@ -276,6 +417,37 @@ func completedFromCache(_ record: CachedCompletedRecord) -> CompletedItem { ) } +func completedFloaterToCache(_ item: CompletedItem) -> CachedCompletedFloaterRecord { + CachedCompletedFloaterRecord( + id: item.id, + originalFloaterId: item.originalTodoId, + title: item.title, + description: item.description, + priority: item.priority, + completedAtEpochMs: item.completedAt.map { Int64($0.timeIntervalSince1970 * 1000.0) } ?? 0, + listId: item.listId, + listName: item.listName, + listColor: item.listColor + ) +} + +func completedFloaterFromCache(_ record: CachedCompletedFloaterRecord) -> CompletedItem { + CompletedItem( + id: record.id, + originalTodoId: record.originalFloaterId, + title: record.title, + description: record.description, + priority: record.priority, + due: nil, + completedAt: record.completedAtEpochMs > 0 ? Date(timeIntervalSince1970: TimeInterval(record.completedAtEpochMs) / 1000.0) : nil, + rrule: nil, + instanceDate: nil, + listId: record.listId, + listName: record.listName, + listColor: record.listColor + ) +} + extension ISO8601DateFormatter { static let full: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() diff --git a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift index d5459736..91e90a5e 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift @@ -7,8 +7,11 @@ extension Notification.Name { private struct RemoteSnapshot { let todos: [TodoItem] + let floaters: [TodoItem] let completedItems: [CompletedItem] + let completedFloaters: [CompletedItem] let lists: [ListSummary] + let floaterLists: [ListSummary] let aiSummaryEnabled: Bool var todoUpdatedAtByCanonical: [String: Int64] { @@ -24,6 +27,20 @@ private struct RemoteSnapshot { result[list.id] = max(result[list.id] ?? 0, updatedAt) } } + + var floaterListUpdatedAtByID: [String: Int64] { + floaterLists.reduce(into: [:]) { result, list in + let updatedAt = list.updatedAt?.epochMilliseconds ?? 0 + result[list.id] = max(result[list.id] ?? 0, updatedAt) + } + } + + var floaterUpdatedAtByCanonical: [String: Int64] { + floaters.reduce(into: [:]) { result, floater in + let updatedAt = floater.updatedAt?.epochMilliseconds ?? 0 + result[floater.canonicalId] = max(result[floater.canonicalId] ?? 0, updatedAt) + } + } } func mergeCompletedRecordsWithPendingOverrides( @@ -51,6 +68,31 @@ func mergeCompletedRecordsWithPendingOverrides( return mergedRecords } +func mergeCompletedFloaterRecordsWithPendingOverrides( + localRecords: [CachedCompletedFloaterRecord], + remoteRecords: [CachedCompletedFloaterRecord], + pendingFloaterTargets: Set, + pendingDeletedListIds: Set = [] +) -> [CachedCompletedFloaterRecord] { + var mergedRecords = remoteRecords.filter { record in + guard let listId = record.listId else { + return true + } + return !pendingDeletedListIds.contains(listId) + } + + for canonicalID in pendingFloaterTargets { + let localRecordsForFloater = localRecords.filter { $0.originalFloaterId == canonicalID } + guard !localRecordsForFloater.isEmpty else { + continue + } + mergedRecords.removeAll { $0.originalFloaterId == canonicalID } + mergedRecords.append(contentsOf: localRecordsForFloater) + } + + return mergedRecords +} + @MainActor final class SyncManager { private let api: TdayAPIService @@ -130,19 +172,36 @@ final class SyncManager { private func fetchRemoteSnapshot() async throws -> RemoteSnapshot { async let todosTask = api.getTodos(timeline: true) + async let floatersTask = api.getFloaters() async let completedTask = api.getCompletedTodos() + async let completedFloatersTask = api.getCompletedFloaters() async let listsTask = api.getLists() + async let floaterListsTask = api.getFloaterLists() async let appSettingsTask = api.getAppSettings() let todosResponse = try await todosTask + let floatersResponse = try await floatersTask let completedResponse = try await completedTask + let completedFloatersResponse = try await completedFloatersTask let listsResponse = try await listsTask + let floaterListsResponse = try await floaterListsTask let appSettingsResponse = try await appSettingsTask let todos = todosResponse.todos.map(mapTodoDTO) + let floaters = floatersResponse.floaters.map(mapFloaterDTO) let completedItems = completedResponse.completedTodos.map(mapCompletedDTO) + let completedFloaters = completedFloatersResponse.completedFloaters.map(mapCompletedFloaterDTO) let lists = listsResponse.lists.map { mapListDTO($0) } + let floaterLists = floaterListsResponse.lists.map { mapFloaterListDTO($0) } let aiSummaryEnabled = appSettingsResponse.aiSummaryEnabled - return RemoteSnapshot(todos: todos, completedItems: completedItems, lists: lists, aiSummaryEnabled: aiSummaryEnabled) + return RemoteSnapshot( + todos: todos, + floaters: floaters, + completedItems: completedItems, + completedFloaters: completedFloaters, + lists: lists, + floaterLists: floaterLists, + aiSummaryEnabled: aiSummaryEnabled + ) } private func mergeRemoteWithLocal(localState: OfflineSyncState, remote: RemoteSnapshot) -> OfflineSyncState { @@ -151,6 +210,11 @@ final class SyncManager { mutation.kind.affectsTodo ? mutation.targetId : nil } ) + let pendingFloaterTargets = Set( + localState.pendingMutations.compactMap { mutation -> String? in + mutation.kind.affectsFloater ? mutation.targetId : nil + } + ) let pendingListTargets = Set( localState.pendingMutations.compactMap { mutation -> String? in switch mutation.kind { @@ -161,17 +225,38 @@ final class SyncManager { } } ) + let pendingFloaterListTargets = Set( + localState.pendingMutations.compactMap { mutation -> String? in + switch mutation.kind { + case .createFloaterList, .updateFloaterList, .deleteFloaterList: + return mutation.targetId + default: + return nil + } + } + ) let pendingDeletedListIds = Set( localState.pendingMutations.compactMap { mutation -> String? in mutation.kind == .deleteList ? mutation.targetId : nil } ) + let pendingDeletedFloaterListIds = Set( + localState.pendingMutations.compactMap { mutation -> String? in + mutation.kind == .deleteFloaterList ? mutation.targetId : nil + } + ) let remoteTodos = remote.todos.filter { todo in guard let listId = todo.listId else { return true } return !pendingDeletedListIds.contains(listId) } + let remoteFloaters = remote.floaters.filter { floater in + guard let listId = floater.listId else { + return true + } + return !pendingDeletedFloaterListIds.contains(listId) + } var remoteTodosByKey = Dictionary(uniqueKeysWithValues: remoteTodos.map { (todoMergeKey(item: $0), todoToCache($0)) }) var mergedTodos: [CachedTodoRecord] = [] @@ -195,6 +280,25 @@ final class SyncManager { } mergedTodos.append(contentsOf: remoteTodosByKey.values) + var remoteFloatersByID = Dictionary(uniqueKeysWithValues: remoteFloaters.map { ($0.canonicalId, floaterToCache($0)) }) + var mergedFloaters: [CachedFloaterRecord] = [] + for localFloater in localState.floaters { + let remoteFloater = remoteFloatersByID[localFloater.canonicalId] + if remoteFloater == nil, + !localFloater.canonicalId.hasPrefix(LOCAL_FLOATER_PREFIX), + !pendingFloaterTargets.contains(localFloater.canonicalId) { + continue + } + let localWins = localFloater.canonicalId.hasPrefix(LOCAL_FLOATER_PREFIX) || + pendingFloaterTargets.contains(localFloater.canonicalId) || + localFloater.updatedAtEpochMs > (remoteFloater?.updatedAtEpochMs ?? 0) + if localWins { + mergedFloaters.append(localFloater) + remoteFloatersByID.removeValue(forKey: localFloater.canonicalId) + } + } + mergedFloaters.append(contentsOf: remoteFloatersByID.values) + var remoteListsByID = Dictionary(uniqueKeysWithValues: remote.lists.map { ($0.id, listToCache($0)) }) var mergedLists: [CachedListRecord] = [] for localList in localState.lists { @@ -216,12 +320,44 @@ final class SyncManager { contentsOf: remoteListsByID.values.filter { !pendingDeletedListIds.contains($0.id) } ) + var remoteFloaterListsByID = Dictionary(uniqueKeysWithValues: remote.floaterLists.map { ($0.id, floaterListToCache($0)) }) + var mergedFloaterLists: [CachedFloaterListRecord] = [] + for localList in localState.floaterLists { + let remoteList = remoteFloaterListsByID[localList.id] + if remoteList == nil, + !localList.id.hasPrefix(LOCAL_FLOATER_LIST_PREFIX), + !pendingFloaterListTargets.contains(localList.id) { + continue + } + let localWins = localList.id.hasPrefix(LOCAL_FLOATER_LIST_PREFIX) || + pendingFloaterListTargets.contains(localList.id) || + localList.updatedAtEpochMs > (remoteList?.updatedAtEpochMs ?? 0) + if localWins { + mergedFloaterLists.append(localList) + remoteFloaterListsByID.removeValue(forKey: localList.id) + } + } + mergedFloaterLists.append( + contentsOf: remoteFloaterListsByID.values.filter { !pendingDeletedFloaterListIds.contains($0.id) } + ) + let mergedCompleted = mergeCompletedRecordsWithPendingOverrides( localRecords: localState.completedItems, remoteRecords: remote.completedItems.map(completedToCache), pendingTodoTargets: pendingTodoTargets, pendingDeletedListIds: pendingDeletedListIds ) + let mergedCompletedFloaters = mergeCompletedFloaterRecordsWithPendingOverrides( + localRecords: localState.completedFloaters, + remoteRecords: remote.completedFloaters.map(completedFloaterToCache), + pendingFloaterTargets: pendingFloaterTargets, + pendingDeletedListIds: pendingDeletedFloaterListIds + ) + + let todoCountsByList = Dictionary(grouping: mergedTodos.filter { !$0.completed }, by: { $0.listId }) + .mapValues(\.count) + let floaterCountsByList = Dictionary(grouping: mergedFloaters.filter { !$0.completed }, by: { $0.listId }) + .mapValues(\.count) let generatedMutations = buildLocalWinsMutations(localState: localState, remote: remote) let pendingMutations = dedupePendingMutations(localState.pendingMutations + generatedMutations) @@ -230,8 +366,35 @@ final class SyncManager { lastSuccessfulSyncEpochMs: localState.lastSuccessfulSyncEpochMs, lastSyncAttemptEpochMs: localState.lastSyncAttemptEpochMs, todos: mergedTodos.sorted(by: cachedTodoSortPrecedes), + floaters: mergedFloaters.sorted(by: cachedFloaterSortPrecedes), completedItems: mergedCompleted.sorted { $0.completedAtEpochMs > $1.completedAtEpochMs }, - lists: orderListsLikeWeb(mergedLists), + completedFloaters: mergedCompletedFloaters.sorted { $0.completedAtEpochMs > $1.completedAtEpochMs }, + lists: orderListsLikeWeb( + mergedLists.map { list in + CachedListRecord( + id: list.id, + name: list.name, + color: list.color, + iconKey: list.iconKey, + todoCount: todoCountsByList[list.id] ?? 0, + updatedAtEpochMs: list.updatedAtEpochMs, + createdAtEpochMs: list.createdAtEpochMs + ) + } + ), + floaterLists: orderFloaterListsLikeWeb( + mergedFloaterLists.map { list in + CachedFloaterListRecord( + id: list.id, + name: list.name, + color: list.color, + iconKey: list.iconKey, + todoCount: floaterCountsByList[list.id] ?? 0, + updatedAtEpochMs: list.updatedAtEpochMs, + createdAtEpochMs: list.createdAtEpochMs + ) + } + ), pendingMutations: pendingMutations, aiSummaryEnabled: remote.aiSummaryEnabled ) @@ -244,6 +407,11 @@ final class SyncManager { mutation.kind.affectsTodo ? mutation.targetId : nil } ) + let pendingFloaterTargets = Set( + localState.pendingMutations.compactMap { mutation -> String? in + mutation.kind.affectsFloater ? mutation.targetId : nil + } + ) let pendingListTargets = Set( localState.pendingMutations.compactMap { mutation -> String? in switch mutation.kind { @@ -254,6 +422,16 @@ final class SyncManager { } } ) + let pendingFloaterListTargets = Set( + localState.pendingMutations.compactMap { mutation -> String? in + switch mutation.kind { + case .createFloaterList, .updateFloaterList, .deleteFloaterList: + return mutation.targetId + default: + return nil + } + } + ) var generated: [PendingMutationRecord] = [] for todo in localState.todos @@ -285,6 +463,35 @@ final class SyncManager { } } + for floater in localState.floaters + where !floater.canonicalId.hasPrefix(LOCAL_FLOATER_PREFIX) && + !pendingFloaterTargets.contains(floater.canonicalId) { + guard let remoteUpdatedAt = remote.floaterUpdatedAtByCanonical[floater.canonicalId], floater.updatedAtEpochMs > remoteUpdatedAt else { + continue + } + let mutation = PendingMutationRecord( + mutationId: UUID().uuidString, + kind: .updateFloater, + targetId: floater.canonicalId, + timestampEpochMs: floater.updatedAtEpochMs, + title: floater.title, + description: floater.description, + priority: floater.priority, + dueEpochMs: nil, + rrule: nil, + listId: floater.listId, + pinned: floater.pinned, + completed: floater.completed, + instanceDateEpochMs: nil, + name: nil, + color: nil, + iconKey: nil + ) + if !existingKeys.contains(mutationKey(for: mutation)) { + generated.append(mutation) + } + } + for list in localState.lists where !list.id.hasPrefix(LOCAL_LIST_PREFIX) && !pendingListTargets.contains(list.id) { guard let remoteUpdatedAt = remote.listUpdatedAtByID[list.id], list.updatedAtEpochMs > remoteUpdatedAt else { continue @@ -312,6 +519,33 @@ final class SyncManager { } } + for list in localState.floaterLists where !list.id.hasPrefix(LOCAL_FLOATER_LIST_PREFIX) && !pendingFloaterListTargets.contains(list.id) { + guard let remoteUpdatedAt = remote.floaterListUpdatedAtByID[list.id], list.updatedAtEpochMs > remoteUpdatedAt else { + continue + } + let mutation = PendingMutationRecord( + mutationId: UUID().uuidString, + kind: .updateFloaterList, + targetId: list.id, + timestampEpochMs: list.updatedAtEpochMs, + title: nil, + description: nil, + priority: nil, + dueEpochMs: nil, + rrule: nil, + listId: nil, + pinned: nil, + completed: nil, + instanceDateEpochMs: nil, + name: list.name, + color: list.color, + iconKey: list.iconKey + ) + if !existingKeys.contains(mutationKey(for: mutation)) { + generated.append(mutation) + } + } + return generated } @@ -319,7 +553,9 @@ final class SyncManager { var state = initialState var remaining: [PendingMutationRecord] = [] var resolvedTodoIDs: [String: String] = [:] + var resolvedFloaterIDs: [String: String] = [:] var resolvedListIDs: [String: String] = [:] + var resolvedFloaterListIDs: [String: String] = [:] let orderedMutations = initialState.pendingMutations.sorted { $0.timestampEpochMs < $1.timestampEpochMs } for index in orderedMutations.indices { @@ -330,7 +566,9 @@ final class SyncManager { state: &state, remoteSnapshot: remoteSnapshot, resolvedTodoIDs: &resolvedTodoIDs, - resolvedListIDs: &resolvedListIDs + resolvedFloaterIDs: &resolvedFloaterIDs, + resolvedListIDs: &resolvedListIDs, + resolvedFloaterListIDs: &resolvedFloaterListIDs ) } catch { if isLikelyConnectivityIssue(error) { @@ -352,9 +590,16 @@ final class SyncManager { state: inout OfflineSyncState, remoteSnapshot: RemoteSnapshot, resolvedTodoIDs: inout [String: String], - resolvedListIDs: inout [String: String] + resolvedFloaterIDs: inout [String: String], + resolvedListIDs: inout [String: String], + resolvedFloaterListIDs: inout [String: String] ) async throws { - let targetID = resolveTargetID(mutation.targetId, todoMap: resolvedTodoIDs, listMap: resolvedListIDs) + let targetID = resolveTargetID( + mutation.targetId, + todoMap: resolvedTodoIDs, + floaterMap: resolvedFloaterIDs, + listMap: resolvedListIDs.merging(resolvedFloaterListIDs) { current, _ in current } + ) switch mutation.kind { case .createList: guard let localListID = mutation.targetId else { return } @@ -383,19 +628,47 @@ final class SyncManager { } _ = try await api.deleteListByBody(payload: DeleteListRequest(id: targetID)) + case .createFloaterList: + guard let localListID = mutation.targetId else { return } + if !localListID.hasPrefix(LOCAL_FLOATER_LIST_PREFIX) { + return + } + let response = try await api.createFloaterList( + payload: CreateFloaterListRequest(name: mutation.name ?? "Untitled", color: mutation.color, iconKey: mutation.iconKey) + ) + guard let createdList = response.list else { return } + resolvedFloaterListIDs[localListID] = createdList.id + state = replaceLocalFloaterListID(state, localListID: localListID, serverListID: createdList.id) + + case .updateFloaterList: + guard let targetID else { return } + let remoteUpdatedAt = remoteSnapshot.floaterListUpdatedAtByID[targetID] ?? 0 + guard remoteUpdatedAt <= mutation.timestampEpochMs else { return } + _ = try await api.patchFloaterListByBody( + payload: UpdateFloaterListRequest(id: targetID, name: mutation.name, color: mutation.color, iconKey: mutation.iconKey) + ) + + case .deleteFloaterList: + guard let targetID else { return } + if targetID.hasPrefix(LOCAL_FLOATER_LIST_PREFIX) { + return + } + _ = try await api.deleteFloaterListByBody(payload: DeleteFloaterListRequest(id: targetID)) + case .createTodo: guard let localTodoID = mutation.targetId else { return } if !localTodoID.hasPrefix(LOCAL_TODO_PREFIX) { return } + guard let dueEpochMs = mutation.dueEpochMs else { return } let resolvedListID = mutation.listId.flatMap { resolvedListIDs[$0] ?? $0 } let response = try await api.createTodo( payload: CreateTodoRequest( title: mutation.title ?? "Untitled", description: mutation.description, priority: mutation.priority ?? "Low", - due: mutation.dueEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() }, - rrule: mutation.dueEpochMs == nil ? nil : mutation.rrule, + due: Date(epochMilliseconds: dueEpochMs).ISO8601Format(), + rrule: mutation.rrule, listID: resolvedListID ) ) @@ -403,6 +676,24 @@ final class SyncManager { resolvedTodoIDs[localTodoID] = createdTodo.id state = replaceLocalTodoID(state, localTodoID: localTodoID, serverTodoID: createdTodo.id) + case .createFloater: + guard let localFloaterID = mutation.targetId else { return } + if !localFloaterID.hasPrefix(LOCAL_FLOATER_PREFIX) { + return + } + let resolvedListID = mutation.listId.flatMap { resolvedFloaterListIDs[$0] ?? $0 } + let response = try await api.createFloater( + payload: CreateFloaterRequest( + title: mutation.title ?? "Untitled", + description: mutation.description, + priority: mutation.priority ?? "Low", + listID: resolvedListID + ) + ) + guard let createdFloater = response.floater else { return } + resolvedFloaterIDs[localFloaterID] = createdFloater.id + state = replaceLocalFloaterID(state, localFloaterID: localFloaterID, serverFloaterID: createdFloater.id) + case .updateTodo: guard let targetID else { return } let remoteUpdatedAt = remoteSnapshot.todoUpdatedAtByCanonical[targetID] ?? 0 @@ -446,6 +737,23 @@ final class SyncManager { ) } + case .updateFloater: + guard let targetID else { return } + let remoteUpdatedAt = remoteSnapshot.floaterUpdatedAtByCanonical[targetID] ?? 0 + guard remoteUpdatedAt <= mutation.timestampEpochMs else { return } + let resolvedListID = mutation.listId.flatMap { resolvedFloaterListIDs[$0] ?? $0 } + _ = try await api.patchFloaterByBody( + payload: UpdateFloaterRequest( + id: targetID, + title: mutation.title, + description: mutation.description, + pinned: mutation.pinned, + priority: mutation.priority, + completed: mutation.completed, + listID: resolvedListID + ) + ) + case .deleteTodo: guard let targetID else { return } if let instanceDateEpochMs = mutation.instanceDateEpochMs { @@ -459,34 +767,53 @@ final class SyncManager { _ = try await api.deleteTodoByBody(payload: DeleteTodoRequest(id: targetID)) } + case .deleteFloater: + guard let targetID else { return } + if targetID.hasPrefix(LOCAL_FLOATER_PREFIX) { + return + } + _ = try await api.deleteFloaterByBody(payload: DeleteFloaterRequest(id: targetID)) + case .setPinned: guard let targetID else { return } - _ = try await api.patchTodoByBody( - payload: UpdateTodoRequest( - id: targetID, - title: nil, - description: nil, - pinned: mutation.pinned, - priority: nil, - completed: nil, - due: nil, - rrule: nil, - listID: nil, - dateChanged: nil, - rruleChanged: nil, - instanceDate: mutation.instanceDateEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() } + if targetID.hasPrefix(LOCAL_FLOATER_PREFIX) || remoteSnapshot.floaterUpdatedAtByCanonical[targetID] != nil { + _ = try await api.patchFloaterByBody( + payload: UpdateFloaterRequest(id: targetID, title: nil, description: nil, pinned: mutation.pinned, priority: nil, completed: nil, listID: nil) ) - ) + } else { + _ = try await api.patchTodoByBody( + payload: UpdateTodoRequest( + id: targetID, + title: nil, + description: nil, + pinned: mutation.pinned, + priority: nil, + completed: nil, + due: nil, + rrule: nil, + listID: nil, + dateChanged: nil, + rruleChanged: nil, + instanceDate: mutation.instanceDateEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() } + ) + ) + } case .setPriority: guard let targetID else { return } - _ = try await api.prioritizeTodoByBody( - payload: TodoPrioritizeRequest( - id: targetID, - priority: mutation.priority ?? "Low", - instanceDate: mutation.instanceDateEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() } + if targetID.hasPrefix(LOCAL_FLOATER_PREFIX) || remoteSnapshot.floaterUpdatedAtByCanonical[targetID] != nil { + _ = try await api.prioritizeFloaterByBody( + payload: FloaterPrioritizeRequest(id: targetID, priority: mutation.priority ?? "Low") ) - ) + } else { + _ = try await api.prioritizeTodoByBody( + payload: TodoPrioritizeRequest( + id: targetID, + priority: mutation.priority ?? "Low", + instanceDate: mutation.instanceDateEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() } + ) + ) + } case .completeTodo, .completeTodoInstance: guard let targetID else { return } @@ -505,6 +832,14 @@ final class SyncManager { instanceDate: mutation.instanceDateEpochMs.map { Date(epochMilliseconds: $0).ISO8601Format() } ) ) + + case .completeFloater: + guard let targetID else { return } + _ = try await api.completeFloaterByBody(payload: FloaterCompleteRequest(id: targetID)) + + case .uncompleteFloater: + guard let targetID else { return } + _ = try await api.uncompleteFloaterByBody(payload: FloaterUncompleteRequest(id: targetID)) } } @@ -531,6 +866,7 @@ final class SyncManager { } return todo }, + floaters: state.floaters, completedItems: state.completedItems.map { item in if item.originalTodoId == localTodoID { return CachedCompletedRecord( @@ -550,7 +886,9 @@ final class SyncManager { } return item }, + completedFloaters: state.completedFloaters, lists: state.lists, + floaterLists: state.floaterLists, pendingMutations: state.pendingMutations.map { mutation in PendingMutationRecord( mutationId: mutation.mutationId, @@ -575,6 +913,70 @@ final class SyncManager { ) } + private func replaceLocalFloaterID(_ state: OfflineSyncState, localFloaterID: String, serverFloaterID: String) -> OfflineSyncState { + OfflineSyncState( + lastSuccessfulSyncEpochMs: state.lastSuccessfulSyncEpochMs, + lastSyncAttemptEpochMs: state.lastSyncAttemptEpochMs, + todos: state.todos, + floaters: state.floaters.map { floater in + if floater.canonicalId == localFloaterID || floater.id == localFloaterID { + return CachedFloaterRecord( + id: serverFloaterID, + canonicalId: serverFloaterID, + title: floater.title, + description: floater.description, + priority: floater.priority, + pinned: floater.pinned, + completed: floater.completed, + listId: floater.listId, + updatedAtEpochMs: floater.updatedAtEpochMs + ) + } + return floater + }, + completedItems: state.completedItems, + completedFloaters: state.completedFloaters.map { item in + if item.originalFloaterId == localFloaterID { + return CachedCompletedFloaterRecord( + id: item.id, + originalFloaterId: serverFloaterID, + title: item.title, + description: item.description, + priority: item.priority, + completedAtEpochMs: item.completedAtEpochMs, + listId: item.listId, + listName: item.listName, + listColor: item.listColor + ) + } + return item + }, + lists: state.lists, + floaterLists: state.floaterLists, + pendingMutations: state.pendingMutations.map { mutation in + PendingMutationRecord( + mutationId: mutation.mutationId, + kind: mutation.kind, + targetId: mutation.targetId == localFloaterID ? serverFloaterID : mutation.targetId, + timestampEpochMs: mutation.timestampEpochMs, + title: mutation.title, + description: mutation.description, + priority: mutation.priority, + dueEpochMs: mutation.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 + ) + }, + aiSummaryEnabled: state.aiSummaryEnabled + ) + } + private func replaceLocalListID(_ state: OfflineSyncState, localListID: String, serverListID: String) -> OfflineSyncState { OfflineSyncState( lastSuccessfulSyncEpochMs: state.lastSuccessfulSyncEpochMs, @@ -598,6 +1000,7 @@ final class SyncManager { } return todo }, + floaters: state.floaters, completedItems: state.completedItems.map { completed in guard completed.listId == localListID else { return completed @@ -617,6 +1020,7 @@ final class SyncManager { listColor: completed.listColor ) }, + completedFloaters: state.completedFloaters, lists: state.lists.map { list in if list.id == localListID { return CachedListRecord( @@ -631,6 +1035,7 @@ final class SyncManager { } return list }, + floaterLists: state.floaterLists, pendingMutations: state.pendingMutations.map { mutation in PendingMutationRecord( mutationId: mutation.mutationId, @@ -655,13 +1060,93 @@ final class SyncManager { ) } - private func resolveTargetID(_ original: String?, todoMap: [String: String], listMap: [String: String]) -> String? { + private func replaceLocalFloaterListID(_ state: OfflineSyncState, localListID: String, serverListID: String) -> OfflineSyncState { + OfflineSyncState( + lastSuccessfulSyncEpochMs: state.lastSuccessfulSyncEpochMs, + lastSyncAttemptEpochMs: state.lastSyncAttemptEpochMs, + todos: state.todos, + floaters: state.floaters.map { floater in + guard floater.listId == localListID else { + return floater + } + return CachedFloaterRecord( + id: floater.id, + canonicalId: floater.canonicalId, + title: floater.title, + description: floater.description, + priority: floater.priority, + pinned: floater.pinned, + completed: floater.completed, + listId: serverListID, + updatedAtEpochMs: floater.updatedAtEpochMs + ) + }, + completedItems: state.completedItems, + completedFloaters: state.completedFloaters.map { completed in + guard completed.listId == localListID else { + return completed + } + return CachedCompletedFloaterRecord( + id: completed.id, + originalFloaterId: completed.originalFloaterId, + title: completed.title, + description: completed.description, + priority: completed.priority, + completedAtEpochMs: completed.completedAtEpochMs, + listId: serverListID, + listName: completed.listName, + listColor: completed.listColor + ) + }, + lists: state.lists, + floaterLists: state.floaterLists.map { list in + guard list.id == localListID else { + return list + } + return CachedFloaterListRecord( + id: serverListID, + name: list.name, + color: list.color, + iconKey: list.iconKey, + todoCount: list.todoCount, + updatedAtEpochMs: list.updatedAtEpochMs, + createdAtEpochMs: list.createdAtEpochMs + ) + }, + pendingMutations: state.pendingMutations.map { mutation in + PendingMutationRecord( + mutationId: mutation.mutationId, + kind: mutation.kind, + targetId: mutation.targetId == localListID ? serverListID : mutation.targetId, + timestampEpochMs: mutation.timestampEpochMs, + title: mutation.title, + description: mutation.description, + priority: mutation.priority, + dueEpochMs: mutation.dueEpochMs, + rrule: mutation.rrule, + listId: mutation.listId == localListID ? serverListID : mutation.listId, + pinned: mutation.pinned, + completed: mutation.completed, + instanceDateEpochMs: mutation.instanceDateEpochMs, + name: mutation.name, + color: mutation.color, + iconKey: mutation.iconKey + ) + }, + aiSummaryEnabled: state.aiSummaryEnabled + ) + } + + private func resolveTargetID(_ original: String?, todoMap: [String: String], floaterMap: [String: String], listMap: [String: String]) -> String? { guard let original else { return nil } if let todoID = todoMap[original] { return todoID } + if let floaterID = floaterMap[original] { + return floaterID + } if let listID = listMap[original] { return listID } @@ -708,7 +1193,41 @@ private extension MutationKind { return true case .createList, .updateList, - .deleteList: + .deleteList, + .createFloaterList, + .updateFloaterList, + .deleteFloaterList, + .createFloater, + .updateFloater, + .deleteFloater, + .completeFloater, + .uncompleteFloater: + return false + } + } + + var affectsFloater: Bool { + switch self { + case .createFloater, + .updateFloater, + .deleteFloater, + .completeFloater, + .uncompleteFloater: + return true + case .createList, + .updateList, + .deleteList, + .createFloaterList, + .updateFloaterList, + .deleteFloaterList, + .createTodo, + .updateTodo, + .deleteTodo, + .setPinned, + .setPriority, + .completeTodo, + .completeTodoInstance, + .uncompleteTodo: return false } } diff --git a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift index a6ac9f1d..4c15e7d5 100644 --- a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift @@ -39,6 +39,7 @@ final class TodoRepository { let normalizedDescription = payload.description.nilIfBlank let normalizedListID = payload.listId.nilIfBlank let normalizedPriorityValue = normalizedPriority(payload.priority) + let normalizedDue = payload.due ?? Date().addingTimeInterval(60 * 60) let mutationID = UUID().uuidString let mutation = PendingMutationRecord( mutationId: mutationID, @@ -48,8 +49,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due?.epochMilliseconds, - rrule: payload.due == nil ? nil : payload.rrule, + dueEpochMs: normalizedDue.epochMilliseconds, + rrule: payload.rrule, listId: normalizedListID, pinned: false, completed: false, @@ -68,8 +69,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due?.epochMilliseconds, - rrule: payload.due == nil ? nil : payload.rrule, + dueEpochMs: normalizedDue.epochMilliseconds, + rrule: payload.rrule, instanceDateEpochMs: nil, pinned: false, completed: false, @@ -81,7 +82,7 @@ final class TodoRepository { return nextState } - if normalizedListID?.hasPrefix(LOCAL_LIST_PREFIX) == true { + if normalizedListID?.hasPrefix(LOCAL_FLOATER_LIST_PREFIX) == true { _ = await syncManager.syncCachedData(force: true, replayPendingMutations: true) return } @@ -92,8 +93,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - due: payload.due?.ISO8601Format(), - rrule: payload.due == nil ? nil : payload.rrule, + due: normalizedDue.ISO8601Format(), + rrule: payload.rrule, listID: normalizedListID ) ) @@ -122,6 +123,95 @@ final class TodoRepository { } } + func createFloater(payload: CreateTaskPayload) async throws { + let normalizedTitle = payload.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedTitle.isEmpty else { + return + } + + let now = Date().epochMilliseconds + let localFloaterID = LOCAL_FLOATER_PREFIX + UUID().uuidString.lowercased() + let normalizedDescription = payload.description.nilIfBlank + let normalizedListID = payload.listId.nilIfBlank + let normalizedPriorityValue = normalizedPriority(payload.priority) + let mutationID = UUID().uuidString + let mutation = PendingMutationRecord( + mutationId: mutationID, + kind: .createFloater, + targetId: localFloaterID, + timestampEpochMs: now, + title: normalizedTitle, + description: normalizedDescription, + priority: normalizedPriorityValue, + dueEpochMs: nil, + rrule: nil, + listId: normalizedListID, + pinned: false, + completed: false, + instanceDateEpochMs: nil, + name: nil, + color: nil, + iconKey: nil + ) + + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + nextState.floaters.append( + CachedFloaterRecord( + id: localFloaterID, + canonicalId: localFloaterID, + title: normalizedTitle, + description: normalizedDescription, + priority: normalizedPriorityValue, + pinned: false, + completed: false, + listId: normalizedListID, + updatedAtEpochMs: now + ) + ) + nextState.pendingMutations.append(mutation) + return nextState + } + + if normalizedListID?.hasPrefix(LOCAL_LIST_PREFIX) == true { + _ = await syncManager.syncCachedData(force: true, replayPendingMutations: true) + return + } + + do { + let response = try await api.createFloater( + payload: CreateFloaterRequest( + title: normalizedTitle, + description: normalizedDescription, + priority: normalizedPriorityValue, + listID: normalizedListID + ) + ) + guard let createdDTO = response.floater else { + return + } + let createdFloater = mapFloaterDTO(createdDTO) + _ = try await cacheManager.updateOfflineState { state in + var nextState = self.replaceLocalFloaterID( + state, + localFloaterID: localFloaterID, + serverFloaterID: createdFloater.canonicalId + ) + let createdRecord = floaterToCache(createdFloater) + nextState.floaters = nextState.floaters.map { floater in + guard floater.canonicalId == createdFloater.canonicalId else { + return floater + } + return createdRecord + } + nextState.pendingMutations.removeAll { $0.mutationId == mutationID } + return nextState + } + } catch { + // Keep the pending CREATE_FLOATER mutation so background sync can retry it. + } + } + func updateTodo(_ todo: TodoItem, payload: CreateTaskPayload) async throws { let normalizedTitle = payload.title.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedTitle.isEmpty else { @@ -132,6 +222,7 @@ final class TodoRepository { let normalizedDescription = payload.description.nilIfBlank let normalizedListID = payload.listId.nilIfBlank let normalizedPriorityValue = normalizedPriority(payload.priority) + let normalizedDue = payload.due ?? todo.due ?? Date().addingTimeInterval(60 * 60) _ = try await cacheManager.updateOfflineState { state in var nextState = state nextState.todos = state.todos.map { current in @@ -143,8 +234,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due?.epochMilliseconds, - rrule: payload.due == nil ? nil : payload.rrule, + dueEpochMs: normalizedDue.epochMilliseconds, + rrule: payload.rrule, instanceDateEpochMs: current.instanceDateEpochMs, pinned: current.pinned, completed: current.completed, @@ -162,8 +253,8 @@ final class TodoRepository { title: normalizedTitle, description: normalizedDescription, priority: normalizedPriorityValue, - dueEpochMs: payload.due?.epochMilliseconds, - rrule: payload.due == nil ? nil : payload.rrule, + dueEpochMs: normalizedDue.epochMilliseconds, + rrule: payload.rrule, listId: normalizedListID, pinned: todo.pinned, completed: todo.completed, @@ -181,6 +272,61 @@ final class TodoRepository { } } + func updateFloater(_ floater: TodoItem, payload: CreateTaskPayload) async throws { + let normalizedTitle = payload.title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedTitle.isEmpty else { + return + } + + let now = Date().epochMilliseconds + let normalizedDescription = payload.description.nilIfBlank + let normalizedListID = payload.listId.nilIfBlank + let normalizedPriorityValue = normalizedPriority(payload.priority) + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + nextState.floaters = state.floaters.map { current in + guard current.canonicalId == floater.canonicalId else { return current } + return CachedFloaterRecord( + id: current.id, + canonicalId: current.canonicalId, + title: normalizedTitle, + description: normalizedDescription, + priority: normalizedPriorityValue, + pinned: current.pinned, + completed: current.completed, + listId: normalizedListID, + updatedAtEpochMs: now + ) + } + nextState.pendingMutations.removeAll { $0.kind == .updateFloater && $0.targetId == floater.canonicalId } + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: UUID().uuidString, + kind: .updateFloater, + targetId: floater.canonicalId, + timestampEpochMs: now, + title: normalizedTitle, + description: normalizedDescription, + priority: normalizedPriorityValue, + dueEpochMs: nil, + rrule: nil, + listId: normalizedListID, + pinned: floater.pinned, + completed: floater.completed, + instanceDateEpochMs: nil, + 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 moveTodo(_ todo: TodoItem, due: Date) async throws { let now = Date().epochMilliseconds let dueEpochMs = due.epochMilliseconds @@ -304,6 +450,42 @@ final class TodoRepository { } } + func deleteFloater(_ floater: TodoItem) async throws { + let now = Date().epochMilliseconds + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + nextState.floaters.removeAll { $0.canonicalId == floater.canonicalId } + nextState.pendingMutations.removeAll { $0.targetId == floater.canonicalId && ($0.kind == .createFloater || $0.kind == .updateFloater) } + if !floater.canonicalId.hasPrefix(LOCAL_FLOATER_PREFIX) { + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: UUID().uuidString, + kind: .deleteFloater, + targetId: floater.canonicalId, + timestampEpochMs: now, + title: nil, + description: nil, + priority: nil, + dueEpochMs: nil, + rrule: nil, + listId: nil, + pinned: nil, + completed: nil, + instanceDateEpochMs: nil, + name: nil, + color: nil, + iconKey: nil + ) + ) + } + return nextState + } + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) + if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { + throw error + } + } + func completeTodo(_ todo: TodoItem) async throws { let now = Date().epochMilliseconds let mutationID = UUID().uuidString @@ -365,6 +547,69 @@ final class TodoRepository { } } + func completeFloater(_ floater: TodoItem) async throws { + let now = Date().epochMilliseconds + let mutationID = UUID().uuidString + _ = try await cacheManager.updateOfflineState { state in + var nextState = state + nextState.floaters = state.floaters.map { current in + guard current.canonicalId == floater.canonicalId else { + return current + } + return CachedFloaterRecord( + id: current.id, + canonicalId: current.canonicalId, + title: current.title, + description: current.description, + priority: current.priority, + pinned: current.pinned, + completed: true, + listId: current.listId, + updatedAtEpochMs: now + ) + } + nextState.completedFloaters.insert( + CachedCompletedFloaterRecord( + id: LOCAL_COMPLETED_FLOATER_PREFIX + UUID().uuidString.lowercased(), + originalFloaterId: floater.canonicalId, + title: floater.title, + description: floater.description, + priority: floater.priority, + completedAtEpochMs: now, + listId: floater.listId, + listName: state.floaterLists.first(where: { $0.id == floater.listId })?.name, + listColor: state.floaterLists.first(where: { $0.id == floater.listId })?.color + ), + at: 0 + ) + nextState.pendingMutations.append( + PendingMutationRecord( + mutationId: mutationID, + kind: .completeFloater, + targetId: floater.canonicalId, + timestampEpochMs: now, + title: nil, + description: nil, + priority: nil, + dueEpochMs: nil, + rrule: nil, + listId: nil, + pinned: nil, + completed: true, + instanceDateEpochMs: nil, + 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 setPinned(_ todo: TodoItem, pinned: Bool) async throws { try await updateSimpleTodoMutation(todo, kind: .setPinned, pinned: pinned, priority: nil) } @@ -488,6 +733,7 @@ final class TodoRepository { updatedAtEpochMs: todo.updatedAtEpochMs ) }, + floaters: state.floaters, completedItems: state.completedItems.map { item in guard item.originalTodoId == localTodoID else { return item @@ -507,7 +753,9 @@ final class TodoRepository { listColor: item.listColor ) }, + completedFloaters: state.completedFloaters, lists: state.lists, + floaterLists: state.floaterLists, pendingMutations: state.pendingMutations.map { mutation in PendingMutationRecord( mutationId: mutation.mutationId, @@ -532,12 +780,76 @@ final class TodoRepository { ) } + private func replaceLocalFloaterID(_ state: OfflineSyncState, localFloaterID: String, serverFloaterID: String) -> OfflineSyncState { + OfflineSyncState( + lastSuccessfulSyncEpochMs: state.lastSuccessfulSyncEpochMs, + lastSyncAttemptEpochMs: state.lastSyncAttemptEpochMs, + todos: state.todos, + floaters: state.floaters.map { floater in + guard floater.canonicalId == localFloaterID || floater.id == localFloaterID else { + return floater + } + return CachedFloaterRecord( + id: serverFloaterID, + canonicalId: serverFloaterID, + title: floater.title, + description: floater.description, + priority: floater.priority, + pinned: floater.pinned, + completed: floater.completed, + listId: floater.listId, + updatedAtEpochMs: floater.updatedAtEpochMs + ) + }, + completedItems: state.completedItems, + completedFloaters: state.completedFloaters.map { item in + guard item.originalFloaterId == localFloaterID else { + return item + } + return CachedCompletedFloaterRecord( + id: item.id, + originalFloaterId: serverFloaterID, + title: item.title, + description: item.description, + priority: item.priority, + completedAtEpochMs: item.completedAtEpochMs, + listId: item.listId, + listName: item.listName, + listColor: item.listColor + ) + }, + lists: state.lists, + floaterLists: state.floaterLists, + pendingMutations: state.pendingMutations.map { mutation in + PendingMutationRecord( + mutationId: mutation.mutationId, + kind: mutation.kind, + targetId: mutation.targetId == localFloaterID ? serverFloaterID : mutation.targetId, + timestampEpochMs: mutation.timestampEpochMs, + title: mutation.title, + description: mutation.description, + priority: mutation.priority, + dueEpochMs: mutation.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 + ) + }, + aiSummaryEnabled: state.aiSummaryEnabled + ) + } + private func buildDashboardSummary(from state: OfflineSyncState) -> DashboardSummary { - let timelineTodos = state.todos.map(todoFromCache).filter { !$0.completed } + let timelineTodos = state.todos.map(todoFromCache).filter { !$0.completed && $0.due != nil } + let floaters = state.floaters.map(floaterFromCache).filter { !$0.completed } let now = Date() let todayTodos = timelineTodos.filter { isTodayTodo($0, now: now) } let scheduledTodos = timelineTodos.filter { isScheduledTodo($0, now: now) } - let anytimeTodos = timelineTodos.filter { $0.due == nil } let todoCountsByList = Dictionary(grouping: timelineTodos, by: \.listId).mapValues(\.count) let lists = orderListsLikeWeb(state.lists).map { list in listFromCache(list, todoCountOverride: todoCountsByList[list.id] ?? 0) @@ -548,14 +860,15 @@ final class TodoRepository { scheduledCount: scheduledTodos.count, allCount: timelineTodos.count, priorityCount: timelineTodos.filter { isPriorityTodo($0.priority) }.count, - anytimeCount: anytimeTodos.count, + floaterCount: floaters.count, completedCount: state.completedItems.count, lists: lists ) } private func buildTodos(from state: OfflineSyncState, mode: TodoListMode, listId: String?) -> [TodoItem] { - let items = state.todos.map(todoFromCache).filter { !$0.completed } + let items = state.todos.map(todoFromCache).filter { !$0.completed && $0.due != nil } + let floaters = state.floaters.map(floaterFromCache).filter { !$0.completed } let now = Date() let filtered: [TodoItem] @@ -570,8 +883,10 @@ final class TodoRepository { filtered = items case .priority: filtered = items.filter { isPriorityTodo($0.priority) } - case .anytime: - filtered = items.filter { $0.due == nil } + case .floater: + filtered = listId.nilIfBlank.map { id in + floaters.filter { $0.listId == id } + } ?? floaters case .list: filtered = items.filter { $0.listId == listId } } diff --git a/ios-swiftUI/Tday/Core/Model/ApiModels.swift b/ios-swiftUI/Tday/Core/Model/ApiModels.swift index 10cb6307..8403c47b 100644 --- a/ios-swiftUI/Tday/Core/Model/ApiModels.swift +++ b/ios-swiftUI/Tday/Core/Model/ApiModels.swift @@ -87,6 +87,10 @@ struct TodosResponse: Codable { let todos: [TodoDTO] } +struct FloatersResponse: Codable { + let floaters: [FloaterDTO] +} + struct TodoSummaryRequest: Codable { let mode: String let listId: String? @@ -122,7 +126,7 @@ struct CreateTodoRequest: Codable { let title: String let description: String? let priority: String - let due: String? + let due: String let rrule: String? let listID: String? } @@ -150,6 +154,59 @@ struct CreateTodoResponse: Codable { let todo: TodoDTO? } +struct CreateFloaterRequest: Codable { + let title: String + let description: String? + let priority: String + let listID: String? +} + +struct FloaterDTO: Codable, Equatable { + let id: String + let title: String + let description: String? + let pinned: Bool + let priority: String + let completed: Bool + let order: Int? + let listID: String? + let userID: String? + let updatedAt: String? + let createdAt: String? +} + +struct CreateFloaterResponse: Codable { + let message: String? + let floater: FloaterDTO? +} + +struct UpdateFloaterRequest: Codable { + let id: String + let title: String? + let description: String? + let pinned: Bool? + let priority: String? + let completed: Bool? + let listID: String? +} + +struct DeleteFloaterRequest: Codable { + let id: String +} + +struct FloaterCompleteRequest: Codable { + let id: String +} + +struct FloaterUncompleteRequest: Codable { + let id: String +} + +struct FloaterPrioritizeRequest: Codable { + let id: String + let priority: String +} + struct UpdateTodoRequest: Codable { let id: String let title: String? @@ -208,12 +265,22 @@ struct ListsResponse: Codable { let lists: [ListDTO] } +struct FloaterListsResponse: Codable { + let lists: [FloaterListDTO] +} + struct CreateListRequest: Codable { let name: String let color: String? let iconKey: String? } +struct CreateFloaterListRequest: Codable { + let name: String + let color: String? + let iconKey: String? +} + struct ListDTO: Codable, Equatable { let id: String let name: String @@ -224,11 +291,27 @@ struct ListDTO: Codable, Equatable { let createdAt: String? } +struct FloaterListDTO: Codable, Equatable { + let id: String + let name: String + let color: String? + let todoCount: Int + let iconKey: String? + let userID: String? + let updatedAt: String? + let createdAt: String? +} + struct CreateListResponse: Codable { let message: String? let list: ListDTO? } +struct CreateFloaterListResponse: Codable { + let message: String? + let list: FloaterListDTO? +} + struct UpdateListRequest: Codable { let id: String let name: String? @@ -236,6 +319,13 @@ struct UpdateListRequest: Codable { let iconKey: String? } +struct UpdateFloaterListRequest: Codable { + let id: String + let name: String? + let color: String? + let iconKey: String? +} + struct DeleteListRequest: Codable { let id: String? let ids: [String] @@ -246,6 +336,16 @@ struct DeleteListRequest: Codable { } } +struct DeleteFloaterListRequest: Codable { + let id: String? + let ids: [String] + + init(id: String? = nil, ids: [String] = []) { + self.id = id + self.ids = ids + } +} + struct ListDetailResponse: Codable { let list: ListDTO let todos: [ListTodoDTO] @@ -267,6 +367,27 @@ struct ListDetailResponse: Codable { } } +struct FloaterListDetailResponse: Codable { + let list: FloaterListDTO + let floaters: [FloaterListTodoDTO] + + init(list: FloaterListDTO, floaters: [FloaterListTodoDTO] = []) { + self.list = list + self.floaters = floaters + } + + private enum CodingKeys: String, CodingKey { + case list + case floaters + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + list = try container.decode(FloaterListDTO.self, forKey: .list) + floaters = try container.decodeIfPresent([FloaterListTodoDTO].self, forKey: .floaters) ?? [] + } +} + struct DeleteListResponse: Codable { let message: String? let deletedIds: [String] @@ -288,6 +409,27 @@ struct DeleteListResponse: Codable { } } +struct DeleteFloaterListResponse: Codable { + let message: String? + let deletedIds: [String] + + init(message: String? = nil, deletedIds: [String] = []) { + self.message = message + self.deletedIds = deletedIds + } + + private enum CodingKeys: String, CodingKey { + case message + case deletedIds + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + message = try container.decodeIfPresent(String.self, forKey: .message) + deletedIds = try container.decodeIfPresent([String].self, forKey: .deletedIds) ?? [] + } +} + struct ListTodoDTO: Codable, Equatable { let id: String let title: String @@ -297,10 +439,22 @@ struct ListTodoDTO: Codable, Equatable { let order: Int } +struct FloaterListTodoDTO: Codable, Equatable { + let id: String + let title: String + let priority: String + let completed: Bool + let order: Int +} + struct CompletedTodosResponse: Codable { let completedTodos: [CompletedTodoDTO] } +struct CompletedFloatersResponse: Codable { + let completedFloaters: [CompletedFloaterDTO] +} + struct CompletedTodoDTO: Codable, Equatable { let id: String let originalTodoID: String? @@ -333,6 +487,32 @@ struct DeleteCompletedTodoRequest: Codable { let id: String } +struct CompletedFloaterDTO: Codable, Equatable { + let id: String + let originalFloaterID: String? + let title: String + let description: String? + let priority: String + let completedAt: String? + let daysToComplete: Double? + let userID: String? + let listID: String? + let listName: String? + let listColor: String? +} + +struct UpdateCompletedFloaterRequest: Codable { + let id: String + let title: String? + let description: String? + let priority: String? + let listID: String? +} + +struct DeleteCompletedFloaterRequest: Codable { + let id: String +} + struct PreferencesResponse: Codable { let preferences: PreferencesDTO? } @@ -368,8 +548,11 @@ struct AuthRedirectResponse: Codable { } typealias TodoDto = TodoDTO +typealias FloaterDto = FloaterDTO typealias ListDto = ListDTO +typealias FloaterListDto = FloaterListDTO typealias CompletedTodoDto = CompletedTodoDTO +typealias CompletedFloaterDto = CompletedFloaterDTO typealias PreferencesDto = PreferencesDTO typealias AuthCallbackResponse = AuthRedirectResponse diff --git a/ios-swiftUI/Tday/Core/Model/DomainModels.swift b/ios-swiftUI/Tday/Core/Model/DomainModels.swift index 63fdccdd..66fec947 100644 --- a/ios-swiftUI/Tday/Core/Model/DomainModels.swift +++ b/ios-swiftUI/Tday/Core/Model/DomainModels.swift @@ -7,7 +7,7 @@ enum TodoListMode: String, Codable, CaseIterable, Hashable { case scheduled = "SCHEDULED" case all = "ALL" case priority = "PRIORITY" - case anytime = "ANYTIME" + case floater = "FLOATER" case list = "LIST" var title: String { @@ -22,8 +22,8 @@ enum TodoListMode: String, Codable, CaseIterable, Hashable { return "All Tasks" case .priority: return "Priority" - case .anytime: - return "Anytime" + case .floater: + return "Floater" case .list: return "List" } @@ -41,8 +41,8 @@ enum TodoListMode: String, Codable, CaseIterable, Hashable { return "all" case .priority: return "priority" - case .anytime: - return "anytime" + case .floater: + return "floater" case .list: return "list" } @@ -95,7 +95,7 @@ extension TodoListMode { switch self { case .scheduled, .all, .priority, .list: return true - case .today, .overdue, .anytime: + case .today, .overdue, .floater: return false } } @@ -231,7 +231,7 @@ struct DashboardSummary: Equatable, Hashable, Codable { let scheduledCount: Int let allCount: Int let priorityCount: Int - let anytimeCount: Int + let floaterCount: Int let completedCount: Int let lists: [ListSummary] } diff --git a/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift b/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift index 9c4a132d..aaef53f5 100644 --- a/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift +++ b/ios-swiftUI/Tday/Core/Model/OfflineSyncModels.swift @@ -1,15 +1,21 @@ import Foundation let LOCAL_LIST_PREFIX = "local-list-" +let LOCAL_FLOATER_LIST_PREFIX = "local-floater-list-" let LOCAL_TODO_PREFIX = "local-todo-" +let LOCAL_FLOATER_PREFIX = "local-floater-" let LOCAL_COMPLETED_PREFIX = "local-completed-" +let LOCAL_COMPLETED_FLOATER_PREFIX = "local-completed-floater-" struct OfflineSyncState: Equatable, Codable { var lastSuccessfulSyncEpochMs: Int64 = 0 var lastSyncAttemptEpochMs: Int64 = 0 var todos: [CachedTodoRecord] = [] + var floaters: [CachedFloaterRecord] = [] var completedItems: [CachedCompletedRecord] = [] + var completedFloaters: [CachedCompletedFloaterRecord] = [] var lists: [CachedListRecord] = [] + var floaterLists: [CachedFloaterListRecord] = [] var pendingMutations: [PendingMutationRecord] = [] var aiSummaryEnabled: Bool = true } @@ -29,6 +35,18 @@ struct CachedTodoRecord: Identifiable, Equatable, Codable { let updatedAtEpochMs: Int64 } +struct CachedFloaterRecord: Identifiable, Equatable, Codable { + let id: String + let canonicalId: String + let title: String + let description: String? + let priority: String + let pinned: Bool + let completed: Bool + let listId: String? + let updatedAtEpochMs: Int64 +} + struct CachedListRecord: Identifiable, Equatable, Codable { let id: String let name: String @@ -39,6 +57,16 @@ struct CachedListRecord: Identifiable, Equatable, Codable { let createdAtEpochMs: Int64 } +struct CachedFloaterListRecord: Identifiable, Equatable, Codable { + let id: String + let name: String + let color: String? + let iconKey: String? + let todoCount: Int + let updatedAtEpochMs: Int64 + let createdAtEpochMs: Int64 +} + struct CachedCompletedRecord: Identifiable, Equatable, Codable { let id: String let originalTodoId: String? @@ -54,18 +82,38 @@ struct CachedCompletedRecord: Identifiable, Equatable, Codable { let listColor: String? } +struct CachedCompletedFloaterRecord: Identifiable, Equatable, Codable { + let id: String + let originalFloaterId: String? + let title: String + let description: String? + let priority: String + let completedAtEpochMs: Int64 + let listId: String? + let listName: String? + let listColor: String? +} + enum MutationKind: String, Codable, CaseIterable { case createList = "CREATE_LIST" case updateList = "UPDATE_LIST" case deleteList = "DELETE_LIST" + case createFloaterList = "CREATE_FLOATER_LIST" + case updateFloaterList = "UPDATE_FLOATER_LIST" + case deleteFloaterList = "DELETE_FLOATER_LIST" case createTodo = "CREATE_TODO" case updateTodo = "UPDATE_TODO" case deleteTodo = "DELETE_TODO" + case createFloater = "CREATE_FLOATER" + case updateFloater = "UPDATE_FLOATER" + case deleteFloater = "DELETE_FLOATER" case setPinned = "SET_PINNED" case setPriority = "SET_PRIORITY" case completeTodo = "COMPLETE_TODO" case completeTodoInstance = "COMPLETE_TODO_INSTANCE" case uncompleteTodo = "UNCOMPLETE_TODO" + case completeFloater = "COMPLETE_FLOATER" + case uncompleteFloater = "UNCOMPLETE_FLOATER" } struct PendingMutationRecord: Identifiable, Equatable, Codable { diff --git a/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift b/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift index a2f7f689..1602ee7d 100644 --- a/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift +++ b/ios-swiftUI/Tday/Core/Navigation/AppRoute.swift @@ -7,7 +7,8 @@ enum AppRoute: Hashable { case scheduledTodos case allTodos(highlightTodoId: String?) case priorityTodos - case anytimeTodos + case floaterTodos + case floaterListTodos(listId: String, listName: String) case listTodos(listId: String, listName: String) case completed case calendar @@ -31,8 +32,10 @@ enum AppRoute: Hashable { return "todos/all" case .priorityTodos: return "todos/priority" - case .anytimeTodos: - return "todos/anytime" + case .floaterTodos: + return "floater" + case let .floaterListTodos(listId, listName): + return "floater/list/\(listId)/\(listName)" case let .listTodos(listId, listName): return "todos/list/\(listId)/\(listName)" case .completed: @@ -87,8 +90,12 @@ enum AppRoute: Hashable { return .allTodos(highlightTodoId: highlightTodoId) case "priority": return .priorityTodos - case "anytime": - return .anytimeTodos + case "floater": + let remaining = Array(components.dropFirst(2)) + if remaining.first == "list", remaining.count >= 3 { + return .floaterListTodos(listId: remaining[1], listName: remaining[2]) + } + return .floaterTodos case "list": let remaining = Array(components.dropFirst(2)) guard remaining.count >= 2 else { @@ -99,6 +106,13 @@ enum AppRoute: Hashable { return nil } default: + if first == "floater" { + let remaining = Array(components.dropFirst()) + if remaining.first == "list", remaining.count >= 3 { + return .floaterListTodos(listId: remaining[1], listName: remaining[2]) + } + return .floaterTodos + } return nil } } diff --git a/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift b/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift index 960d0e90..8377d216 100644 --- a/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift +++ b/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift @@ -158,6 +158,10 @@ final class TdayAPIService { return try await request(path: "/api/todo", method: "GET", queryItems: queryItems, responseType: TodosResponse.self) } + func getFloaters() async throws -> FloatersResponse { + try await request(path: "/api/floater", method: "GET", responseType: FloatersResponse.self) + } + func getAppSettings() async throws -> AppSettingsResponse { try await request(path: "/api/app-settings", method: "GET", responseType: AppSettingsResponse.self) } @@ -182,6 +186,10 @@ final class TdayAPIService { try await request(path: "/api/todo", method: "POST", body: payload, responseType: CreateTodoResponse.self) } + func createFloater(payload: CreateFloaterRequest) async throws -> CreateFloaterResponse { + try await request(path: "/api/floater", method: "POST", body: payload, responseType: CreateFloaterResponse.self) + } + func patchTodo(payload: UpdateTodoRequest) async throws -> MessageResponse { try await patchTodoByBody(payload: payload) } @@ -214,6 +222,26 @@ final class TdayAPIService { try await request(path: "/api/todo", method: "DELETE", body: payload, responseType: MessageResponse.self) } + func patchFloaterByBody(payload: UpdateFloaterRequest) async throws -> MessageResponse { + try await request(path: "/api/floater", method: "PATCH", body: payload, responseType: MessageResponse.self) + } + + func deleteFloaterByBody(payload: DeleteFloaterRequest) async throws -> MessageResponse { + try await request(path: "/api/floater", method: "DELETE", body: payload, responseType: MessageResponse.self) + } + + func completeFloaterByBody(payload: FloaterCompleteRequest) async throws -> MessageResponse { + try await request(path: "/api/floater/complete", method: "PATCH", body: payload, responseType: MessageResponse.self) + } + + func uncompleteFloaterByBody(payload: FloaterUncompleteRequest) async throws -> MessageResponse { + try await request(path: "/api/floater/uncomplete", method: "PATCH", body: payload, responseType: MessageResponse.self) + } + + func prioritizeFloaterByBody(payload: FloaterPrioritizeRequest) async throws -> MessageResponse { + try await request(path: "/api/floater/prioritize", method: "PATCH", body: payload, responseType: MessageResponse.self) + } + func completeTodoByBody(payload: TodoCompleteRequest) async throws -> MessageResponse { try await request(path: "/api/todo/complete", method: "PATCH", body: payload, responseType: MessageResponse.self) } @@ -254,6 +282,10 @@ final class TdayAPIService { try await request(path: "/api/completedTodo", method: "GET", responseType: CompletedTodosResponse.self) } + func getCompletedFloaters() async throws -> CompletedFloatersResponse { + try await request(path: "/api/completedFloater", method: "GET", responseType: CompletedFloatersResponse.self) + } + func patchCompletedTodoByBody(payload: UpdateCompletedTodoRequest) async throws -> MessageResponse { try await request(path: "/api/completedTodo", method: "PATCH", body: payload, responseType: MessageResponse.self) } @@ -266,6 +298,14 @@ final class TdayAPIService { try await request(path: "/api/completedTodo", method: "DELETE", body: payload, responseType: MessageResponse.self) } + func patchCompletedFloaterByBody(payload: UpdateCompletedFloaterRequest) async throws -> MessageResponse { + try await request(path: "/api/completedFloater", method: "PATCH", body: payload, responseType: MessageResponse.self) + } + + func deleteCompletedFloaterByBody(payload: DeleteCompletedFloaterRequest) async throws -> MessageResponse { + try await request(path: "/api/completedFloater", method: "DELETE", body: payload, responseType: MessageResponse.self) + } + func deleteCompletedTodo(payload: DeleteCompletedTodoRequest) async throws -> MessageResponse { try await deleteCompletedTodoByBody(payload: payload) } @@ -274,6 +314,10 @@ final class TdayAPIService { try await request(path: "/api/list", method: "GET", responseType: ListsResponse.self) } + func getFloaterLists() async throws -> FloaterListsResponse { + try await request(path: "/api/floaterList", method: "GET", responseType: FloaterListsResponse.self) + } + func getListTodos(listID: String, start: Int64, end: Int64) async throws -> ListDetailResponse { try await request( path: "/api/list/\(listID)", @@ -286,14 +330,30 @@ final class TdayAPIService { ) } + func getFloaterListTodos(listID: String) async throws -> FloaterListDetailResponse { + try await request( + path: "/api/floaterList/\(listID)", + method: "GET", + responseType: FloaterListDetailResponse.self + ) + } + func createList(payload: CreateListRequest) async throws -> CreateListResponse { try await request(path: "/api/list", method: "POST", body: payload, responseType: CreateListResponse.self) } + func createFloaterList(payload: CreateFloaterListRequest) async throws -> CreateFloaterListResponse { + try await request(path: "/api/floaterList", method: "POST", body: payload, responseType: CreateFloaterListResponse.self) + } + func patchListByBody(payload: UpdateListRequest) async throws -> MessageResponse { try await request(path: "/api/list", method: "PATCH", body: payload, responseType: MessageResponse.self) } + func patchFloaterListByBody(payload: UpdateFloaterListRequest) async throws -> MessageResponse { + try await request(path: "/api/floaterList", method: "PATCH", body: payload, responseType: MessageResponse.self) + } + func patchList(payload: UpdateListRequest) async throws -> MessageResponse { try await patchListByBody(payload: payload) } @@ -302,6 +362,10 @@ final class TdayAPIService { try await request(path: "/api/list", method: "DELETE", body: payload, responseType: DeleteListResponse.self) } + func deleteFloaterListByBody(payload: DeleteFloaterListRequest) async throws -> DeleteFloaterListResponse { + try await request(path: "/api/floaterList", method: "DELETE", body: payload, responseType: DeleteFloaterListResponse.self) + } + func deleteList(payload: DeleteListRequest) async throws -> DeleteListResponse { try await deleteListByBody(payload: payload) } diff --git a/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift b/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift index b02e931f..79773f19 100644 --- a/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift +++ b/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift @@ -2,14 +2,14 @@ import SwiftUI enum RootFeedTab: Hashable { case home - case anytime + case floater var title: String { switch self { case .home: return "Home" - case .anytime: - return "Anytime" + case .floater: + return "Floater" } } @@ -17,7 +17,7 @@ enum RootFeedTab: Hashable { switch self { case .home: return "house.fill" - case .anytime: + case .floater: return "tray.full.fill" } } @@ -31,7 +31,7 @@ struct RootFeedDock: View { @Environment(\.tdayColors) private var colors @State private var tapExpanded = false - private let tabs: [RootFeedTab] = [.home, .anytime] + private let tabs: [RootFeedTab] = [.home, .floater] private let accentColor = Color(red: 125.0 / 255.0, green: 103.0 / 255.0, blue: 182.0 / 255.0) private let animation = Animation.interactiveSpring(response: 0.36, dampingFraction: 0.88, blendDuration: 0.04) diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index 36990377..39501dfe 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -47,20 +47,26 @@ struct AppRootView: View { ) { route in handleRoute(route) } - case .anytime: + case .floater: TodoListScreen( container: container, - mode: .anytime, + mode: .floater, listId: nil, listName: nil, highlightedTodoId: nil, - rootFeedTab: .anytime, + rootFeedTab: .floater, onRootFeedTabSelected: handleRootFeedTabSelection, showsRootControls: false, usesRootFeedHeader: true, createTaskRequestID: rootCreateTaskRequestID, onRootDockCollapsedChange: { rootDockCollapsed = $0 }, - onRootControlsVisibleChange: { rootControlsVisible = $0 } + onRootControlsVisibleChange: { rootControlsVisible = $0 }, + onOpenFloaterList: { listId, listName in + handleRoute(.floaterListTodos(listId: listId, listName: listName)) + }, + onOpenSettings: { + handleRoute(.settings) + } ) } @@ -93,13 +99,22 @@ struct AppRootView: View { TodoListScreen(container: container, mode: .all, listId: nil, listName: nil, highlightedTodoId: highlightTodoId) case .priorityTodos: TodoListScreen(container: container, mode: .priority, listId: nil, listName: nil, highlightedTodoId: nil) - case .anytimeTodos: + case .floaterTodos: Color.clear .navigationBarBackButtonHidden(true) .toolbar(.hidden, for: .navigationBar) .onAppear { - selectRootFeedTab(.anytime) + selectRootFeedTab(.floater) } + case let .floaterListTodos(listId, listName): + TodoListScreen( + container: container, + mode: .floater, + listId: listId, + listName: listName, + highlightedTodoId: nil, + usesRootFeedHeader: true + ) case let .listTodos(listId, listName): TodoListScreen( container: container, @@ -241,8 +256,8 @@ struct AppRootView: View { switch route { case .home: selectRootFeedTab(.home) - case .anytimeTodos: - selectRootFeedTab(.anytime) + case .floaterTodos: + selectRootFeedTab(.floater) default: appViewModel.navigate(to: route) } @@ -342,8 +357,8 @@ private extension AppRoute { switch self { case .home: return .home - case .anytimeTodos: - return .anytime + case .floaterTodos: + return .floater default: return nil } diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index 28cb6213..1f08fc40 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -323,7 +323,7 @@ final class AppViewModel { func navigate(to route: AppRoute) { switch route { - case .home, .anytimeTodos: + case .home, .floaterTodos: navigationPath = [] default: navigationPath.append(route) diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 6bf44e47..14a644ef 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -2527,7 +2527,7 @@ private struct CalendarTaskDragPreview: View { .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Anytime") + Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Floater") .font(.tdayRounded(size: 12, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant) .lineLimit(1) @@ -2607,7 +2607,7 @@ private struct CalendarPendingTaskRow: View { strikeColor: colors.onSurface.opacity(0.65) ) - Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Anytime") + Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Floater") .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant.opacity(0.8)) } diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index f57db084..4de09d3e 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -439,11 +439,7 @@ struct HomeScreen: View { } openingSearchResultID = todo.id closeSearch() - if todo.due == nil { - onNavigate(.anytimeTodos) - } else { - onNavigate(.allTodos(highlightTodoId: todo.id)) - } + onNavigate(.allTodos(highlightTodoId: todo.id)) DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { openingSearchResultID = nil } @@ -639,7 +635,7 @@ private struct HomeTodayTaskRow: View { private var priorityIcon: String? { priorityIndicatorSymbolName(todo.priority) } private var isOverdue: Bool { !todo.completed && (todo.due ?? .distantFuture) < Date() } - private var dueText: String { todo.due?.formatted(date: .omitted, time: .shortened) ?? "Anytime" } + 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 isCompleting: Bool { completionPhase != .active } @@ -1214,7 +1210,7 @@ private struct HomeSearchResultsOverlay: View { .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(todo.due.map(Self.dueFormatter.string(from:)) ?? "Anytime") + Text(todo.due.map(Self.dueFormatter.string(from:)) ?? "") .font(.tdayRounded(size: 12, weight: .bold)) .foregroundStyle(colors.onSurfaceVariant) .lineLimit(1) @@ -1385,7 +1381,7 @@ private struct HomeTdayLogoMark: View { } } -private struct CreateListSheet: View { +struct CreateListSheet: View { let onSubmit: (String, String?, String?) -> Void @Environment(\.dismiss) private var dismiss diff --git a/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift b/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift index c61e73fb..611b4c1d 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeViewModel.swift @@ -7,7 +7,7 @@ final class HomeViewModel { private let container: AppContainer var isLoading = true - var summary = DashboardSummary(todayCount: 0, scheduledCount: 0, allCount: 0, priorityCount: 0, anytimeCount: 0, completedCount: 0, lists: []) + var summary = DashboardSummary(todayCount: 0, scheduledCount: 0, allCount: 0, priorityCount: 0, floaterCount: 0, completedCount: 0, lists: []) var searchableTodos: [TodoItem] = [] var todayTodos: [TodoItem] = [] var errorMessage: String? diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index aa422d03..e514f603 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -90,6 +90,14 @@ private func isTodoRootDaytime(_ date: Date) -> Bool { return (6..<18).contains(hour) } +private func normalizedTodoSearchQuery(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(with: .current) +} + +private func todoSearchText(_ value: String) -> String { + value.lowercased(with: .current) +} + struct TimelinePinnedSectionHeaderBackground: ViewModifier { @Environment(\.tdayColors) private var colors @@ -247,6 +255,307 @@ private struct RootFeedTitleRow: View { } } +private struct RootFeedSearchTitleRow: View { + let title: String + @Binding var searchExpanded: Bool + @Binding var searchQuery: String + var searchFieldFocused: FocusState.Binding + let onSearchClose: () -> Void + let onCreateList: () -> Void + let onOpenSettings: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + let buttonSize = TodoTimelineMetrics.topBarButtonFrame + let buttonGap: CGFloat = 8 + + GeometryReader { proxy in + let totalWidth = proxy.size.width + let searchWidth = searchExpanded ? max(buttonSize, totalWidth) : buttonSize + let collapsedSearchOffset = -((buttonSize * 2) + (buttonGap * 2)) + let searchOffsetX = searchExpanded ? 0 : collapsedSearchOffset + let daytime = isTodoRootDaytime(Date()) + let iconColor = daytime + ? Color(red: 244.0 / 255.0, green: 197.0 / 255.0, blue: 66.0 / 255.0) + : Color(red: 168.0 / 255.0, green: 184.0 / 255.0, blue: 232.0 / 255.0) + + ZStack(alignment: .trailing) { + HStack(spacing: 8) { + Image(systemName: daytime ? "sun.max.fill" : "moon.stars.fill") + .font(.system(size: 26, weight: .regular)) + .foregroundStyle(iconColor) + + Text(title) + .font(.tdayRounded(size: 32, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 2) + .opacity(searchExpanded ? 0 : 1) + .allowsHitTesting(false) + + HStack(spacing: buttonGap) { + RootFeedHeaderIconButton(icon: "text.badge.plus", action: onCreateList) + .accessibilityLabel("Create list") + RootFeedHeaderIconButton(icon: "ellipsis", action: onOpenSettings) + .accessibilityLabel("More") + } + .opacity(searchExpanded ? 0 : 1) + .allowsHitTesting(!searchExpanded) + + ZStack { + Button { + withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { + searchExpanded = true + } + } label: { + Image(systemName: "magnifyingglass") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(colors.onSurface) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.08, + normalShadowOpacity: 0.16 + ) + ) + .opacity(searchExpanded ? 0 : 1) + .allowsHitTesting(!searchExpanded) + .accessibilityLabel("Search") + + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(colors.onSurface) + .frame(width: 30, height: 30) + + TextField("", text: $searchQuery, prompt: Text("Search").foregroundStyle(colors.onSurfaceVariant)) + .focused(searchFieldFocused) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.tdayRounded(size: 18, weight: .bold)) + .foregroundStyle(colors.onSurface) + .tint(colors.primary) + .disabled(!searchExpanded) + + Button(action: onSearchClose) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0, + normalShadowOpacity: 0 + ) + ) + .accessibilityLabel("Cancel search") + } + .padding(.horizontal, 14) + .opacity(searchExpanded ? 1 : 0) + .allowsHitTesting(searchExpanded) + } + .frame(width: searchWidth, height: buttonSize) + .background(colors.surface, in: Capsule()) + .overlay( + Capsule() + .stroke(colors.onSurface.opacity(0.26), lineWidth: 1) + ) + .offset(x: searchOffsetX) + .zIndex(2) + .animation(.spring(response: 0.28, dampingFraction: 0.86), value: searchExpanded) + } + } + .frame(height: TodoTimelineMetrics.topBarRowHeight) + } +} + +private struct RootFeedHeaderIconButton: View { + let icon: String + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(colors.onSurface) + .frame( + width: TodoTimelineMetrics.topBarButtonFrame, + height: TodoTimelineMetrics.topBarButtonFrame + ) + .background(colors.surface) + .clipShape(Circle()) + .overlay { + Circle() + .stroke(colors.onSurface.opacity(0.34), lineWidth: 1) + } + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.08, + normalShadowOpacity: 0.16 + ) + ) + } +} + +private struct FloaterSearchResultsCard: View { + let todos: [TodoItem] + let listsByID: [String: ListSummary] + let onOpenTodo: (TodoItem) -> Void + + @Environment(\.tdayColors) private var colors + private let maxResultsHeight: CGFloat = 320 + private let rowHeight: CGFloat = 66 + + private var resultsHeight: CGFloat { + min(CGFloat(max(todos.count, 1)) * rowHeight, maxResultsHeight) + } + + var body: some View { + VStack(spacing: 0) { + if todos.isEmpty { + Text("No matching tasks") + .font(.tdayRounded(size: 14, weight: .bold)) + .foregroundStyle(colors.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.vertical, 12) + } else { + ScrollView(showsIndicators: true) { + VStack(spacing: 0) { + ForEach(todos) { todo in + let list = todo.listId.flatMap { listsByID[$0] } + HStack(spacing: 10) { + Image(systemName: todoListSymbolName(for: list?.iconKey)) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(todoListAccentColor(for: list?.color).opacity(0.92)) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 3) { + Text(todo.title) + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + + Text(list?.name ?? todo.priority) + .font(.tdayRounded(size: 12, weight: .bold)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + } + + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .contentShape(Rectangle()) + .onTapGesture { + onOpenTodo(todo) + } + } + } + } + .frame(height: resultsHeight) + } + } + .background(colors.surface, in: RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(colors.onSurface.opacity(0.2), lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.12), radius: 12, x: 0, y: 6) + } +} + +private struct FloaterListCard: View { + let list: ListSummary + let count: Int + let onTap: () -> Void + + @Environment(\.tdayColors) private var colors + + private var symbolName: String { + todoListSymbolName(for: list.iconKey) + } + + private var containerColor: Color { + todoBlendColor(colors.surfaceVariant, todoListAccentColor(for: list.color), amount: 0.66) + } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 26, style: .continuous) + + Button(action: onTap) { + ZStack { + shape.fill(containerColor) + shape.fill( + RadialGradient( + colors: [Color.white.opacity(0.22), Color.white.opacity(0.08), .clear], + center: .topLeading, + startRadius: 8, + endRadius: 120 + ) + ) + shape.fill( + LinearGradient( + colors: [ + Color.white.opacity(0.12), + Color(red: 231.0 / 255.0, green: 243.0 / 255.0, blue: 255.0 / 255.0).opacity(0.1), + Color(red: 255.0 / 255.0, green: 242.0 / 255.0, blue: 250.0 / 255.0).opacity(0.08), + .clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + Image(systemName: symbolName) + .font(.system(size: 60, weight: .regular)) + .foregroundStyle(todoBlendColor(containerColor, .white, amount: 0.34).opacity(0.42)) + .offset(x: 18, y: 8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + .allowsHitTesting(false) + + HStack { + HStack(spacing: 10) { + Image(systemName: symbolName) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 32, height: 32) + + Text(list.name) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + } + + Spacer() + + Text("\(count)") + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(.white) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .frame(maxWidth: .infinity, minHeight: 70, maxHeight: 70) + .clipShape(shape) + .contentShape(shape) + } + .buttonStyle(.plain) + .shadow(color: .black.opacity(0.14), radius: 10, x: 0, y: 7) + } +} + struct TodoListScreen: View { let highlightedTodoId: String? let onListDeleted: () -> Void @@ -257,10 +566,14 @@ struct TodoListScreen: View { let createTaskRequestID: Int let onRootDockCollapsedChange: (Bool) -> Void let onRootControlsVisibleChange: (Bool) -> Void + let onOpenFloaterList: (String, String) -> Void + let onOpenSettings: () -> Void @State private var viewModel: TodoListViewModel @Environment(\.tdayColors) private var colors @Environment(\.dismiss) private var dismiss + @FocusState private var rootFloaterSearchFieldFocused: Bool @State private var showingCreateTask = false + @State private var showingCreateList = false @State private var editingTodo: TodoItem? @State private var showingSummary = false @State private var showingListSettings = false @@ -275,6 +588,9 @@ struct TodoListScreen: View { @State private var completionPhases: [String: TodoCompletionPhase] = [:] @State private var flashTodoId: String? @State private var highlightedScrollRequestID = 0 + @State private var rootFloaterSearchExpanded = false + @State private var rootFloaterSearchQuery = "" + @State private var openingRootFloaterSearchResultID: String? init( container: AppContainer, @@ -289,6 +605,8 @@ struct TodoListScreen: View { createTaskRequestID: Int = 0, onRootDockCollapsedChange: @escaping (Bool) -> Void = { _ in }, onRootControlsVisibleChange: @escaping (Bool) -> Void = { _ in }, + onOpenFloaterList: @escaping (String, String) -> Void = { _, _ in }, + onOpenSettings: @escaping () -> Void = {}, onListDeleted: @escaping () -> Void = {} ) { self.highlightedTodoId = highlightedTodoId @@ -300,6 +618,8 @@ struct TodoListScreen: View { self.createTaskRequestID = createTaskRequestID self.onRootDockCollapsedChange = onRootDockCollapsedChange self.onRootControlsVisibleChange = onRootControlsVisibleChange + self.onOpenFloaterList = onOpenFloaterList + self.onOpenSettings = onOpenSettings _viewModel = State(initialValue: TodoListViewModel(container: container, mode: mode, listId: listId, listName: listName)) _collapsedSectionIDs = State(initialValue: mode == .priority || mode == .all || mode == .list ? ["earlier"] : []) } @@ -312,6 +632,52 @@ struct TodoListScreen: View { ) } + private var floaterListRows: [(list: ListSummary, count: Int)] { + guard viewModel.mode == .floater, viewModel.listId == nil else { + return [] + } + let counts = Dictionary(grouping: viewModel.items.compactMap(\.listId), by: { $0 }).mapValues(\.count) + return viewModel.lists.compactMap { list in + guard let count = counts[list.id], count > 0 else { + return nil + } + return (list, count) + } + } + + private var isRootFloaterScreen: Bool { + viewModel.mode == .floater && viewModel.listId == nil + } + + private var floaterListByID: [String: ListSummary] { + Dictionary(viewModel.lists.map { ($0.id, $0) }, uniquingKeysWith: { _, latest in latest }) + } + + private var normalizedRootFloaterSearchQuery: String { + normalizedTodoSearchQuery(rootFloaterSearchQuery) + } + + private var rootFloaterSearchResults: [TodoItem] { + guard isRootFloaterScreen, !normalizedRootFloaterSearchQuery.isEmpty else { + return [] + } + + return viewModel.items.filter { todo in + todoSearchText(todo.title).contains(normalizedRootFloaterSearchQuery) || + (todo.description.map { todoSearchText($0).contains(normalizedRootFloaterSearchQuery) } ?? false) || + (todo.listId.flatMap { floaterListByID[$0]?.name }.map { + todoSearchText($0).contains(normalizedRootFloaterSearchQuery) + } ?? false) + } + .sorted(by: floaterTodoSortPrecedes) + .prefix(20) + .map { $0 } + } + + private var showRootFloaterSearchResults: Bool { + isRootFloaterScreen && rootFloaterSearchExpanded && !normalizedRootFloaterSearchQuery.isEmpty + } + private var isTodayMode: Bool { viewModel.mode == .today } @@ -320,7 +686,7 @@ struct TodoListScreen: View { viewModel.mode == .overdue || viewModel.mode == .scheduled || viewModel.mode == .priority || - viewModel.mode == .anytime || + viewModel.mode == .floater || viewModel.mode == .all || viewModel.mode == .list } @@ -362,7 +728,7 @@ struct TodoListScreen: View { private var canSummarizeCurrentMode: Bool { viewModel.mode != .list && viewModel.mode != .overdue && viewModel.aiSummaryEnabled - && viewModel.mode != .anytime + && viewModel.mode != .floater } private var heroTopBarAction: TimelineTopBarAction? { @@ -479,12 +845,30 @@ struct TodoListScreen: View { .onChange(of: timelineScrollOffset, initial: true) { _, offset in onRootDockCollapsedChange(max(offset, 0) > TodoTimelineMetrics.rootDockCollapseThreshold) } + .onChange(of: rootFloaterSearchExpanded, initial: true) { _, expanded in + guard isRootFloaterScreen else { + onRootControlsVisibleChange(true) + return + } + onRootControlsVisibleChange(!expanded) + if expanded { + rootFloaterSearchFieldFocused = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.30) { + if rootFloaterSearchExpanded { + rootFloaterSearchFieldFocused = true + } + } + } else { + rootFloaterSearchFieldFocused = false + } + } .onChange(of: createTaskRequestID) { _, requestID in guard requestID > 0 else { return } + closeRootFloaterSearch() showingCreateTask = true } .onAppear { - onRootControlsVisibleChange(true) + onRootControlsVisibleChange(!(isRootFloaterScreen && rootFloaterSearchExpanded)) onRootDockCollapsedChange(shouldCollapseRootDock) } .onDisappear { @@ -493,6 +877,13 @@ struct TodoListScreen: View { .sheet(isPresented: $showingCreateTask) { createTaskSheetContent } + .sheet(isPresented: $showingCreateList) { + CreateListSheet { name, color, iconKey in + Task { + await viewModel.createList(name: name, color: color, iconKey: iconKey) + } + } + } .sheet(item: $editingTodo) { todo in editTaskSheetContent(for: todo) } @@ -583,7 +974,29 @@ struct TodoListScreen: View { } private var rootFeedTitleRow: some View { - RootFeedTitleRow(title: viewModel.title) + Group { + if isRootFloaterScreen { + RootFeedSearchTitleRow( + title: viewModel.title, + searchExpanded: $rootFloaterSearchExpanded, + searchQuery: $rootFloaterSearchQuery, + searchFieldFocused: $rootFloaterSearchFieldFocused, + onSearchClose: { + closeRootFloaterSearch() + }, + onCreateList: { + closeRootFloaterSearch() + showingCreateList = true + }, + onOpenSettings: { + closeRootFloaterSearch() + onOpenSettings() + } + ) + } else { + RootFeedTitleRow(title: viewModel.title) + } + } .background { TimelineScrollOffsetObserver { timelineScrollOffset = $0 } .frame(width: 0, height: 0) @@ -637,9 +1050,10 @@ struct TodoListScreen: View { lists: viewModel.lists, titleText: "New task", submitText: "Create", - initialPayload: CreateTaskPayload(title: "", description: nil, priority: viewModel.mode == .priority ? "High" : "Low", due: viewModel.mode == .anytime ? nil : Date().addingTimeInterval(60 * 60), rrule: nil, listId: viewModel.listId), - defaultScheduled: viewModel.mode != .anytime, - onParseTaskTitleNlp: { title, dueRef in + initialPayload: CreateTaskPayload(title: "", description: nil, priority: viewModel.mode == .priority ? "High" : "Low", due: viewModel.mode == .floater ? nil : Date().addingTimeInterval(60 * 60), rrule: nil, listId: viewModel.listId), + defaultScheduled: viewModel.mode != .floater, + showScheduleControls: viewModel.mode != .floater, + onParseTaskTitleNlp: viewModel.mode == .floater ? nil : { title, dueRef in await viewModel.parseTaskTitleNlp(text: title, referenceDueEpochMs: dueRef) }, onDismiss: { showingCreateTask = false }, @@ -655,7 +1069,9 @@ struct TodoListScreen: View { 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 + defaultScheduled: viewModel.mode != .floater, + showScheduleControls: viewModel.mode != .floater, + onParseTaskTitleNlp: viewModel.mode == .floater ? nil : { title, dueRef in await viewModel.parseTaskTitleNlp(text: title, referenceDueEpochMs: dueRef) }, onDismiss: { editingTodo = nil }, @@ -861,6 +1277,48 @@ struct TodoListScreen: View { } } + private func closeRootFloaterSearch() { + rootFloaterSearchFieldFocused = false + withAnimation(.spring(response: 0.28, dampingFraction: 0.86)) { + rootFloaterSearchExpanded = false + } + rootFloaterSearchQuery = "" + } + + private func openRootFloaterSearchResult(_ todo: TodoItem, using proxy: ScrollViewProxy) { + guard openingRootFloaterSearchResultID == nil else { + return + } + openingRootFloaterSearchResultID = todo.id + closeRootFloaterSearch() + + highlightedScrollRequestID += 1 + let requestID = highlightedScrollRequestID + DispatchQueue.main.asyncAfter(deadline: .now() + TodoTimelineMetrics.searchResultScrollDelay) { + guard requestID == highlightedScrollRequestID else { + return + } + openingRootFloaterSearchResultID = nil + withAnimation(.easeInOut(duration: TodoTimelineMetrics.searchResultScrollDuration)) { + proxy.scrollTo(timelineTodoScrollID(todo.id), anchor: .center) + } + DispatchQueue.main.asyncAfter(deadline: .now() + TodoTimelineMetrics.searchResultFlashDelay) { + guard requestID == highlightedScrollRequestID else { + return + } + flashTodoId = todo.id + } + DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { + guard requestID == highlightedScrollRequestID else { + return + } + if flashTodoId == todo.id || flashTodoId == todo.canonicalId { + flashTodoId = nil + } + } + } + } + private func matchesHighlightedTodo(_ todo: TodoItem, id: String) -> Bool { todo.id == id || todo.canonicalId == id } @@ -1136,6 +1594,26 @@ struct TodoListScreen: View { List { timelineHeroTitleRow + if showRootFloaterSearchResults { + FloaterSearchResultsCard( + todos: rootFloaterSearchResults, + listsByID: floaterListByID, + onOpenTodo: { todo in + openRootFloaterSearchResult(todo, using: scrollProxy) + } + ) + .listRowInsets( + EdgeInsets( + top: 0, + leading: TodoTimelineMetrics.horizontalPadding, + bottom: 10, + trailing: TodoTimelineMetrics.horizontalPadding + ) + ) + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + } + if let errorMessage = viewModel.errorMessage { Section { ErrorRetryView(message: errorMessage) { @@ -1156,6 +1634,32 @@ struct TodoListScreen: View { ) } + if !floaterListRows.isEmpty { + Section { + ForEach(floaterListRows, id: \.list.id) { row in + FloaterListCard( + list: row.list, + count: row.count, + onTap: { + onOpenFloaterList(row.list.id, row.list.name) + } + ) + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 10, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + } + } header: { + Text("My Lists") + .font(.tdayRounded(size: 24, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 4) + .padding(.bottom, 10) + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowSeparator(.hidden) + } + } + Color.clear .frame(height: 120) .listRowInsets(EdgeInsets()) @@ -1208,7 +1712,7 @@ struct TodoListScreen: View { } } HStack(spacing: 6) { - Text(todo.due?.formatted(date: .abbreviated, time: .shortened) ?? "Anytime") + Text(todo.due?.formatted(date: .abbreviated, time: .shortened) ?? "Floater") .font(.tdayRounded(size: 12, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant) } @@ -1579,7 +2083,7 @@ struct TodoListScreen: View { private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { guard let due = todo.due else { - return "Anytime" + return "Floater" } let timeText = due.formatted(date: .omitted, time: .shortened) let dueBodyText = if section.id == "earlier" && @@ -1609,8 +2113,8 @@ struct TodoListScreen: View { return "Overdue, \(dueBodyText)" } return "Due \(dueBodyText)" - case .anytime: - return "Anytime" + case .floater: + return "Floater" case .list: if !todo.completed && due < Date() { return "Overdue, \(dueBodyText)" @@ -2027,7 +2531,7 @@ private struct TodoDragPreview: View { .font(.tdayRounded(size: 16, weight: .bold)) .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Anytime") + Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Floater") .font(.tdayRounded(size: 12, weight: .semibold)) .foregroundStyle(colors.onSurfaceVariant) .lineLimit(1) @@ -3110,8 +3614,8 @@ private func buildSections( placesEarlierBeforeToday: true, includeEmptyEarlierTarget: includeEmptyEarlierTarget ) - case .anytime: - return buildAnytimeTimelineSections(items: items) + case .floater: + return buildFloaterTimelineSections(items: items) case .list: return buildFutureTimelineSections( items: items, @@ -3122,61 +3626,78 @@ private func buildSections( } } -private func buildAnytimeTimelineSections(items: [TodoItem]) -> [TodoTimelineSection] { - let anytimeItems = items.filter { $0.due == nil } - let priorityItems = anytimeItems - .filter { $0.pinned || $0.priority.caseInsensitiveCompare("High") == .orderedSame || $0.priority.caseInsensitiveCompare("Medium") == .orderedSame } - .sorted(by: anytimeTodoSortPrecedes) - let openItems = anytimeItems - .filter { item in !priorityItems.contains(where: { $0.id == item.id }) } - .sorted(by: anytimeTodoSortPrecedes) +private func buildFloaterTimelineSections(items: [TodoItem]) -> [TodoTimelineSection] { + let floaterItems = items + .sorted(by: floaterTodoSortPrecedes) + + let sectionSpecs: [(id: String, title: String, items: [TodoItem])] = [ + ( + "floater-high", + "High", + floaterItems.filter { $0.priority.caseInsensitiveCompare("High") == .orderedSame } + ), + ( + "floater-medium", + "Medium", + floaterItems.filter { $0.priority.caseInsensitiveCompare("Medium") == .orderedSame } + ), + ( + "floater-low", + "Low", + floaterItems.filter { + $0.priority.caseInsensitiveCompare("High") != .orderedSame && + $0.priority.caseInsensitiveCompare("Medium") != .orderedSame + } + ), + ] - var sections: [TodoTimelineSection] = [] - if !priorityItems.isEmpty { - sections.append( - TodoTimelineSection( - id: "anytime-priority", - title: "Priority", - items: priorityItems, - isCollapsible: false, - targetDate: nil - ) + let sections = sectionSpecs.compactMap { spec -> TodoTimelineSection? in + guard !spec.items.isEmpty else { + return nil + } + return TodoTimelineSection( + id: spec.id, + title: spec.title, + items: spec.items, + isCollapsible: false, + targetDate: nil ) } - if !openItems.isEmpty || sections.isEmpty { - sections.append( + + if sections.isEmpty { + return [ TodoTimelineSection( - id: "anytime-open", - title: "Open", - items: openItems, + id: "floater-low", + title: "Low", + items: [], isCollapsible: false, targetDate: nil - ) - ) + ), + ] } return sections } -private func anytimeTodoSortPrecedes(_ lhs: TodoItem, _ rhs: TodoItem) -> Bool { +private func floaterTodoSortPrecedes(_ lhs: TodoItem, _ rhs: TodoItem) -> Bool { if lhs.pinned != rhs.pinned { return lhs.pinned && !rhs.pinned } - let lhsPriority = anytimePriorityRank(lhs.priority) - let rhsPriority = anytimePriorityRank(rhs.priority) + let lhsPriority = floaterPriorityRank(lhs.priority) + let rhsPriority = floaterPriorityRank(rhs.priority) if lhsPriority != rhsPriority { - return lhsPriority > rhsPriority + return lhsPriority < rhsPriority } return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending } -private func anytimePriorityRank(_ priority: String) -> Int { +private func floaterPriorityRank(_ priority: String) -> Int { switch priority.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { case "high", "urgent", "important": - return 3 + return 0 case "medium": - return 2 - default: return 1 + default: + return 2 } } @@ -3411,8 +3932,8 @@ private func emptyTimelineMessage(for mode: TodoListMode) -> String { return "No tasks yet" case .priority: return "No priority tasks" - case .anytime: - return "No anytime tasks" + case .floater: + return "No floater tasks" case .list: return "No tasks in this list" } @@ -3430,7 +3951,7 @@ private func emptyTimelineSystemImage(for mode: TodoListMode, listIconKey: Strin return "tray.fill" case .priority: return "flag.fill" - case .anytime: + case .floater: return "tray.full.fill" case .list: return todoListSymbolName(for: listIconKey) @@ -3449,7 +3970,7 @@ private func todoModeAccentColor(_ mode: TodoListMode, listColorKey: String?) -> return todoHexColor(0x5E6878) case .priority: return todoHexColor(0xE65E52) - case .anytime: + case .floater: return todoHexColor(0x4D8F83) case .list: return todoListAccentColor(for: listColorKey) @@ -3493,6 +4014,30 @@ func todoListAccentColor(for key: String?) -> Color { } } +private func todoBlendColor(_ lhs: Color, _ rhs: Color, amount: CGFloat) -> Color { + let lhsColor = UIColor(lhs) + let rhsColor = UIColor(rhs) + var lhsRed: CGFloat = 0 + var lhsGreen: CGFloat = 0 + var lhsBlue: CGFloat = 0 + var lhsAlpha: CGFloat = 0 + var rhsRed: CGFloat = 0 + var rhsGreen: CGFloat = 0 + var rhsBlue: CGFloat = 0 + var rhsAlpha: CGFloat = 0 + lhsColor.getRed(&lhsRed, green: &lhsGreen, blue: &lhsBlue, alpha: &lhsAlpha) + rhsColor.getRed(&rhsRed, green: &rhsGreen, blue: &rhsBlue, alpha: &rhsAlpha) + let mix = min(max(amount, 0), 1) + return Color( + uiColor: UIColor( + red: lhsRed + ((rhsRed - lhsRed) * mix), + green: lhsGreen + ((rhsGreen - lhsGreen) * mix), + blue: lhsBlue + ((rhsBlue - lhsBlue) * mix), + alpha: lhsAlpha + ((rhsAlpha - lhsAlpha) * mix) + ) + ) +} + private func normalizedTodoListColorKey(_ key: String?) -> String { switch key { case "GREEN": diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift index 3e8b9ed1..ebc91031 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListViewModel.swift @@ -57,7 +57,7 @@ final class TodoListViewModel { summaryError = "AI summary is disabled by admin" return } - guard mode != .list && mode != .overdue && mode != .anytime else { + guard mode != .list && mode != .overdue && mode != .floater else { summaryError = "Summary is available for Today, Scheduled, All, and Priority" return } @@ -89,7 +89,11 @@ final class TodoListViewModel { func addTask(_ payload: CreateTaskPayload) async { do { - try await container.createTodo(payload) + if mode == .floater { + try await container.todoRepository.createFloater(payload: payload) + } else { + try await container.createTodo(payload) + } hydrateFromCache() } catch { errorMessage = userFacingMessage(for: error, fallback: "Could not create task.") @@ -98,7 +102,11 @@ final class TodoListViewModel { func updateTask(_ todo: TodoItem, payload: CreateTaskPayload) async { do { - try await container.todoRepository.updateTodo(todo, payload: payload) + if mode == .floater { + try await container.todoRepository.updateFloater(todo, payload: payload) + } else { + try await container.todoRepository.updateTodo(todo, payload: payload) + } hydrateFromCache() } catch { errorMessage = userFacingMessage(for: error, fallback: "Could not update task.") @@ -129,7 +137,11 @@ final class TodoListViewModel { func complete(_ todo: TodoItem) async { do { - try await container.completeTodo(todo) + if mode == .floater { + try await container.todoRepository.completeFloater(todo) + } else { + try await container.completeTodo(todo) + } hydrateFromCache() } catch { errorMessage = userFacingMessage(for: error, fallback: "Could not complete task.") @@ -138,7 +150,11 @@ final class TodoListViewModel { func delete(_ todo: TodoItem) async { do { - try await container.todoRepository.deleteTodo(todo) + if mode == .floater { + try await container.todoRepository.deleteFloater(todo) + } else { + try await container.todoRepository.deleteTodo(todo) + } hydrateFromCache() } catch { errorMessage = userFacingMessage(for: error, fallback: "Could not delete task.") @@ -148,7 +164,11 @@ final class TodoListViewModel { func updateListSettings(name: String, color: String?, iconKey: String?) async { guard let listId else { return } do { - try await container.listRepository.updateList(listId: listId, name: name, color: color, iconKey: iconKey) + if mode == .floater { + try await container.floaterListRepository.updateList(listId: listId, name: name, color: color, iconKey: iconKey) + } else { + try await container.listRepository.updateList(listId: listId, name: name, color: color, iconKey: iconKey) + } hydrateFromCache() title = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? (listName ?? mode.title) : name } catch { @@ -156,18 +176,39 @@ final class TodoListViewModel { } } + func createList(name: String, color: String?, iconKey: String?) async { + do { + if mode == .floater { + try await container.floaterListRepository.createList(name: name, color: color, iconKey: iconKey) + } else { + try await container.listRepository.createList(name: name, color: color, iconKey: iconKey) + } + hydrateFromCache() + } catch { + errorMessage = userFacingMessage(for: error, fallback: "Could not create list.") + } + } + func deleteList(onOptimisticDelete: @escaping () -> Void) async { guard let listId else { return } do { - try await container.listRepository.deleteList( - listId: listId, - onOptimisticDelete: { - self.lists.removeAll { $0.id == listId } - self.items.removeAll { $0.listId == listId } - self.errorMessage = nil - onOptimisticDelete() - } - ) + let optimisticDelete = { + self.lists.removeAll { $0.id == listId } + self.items.removeAll { $0.listId == listId } + self.errorMessage = nil + onOptimisticDelete() + } + if mode == .floater { + try await container.floaterListRepository.deleteList( + listId: listId, + onOptimisticDelete: optimisticDelete + ) + } else { + try await container.listRepository.deleteList( + listId: listId, + onOptimisticDelete: optimisticDelete + ) + } } catch { errorMessage = userFacingMessage(for: error, fallback: "Could not delete list.") hydrateFromCache() @@ -179,7 +220,11 @@ final class TodoListViewModel { } private func hydrateFromCache() { - lists = container.listRepository.fetchListsSnapshot() + if mode == .floater { + lists = container.floaterListRepository.fetchListsSnapshot() + } else { + lists = container.listRepository.fetchListsSnapshot() + } items = container.todoRepository.fetchTodosSnapshot(mode: mode, listId: listId) aiSummaryEnabled = container.settingsRepository.isAiSummaryEnabledSnapshot() errorMessage = nil diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index de6b1600..568c7f1c 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -29,6 +29,7 @@ struct CreateTaskSheet: View { let submitText: String let initialPayload: CreateTaskPayload? let defaultScheduled: Bool + let showScheduleControls: Bool let onParseTaskTitleNlp: ((String, Int64) async -> TodoTitleNlpResponse?)? let onDismiss: () -> Void let onSubmit: (CreateTaskPayload) async -> Void @@ -92,6 +93,7 @@ struct CreateTaskSheet: View { submitText: String, initialPayload: CreateTaskPayload?, defaultScheduled: Bool = true, + showScheduleControls: Bool = true, onParseTaskTitleNlp: ((String, Int64) async -> TodoTitleNlpResponse?)?, onDismiss: @escaping () -> Void, onSubmit: @escaping (CreateTaskPayload) async -> Void @@ -101,6 +103,7 @@ struct CreateTaskSheet: View { self.submitText = submitText self.initialPayload = initialPayload self.defaultScheduled = defaultScheduled + self.showScheduleControls = showScheduleControls self.onParseTaskTitleNlp = onParseTaskTitleNlp self.onDismiss = onDismiss self.onSubmit = onSubmit @@ -118,6 +121,7 @@ struct CreateTaskSheet: View { titleText: title, submitText: "Save", initialPayload: initialPayload, + showScheduleControls: true, onParseTaskTitleNlp: onParseTaskTitleNlp, onDismiss: {}, onSubmit: { payload in @@ -153,21 +157,23 @@ struct CreateTaskSheet: View { VStack(spacing: 14) { CreateTaskSheetTextCard(title: $title, notes: $notes) - CreateTaskSheetSectionTitle(text: "Schedule") - CreateTaskSheetGroupCard { - CreateTaskSheetScheduleToggleRow( - isOn: $scheduleEnabled - ) + if showScheduleControls { + CreateTaskSheetSectionTitle(text: "Schedule") + CreateTaskSheetGroupCard { + CreateTaskSheetScheduleToggleRow( + isOn: $scheduleEnabled + ) - if scheduleEnabled { - CreateTaskSheetDivider() + if scheduleEnabled { + CreateTaskSheetDivider() - CreateTaskSheetDueRow( - dueDate: $dueDate, - onDateTap: { activeSelector = .date }, - onTimeTap: { activeSelector = .time } - ) - .transition(.opacity.combined(with: .move(edge: .top))) + CreateTaskSheetDueRow( + dueDate: $dueDate, + onDateTap: { activeSelector = .date }, + onTimeTap: { activeSelector = .time } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } } } @@ -189,18 +195,20 @@ struct CreateTaskSheet: View { onTap: { activeSelector = .priority } ) - CreateTaskSheetDivider() + if showScheduleControls { + CreateTaskSheetDivider() - CreateTaskSheetSelectorTriggerRow( - iconName: "repeat", - title: "Repeat", - value: selectedRepeatLabel, - isEnabled: scheduleEnabled, - onTap: { - guard scheduleEnabled else { return } - activeSelector = .recurrence - } - ) + CreateTaskSheetSelectorTriggerRow( + iconName: "repeat", + title: "Repeat", + value: selectedRepeatLabel, + isEnabled: scheduleEnabled, + onTap: { + guard scheduleEnabled else { return } + activeSelector = .recurrence + } + ) + } } } .padding(.horizontal, 18) @@ -252,15 +260,15 @@ struct CreateTaskSheet: View { private func hydrateFromInitialPayload() { guard let initialPayload else { - scheduleEnabled = defaultScheduled - repeatRule = defaultScheduled ? repeatRule : nil + scheduleEnabled = showScheduleControls && defaultScheduled + repeatRule = scheduleEnabled ? repeatRule : nil return } title = initialPayload.title notes = initialPayload.description ?? "" priority = initialPayload.priority selectedListID = initialPayload.listId - if let due = initialPayload.due { + if showScheduleControls, let due = initialPayload.due { dueDate = due scheduleEnabled = true repeatRule = initialPayload.rrule @@ -271,7 +279,7 @@ struct CreateTaskSheet: View { } private func scheduleNlpParse() { - guard let onParseTaskTitleNlp else { + guard showScheduleControls, let onParseTaskTitleNlp else { return } parserTask?.cancel() @@ -299,8 +307,8 @@ struct CreateTaskSheet: View { title: title.trimmingCharacters(in: .whitespacesAndNewlines), description: notes.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : notes.trimmingCharacters(in: .whitespacesAndNewlines), priority: priority, - due: scheduleEnabled ? dueDate : nil, - rrule: scheduleEnabled ? repeatRule : nil, + due: showScheduleControls && scheduleEnabled ? dueDate : nil, + rrule: showScheduleControls && scheduleEnabled ? repeatRule : nil, listId: selectedListID ) await onSubmit(payload) @@ -537,7 +545,7 @@ private struct CreateTaskSheetScheduleToggleRow: View { Text("Schedule") .font(.tdayRounded(size: 18, weight: .heavy)) .foregroundStyle(colors.onSurface) - Text(isOn ? "Task has a due date" : "Anytime task") + Text(isOn ? "Task has a due date" : "Floater task") .font(.tdayRounded(size: 12, weight: .bold)) .foregroundStyle(colors.onSurfaceVariant.opacity(0.78)) } diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 8abb6060..71f5dfe4 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -82,6 +82,7 @@ D34840EBE7C4C90D2525F0DA /* VersionCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3297DCE3ECD21F5B0A4635 /* VersionCompatibility.swift */; }; DCD00FC940427B88E235F7F4 /* OfflineCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85ADE7E6DE0AADEF0E503DE5 /* OfflineCacheManager.swift */; }; DEA51B1F722372A094C125F9 /* ListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D19AB4B0B909242B323D5ABF /* ListRepository.swift */; }; + F1A2B3C4D5E6F708192A3B4D /* FloaterListRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1A2B3C4D5E6F708192A3B4C /* FloaterListRepository.swift */; }; E1BD58F3802B8806874A2E29 /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DABE0225668571D48D55B96 /* AuthViewModel.swift */; }; EAFB2C3D4E5F678901ABCDEE /* LaunchSplashStack.png in Resources */ = {isa = PBXBuildFile; fileRef = EAFB2C3D4E5F678901ABCDEF /* LaunchSplashStack.png */; }; EF64B4D7E0FB06A86DA86E0C /* TaskFloatingActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DE4A047CF237AD9F605D02E /* TaskFloatingActionButton.swift */; }; @@ -167,6 +168,7 @@ CEFF55971ADA0C60CD0F3074 /* AppRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoute.swift; sourceTree = ""; }; D0ACC8B538184310B385746A /* ServerConfigRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigRepository.swift; sourceTree = ""; }; D19AB4B0B909242B323D5ABF /* ListRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListRepository.swift; sourceTree = ""; }; + F1A2B3C4D5E6F708192A3B4C /* FloaterListRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloaterListRepository.swift; sourceTree = ""; }; D206496BC88FF6272CFC286C /* ServerURLPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerURLPersistenceTests.swift; sourceTree = ""; }; D24DBD8CA3EE648D4560B880 /* ServerURLState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerURLState.swift; sourceTree = ""; }; D26B48A87B57A0417CC3F578 /* TdayTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TdayTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -219,6 +221,7 @@ isa = PBXGroup; children = ( D19AB4B0B909242B323D5ABF /* ListRepository.swift */, + F1A2B3C4D5E6F708192A3B4C /* FloaterListRepository.swift */, ); path = List; sourceTree = ""; @@ -714,6 +717,7 @@ C0768A60B1B807FCA7A6D37F /* HomeScreen.swift in Sources */, 9D9B4F301D7261C3F4F95B6D /* HomeViewModel.swift in Sources */, DEA51B1F722372A094C125F9 /* ListRepository.swift in Sources */, + F1A2B3C4D5E6F708192A3B4D /* FloaterListRepository.swift in Sources */, 957E83469CA6C09174B1B374 /* LoginCredentialCoordinator.swift in Sources */, 1618367E71392D77BC8C61B6 /* NavigationBackHistoryTitle.swift in Sources */, C013C84CCBF84A849CE93963 /* NetworkConfiguration.swift in Sources */, diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt index 967fe1a4..5b72f308 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/CompletedModels.kt @@ -14,9 +14,9 @@ data class CompletedTodoDto( val title: String, val description: String? = null, val priority: String = "Low", - val due: String? = null, + val due: String, val completedAt: String? = null, - val completedOnTime: Boolean? = null, + val completedOnTime: Boolean, val daysToComplete: Double? = null, val rrule: String? = null, val userID: String? = null, diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/FloaterModels.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/FloaterModels.kt new file mode 100644 index 00000000..f50d611a --- /dev/null +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/FloaterModels.kt @@ -0,0 +1,109 @@ +package com.ohmz.tday.shared.model + +import kotlinx.serialization.Serializable + +@Serializable +data class FloatersResponse( + val floaters: List = emptyList(), +) + +@Serializable +data class FloaterDto( + val id: String, + val title: String = "", + val description: String? = null, + val pinned: Boolean = false, + val priority: String = "Low", + val completed: Boolean = false, + val order: Int? = null, + val listID: String? = null, + val userID: String? = null, + val updatedAt: String? = null, + val createdAt: String? = null, +) + +@Serializable +data class CreateFloaterRequest( + val title: String, + val description: String? = null, + val priority: String = "Low", + val listID: String? = null, +) + +@Serializable +data class CreateFloaterResponse( + val message: String? = null, + val floater: FloaterDto? = null, +) + +@Serializable +data class UpdateFloaterRequest( + val id: String, + val title: String? = null, + val description: String? = null, + val pinned: Boolean? = null, + val priority: String? = null, + val completed: Boolean? = null, + val listID: String? = null, +) + +@Serializable +data class DeleteFloaterRequest( + val id: String, +) + +@Serializable +data class FloaterCompleteRequest( + val id: String, +) + +@Serializable +data class FloaterUncompleteRequest( + val id: String, +) + +@Serializable +data class FloaterPrioritizeRequest( + val id: String, + val priority: String, +) + +@Serializable +data class FloaterReorderRequest( + val id: String, + val order: Int, +) + +@Serializable +data class CompletedFloatersResponse( + val completedFloaters: List = emptyList(), +) + +@Serializable +data class CompletedFloaterDto( + val id: String, + val originalFloaterID: String? = null, + val title: String, + val description: String? = null, + val priority: String = "Low", + val completedAt: String? = null, + val daysToComplete: Double? = null, + val userID: String? = null, + val listID: String? = null, + val listName: String? = null, + val listColor: String? = null, +) + +@Serializable +data class UpdateCompletedFloaterRequest( + val id: String, + val title: String? = null, + val description: String? = null, + val priority: String? = null, + val listID: String? = null, +) + +@Serializable +data class DeleteCompletedFloaterRequest( + val id: String, +) diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/ListModels.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/ListModels.kt index ba6d4b4d..3266a503 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/ListModels.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/ListModels.kt @@ -67,3 +67,68 @@ data class ListTodoDto( val completed: Boolean, val order: Int, ) + +@Serializable +data class FloaterListsResponse( + val lists: List = emptyList(), +) + +@Serializable +data class CreateFloaterListRequest( + val name: String, + val color: String? = null, + val iconKey: String? = null, +) + +@Serializable +data class FloaterListDto( + val id: String, + val name: String, + val color: String? = null, + val todoCount: Int = 0, + val iconKey: String? = null, + val userID: String? = null, + val updatedAt: String? = null, + val createdAt: String? = null, +) + +@Serializable +data class CreateFloaterListResponse( + val message: String? = null, + val list: FloaterListDto? = null, +) + +@Serializable +data class FloaterListDetailResponse( + val list: FloaterListDto, + val floaters: List = emptyList(), +) + +@Serializable +data class UpdateFloaterListRequest( + val id: String, + val name: String? = null, + val color: String? = null, + val iconKey: String? = null, +) + +@Serializable +data class DeleteFloaterListRequest( + val id: String? = null, + val ids: List = emptyList(), +) + +@Serializable +data class DeleteFloaterListResponse( + val message: String? = null, + val deletedIds: List = emptyList(), +) + +@Serializable +data class FloaterListTodoDto( + val id: String, + val title: String, + val priority: String, + val completed: Boolean, + val order: Int, +) diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/TodoModels.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/TodoModels.kt index b5777d94..f83e2627 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/TodoModels.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/TodoModels.kt @@ -47,7 +47,7 @@ data class CreateTodoRequest( val title: String, val description: String? = null, val priority: String = "Low", - val due: String? = null, + val due: String, val rrule: String? = null, val listID: String? = null, ) @@ -59,7 +59,7 @@ data class TodoDto( val description: String? = null, val pinned: Boolean = false, val priority: String = "Low", - val due: String? = null, + val due: String, val rrule: String? = null, val timeZone: String? = null, val instanceDate: String? = null, diff --git a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/validation/ContractValidators.kt b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/validation/ContractValidators.kt index 9af2458f..65912236 100644 --- a/shared/src/commonMain/kotlin/com/ohmz/tday/shared/validation/ContractValidators.kt +++ b/shared/src/commonMain/kotlin/com/ohmz/tday/shared/validation/ContractValidators.kt @@ -1,5 +1,7 @@ package com.ohmz.tday.shared.validation +import com.ohmz.tday.shared.model.CreateFloaterListRequest +import com.ohmz.tday.shared.model.CreateFloaterRequest import com.ohmz.tday.shared.model.CreateListRequest import com.ohmz.tday.shared.model.CreateTodoRequest @@ -9,12 +11,22 @@ object ContractValidators { if (request.title.isBlank()) { errors += "title cannot be blank" } - if (!request.rrule.isNullOrBlank() && request.due.isNullOrBlank()) { + if (request.due.isBlank()) { + errors += "due is required" + } else if (!request.rrule.isNullOrBlank() && request.due.isBlank()) { errors += "due is required for recurring tasks" } return errors } + fun validateFloaterCreate(request: CreateFloaterRequest): List { + return if (request.title.isBlank()) { + listOf("title cannot be blank") + } else { + emptyList() + } + } + fun validateListCreate(request: CreateListRequest): List { return if (request.name.isBlank()) { listOf("name cannot be blank") @@ -22,4 +34,12 @@ object ContractValidators { emptyList() } } + + fun validateFloaterListCreate(request: CreateFloaterListRequest): List { + return if (request.name.isBlank()) { + listOf("name cannot be blank") + } else { + emptyList() + } + } } diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/config/DatabaseConfig.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/config/DatabaseConfig.kt index 78123030..ceff8373 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/config/DatabaseConfig.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/config/DatabaseConfig.kt @@ -84,8 +84,8 @@ class DatabaseConfig(private val config: AppConfig) { } SchemaUtils.createMissingTablesAndColumns( - Users, Accounts, VerificationTokens, Lists, Todos, TodoInstances, - CompletedTodos, Files, UserPreferences, AppConfigs, + Users, Accounts, VerificationTokens, Lists, FloaterLists, Todos, TodoInstances, + CompletedTodos, Floaters, CompletedFloaters, Files, UserPreferences, AppConfigs, EventLogs, CronLogs, AuthThrottles, AuthSignals, ) } diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedFloaters.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedFloaters.kt new file mode 100644 index 00000000..35239dbe --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedFloaters.kt @@ -0,0 +1,21 @@ +package com.ohmz.tday.db.tables + +import com.ohmz.tday.db.enums.* +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.datetime + +object CompletedFloaters : Table("completedfloaters") { + val id = varchar("id", 30) + val originalFloaterID = varchar("originalFloaterID", 30) + val title = text("title") + val description = text("description").nullable() + val priority = pgEnum("priority", "\"Priority\"") + val completedAt = datetime("completedAt") + val daysToComplete = decimal("daysToComplete", 10, 2) + val userID = varchar("userID", 30).references(Users.id).index() + val listID = varchar("projectID", 30).references(FloaterLists.id).nullable().index() + val listName = varchar("projectName", 255).nullable() + val listColor = varchar("projectColor", 32).nullable() + + override val primaryKey = PrimaryKey(id) +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt index d26ae4ae..bab26ac4 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/CompletedTodos.kt @@ -12,8 +12,8 @@ object CompletedTodos : Table("CompletedTodo") { val description = text("description").nullable() val priority = pgEnum("priority", "\"Priority\"") val completedAt = datetime("completedAt") - val due = datetime("due").nullable() - val completedOnTime = bool("completedOnTime").nullable() + val due = datetime("due") + val completedOnTime = bool("completedOnTime") val daysToComplete = decimal("daysToComplete", 10, 2) val rrule = text("rrule").nullable() val userID = varchar("userID", 30).references(Users.id).index() diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/FloaterLists.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/FloaterLists.kt new file mode 100644 index 00000000..b7c51847 --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/FloaterLists.kt @@ -0,0 +1,17 @@ +package com.ohmz.tday.db.tables + +import com.ohmz.tday.db.enums.* +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.datetime + +object FloaterLists : Table("FloaterProject") { + val id = varchar("id", 30) + val name = text("name") + val color = pgEnum("color", "\"ProjectColor\"").nullable() + val iconKey = varchar("iconKey", 64).nullable() + val userID = varchar("userID", 30).references(Users.id).index() + val createdAt = datetime("createdAt") + val updatedAt = datetime("updatedAt") + + override val primaryKey = PrimaryKey(id) +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Floaters.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Floaters.kt new file mode 100644 index 00000000..0f14ecac --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Floaters.kt @@ -0,0 +1,25 @@ +package com.ohmz.tday.db.tables + +import com.ohmz.tday.db.enums.* +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.javatime.datetime + +object Floaters : Table("floaters") { + val id = varchar("id", 30) + val title = text("title") + val description = text("description").nullable() + val createdAt = datetime("createdAt") + val updatedAt = datetime("updatedAt") + val userID = varchar("userID", 30).references(Users.id).index() + val pinned = bool("pinned").default(false) + val order = integer("order").autoIncrement() + val priority = pgEnum("priority", "\"Priority\"") + val completed = bool("completed").default(false) + val listID = varchar("projectID", 30).references(FloaterLists.id).nullable().index() + + override val primaryKey = PrimaryKey(id) + + init { + index(false, userID, listID) + } +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Todos.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Todos.kt index 6e796df1..49cf7476 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Todos.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/Todos.kt @@ -15,7 +15,7 @@ object Todos : Table("todos") { val pinned = bool("pinned").default(false) val order = integer("order").autoIncrement() val priority = pgEnum("priority", "\"Priority\"") - val due = datetime("due").nullable() + val due = datetime("due") val exdates = registerColumn>("exdates", TimestampArrayColumnType()) val rrule = text("rrule").nullable() val timeZone = varchar("timeZone", 64).default("UTC") diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/di/AppModule.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/di/AppModule.kt index 1d76756d..32c3da7c 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/di/AppModule.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/di/AppModule.kt @@ -29,9 +29,12 @@ val securityModule = module { val serviceModule = module { single { CacheServiceImpl() } single { TodoServiceImpl(get(), get()) } + single { FloaterServiceImpl(get(), get()) } single { ListServiceImpl(get()) } + single { FloaterListServiceImpl(get()) } single { UserServiceImpl(get()) } single { CompletedTodoServiceImpl(get(), get()) } + single { CompletedFloaterServiceImpl(get(), get()) } single { PreferencesServiceImpl() } single { AppConfigServiceImpl() } single { TodoSummaryServiceImpl(get()) } diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/domain/Validations.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/domain/Validations.kt index a43e1b2f..a157742c 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/domain/Validations.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/domain/Validations.kt @@ -29,6 +29,13 @@ val validatePatchTodo = Validation { } } +val validateCreateFloater = Validation { + FloaterCreateRequest::title { + minLength(1) hint "Title is required" + maxLength(500) hint "Title too long" + } +} + val validateCreateList = Validation { ListCreateRequest::name { minLength(1) hint "Name is required" @@ -42,6 +49,19 @@ val validatePatchList = Validation { } } +val validateCreateFloaterList = Validation { + FloaterListCreateRequest::name { + minLength(1) hint "Name is required" + maxLength(255) hint "Name too long" + } +} + +val validatePatchFloaterList = Validation { + FloaterListPatchRequest::id { + minLength(1) hint "List id is required" + } +} + val validateRegister = Validation { RegisterRequest::fname { minLength(2) hint "First name must be at least two characters" diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/CompletedFloaterRequests.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/CompletedFloaterRequests.kt new file mode 100644 index 00000000..145281c2 --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/CompletedFloaterRequests.kt @@ -0,0 +1,4 @@ +package com.ohmz.tday.models.request + +typealias UpdateCompletedFloaterRequest = com.ohmz.tday.shared.model.UpdateCompletedFloaterRequest +typealias DeleteCompletedFloaterRequest = com.ohmz.tday.shared.model.DeleteCompletedFloaterRequest diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/FloaterListRequests.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/FloaterListRequests.kt new file mode 100644 index 00000000..c86372fc --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/FloaterListRequests.kt @@ -0,0 +1,5 @@ +package com.ohmz.tday.models.request + +typealias FloaterListCreateRequest = com.ohmz.tday.shared.model.CreateFloaterListRequest +typealias FloaterListPatchRequest = com.ohmz.tday.shared.model.UpdateFloaterListRequest +typealias FloaterListDeleteRequest = com.ohmz.tday.shared.model.DeleteFloaterListRequest diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/FloaterRequests.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/FloaterRequests.kt new file mode 100644 index 00000000..faf83a56 --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/models/request/FloaterRequests.kt @@ -0,0 +1,9 @@ +package com.ohmz.tday.models.request + +typealias FloaterCreateRequest = com.ohmz.tday.shared.model.CreateFloaterRequest +typealias FloaterPatchRequest = com.ohmz.tday.shared.model.UpdateFloaterRequest +typealias FloaterDeleteRequest = com.ohmz.tday.shared.model.DeleteFloaterRequest +typealias FloaterCompleteRequest = com.ohmz.tday.shared.model.FloaterCompleteRequest +typealias FloaterUncompleteRequest = com.ohmz.tday.shared.model.FloaterUncompleteRequest +typealias FloaterPrioritizeRequest = com.ohmz.tday.shared.model.FloaterPrioritizeRequest +typealias FloaterReorderRequest = com.ohmz.tday.shared.model.FloaterReorderRequest diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/models/response/FloaterListResponses.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/models/response/FloaterListResponses.kt new file mode 100644 index 00000000..8d188ee2 --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/models/response/FloaterListResponses.kt @@ -0,0 +1,7 @@ +package com.ohmz.tday.models.response + +typealias FloaterListResponse = com.ohmz.tday.shared.model.FloaterListDto +typealias FloaterListTodoResponse = com.ohmz.tday.shared.model.FloaterListTodoDto +typealias CreateFloaterListResponse = com.ohmz.tday.shared.model.CreateFloaterListResponse +typealias DeleteFloaterListResponse = com.ohmz.tday.shared.model.DeleteFloaterListResponse +typealias FloaterListDetailResponse = com.ohmz.tday.shared.model.FloaterListDetailResponse diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/models/response/FloaterResponses.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/models/response/FloaterResponses.kt new file mode 100644 index 00000000..a11dcdae --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/models/response/FloaterResponses.kt @@ -0,0 +1,5 @@ +package com.ohmz.tday.models.response + +typealias FloaterResponse = com.ohmz.tday.shared.model.FloaterDto +typealias CreateFloaterResponse = com.ohmz.tday.shared.model.CreateFloaterResponse +typealias CompletedFloaterResponse = com.ohmz.tday.shared.model.CompletedFloaterDto diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/plugins/Routing.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/plugins/Routing.kt index 03cb2d0c..206f9ca9 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/plugins/Routing.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/plugins/Routing.kt @@ -31,10 +31,13 @@ fun Application.configureRouting() { route("/api") { todoRoutes() + floaterRoutes() listRoutes() + floaterListRoutes() userRoutes() preferencesRoutes() completedTodoRoutes() + completedFloaterRoutes() timezoneRoutes() appSettingsRoutes() adminRoutes() diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedFloaterRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedFloaterRoutes.kt new file mode 100644 index 00000000..6d54a741 --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedFloaterRoutes.kt @@ -0,0 +1,65 @@ +package com.ohmz.tday.routes + +import arrow.core.Either +import com.ohmz.tday.di.inject +import com.ohmz.tday.domain.AppError +import com.ohmz.tday.domain.withAuth +import com.ohmz.tday.models.request.DeleteCompletedFloaterRequest +import com.ohmz.tday.models.request.UpdateCompletedFloaterRequest +import com.ohmz.tday.services.CompletedFloaterService +import io.ktor.server.request.receive +import io.ktor.server.request.receiveNullable +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.patch +import io.ktor.server.routing.route + +fun Route.completedFloaterRoutes() { + val completedFloaterService by inject() + + route("/completedFloater") { + get { + call.withAuth { user -> + completedFloaterService.getAll(user.id) + .map { mapOf("completedFloaters" to it) } + } + } + + delete { + call.withAuth { user -> + val body = runCatching { call.receiveNullable() }.getOrNull() + if (body?.id?.isNotBlank() == true) { + completedFloaterService.deleteById(user.id, body.id) + .map { count -> + mapOf("message" to if (count > 0) "completed floater removed" else "completed floater already removed") + } + } else { + completedFloaterService.deleteAll(user.id) + .map { mapOf("message" to "completed floaters cleared") } + } + } + } + + patch { + call.withAuth { user -> + val body = call.receive() + val fields = mutableMapOf() + body.title?.let { fields["title"] = it } + body.description?.let { fields["description"] = it } + body.priority?.let { fields["priority"] = it } + body.listID?.let { fields["listID"] = it.takeIf { value -> value.isNotBlank() } } + if (fields.isEmpty()) { + return@withAuth completedFloaterService.deleteById(user.id, body.id) + .map { count -> + mapOf("message" to if (count > 0) "completed floater removed" else "completed floater already removed") + } + } + completedFloaterService.update(user.id, body.id, fields) + .map { count -> + mapOf("message" to if (count > 0) "completed floater updated" else "completed floater already removed") + } + } + } + } +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt index 66bbab4d..3c35600e 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/CompletedTodoRoutes.kt @@ -46,7 +46,7 @@ fun Route.completedTodoRoutes() { val due = body.due if (due != null) { if (due.isBlank()) { - fields["due"] = null + return@withAuth Either.Left(AppError.BadRequest("due is required")) } else { val parsed = parseTodoDateTime(due) ?: return@withAuth Either.Left( diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/FloaterListRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/FloaterListRoutes.kt new file mode 100644 index 00000000..1d94d72f --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/FloaterListRoutes.kt @@ -0,0 +1,101 @@ +package com.ohmz.tday.routes + +import arrow.core.Either +import arrow.core.raise.either +import com.ohmz.tday.di.inject +import com.ohmz.tday.domain.AppError +import com.ohmz.tday.domain.validateCreateFloaterList +import com.ohmz.tday.domain.validateOrFail +import com.ohmz.tday.domain.validatePatchFloaterList +import com.ohmz.tday.domain.withAuth +import com.ohmz.tday.models.request.FloaterListCreateRequest +import com.ohmz.tday.models.request.FloaterListDeleteRequest +import com.ohmz.tday.models.request.FloaterListPatchRequest +import com.ohmz.tday.models.response.CreateFloaterListResponse +import com.ohmz.tday.models.response.DeleteFloaterListResponse +import com.ohmz.tday.models.response.FloaterListDetailResponse +import com.ohmz.tday.services.FloaterListService +import io.ktor.server.request.receive +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.patch +import io.ktor.server.routing.post +import io.ktor.server.routing.route + +fun Route.floaterListRoutes() { + val floaterListService by inject() + + route("/floaterList") { + get { + call.withAuth { user -> + floaterListService.getAll(user.id).map { mapOf("lists" to it) } + } + } + + post { + call.withAuth { user -> + either { + val body = call.receive() + validateCreateFloaterList.validateOrFail(body).bind() + val list = floaterListService.create(user.id, body.name, body.color, body.iconKey).bind() + CreateFloaterListResponse(message = "floater list created", list = list) + } + } + } + + patch { + call.withAuth { user -> + either { + val body = call.receive() + validatePatchFloaterList.validateOrFail(body).bind() + floaterListService.update(user.id, body.id, body.name, body.color, body.iconKey).bind() + mapOf("message" to "floater list updated") + } + } + } + + delete { + call.withAuth { user -> + either { + val body = call.receive() + val ids = body.normalizedIds().bind() + val deletedIds = floaterListService.deleteMany(user.id, ids).bind() + DeleteFloaterListResponse( + message = if (ids.size == 1) { + if (deletedIds.isEmpty()) "floater list already deleted" else "floater list deleted" + } else { + "${deletedIds.size} floater lists deleted" + }, + deletedIds = deletedIds, + ) + } + } + } + + get("/{id}") { + call.withAuth { user -> + val listId = call.parameters["id"] + ?: return@withAuth Either.Left(AppError.BadRequest("floater list id is required")) + either { + val list = floaterListService.getById(user.id, listId).bind() + val floaters = floaterListService.getFloatersForList(user.id, listId).bind() + FloaterListDetailResponse(list = list, floaters = floaters) + } + } + } + } +} + +private fun FloaterListDeleteRequest.normalizedIds(): Either> { + val normalizedIds = buildList { + id?.trim()?.takeIf(String::isNotEmpty)?.let(::add) + ids.map(String::trim).filter(String::isNotEmpty).forEach(::add) + }.distinct() + + return if (normalizedIds.isEmpty()) { + Either.Left(AppError.BadRequest("at least one floater list id is required")) + } else { + Either.Right(normalizedIds) + } +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/FloaterRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/FloaterRoutes.kt new file mode 100644 index 00000000..1638bac0 --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/FloaterRoutes.kt @@ -0,0 +1,115 @@ +package com.ohmz.tday.routes + +import arrow.core.raise.either +import com.ohmz.tday.di.inject +import com.ohmz.tday.domain.AppError +import com.ohmz.tday.domain.validateCreateFloater +import com.ohmz.tday.domain.validateOrFail +import com.ohmz.tday.domain.withAuth +import com.ohmz.tday.models.request.* +import com.ohmz.tday.models.response.CreateFloaterResponse +import com.ohmz.tday.services.FloaterService +import io.ktor.server.request.receive +import io.ktor.server.routing.Route +import io.ktor.server.routing.delete +import io.ktor.server.routing.get +import io.ktor.server.routing.patch +import io.ktor.server.routing.post +import io.ktor.server.routing.route + +fun Route.floaterRoutes() { + val floaterService by inject() + + route("/floater") { + get { + call.withAuth { user -> + floaterService.getAll(user.id).map { mapOf("floaters" to it) } + } + } + + post { + call.withAuth { user -> + either { + val body = call.receive() + validateCreateFloater.validateOrFail(body).bind() + val floater = floaterService.create( + userId = user.id, + title = body.title, + description = body.description, + priority = body.priority, + listID = body.listID, + ).bind() + CreateFloaterResponse(message = "floater created", floater = floater) + } + } + } + + patch { + call.withAuth { user -> + either { + val body = call.receive() + val fields = mutableMapOf() + body.title?.let { fields["title"] = it } + body.description?.let { fields["description"] = it } + body.priority?.let { fields["priority"] = it } + body.pinned?.let { fields["pinned"] = it } + body.completed?.let { fields["completed"] = it } + body.listID?.let { fields["listID"] = it.takeIf { value -> value.isNotBlank() } } + floaterService.update(user.id, body.id, fields).bind() + mapOf("message" to "floater updated") + } + } + } + + delete { + call.withAuth { user -> + val body = call.receive() + if (body.id.isBlank()) { + return@withAuth arrow.core.Either.Left(AppError.BadRequest("floater id is required")) + } + floaterService.delete(user.id, body.id) + .map { count -> mapOf("message" to if (count > 0) "floater deleted" else "floater already deleted") } + } + } + + route("/complete") { + patch { + call.withAuth { user -> + val body = call.receive() + floaterService.completeFloater(user.id, body.id) + .map { mapOf("message" to "floater completed") } + } + } + } + + route("/uncomplete") { + patch { + call.withAuth { user -> + val body = call.receive() + floaterService.uncompleteFloater(user.id, body.id) + .map { mapOf("message" to "floater uncompleted") } + } + } + } + + route("/prioritize") { + patch { + call.withAuth { user -> + val body = call.receive() + floaterService.prioritize(user.id, body.id, body.priority) + .map { mapOf("message" to "priority updated") } + } + } + } + + route("/reorder") { + patch { + call.withAuth { user -> + val body = call.receive() + floaterService.reorder(user.id, body.id, body.order) + .map { mapOf("message" to "order updated") } + } + } + } + } +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/TodoRoutes.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/TodoRoutes.kt index f1744b33..4a5e9149 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/routes/TodoRoutes.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/routes/TodoRoutes.kt @@ -24,7 +24,7 @@ private const val MSG = "message" private const val TODOS = "todos" private const val SUMMARY = "summary" private const val ERR_INVALID_DUE = "due must be a valid ISO-8601 datetime" -private const val ERR_RECURRING_REQUIRES_DUE = "due is required for recurring tasks" +private const val ERR_DUE_REQUIRED = "due is required" private const val ERR_INVALID_INSTANCE_DATE = "instanceDate must be a valid ISO-8601 datetime" fun Route.todoRoutes() { @@ -51,13 +51,13 @@ private fun Route.todoCreateRoute(todoService: TodoService) { val body = call.receive() validateCreateTodo.validateOrFail(body).bind() val due = parseTodoDateTime(body.due) - if (!body.due.isNullOrBlank() && due == null) { + if (body.due.isBlank()) { + raise(AppError.BadRequest(ERR_DUE_REQUIRED)) + } + if (due == null) { raise(AppError.BadRequest(ERR_INVALID_DUE)) } val rrule = body.rrule?.takeIf { it.isNotBlank() } - if (rrule != null && due == null) { - raise(AppError.BadRequest(ERR_RECURRING_REQUIRES_DUE)) - } val todo = todoService.create(user.id, body.title, body.description, body.priority, due, rrule, body.listID).bind() CreateTodoResponse(message = "todo created", todo = todo) } @@ -100,13 +100,9 @@ private fun Route.todoPatchRoute(todoService: TodoService) { body.pinned?.let { fields["pinned"] = it } body.completed?.let { fields["completed"] = it } val requestedRrule = body.rrule?.takeIf { it.isNotBlank() } - if (body.dateChanged == true && body.due.isNullOrBlank() && requestedRrule != null) { - raise(AppError.BadRequest(ERR_RECURRING_REQUIRES_DUE)) - } if (body.dateChanged == true) { if (body.due.isNullOrBlank()) { - fields["due"] = null - fields["rrule"] = null + raise(AppError.BadRequest(ERR_DUE_REQUIRED)) } else { val parsed = parseTodoDateTime(body.due) ?: raise(AppError.BadRequest(ERR_INVALID_DUE)) diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/AdminService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/AdminService.kt index 435b0354..bfb1b31a 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/AdminService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/AdminService.kt @@ -89,8 +89,10 @@ class AdminServiceImpl : AdminService { newSuspendedTransaction(Dispatchers.IO) { CompletedTodos.deleteWhere { CompletedTodos.userID eq targetId } + CompletedFloaters.deleteWhere { CompletedFloaters.userID eq targetId } Files.deleteWhere { Files.userID eq targetId } Todos.deleteWhere { Todos.userID eq targetId } + Floaters.deleteWhere { Floaters.userID eq targetId } Lists.deleteWhere { Lists.userID eq targetId } UserPreferences.deleteWhere { UserPreferences.userID eq targetId } Users.deleteWhere { Users.id eq targetId } diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedFloaterService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedFloaterService.kt new file mode 100644 index 00000000..e4ac151c --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedFloaterService.kt @@ -0,0 +1,99 @@ +package com.ohmz.tday.services + +import arrow.core.Either +import arrow.core.right +import com.ohmz.tday.db.tables.CompletedFloaters +import com.ohmz.tday.db.tables.FloaterLists +import com.ohmz.tday.db.enums.Priority +import com.ohmz.tday.domain.AppError +import com.ohmz.tday.models.response.CompletedFloaterResponse +import com.ohmz.tday.security.FieldEncryption +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction + +interface CompletedFloaterService { + suspend fun getAll(userId: String): Either> + suspend fun deleteAll(userId: String): Either + suspend fun deleteById(userId: String, id: String): Either + suspend fun update(userId: String, id: String, fields: Map): Either +} + +class CompletedFloaterServiceImpl( + private val fieldEncryption: FieldEncryption, + private val cache: CacheService, +) : CompletedFloaterService { + override suspend fun getAll(userId: String): Either> { + val floaters = newSuspendedTransaction(Dispatchers.IO) { + CompletedFloaters.selectAll().where { CompletedFloaters.userID eq userId } + .orderBy(CompletedFloaters.completedAt, SortOrder.DESC) + .map { it.toCompletedFloaterResponse() } + } + return floaters.right() + } + + override suspend fun deleteAll(userId: String): Either { + val count = newSuspendedTransaction(Dispatchers.IO) { + CompletedFloaters.deleteWhere { CompletedFloaters.userID eq userId } + } + cache.invalidateFloaterCaches(userId) + return count.right() + } + + override suspend fun deleteById(userId: String, id: String): Either { + val count = newSuspendedTransaction(Dispatchers.IO) { + CompletedFloaters.deleteWhere { (CompletedFloaters.id eq id) and (CompletedFloaters.userID eq userId) } + } + cache.invalidateFloaterCaches(userId) + return count.right() + } + + override suspend fun update(userId: String, id: String, fields: Map): Either { + val result = newSuspendedTransaction(Dispatchers.IO) { + val requestedListId = fields["listID"] as? String + val list = requestedListId?.let { listId -> + FloaterLists.selectAll() + .where { (FloaterLists.id eq listId) and (FloaterLists.userID eq userId) } + .firstOrNull() + } + if (requestedListId != null && list == null) { + return@newSuspendedTransaction null + } + CompletedFloaters.update({ (CompletedFloaters.id eq id) and (CompletedFloaters.userID eq userId) }) { stmt -> + fields["title"]?.let { stmt[CompletedFloaters.title] = it as String } + fields["description"]?.let { + stmt[CompletedFloaters.description] = fieldEncryption.encryptIfSensitive("description", it as? String) + } + fields["priority"]?.let { stmt[CompletedFloaters.priority] = Priority.valueOf(it as String) } + if (fields.containsKey("listID")) { + stmt[CompletedFloaters.listID] = requestedListId + stmt[CompletedFloaters.listName] = list?.get(FloaterLists.name) + stmt[CompletedFloaters.listColor] = list?.get(FloaterLists.color)?.name + } + } + } + val count = result ?: return Either.Left(AppError.BadRequest("floater list not found")) + if (count > 0) cache.invalidateFloaterCaches(userId) + return count.right() + } + + private fun ResultRow.toCompletedFloaterResponse(): CompletedFloaterResponse = CompletedFloaterResponse( + id = this[CompletedFloaters.id], + originalFloaterID = this[CompletedFloaters.originalFloaterID], + title = this[CompletedFloaters.title], + description = fieldEncryption.decryptIfEncrypted(this[CompletedFloaters.description]), + priority = this[CompletedFloaters.priority].name, + completedAt = this[CompletedFloaters.completedAt].toString(), + daysToComplete = this[CompletedFloaters.daysToComplete].toDouble(), + userID = this[CompletedFloaters.userID], + listID = this[CompletedFloaters.listID], + listName = this[CompletedFloaters.listName], + listColor = this[CompletedFloaters.listColor], + ) +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt index c35f1cf0..f3a2827d 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/CompletedTodoService.kt @@ -68,7 +68,7 @@ class CompletedTodoServiceImpl( stmt[CompletedTodos.description] = fieldEncryption.encryptIfSensitive("description", it as? String) } fields["priority"]?.let { stmt[CompletedTodos.priority] = Priority.valueOf(it as String) } - if (fields.containsKey("due")) stmt[CompletedTodos.due] = fields["due"] as? LocalDateTime + (fields["due"] as? LocalDateTime)?.let { stmt[CompletedTodos.due] = it } if (fields.containsKey("rrule")) stmt[CompletedTodos.rrule] = fields["rrule"] as? String fields["listID"]?.let { listId -> stmt[CompletedTodos.listID] = listId as? String @@ -88,7 +88,7 @@ class CompletedTodoServiceImpl( description = fieldEncryption.decryptIfEncrypted(this[CompletedTodos.description]), priority = this[CompletedTodos.priority].name, completedAt = this[CompletedTodos.completedAt].toString(), - due = this[CompletedTodos.due]?.toString(), + due = this[CompletedTodos.due].toString(), completedOnTime = this[CompletedTodos.completedOnTime], daysToComplete = this[CompletedTodos.daysToComplete].toDouble(), rrule = this[CompletedTodos.rrule], diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/FloaterListService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/FloaterListService.kt new file mode 100644 index 00000000..12a22af6 --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/FloaterListService.kt @@ -0,0 +1,188 @@ +package com.ohmz.tday.services + +import arrow.core.Either +import arrow.core.right +import arrow.core.raise.either +import com.ohmz.tday.db.enums.ListColor +import com.ohmz.tday.db.tables.CompletedFloaters +import com.ohmz.tday.db.tables.FloaterLists +import com.ohmz.tday.db.tables.Floaters +import com.ohmz.tday.db.util.CuidGenerator +import com.ohmz.tday.domain.AppError +import com.ohmz.tday.models.response.FloaterListResponse +import com.ohmz.tday.models.response.FloaterListTodoResponse +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.* +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import java.time.LocalDateTime + +interface FloaterListService { + suspend fun getAll(userId: String): Either> + suspend fun getById(userId: String, listId: String): Either + suspend fun getFloatersForList(userId: String, listId: String): Either> + suspend fun create(userId: String, name: String, color: String?, iconKey: String?): Either + suspend fun update(userId: String, id: String, name: String?, color: String?, iconKey: String?): Either + suspend fun delete(userId: String, id: String): Either + suspend fun deleteMany(userId: String, ids: List): Either> = either { + ids.distinct().filter { it.isNotBlank() }.mapNotNull { id -> + val deletedCount = delete(userId, id).bind() + id.takeIf { deletedCount > 0 } + } + } +} + +class FloaterListServiceImpl(private val cache: CacheService) : FloaterListService { + override suspend fun getAll(userId: String): Either> { + val lists = newSuspendedTransaction(Dispatchers.IO) { + val counts = Floaters + .select(Floaters.listID) + .where { (Floaters.userID eq userId) and (Floaters.completed eq false) } + .mapNotNull { it[Floaters.listID] } + .groupingBy { it } + .eachCount() + + FloaterLists.selectAll().where { FloaterLists.userID eq userId } + .orderBy(FloaterLists.createdAt, SortOrder.DESC) + .map { it.toFloaterListResponse(counts[it[FloaterLists.id]] ?: 0) } + } + return lists.right() + } + + override suspend fun getById(userId: String, listId: String): Either { + val list = newSuspendedTransaction(Dispatchers.IO) { + val count = Floaters.selectAll().where { + (Floaters.userID eq userId) and + (Floaters.listID eq listId) and + (Floaters.completed eq false) + }.count().toInt() + + FloaterLists.selectAll().where { (FloaterLists.id eq listId) and (FloaterLists.userID eq userId) } + .firstOrNull()?.toFloaterListResponse(count) + } + return list?.right() ?: Either.Left(AppError.NotFound("floater list not found")) + } + + override suspend fun getFloatersForList(userId: String, listId: String): Either> { + val floaters = newSuspendedTransaction(Dispatchers.IO) { + Floaters.selectAll().where { + (Floaters.userID eq userId) and (Floaters.listID eq listId) and (Floaters.completed eq false) + }.orderBy(Floaters.priority to SortOrder.DESC, Floaters.pinned to SortOrder.DESC, Floaters.order to SortOrder.ASC) + .map { row -> + FloaterListTodoResponse( + id = row[Floaters.id], + title = row[Floaters.title], + priority = row[Floaters.priority].name, + completed = row[Floaters.completed], + order = row[Floaters.order], + ) + } + } + return floaters.right() + } + + override suspend fun create(userId: String, name: String, color: String?, iconKey: String?): Either { + val id = CuidGenerator.newCuid() + val now = LocalDateTime.now() + newSuspendedTransaction(Dispatchers.IO) { + FloaterLists.insert { + it[FloaterLists.id] = id + it[FloaterLists.name] = name + it[FloaterLists.color] = color?.let { c -> ListColor.valueOf(c) } + it[FloaterLists.iconKey] = iconKey + it[FloaterLists.userID] = userId + it[FloaterLists.createdAt] = now + it[FloaterLists.updatedAt] = now + } + } + cache.invalidateFloaterListCaches(userId) + return FloaterListResponse( + id = id, + name = name, + color = color, + iconKey = iconKey, + userID = userId, + createdAt = now.toString(), + updatedAt = now.toString(), + ).right() + } + + override suspend fun update(userId: String, id: String, name: String?, color: String?, iconKey: String?): Either { + newSuspendedTransaction(Dispatchers.IO) { + FloaterLists.update({ (FloaterLists.id eq id) and (FloaterLists.userID eq userId) }) { + name?.let { n -> it[FloaterLists.name] = n } + color?.let { c -> it[FloaterLists.color] = ListColor.valueOf(c) } + iconKey?.let { k -> it[FloaterLists.iconKey] = k } + it[FloaterLists.updatedAt] = LocalDateTime.now() + } + } + cache.invalidateFloaterListCaches(userId) + return Unit.right() + } + + override suspend fun delete(userId: String, id: String): Either = + deleteMany(userId, listOf(id)).map { it.size } + + override suspend fun deleteMany(userId: String, ids: List): Either> { + val normalizedIds = ids.map(String::trim).filter(String::isNotEmpty).distinct() + if (normalizedIds.isEmpty()) return emptyList().right() + + val deletedIds = newSuspendedTransaction(Dispatchers.IO) { + val existingIds = FloaterLists + .select(FloaterLists.id) + .where { (FloaterLists.userID eq userId) and (FloaterLists.id inList normalizedIds) } + .map { it[FloaterLists.id] } + + if (existingIds.isEmpty()) return@newSuspendedTransaction emptyList() + + val floaterIds = Floaters + .select(Floaters.id) + .where { (Floaters.userID eq userId) and (Floaters.listID inList existingIds) } + .map { it[Floaters.id] } + + if (floaterIds.isNotEmpty()) { + CompletedFloaters.deleteWhere { + SqlExpressionBuilder.run { + (CompletedFloaters.userID eq userId) and + ((CompletedFloaters.listID inList existingIds) or (CompletedFloaters.originalFloaterID inList floaterIds)) + } + } + Floaters.deleteWhere { + SqlExpressionBuilder.run { + (Floaters.userID eq userId) and (Floaters.id inList floaterIds) + } + } + } else { + CompletedFloaters.deleteWhere { + SqlExpressionBuilder.run { + (CompletedFloaters.userID eq userId) and (CompletedFloaters.listID inList existingIds) + } + } + } + + FloaterLists.deleteWhere { + SqlExpressionBuilder.run { + (FloaterLists.userID eq userId) and (FloaterLists.id inList existingIds) + } + } + existingIds + } + + if (deletedIds.isNotEmpty()) { + cache.invalidateFloaterListCaches(userId) + } + + return deletedIds.right() + } + + private fun ResultRow.toFloaterListResponse(todoCountOverride: Int = 0): FloaterListResponse = FloaterListResponse( + id = this[FloaterLists.id], + name = this[FloaterLists.name], + color = this[FloaterLists.color]?.name, + iconKey = this[FloaterLists.iconKey], + userID = this[FloaterLists.userID], + todoCount = todoCountOverride, + createdAt = this[FloaterLists.createdAt].toString(), + updatedAt = this[FloaterLists.updatedAt].toString(), + ) +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/FloaterService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/FloaterService.kt new file mode 100644 index 00000000..7b13de86 --- /dev/null +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/FloaterService.kt @@ -0,0 +1,229 @@ +package com.ohmz.tday.services + +import arrow.core.Either +import arrow.core.right +import com.ohmz.tday.db.enums.Priority +import com.ohmz.tday.db.tables.CompletedFloaters +import com.ohmz.tday.db.tables.FloaterLists +import com.ohmz.tday.db.tables.Floaters +import com.ohmz.tday.db.util.CuidGenerator +import com.ohmz.tday.domain.AppError +import com.ohmz.tday.models.response.FloaterResponse +import com.ohmz.tday.security.FieldEncryption +import kotlinx.coroutines.Dispatchers +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SortOrder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and +import org.jetbrains.exposed.sql.deleteWhere +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.update +import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.Duration +import java.time.LocalDateTime + +interface FloaterService { + suspend fun create(userId: String, title: String, description: String?, priority: String, listID: String?): Either + suspend fun getAll(userId: String): Either> + suspend fun update(userId: String, id: String, fields: Map): Either + suspend fun delete(userId: String, id: String): Either + suspend fun completeFloater(userId: String, floaterId: String): Either + suspend fun uncompleteFloater(userId: String, floaterId: String): Either + suspend fun prioritize(userId: String, floaterId: String, priority: String): Either + suspend fun reorder(userId: String, floaterId: String, newOrder: Int): Either +} + +class FloaterServiceImpl( + private val fieldEncryption: FieldEncryption, + private val cache: CacheService, +) : FloaterService { + override suspend fun create( + userId: String, + title: String, + description: String?, + priority: String, + listID: String?, + ): Either { + val id = CuidGenerator.newCuid() + val now = LocalDateTime.now() + val normalizedListID = listID?.takeIf { it.isNotBlank() } + val validList = newSuspendedTransaction(Dispatchers.IO) { + if (normalizedListID != null && !floaterListExists(userId, normalizedListID)) { + return@newSuspendedTransaction false + } + Floaters.insert { + it[Floaters.id] = id + it[Floaters.title] = title + it[Floaters.description] = fieldEncryption.encryptIfSensitive("description", description) + it[Floaters.priority] = Priority.valueOf(priority) + it[Floaters.listID] = normalizedListID + it[Floaters.userID] = userId + it[Floaters.createdAt] = now + it[Floaters.updatedAt] = now + } + true + } + if (!validList) return Either.Left(AppError.BadRequest("floater list not found")) + cache.invalidateFloaterCaches(userId) + return FloaterResponse( + id = id, + title = title, + description = description, + priority = priority, + completed = false, + pinned = false, + order = 0, + listID = normalizedListID, + userID = userId, + createdAt = now.toString(), + updatedAt = now.toString(), + ).right() + } + + override suspend fun getAll(userId: String): Either> { + val floaters = newSuspendedTransaction(Dispatchers.IO) { + Floaters.selectAll().where { + (Floaters.userID eq userId) and (Floaters.completed eq false) + } + .orderBy(Floaters.priority to SortOrder.DESC, Floaters.pinned to SortOrder.DESC, Floaters.order to SortOrder.ASC) + .map { it.toFloaterResponse() } + } + return floaters.right() + } + + override suspend fun update(userId: String, id: String, fields: Map): Either { + val validList = newSuspendedTransaction(Dispatchers.IO) { + val listId = fields["listID"] as? String + if (fields.containsKey("listID") && listId != null && !floaterListExists(userId, listId)) { + return@newSuspendedTransaction false + } + Floaters.update({ (Floaters.id eq id) and (Floaters.userID eq userId) }) { stmt -> + fields["title"]?.let { stmt[Floaters.title] = it as String } + fields["description"]?.let { + stmt[Floaters.description] = fieldEncryption.encryptIfSensitive("description", it as? String) + } + fields["priority"]?.let { stmt[Floaters.priority] = Priority.valueOf(it as String) } + fields["pinned"]?.let { stmt[Floaters.pinned] = it as Boolean } + fields["completed"]?.let { stmt[Floaters.completed] = it as Boolean } + if (fields.containsKey("listID")) stmt[Floaters.listID] = fields["listID"] as? String + stmt[Floaters.updatedAt] = LocalDateTime.now() + } + true + } + if (!validList) return Either.Left(AppError.BadRequest("floater list not found")) + cache.invalidateFloaterCaches(userId) + return Unit.right() + } + + override suspend fun delete(userId: String, id: String): Either { + val count = newSuspendedTransaction(Dispatchers.IO) { + CompletedFloaters.deleteWhere { + (CompletedFloaters.userID eq userId) and (CompletedFloaters.originalFloaterID eq id) + } + Floaters.deleteWhere { (Floaters.id eq id) and (Floaters.userID eq userId) } + } + cache.invalidateFloaterCaches(userId) + return count.right() + } + + override suspend fun completeFloater(userId: String, floaterId: String): Either { + newSuspendedTransaction(Dispatchers.IO) { + val floater = Floaters.selectAll().where { + (Floaters.id eq floaterId) and (Floaters.userID eq userId) + }.firstOrNull() ?: return@newSuspendedTransaction + + val now = LocalDateTime.now() + val daysToComplete = Duration.between(floater[Floaters.createdAt], now).toDays().toDouble() + val list = floater[Floaters.listID]?.let { listId -> + FloaterLists.selectAll().where { + (FloaterLists.id eq listId) and (FloaterLists.userID eq userId) + }.firstOrNull() + } + val existingCompleted = CompletedFloaters.selectAll().where { + (CompletedFloaters.userID eq userId) and (CompletedFloaters.originalFloaterID eq floaterId) + }.firstOrNull() + + if (existingCompleted == null) { + CompletedFloaters.insert { + it[CompletedFloaters.id] = CuidGenerator.newCuid() + it[CompletedFloaters.originalFloaterID] = floaterId + it[CompletedFloaters.title] = floater[Floaters.title] + it[CompletedFloaters.description] = floater[Floaters.description] + it[CompletedFloaters.priority] = floater[Floaters.priority] + it[CompletedFloaters.completedAt] = now + it[CompletedFloaters.daysToComplete] = BigDecimal.valueOf(daysToComplete).setScale(2, RoundingMode.HALF_UP) + it[CompletedFloaters.userID] = userId + it[CompletedFloaters.listID] = floater[Floaters.listID] + it[CompletedFloaters.listName] = list?.get(FloaterLists.name) + it[CompletedFloaters.listColor] = list?.get(FloaterLists.color)?.name + } + } + + Floaters.update({ (Floaters.id eq floaterId) and (Floaters.userID eq userId) }) { + it[Floaters.completed] = true + it[Floaters.updatedAt] = now + } + } + cache.invalidateFloaterCaches(userId) + return Unit.right() + } + + override suspend fun uncompleteFloater(userId: String, floaterId: String): Either { + newSuspendedTransaction(Dispatchers.IO) { + Floaters.update({ (Floaters.id eq floaterId) and (Floaters.userID eq userId) }) { + it[Floaters.completed] = false + it[Floaters.updatedAt] = LocalDateTime.now() + } + CompletedFloaters.deleteWhere { + (CompletedFloaters.userID eq userId) and (CompletedFloaters.originalFloaterID eq floaterId) + } + } + cache.invalidateFloaterCaches(userId) + return Unit.right() + } + + override suspend fun prioritize(userId: String, floaterId: String, priority: String): Either { + newSuspendedTransaction(Dispatchers.IO) { + Floaters.update({ (Floaters.id eq floaterId) and (Floaters.userID eq userId) }) { + it[Floaters.priority] = Priority.valueOf(priority) + it[Floaters.updatedAt] = LocalDateTime.now() + } + } + cache.invalidateFloaterCaches(userId) + return Unit.right() + } + + override suspend fun reorder(userId: String, floaterId: String, newOrder: Int): Either { + newSuspendedTransaction(Dispatchers.IO) { + Floaters.update({ (Floaters.id eq floaterId) and (Floaters.userID eq userId) }) { + it[Floaters.order] = newOrder + it[Floaters.updatedAt] = LocalDateTime.now() + } + } + cache.invalidateFloaterCaches(userId) + return Unit.right() + } + + private fun ResultRow.toFloaterResponse(): FloaterResponse = FloaterResponse( + id = this[Floaters.id], + title = this[Floaters.title], + description = fieldEncryption.decryptIfEncrypted(this[Floaters.description]), + createdAt = this[Floaters.createdAt].toString(), + updatedAt = this[Floaters.updatedAt].toString(), + userID = this[Floaters.userID], + pinned = this[Floaters.pinned], + order = this[Floaters.order], + priority = this[Floaters.priority].name, + completed = this[Floaters.completed], + listID = this[Floaters.listID], + ) + + private fun floaterListExists(userId: String, listId: String): Boolean { + return FloaterLists.selectAll().where { + (FloaterLists.id eq listId) and (FloaterLists.userID eq userId) + }.limit(1).any() + } +} diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt index 7615861b..bedc99ba 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/ListService.kt @@ -126,7 +126,6 @@ class ListServiceImpl(private val cache: CacheService) : ListService { .select(Todos.id) .where { (Todos.userID eq userId) and (Todos.listID inList existingIds) } .map { it[Todos.id] } - if (todoIds.isNotEmpty()) { CompletedTodos.deleteWhere { SqlExpressionBuilder.run { diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/MemoryCache.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/MemoryCache.kt index 71b29d5e..9e52d86d 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/MemoryCache.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/MemoryCache.kt @@ -14,6 +14,8 @@ interface CacheService { fun invalidateTodoCaches(userId: String) fun invalidateListCaches(userId: String) fun invalidateCompletedCaches(userId: String) + fun invalidateFloaterCaches(userId: String) + fun invalidateFloaterListCaches(userId: String) } class CacheServiceImpl : CacheService { @@ -69,6 +71,18 @@ class CacheServiceImpl : CacheService { invalidateUserEndpoint(userId, "completedTodo") } + override fun invalidateFloaterCaches(userId: String) { + invalidateUserEndpoint(userId, "floater") + invalidateUserEndpoint(userId, "completedFloater") + invalidateUserEndpoint(userId, "floaterList") + } + + override fun invalidateFloaterListCaches(userId: String) { + invalidateUserEndpoint(userId, "floaterList") + invalidateUserEndpoint(userId, "floater") + invalidateUserEndpoint(userId, "completedFloater") + } + private fun lazyCleanup() { val now = System.currentTimeMillis() if (now - lastCleanup < cleanupIntervalMs) return diff --git a/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt b/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt index f3fdc309..4efd23fe 100644 --- a/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt +++ b/tday-backend/src/main/kotlin/com/ohmz/tday/services/TodoService.kt @@ -27,7 +27,7 @@ import java.time.ZoneId import java.time.ZoneOffset interface TodoService { - suspend fun create(userId: String, title: String, description: String?, priority: String, due: LocalDateTime?, rrule: String?, listID: String?): Either + suspend fun create(userId: String, title: String, description: String?, priority: String, due: LocalDateTime, rrule: String?, listID: String?): Either suspend fun getByDateRange(userId: String, start: Long, end: Long, timeZone: String): Either> suspend fun getTimeline(userId: String, timeZone: String, recurringFutureDays: Int): Either> suspend fun update(userId: String, id: String, fields: Map): Either @@ -48,7 +48,7 @@ class TodoServiceImpl( override suspend fun create( userId: String, title: String, description: String?, - priority: String, due: LocalDateTime?, + priority: String, due: LocalDateTime, rrule: String?, listID: String?, ): Either { val id = CuidGenerator.newCuid() @@ -72,7 +72,7 @@ class TodoServiceImpl( cache.invalidateTodoCaches(userId) return TodoResponse( id = id, title = title, description = description, - priority = priority, due = due?.toString(), + priority = priority, due = due.toString(), rrule = rrule, timeZone = "UTC", completed = false, pinned = false, order = 0, listID = listID, userID = userId, createdAt = now.toString(), updatedAt = now.toString(), @@ -129,7 +129,7 @@ class TodoServiceImpl( fields["priority"]?.let { stmt[Todos.priority] = Priority.valueOf(it as String) } fields["pinned"]?.let { stmt[Todos.pinned] = it as Boolean } fields["completed"]?.let { stmt[Todos.completed] = it as Boolean } - if (fields.containsKey("due")) stmt[Todos.due] = fields["due"] as? LocalDateTime + (fields["due"] as? LocalDateTime)?.let { stmt[Todos.due] = it } if (fields.containsKey("rrule")) stmt[Todos.rrule] = fields["rrule"] as? String if (fields.containsKey("listID")) stmt[Todos.listID] = fields["listID"] as? String stmt[Todos.updatedAt] = LocalDateTime.now() @@ -182,7 +182,7 @@ class TodoServiceImpl( it[CompletedTodos.priority] = todo[Todos.priority] it[CompletedTodos.completedAt] = now it[CompletedTodos.due] = todoDue - it[CompletedTodos.completedOnTime] = todoDue?.let { due -> !now.isAfter(due) } + it[CompletedTodos.completedOnTime] = !now.isAfter(todoDue) it[CompletedTodos.daysToComplete] = BigDecimal.valueOf(daysToComplete).setScale(2, RoundingMode.HALF_UP) it[CompletedTodos.rrule] = todo[Todos.rrule] it[CompletedTodos.userID] = userId @@ -360,7 +360,7 @@ class TodoServiceImpl( pinned = this[Todos.pinned], order = this[Todos.order], priority = this[Todos.priority].name, - due = this[Todos.due]?.toString(), + due = this[Todos.due].toString(), rrule = this[Todos.rrule], timeZone = this[Todos.timeZone], completed = this[Todos.completed], diff --git a/tday-backend/src/main/resources/db/migration/V7__allow_unscheduled_todos.sql b/tday-backend/src/main/resources/db/migration/V7__allow_unscheduled_todos.sql deleted file mode 100644 index 154d8f35..00000000 --- a/tday-backend/src/main/resources/db/migration/V7__allow_unscheduled_todos.sql +++ /dev/null @@ -1,8 +0,0 @@ -ALTER TABLE todos - ALTER COLUMN due DROP NOT NULL; - -ALTER TABLE completedtodo - ALTER COLUMN due DROP NOT NULL; - -ALTER TABLE completedtodo - ALTER COLUMN "completedOnTime" DROP NOT NULL; diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/plugins/RateLimitingTest.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/plugins/RateLimitingTest.kt index f6f24331..5ffccee3 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/plugins/RateLimitingTest.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/plugins/RateLimitingTest.kt @@ -261,7 +261,7 @@ class RateLimitingTest { title: String, description: String?, priority: String, - due: LocalDateTime?, + due: LocalDateTime, rrule: String?, listID: String?, ): Either = unsupported() diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/FloaterRoutesTest.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/FloaterRoutesTest.kt new file mode 100644 index 00000000..630b11ef --- /dev/null +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/FloaterRoutesTest.kt @@ -0,0 +1,267 @@ +package com.ohmz.tday.routes + +import arrow.core.Either +import arrow.core.right +import com.ohmz.tday.domain.AppError +import com.ohmz.tday.plugins.AuthUserKey +import com.ohmz.tday.plugins.configureSerialization +import com.ohmz.tday.security.JwtUserClaims +import com.ohmz.tday.services.FloaterService +import com.ohmz.tday.shared.model.FloaterDto +import io.ktor.client.request.delete +import io.ktor.client.request.get +import io.ktor.client.request.patch +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.application.* +import io.ktor.server.routing.route +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.jupiter.api.Test +import org.koin.dsl.module +import org.koin.ktor.plugin.Koin +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class FloaterRoutesTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `create floater ignores schedule fields and calls floater service`() = testApplication { + val floaterService = RecordingFloaterService() + + application { + configureFloaterRoutesTestApp(floaterService) + } + + val response = client.post("/api/floater") { + contentType(ContentType.Application.Json) + setBody( + """ + { + "title": "Idea without date", + "description": "keep it loose", + "priority": "High", + "listID": "list_123", + "due": "2026-05-29T09:00:00Z", + "rrule": "RRULE:FREQ=DAILY" + } + """.trimIndent(), + ) + } + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("Idea without date", floaterService.lastCreate?.title) + assertEquals("High", floaterService.lastCreate?.priority) + assertEquals("list_123", floaterService.lastCreate?.listID) + } + + @Test + fun `list floaters returns active floater payload`() = testApplication { + val floaterService = RecordingFloaterService( + rows = mutableListOf( + FloaterDto( + id = "floater_1", + title = "Paint shelf", + priority = "Medium", + completed = false, + listID = "list_home", + ), + ), + ) + + application { + configureFloaterRoutesTestApp(floaterService) + } + + val response = client.get("/api/floater") + + assertEquals(HttpStatusCode.OK, response.status) + val payload = json.parseToJsonElement(response.bodyAsText()).jsonObject + val first = payload.getValue("floaters").jsonArray.first().jsonObject + assertEquals("floater_1", first.getValue("id").jsonPrimitive.content) + assertEquals("Paint shelf", first.getValue("title").jsonPrimitive.content) + assertFalse(first.containsKey("due")) + assertFalse(first.containsKey("rrule")) + } + + @Test + fun `patch floater updates floater fields without due semantics`() = testApplication { + val floaterService = RecordingFloaterService() + + application { + configureFloaterRoutesTestApp(floaterService) + } + + val response = client.patch("/api/floater") { + contentType(ContentType.Application.Json) + setBody( + """ + { + "id": "floater_1", + "title": "Move gently", + "priority": "Low", + "pinned": true, + "listID": "" + } + """.trimIndent(), + ) + } + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("floater_1", floaterService.lastUpdateId) + assertEquals("Move gently", floaterService.lastUpdateFields?.get("title")) + assertEquals("Low", floaterService.lastUpdateFields?.get("priority")) + assertEquals(true, floaterService.lastUpdateFields?.get("pinned")) + assertNull(floaterService.lastUpdateFields?.get("listID")) + assertFalse(floaterService.lastUpdateFields?.containsKey("due") == true) + assertFalse(floaterService.lastUpdateFields?.containsKey("rrule") == true) + } + + @Test + fun `complete and uncomplete floater use floater endpoints`() = testApplication { + val floaterService = RecordingFloaterService() + + application { + configureFloaterRoutesTestApp(floaterService) + } + + val completeResponse = client.patch("/api/floater/complete") { + contentType(ContentType.Application.Json) + setBody("""{"id":"floater_1"}""") + } + val uncompleteResponse = client.patch("/api/floater/uncomplete") { + contentType(ContentType.Application.Json) + setBody("""{"id":"floater_1"}""") + } + + assertEquals(HttpStatusCode.OK, completeResponse.status) + assertEquals(HttpStatusCode.OK, uncompleteResponse.status) + assertEquals(listOf("complete:floater_1", "uncomplete:floater_1"), floaterService.events) + } + + @Test + fun `delete floater validates id`() = testApplication { + val floaterService = RecordingFloaterService() + + application { + configureFloaterRoutesTestApp(floaterService) + } + + val response = client.delete("/api/floater") { + contentType(ContentType.Application.Json) + setBody("""{"id":""}""") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertNull(floaterService.lastDeleteId) + } + + private fun Application.configureFloaterRoutesTestApp( + floaterService: FloaterService, + ) { + install(Koin) { + modules( + module { + single { floaterService } + }, + ) + } + configureSerialization() + intercept(ApplicationCallPipeline.Plugins) { + if (call.attributes.getOrNull(AuthUserKey) == null) { + call.attributes.put( + AuthUserKey, + JwtUserClaims( + id = "user_123", + name = "Test User", + email = "user@example.com", + role = "ADMIN", + approvalStatus = "APPROVED", + timeZone = "UTC", + ), + ) + } + } + routing { + route("/api") { + floaterRoutes() + } + } + } + + private data class CreateCall( + val title: String, + val description: String?, + val priority: String, + val listID: String?, + ) + + private class RecordingFloaterService( + val rows: MutableList = mutableListOf(), + ) : FloaterService { + var lastCreate: CreateCall? = null + var lastUpdateId: String? = null + var lastUpdateFields: Map? = null + var lastDeleteId: String? = null + val events = mutableListOf() + + override suspend fun create( + userId: String, + title: String, + description: String?, + priority: String, + listID: String?, + ): Either { + lastCreate = CreateCall(title, description, priority, listID) + return FloaterDto( + id = "floater_created", + title = title, + description = description, + priority = priority, + completed = false, + listID = listID, + userID = userId, + ).right() + } + + override suspend fun getAll(userId: String): Either> = + rows.right() + + override suspend fun update(userId: String, id: String, fields: Map): Either { + lastUpdateId = id + lastUpdateFields = fields + return Unit.right() + } + + override suspend fun delete(userId: String, id: String): Either { + lastDeleteId = id + return 1.right() + } + + override suspend fun completeFloater(userId: String, floaterId: String): Either { + events += "complete:$floaterId" + return Unit.right() + } + + override suspend fun uncompleteFloater(userId: String, floaterId: String): Either { + events += "uncomplete:$floaterId" + return Unit.right() + } + + override suspend fun prioritize(userId: String, floaterId: String, priority: String): Either = + Unit.right() + + override suspend fun reorder(userId: String, floaterId: String, newOrder: Int): Either = + Unit.right() + } +} diff --git a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/TodoRoutesTest.kt b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/TodoRoutesTest.kt index 03b50a76..24734d32 100644 --- a/tday-backend/src/test/kotlin/com/ohmz/tday/routes/TodoRoutesTest.kt +++ b/tday-backend/src/test/kotlin/com/ohmz/tday/routes/TodoRoutesTest.kt @@ -67,7 +67,7 @@ class TodoRoutesTest { } @Test - fun `create todo allows missing due date`() = testApplication { + fun `create todo rejects blank due date`() = testApplication { val todoService = RecordingTodoService() application { @@ -76,23 +76,17 @@ class TodoRoutesTest { val response = client.post("/api/todo") { contentType(ContentType.Application.Json) - setBody( - json.encodeToString( - CreateTodoRequest( - title = "Anytime task", - description = null, - priority = "Low", - ), - ), - ) + setBody("""{"title":"Floater belongs elsewhere","description":null,"priority":"Low","due":""}""") } - assertEquals(HttpStatusCode.OK, response.status) + assertEquals(HttpStatusCode.BadRequest, response.status) + val payload = json.parseToJsonElement(response.bodyAsText()).jsonObject + assertEquals("due is required", payload.getValue("message").jsonPrimitive.content) assertNull(todoService.lastCreateDue) } @Test - fun `create todo requires due date for recurring tasks`() = testApplication { + fun `create todo rejects recurring task with blank due date`() = testApplication { val todoService = RecordingTodoService() application { @@ -102,21 +96,22 @@ class TodoRoutesTest { val response = client.post("/api/todo") { contentType(ContentType.Application.Json) setBody( - json.encodeToString( - CreateTodoRequest( - title = "Repeating task", - description = null, - priority = "Low", - rrule = "RRULE:FREQ=DAILY;INTERVAL=1", - ), - ), + """ + { + "title": "Repeating task", + "description": null, + "priority": "Low", + "due": "", + "rrule": "RRULE:FREQ=DAILY;INTERVAL=1" + } + """.trimIndent(), ) } assertEquals(HttpStatusCode.BadRequest, response.status) val payload = json.parseToJsonElement(response.bodyAsText()).jsonObject assertEquals( - "due is required for recurring tasks", + "due is required", payload.getValue("message").jsonPrimitive.content, ) assertNull(todoService.lastCreateDue) @@ -154,7 +149,7 @@ class TodoRoutesTest { } @Test - fun `patch todo clears due and repeat when dateChanged true and due is missing`() = testApplication { + fun `patch todo rejects due clear when dateChanged true and due is missing`() = testApplication { val todoService = RecordingTodoService() application { @@ -173,12 +168,10 @@ class TodoRoutesTest { ) } - assertEquals(HttpStatusCode.OK, response.status) - val fields = todoService.lastUpdateFields ?: error("expected update fields") - assertTrue(fields.containsKey("due")) - assertNull(fields["due"]) - assertTrue(fields.containsKey("rrule")) - assertNull(fields["rrule"]) + assertEquals(HttpStatusCode.BadRequest, response.status) + val payload = json.parseToJsonElement(response.bodyAsText()).jsonObject + assertEquals("due is required", payload.getValue("message").jsonPrimitive.content) + assertNull(todoService.lastUpdateFields) } @Test @@ -207,7 +200,7 @@ class TodoRoutesTest { assertEquals(HttpStatusCode.BadRequest, response.status) val payload = json.parseToJsonElement(response.bodyAsText()).jsonObject assertEquals( - "due is required for recurring tasks", + "due is required", payload.getValue("message").jsonPrimitive.content, ) assertNull(todoService.lastUpdateFields) @@ -258,7 +251,7 @@ class TodoRoutesTest { title: String, description: String?, priority: String, - due: LocalDateTime?, + due: LocalDateTime, rrule: String?, listID: String?, ): Either { @@ -268,7 +261,7 @@ class TodoRoutesTest { title = title, description = description, priority = priority, - due = due?.toString(), + due = due.toString(), listID = listID, completed = false, pinned = false, From df0a4d069b4eed48904745838f7701c2aba8ddbc Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 11:46:30 -0400 Subject: [PATCH 04/11] feat(ux): unify sheet styling and improve task swipe interactions across Android and Android Standardize the design and behavior of bottom sheets, dialogs, and task list interactions to ensure a consistent experience across platforms. - **Unified Sheet Framework**: - **Android**: Introduced `TdaySheetChrome.kt` with `TdaySheetDefaults` and shared components like `TdaySheetHeader`, `TdaySheetCard`, and `TdaySheetSectionTitle` to replace ad-hoc styling. - **iOS**: Introduced `TdaySheetChrome.swift` implementing the same standardized metrics, shapes, and header logic. - Refactored `CreateTaskSheet`, `ListSettingsBottomSheet`, and `HomeScreen` lists to use these new shared components. - **Task Swipe & Gesture Improvements**: - Implemented a "single-open" swipe policy: opening a swipe action on one task row automatically closes any other open swipe actions in the same list. - Updated `todoTrailingSwipeActions` (iOS) and `TaskSwipeRevealState` (Android) to synchronize state via a shared `openSwipeTaskId`. - Improved `SwipeActions` (iOS) by allowing the pan gesture to explicitly claim the "active row" status during the initial drag phase. - **UI & UX Refinements**: - **Scroll-to-Top**: Added `scrollToTopRequestID` logic to `HomeScreen` and `TodoListScreen` (Floater tab) to allow tapping the active tab icon to scroll the list to the top. - **Pull-to-Refresh**: Enhanced iOS `PullToRefresh` to better handle overscroll distances and added refresh support to `CompletedScreen`, `CalendarScreen`, and `TodoListScreen`. - **Visual Tweak**: Updated priority colors and icons for better accessibility (e.g., using `flag.fill` for high priority) and removed the "Floater" text fallback for tasks without due dates in favor of hiding the subtitle. - **List Management**: Fixed navigation logic to correctly redirect to the Floater tab when a list is deleted from its detail view. --- .../java/com/ohmz/tday/compose/TdayApp.kt | 27 +- .../feature/calendar/CalendarScreen.kt | 106 ++- .../feature/completed/CompletedScreen.kt | 73 +- .../tday/compose/feature/home/HomeScreen.kt | 349 ++++----- .../compose/feature/todos/TodoListScreen.kt | 451 +++++------- .../feature/widget/TodayTasksWidget.kt | 27 +- .../ui/component/CreateTaskBottomSheet.kt | 694 ++++++++---------- .../compose/ui/component/TdaySheetChrome.kt | 318 ++++++++ .../Tday/Feature/App/AppRootView.swift | 21 +- .../Feature/Calendar/CalendarScreen.swift | 36 +- .../Feature/Completed/CompletedScreen.swift | 17 +- .../Tday/Feature/Home/HomeScreen.swift | 399 +++++----- .../Tday/Feature/Todos/TodoListScreen.swift | 409 ++++++----- .../Tday/UI/Component/CreateTaskSheet.swift | 188 +---- .../Tday/UI/Component/PullToRefresh.swift | 71 +- .../Tday/UI/Component/SwipeActions.swift | 52 +- .../Tday/UI/Component/TdaySheetChrome.swift | 166 +++++ ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 + 18 files changed, 1895 insertions(+), 1513 deletions(-) create mode 100644 android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt create mode 100644 ios-swiftUI/Tday/UI/Component/TdaySheetChrome.swift 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 56af2099..33f5ed73 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 @@ -157,6 +157,8 @@ fun TdayApp( var isStartupSplashHeld by remember { mutableStateOf(false) } var rootFeedTab by rememberSaveable { mutableStateOf(RootFeedTab.HOME) } var rootCreateTaskRequestKey by rememberSaveable { mutableStateOf(0) } + var rootHomeScrollToTopRequestKey by remember { mutableStateOf(0) } + var rootFloaterScrollToTopRequestKey by remember { mutableStateOf(0) } var rootDockCollapsed by rememberSaveable { mutableStateOf(false) } var rootControlsVisible by rememberSaveable { mutableStateOf(true) } val snackbarHostState = remember { SnackbarHostState() } @@ -183,6 +185,17 @@ fun TdayApp( showSystemToast(context, taskDeletedToastMessage) } + fun handleRootFeedTabSelection(tab: RootFeedTab) { + if (rootFeedTab == tab) { + when (tab) { + RootFeedTab.HOME -> rootHomeScrollToTopRequestKey += 1 + RootFeedTab.FLOATER -> rootFloaterScrollToTopRequestKey += 1 + } + } else { + rootFeedTab = tab + } + } + HandleStartupNavigation( appUiState = appUiState, currentRoute = currentRoute, @@ -386,6 +399,7 @@ fun TdayApp( showRootFeedDock = false, showCreateTaskButton = false, createTaskRequestKey = rootCreateTaskRequestKey, + scrollToTopRequestKey = rootHomeScrollToTopRequestKey, onRootDockCollapsedChange = { rootDockCollapsed = it }, @@ -415,6 +429,7 @@ fun TdayApp( showCreateTaskButton = false, usesRootFeedHeader = true, createTaskRequestKey = rootCreateTaskRequestKey, + scrollToTopRequestKey = rootFloaterScrollToTopRequestKey, onRootDockCollapsedChange = { rootDockCollapsed = it }, @@ -429,7 +444,7 @@ fun TdayApp( RootFeedDock( activeTab = rootFeedTab, collapsed = rootDockCollapsed, - onTabSelected = { tab -> rootFeedTab = tab }, + onTabSelected = ::handleRootFeedTabSelection, modifier = Modifier .align(Alignment.BottomStart) .zIndex(8f), @@ -690,7 +705,13 @@ fun TdayApp( listName = listName, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, - usesRootFeedHeader = true, + onListDeleted = { + rootFeedTab = RootFeedTab.FLOATER + navController.navigate(AppRoute.Home.route) { + popUpTo(AppRoute.Home.route) { inclusive = false } + launchSingleTop = true + } + }, ) } @@ -963,6 +984,7 @@ private fun TodosRoute( showCreateTaskButton: Boolean = true, usesRootFeedHeader: Boolean = false, createTaskRequestKey: Int = 0, + scrollToTopRequestKey: Int = 0, onRootDockCollapsedChange: (Boolean) -> Unit = {}, onRootControlsVisibleChange: (Boolean) -> Unit = {}, ) { @@ -1016,6 +1038,7 @@ private fun TodosRoute( showCreateTaskButton = showCreateTaskButton, usesRootFeedHeader = usesRootFeedHeader, createTaskRequestKey = createTaskRequestKey, + scrollToTopRequestKey = scrollToTopRequestKey, onRootDockCollapsedChange = onRootDockCollapsedChange, onRootControlsVisibleChange = onRootControlsVisibleChange, ) 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 8c6d1c09..856d8d29 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 @@ -142,6 +142,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -414,6 +415,7 @@ fun CalendarScreen( remember { mutableStateMapOf() } var activeDropDateIso by remember { mutableStateOf(null) } var pendingRescheduleDrop by remember { mutableStateOf(null) } + var openSwipeTaskId by rememberSaveable { mutableStateOf(null) } LaunchedEffect(selectedViewMode) { if (selectedViewMode == CalendarViewMode.DAY) { draggedCalendarTodoId = null @@ -422,6 +424,12 @@ fun CalendarScreen( calendarDropTargetBounds.clear() } } + LaunchedEffect(uiState.items, openSwipeTaskId) { + val openId = openSwipeTaskId ?: return@LaunchedEffect + if (uiState.items.none { it.id == openId }) { + openSwipeTaskId = null + } + } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } @@ -696,6 +704,8 @@ fun CalendarScreen( onInfo = { editTargetId = todo.id }, onDelete = { onDelete(todo) }, dragging = calendarTaskRescheduleEnabled && draggedCalendarTodo?.id == todo.id, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = { openSwipeTaskId = it }, onDragStart = { position -> activeDropDateIso = null draggedCalendarTodoId = todo.id @@ -2061,13 +2071,15 @@ private fun CalendarTaskDragPreview( color = colorScheme.onSurface, maxLines = 1, ) - Text( - text = todo.due?.let(CalendarTaskDragDueTimeFormatter::format) ?: "Floater", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurfaceVariant, - maxLines = 1, - ) + todo.due?.let(CalendarTaskDragDueTimeFormatter::format)?.let { dueText -> + Text( + text = dueText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } } if (listMeta != null) { Icon( @@ -2101,6 +2113,8 @@ private fun CalendarTodoRow( onInfo: () -> Unit, onDelete: () -> Unit, dragging: Boolean, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, onDragStart: (Offset) -> Unit, onDragMove: (Offset) -> Unit, onDragEnd: (Offset?) -> Unit, @@ -2122,6 +2136,19 @@ private fun CalendarTodoRow( var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } + val latestOpenSwipeTaskId = rememberUpdatedState(openSwipeTaskId) + fun claimSwipeSlot() { + if (latestOpenSwipeTaskId.value != todo.id) { + onOpenSwipeTaskIdChange(todo.id) + } + } + + fun closeSwipeSlot() { + targetOffsetX = 0f + if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } + } val animatedOffsetX by animateFloatAsState( targetValue = targetOffsetX, animationSpec = spring(stiffness = Spring.StiffnessLow), @@ -2150,7 +2177,6 @@ private fun CalendarTodoRow( ) val dueText = todo.due ?.let { DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(it) } - ?: "Floater" val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val showListIndicator = listMeta != null val priorityIcon = priorityIconFor(todo.priority) @@ -2159,6 +2185,12 @@ private fun CalendarTodoRow( val rowShape = RoundedCornerShape(16.dp) val foregroundColor = colorScheme.background val actionRevealProgress = (-animatedOffsetX / actionRevealPx).coerceIn(0f, 1f) + LaunchedEffect(openSwipeTaskId, todo.id) { + if (openSwipeTaskId != null && openSwipeTaskId != todo.id && targetOffsetX != 0f) { + targetOffsetX = 0f + swipeHinting = false + } + } Column( modifier = modifier @@ -2192,8 +2224,8 @@ private fun CalendarTodoRow( revealDelay = 0.62f, onClick = { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + closeSwipeSlot() onInfo() - targetOffsetX = 0f }, ) CalendarSwipeActionButton( @@ -2206,7 +2238,7 @@ private fun CalendarTodoRow( revealDelay = 0.04f, onClick = { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) - targetOffsetX = 0f + closeSwipeSlot() onDelete() }, ) @@ -2224,7 +2256,7 @@ private fun CalendarTodoRow( Modifier.pointerInput(todo.id) { detectDragGesturesAfterLongPress( onDragStart = { localOffset -> - targetOffsetX = 0f + closeSwipeSlot() val startPosition = rowOriginInRoot + localOffset dragPointerPosition = startPosition onDragStart(startPosition) @@ -2258,10 +2290,16 @@ private fun CalendarTodoRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> + if (delta < 0f || targetOffsetX != 0f) { + claimSwipeSlot() + } targetOffsetX = (targetOffsetX + delta).coerceIn( -maxElasticDragPx, 0f, ) + if (targetOffsetX == 0f && latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, onDragStopped = { velocity -> val flingOpen = velocity < -1450f @@ -2271,6 +2309,11 @@ private fun CalendarTodoRow( } else { 0f } + if (targetOffsetX != 0f) { + claimSwipeSlot() + } else if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, ) .clickable( @@ -2278,15 +2321,19 @@ private fun CalendarTodoRow( indication = null, ) { if (targetOffsetX != 0f) { - targetOffsetX = 0f + closeSwipeSlot() } else if (!swipeHinting && !pendingCompletion) { swipeHinting = true + claimSwipeSlot() coroutineScope.launch { targetOffsetX = -swipeHintOffsetPx delay(150) targetOffsetX = 0f delay(360) swipeHinting = false + if (latestOpenSwipeTaskId.value == todo.id && targetOffsetX == 0f) { + onOpenSwipeTaskIdChange(null) + } } } }, @@ -2319,7 +2366,7 @@ private fun CalendarTodoRow( enabled = !pendingCompletion, onClick = { ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) - targetOffsetX = 0f + closeSwipeSlot() localChecked = true pendingCompletion = true coroutineScope.launch { @@ -2367,11 +2414,13 @@ private fun CalendarTodoRow( maxLines = 2, onTextLayout = { titleLayoutResult = it }, ) - Text( - text = dueText, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodySmall, - ) + dueText?.let { text -> + Text( + text = text, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + ) + } } if (showListIndicator || showPriorityIcon) { Row( @@ -2443,7 +2492,6 @@ private fun CalendarCompletedTodoRow( ) val dueText = item.due ?.let { DateTimeFormatter.ofPattern("h:mm a").withZone(ZoneId.systemDefault()).format(it) } - ?: "Floater" val listMeta = item.resolveListSummary(lists) val listIndicatorColor = listMeta?.color?.let(::listAccentColor) ?: item.listColor?.let(::listAccentColor) @@ -2525,11 +2573,13 @@ private fun CalendarCompletedTodoRow( }, maxLines = 2, ) - Text( - text = dueText, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodySmall, - ) + dueText?.let { text -> + Text( + text = text, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + style = MaterialTheme.typography.bodySmall, + ) + } } if (showPriorityIcon) { Row( @@ -2716,16 +2766,16 @@ private fun buildMonthCells(month: YearMonth): List { private fun priorityColor(priority: String): Color { return when (priority.lowercase(Locale.getDefault())) { - "high", "urgent", "important" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high", "urgent", "important" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { "medium" -> Icons.Rounded.Flag - "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + "high", "urgent", "important" -> Icons.Rounded.Flag else -> null } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/completed/CompletedScreen.kt index bb92698f..a387b26d 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 @@ -49,7 +49,6 @@ import androidx.compose.material.icons.rounded.Inbox import androidx.compose.material.icons.rounded.LocalBar import androidx.compose.material.icons.rounded.LocalHospital import androidx.compose.material.icons.rounded.MusicNote -import androidx.compose.material.icons.rounded.PriorityHigh import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material.icons.rounded.Restaurant import androidx.compose.material.icons.rounded.Schedule @@ -64,10 +63,12 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -160,9 +161,16 @@ fun CompletedScreen( mutableStateOf(emptySet()) } var editTargetId by rememberSaveable { mutableStateOf(null) } + var openSwipeTaskId by rememberSaveable { mutableStateOf(null) } val editTarget = remember(editTargetId, uiState.items) { editTargetId?.let { targetId -> uiState.items.firstOrNull { it.id == targetId } } } + LaunchedEffect(uiState.items, openSwipeTaskId) { + val openId = openSwipeTaskId ?: return@LaunchedEffect + if (uiState.items.none { it.id == openId }) { + openSwipeTaskId = null + } + } Scaffold( containerColor = colorScheme.background, @@ -260,6 +268,8 @@ fun CompletedScreen( onInfo = { editTargetId = completed.id }, onDelete = { onDelete(completed) }, onUncomplete = { onUncomplete(completed) }, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = { openSwipeTaskId = it }, ) } } @@ -515,6 +525,8 @@ private fun CompletedSwipeRow( onInfo: () -> Unit, onDelete: () -> Unit, onUncomplete: () -> Unit, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -522,6 +534,19 @@ private fun CompletedSwipeRow( val swipeRevealState = rememberTaskSwipeRevealState(item.id) var restorePhase by remember(item.id) { mutableStateOf(CompletedRestorePhase.Completed) } var titleLayoutResult by remember(item.id) { mutableStateOf(null) } + val latestOpenSwipeTaskId = rememberUpdatedState(openSwipeTaskId) + fun claimSwipeSlot() { + if (latestOpenSwipeTaskId.value != item.id) { + onOpenSwipeTaskIdChange(item.id) + } + } + + fun closeSwipeSlot() { + swipeRevealState.close() + if (latestOpenSwipeTaskId.value == item.id) { + onOpenSwipeTaskIdChange(null) + } + } val animatedOffsetX by animateTaskSwipeOffsetAsState( state = swipeRevealState, label = "completedSwipeOffset", @@ -582,6 +607,11 @@ private fun CompletedSwipeRow( val showPriorityIcon = priorityIcon != null val rowShape = RoundedCornerShape(16.dp) val foregroundColor = colorScheme.background + LaunchedEffect(openSwipeTaskId, item.id) { + if (openSwipeTaskId != null && openSwipeTaskId != item.id && swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } + } Column( modifier = modifier @@ -594,11 +624,11 @@ private fun CompletedSwipeRow( }, verticalArrangement = Arrangement.spacedBy(4.dp), ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(CompletedSwipeRowHeight), - ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(CompletedSwipeRowHeight), + ) { Row( modifier = Modifier .align(Alignment.CenterEnd) @@ -619,8 +649,8 @@ private fun CompletedSwipeRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) + closeSwipeSlot() onInfo() - swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -636,8 +666,8 @@ private fun CompletedSwipeRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) + closeSwipeSlot() onDelete() - swipeRevealState.close() }, ) } @@ -649,10 +679,21 @@ private fun CompletedSwipeRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> + if (delta < 0f || swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } swipeRevealState.dragBy(delta) + if (!swipeRevealState.isOpenOrDragging && latestOpenSwipeTaskId.value == item.id) { + onOpenSwipeTaskIdChange(null) + } }, onDragStopped = { velocity -> swipeRevealState.settle(velocity) + if (swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } else if (latestOpenSwipeTaskId.value == item.id) { + onOpenSwipeTaskIdChange(null) + } }, ) .clickable( @@ -660,10 +701,14 @@ private fun CompletedSwipeRow( indication = null, ) { if (swipeRevealState.isOpenOrDragging) { - swipeRevealState.close() + closeSwipeSlot() } else if (!swipeRevealState.isHinting && !isRestoring) { + claimSwipeSlot() coroutineScope.launch { swipeRevealState.playHint() + if (latestOpenSwipeTaskId.value == item.id && !swipeRevealState.isOpenOrDragging) { + onOpenSwipeTaskIdChange(null) + } } } }, @@ -695,7 +740,7 @@ private fun CompletedSwipeRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) - swipeRevealState.close() + closeSwipeSlot() coroutineScope.launch { restorePhase = CompletedRestorePhase.Unchecked delay(COMPLETED_RESTORE_STEP_MS) @@ -857,16 +902,16 @@ private fun EmptyCompletedState( @Composable private fun priorityColor(priority: String): Color { return when (priority.lowercase()) { - "high", "urgent", "important" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high", "urgent", "important" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { "medium" -> Icons.Rounded.Flag - "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + "high", "urgent", "important" -> Icons.Rounded.Flag else -> null } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 31c94791..33307a5d 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 @@ -34,7 +34,6 @@ 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.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -157,6 +156,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -176,7 +176,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput @@ -212,7 +211,12 @@ import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.component.RootFeedDock import com.ohmz.tday.compose.ui.component.RootFeedTab +import com.ohmz.tday.compose.ui.component.TdayCenteredSheetContent import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox +import com.ohmz.tday.compose.ui.component.TdaySheetCard +import com.ohmz.tday.compose.ui.component.TdaySheetDefaults +import com.ohmz.tday.compose.ui.component.TdaySheetHeader +import com.ohmz.tday.compose.ui.component.TdaySheetSectionTitle import com.ohmz.tday.compose.ui.theme.TdayDimens import com.ohmz.tday.compose.ui.theme.TdayFontFamily import kotlinx.coroutines.delay @@ -249,6 +253,7 @@ fun HomeScreen( showRootFeedDock: Boolean = true, showCreateTaskButton: Boolean = true, createTaskRequestKey: Int = 0, + scrollToTopRequestKey: Int = 0, onRootDockCollapsedChange: (Boolean) -> Unit = {}, onRootControlsVisibleChange: (Boolean) -> Unit = {}, ) { @@ -273,6 +278,7 @@ fun HomeScreen( var searchResultsBounds by remember { mutableStateOf(null) } var rootInRoot by remember { mutableStateOf(Offset.Zero) } var showCreateTask by rememberSaveable { mutableStateOf(false) } + var openSwipeTaskId by rememberSaveable { mutableStateOf(null) } var lastHandledCreateTaskRequestKey by rememberSaveable { mutableIntStateOf(createTaskRequestKey) } @@ -379,6 +385,19 @@ fun HomeScreen( LaunchedEffect(dockCollapsed) { onRootDockCollapsedChange(dockCollapsed) } + LaunchedEffect(scrollToTopRequestKey) { + if (scrollToTopRequestKey <= 0) return@LaunchedEffect + closeSearch() + if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + listState.animateScrollToItem(index = 0, scrollOffset = 0) + } + } + LaunchedEffect(uiState.todayTodos, openSwipeTaskId) { + val openId = openSwipeTaskId ?: return@LaunchedEffect + if (uiState.todayTodos.none { it.id == openId }) { + openSwipeTaskId = null + } + } LaunchedEffect(listState.isScrollInProgress, searchExpanded) { if (searchExpanded || listState.isScrollInProgress) return@LaunchedEffect // Snap only when top header row is partially visible. @@ -528,6 +547,8 @@ fun HomeScreen( onComplete = { onCompleteTask(todo) }, onDelete = { onDeleteTask(todo) }, onEdit = { editTargetTodoId = todo.id }, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = { openSwipeTaskId = it }, ) } @@ -708,9 +729,18 @@ fun HomeScreen( activeTab = RootFeedTab.HOME, collapsed = dockCollapsed, onTabSelected = { tab -> - if (tab == RootFeedTab.FLOATER) { - closeSearch() - onOpenFloater() + when (tab) { + RootFeedTab.HOME -> { + searchResultScope.launch { + closeSearch() + listState.animateScrollToItem(index = 0, scrollOffset = 0) + } + } + + RootFeedTab.FLOATER -> { + closeSearch() + onOpenFloater() + } } }, modifier = Modifier @@ -824,17 +854,9 @@ private fun CreateListBottomSheet( ), label = "createListSheetHeight", ) - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val sheetContainerColor = if (isDarkTheme) { - lerp(colorScheme.background, colorScheme.surfaceVariant, 0.34f) - } else { - colorScheme.background - } - val sheetScrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.40f) - } + val sheetContainerColor = TdaySheetDefaults.containerColor() + val sheetScrimColor = TdaySheetDefaults.scrimColor() + val sheetTonalElevation = TdaySheetDefaults.tonalElevation() LaunchedEffect(Unit) { sheetVisible = true @@ -895,22 +917,27 @@ private fun CreateListBottomSheet( interactionSource = remember { MutableInteractionSource() }, indication = null, ) {}, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), + shape = TdaySheetDefaults.TopShape, color = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, + tonalElevation = sheetTonalElevation, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 18.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - ListSheetHeader( - onClose = { + TdayCenteredSheetContent { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TdaySheetHeader( + title = stringResource(R.string.home_new_list), + leftIcon = Icons.Rounded.Close, + leftContentDescription = stringResource(R.string.action_close), + onLeftClick = { dismissKeyboard() onDismiss() }, + confirmContentDescription = stringResource(R.string.action_create_list), onConfirm = { dismissKeyboard() if (canCreate) onCreate() @@ -918,7 +945,7 @@ private fun CreateListBottomSheet( confirmEnabled = canCreate, ) - ListSheetCard { + TdaySheetCard { Column( modifier = Modifier .fillMaxWidth() @@ -959,7 +986,7 @@ private fun CreateListBottomSheet( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(16.dp)) - .background(colorScheme.surfaceVariant) + .background(TdaySheetDefaults.controlSurfaceColor()) .padding(horizontal = 14.dp, vertical = 12.dp), contentAlignment = Alignment.Center, ) { @@ -978,8 +1005,8 @@ private fun CreateListBottomSheet( } } - ListSheetSectionTitle(stringResource(R.string.home_section_color)) - ListSheetCard { + TdaySheetSectionTitle(stringResource(R.string.home_section_color)) + TdaySheetCard { Row( modifier = Modifier .fillMaxWidth() @@ -1014,8 +1041,8 @@ private fun CreateListBottomSheet( } } - ListSheetSectionTitle(stringResource(R.string.home_section_icon)) - ListSheetCard { + TdaySheetSectionTitle(stringResource(R.string.home_section_icon)) + TdaySheetCard { Row( modifier = Modifier .fillMaxWidth() @@ -1036,7 +1063,7 @@ private fun CreateListBottomSheet( if (selected) { selectedAccent.copy(alpha = 0.2f) } else { - colorScheme.surfaceVariant + TdaySheetDefaults.controlSurfaceColor() }, ) .border( @@ -1064,6 +1091,7 @@ private fun CreateListBottomSheet( } Spacer(Modifier.height(4.dp)) + } } } } @@ -1071,133 +1099,6 @@ private fun CreateListBottomSheet( } } -@Composable -private fun ListSheetHeader( - onClose: () -> Unit, - onConfirm: () -> Unit, - confirmEnabled: Boolean, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - ListSheetActionButton( - icon = Icons.Rounded.Close, - contentDescription = stringResource(R.string.action_close), - enabled = true, - accentColor = Color(0xFFE35A5A), - onClick = onClose, - ) - - Text( - text = stringResource(R.string.home_new_list), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.ExtraBold, - ) - - ListSheetActionButton( - icon = Icons.Rounded.Check, - contentDescription = stringResource(R.string.action_create_list), - enabled = confirmEnabled, - accentColor = Color(0xFF2FA35B), - onClick = onConfirm, - ) - } -} - -@Composable -private fun ListSheetActionButton( - icon: ImageVector, - contentDescription: String, - enabled: Boolean, - accentColor: Color, - onClick: () -> Unit, -) { - val view = LocalView.current - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed && enabled) 0.93f else 1f, - label = "listSheetHeaderButtonScale", - ) - val offsetY by animateDpAsState( - targetValue = if (pressed && enabled) 2.dp else 0.dp, - label = "listSheetHeaderButtonOffsetY", - ) - val containerColor = colorScheme.surfaceVariant - val iconTint = colorScheme.onBackground.copy(alpha = if (enabled) 1f else 0.55f) - val borderColor = if (enabled) { - accentColor.copy(alpha = 0.55f) - } else { - accentColor.copy(alpha = 0.3f) - } - - Card( - modifier = Modifier - .size(TdayDimens.FabSize) - .offset(y = offsetY) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .border( - width = 1.5.dp, - color = borderColor, - shape = RoundedCornerShape(999.dp), - ), - onClick = { - if (enabled) performGentleHaptic(view) - onClick() - }, - enabled = enabled, - interactionSource = interactionSource, - shape = RoundedCornerShape(999.dp), - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation( - defaultElevation = if (enabled) TdayDimens.FabElevation else 0.dp, - pressedElevation = if (enabled) TdayDimens.FabPressedElevation else 0.dp, - ), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = iconTint, - modifier = Modifier.size(22.dp), - ) - } - } -} - -@Composable -private fun ListSheetSectionTitle(text: String) { - Text( - text = text, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), - ) -} - -@Composable -private fun ListSheetCard(content: @Composable ColumnScope.() -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column(modifier = Modifier.fillMaxWidth(), content = content) - } -} - @Composable private fun CreateTaskButton( modifier: Modifier, @@ -1595,17 +1496,6 @@ private fun HomeTodayCard( 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() @@ -1613,26 +1503,18 @@ private fun HomeTodayCard( 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 = dateLabel, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.titleLarge, + color = Color.White, + fontFamily = TdayFontFamily, + fontSize = 22.sp, + fontWeight = FontWeight.ExtraBold, + lineHeight = 28.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) Text( text = count.toString(), style = MaterialTheme.typography.headlineLarge, @@ -1656,6 +1538,8 @@ private fun HomeTodayTaskRow( onComplete: () -> Unit, onDelete: () -> Unit, onEdit: () -> Unit, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -1666,6 +1550,19 @@ private fun HomeTodayTaskRow( 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 latestOpenSwipeTaskId = rememberUpdatedState(openSwipeTaskId) + fun claimSwipeSlot() { + if (latestOpenSwipeTaskId.value != todo.id) { + onOpenSwipeTaskIdChange(todo.id) + } + } + + fun closeSwipeSlot() { + swipeRevealState.close() + if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } + } val animatedOffsetX by animateTaskSwipeOffsetAsState( state = swipeRevealState, label = "homeTodaySwipeOffset", @@ -1686,7 +1583,7 @@ private fun HomeTodayTaskRow( label = "homeTodayTitleStrikeProgress", ) val actionRevealProgress = swipeRevealState.revealProgress(animatedOffsetX) - val dueText = todo.due?.let(HOME_TODAY_DUE_FORMATTER::format).orEmpty() + val dueText = todo.due?.let(HOME_TODAY_DUE_FORMATTER::format) val rowShape = RoundedCornerShape(16.dp) val listMeta = todo.listId?.let { listId -> lists.firstOrNull { it.id == listId } } val listIndicatorColor = listColorAccent(listMeta?.color) @@ -1696,10 +1593,17 @@ private fun HomeTodayTaskRow( 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) + val subtitleText = dueText?.let { text -> + if (isOverdue) { + stringResource(R.string.todos_due_overdue_text, text) + } else { + stringResource(R.string.todos_due_text, text) + } + } + LaunchedEffect(openSwipeTaskId, todo.id) { + if (openSwipeTaskId != null && openSwipeTaskId != todo.id && swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } } Column( @@ -1735,8 +1639,8 @@ private fun HomeTodayTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK ) + closeSwipeSlot() onEdit() - swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -1752,8 +1656,8 @@ private fun HomeTodayTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK ) + closeSwipeSlot() onDelete() - swipeRevealState.close() }, ) } @@ -1765,10 +1669,21 @@ private fun HomeTodayTaskRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> + if (delta < 0f || swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } swipeRevealState.dragBy(delta) + if (!swipeRevealState.isOpenOrDragging && latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, onDragStopped = { velocity -> swipeRevealState.settle(velocity) + if (swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } else if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, ) .clickable( @@ -1776,10 +1691,14 @@ private fun HomeTodayTaskRow( indication = null, ) { if (swipeRevealState.isOpenOrDragging) { - swipeRevealState.close() + closeSwipeSlot() } else if (!swipeRevealState.isHinting && !pendingCompletion) { + claimSwipeSlot() coroutineScope.launch { swipeRevealState.playHint() + if (latestOpenSwipeTaskId.value == todo.id && !swipeRevealState.isOpenOrDragging) { + onOpenSwipeTaskIdChange(null) + } } } }, @@ -1805,7 +1724,7 @@ private fun HomeTodayTaskRow( enabled = !pendingCompletion, ) { if (!pendingCompletion) { - swipeRevealState.close() + closeSwipeSlot() localChecked = true pendingCompletion = true coroutineScope.launch { @@ -1873,15 +1792,17 @@ private fun HomeTodayTaskRow( 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, - ) + subtitleText?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + fontFamily = TdayFontFamily, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + lineHeight = 18.sp, + color = subtitleColor, + ) + } } if (listMeta != null || priorityIcon != null) { @@ -2359,16 +2280,16 @@ private fun performGentleHaptic(view: android.view.View) { @Composable private fun priorityColor(priority: String): Color { return when (priority.lowercase(Locale.getDefault())) { - "high", "urgent", "important" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high", "urgent", "important" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { "medium" -> Icons.Rounded.Flag - "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + "high", "urgent", "important" -> Icons.Rounded.Flag else -> null } } 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 c14f7f3b..82af95a4 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 @@ -142,7 +142,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -157,6 +156,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos @@ -224,7 +224,12 @@ import com.ohmz.tday.compose.core.ui.rememberTaskSwipeRevealState import com.ohmz.tday.compose.ui.component.CreateTaskBottomSheet import com.ohmz.tday.compose.ui.component.RootFeedDock import com.ohmz.tday.compose.ui.component.RootFeedTab +import com.ohmz.tday.compose.ui.component.TdayModalBottomSheet import com.ohmz.tday.compose.ui.component.TdayPullToRefreshBox +import com.ohmz.tday.compose.ui.component.TdaySheetCard +import com.ohmz.tday.compose.ui.component.TdaySheetDefaults +import com.ohmz.tday.compose.ui.component.TdaySheetHeader +import com.ohmz.tday.compose.ui.component.TdaySheetSectionTitle import com.ohmz.tday.compose.ui.theme.TdayDimens import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -286,6 +291,7 @@ fun TodoListScreen( showCreateTaskButton: Boolean = true, usesRootFeedHeader: Boolean = false, createTaskRequestKey: Int = 0, + scrollToTopRequestKey: Int = 0, onRootDockCollapsedChange: (Boolean) -> Unit = {}, onRootControlsVisibleChange: (Boolean) -> Unit = {}, ) { @@ -296,11 +302,13 @@ fun TodoListScreen( val selectedListColorKey = selectedList?.color val usesTodayStyle = uiState.mode == TodoListMode.TODAY || uiState.mode == TodoListMode.OVERDUE || uiState.mode == TodoListMode.SCHEDULED || uiState.mode == TodoListMode.ALL || uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.FLOATER || uiState.mode == TodoListMode.LIST - val isFloaterScreen = uiState.mode == TodoListMode.FLOATER || uiState.title.trim() == "Floater" val isRootFloaterScreen = uiState.mode == TodoListMode.FLOATER && uiState.listId.isNullOrBlank() + val isListDetailScreen = + uiState.mode == TodoListMode.LIST || + (uiState.mode == TodoListMode.FLOATER && !uiState.listId.isNullOrBlank()) val usesRootFeedChrome = - usesRootFeedHeader || isFloaterScreen + usesRootFeedHeader || isRootFloaterScreen val titleColor = modeAccentColor( mode = uiState.mode, listColorKey = selectedListColorKey, @@ -395,6 +403,9 @@ fun TodoListScreen( uiState.mode == TodoListMode.PRIORITY || uiState.mode == TodoListMode.LIST var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } + var openSwipeTaskId by rememberSaveable(uiState.mode, uiState.listId) { + mutableStateOf(null) + } var rootFloaterSearchExpanded by rememberSaveable { mutableStateOf(false) } var rootFloaterSearchQuery by rememberSaveable { mutableStateOf("") } val normalizedRootFloaterSearchQuery = remember(rootFloaterSearchQuery) { @@ -435,6 +446,19 @@ fun TodoListScreen( rootFloaterSearchExpanded = false rootFloaterSearchQuery = "" } + LaunchedEffect(scrollToTopRequestKey) { + if (scrollToTopRequestKey <= 0 || !isRootFloaterScreen) return@LaunchedEffect + closeRootFloaterSearch() + if (listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0) { + listState.animateScrollToItem(index = 0, scrollOffset = 0) + } + } + LaunchedEffect(uiState.items, openSwipeTaskId) { + val openId = openSwipeTaskId ?: return@LaunchedEffect + if (uiState.items.none { it.id == openId }) { + openSwipeTaskId = null + } + } var lastHandledCreateTaskRequestKey by rememberSaveable { mutableStateOf(createTaskRequestKey) } @@ -519,7 +543,7 @@ fun TodoListScreen( uiState.mode != TodoListMode.OVERDUE && uiState.mode != TodoListMode.FLOATER && uiState.aiSummaryEnabled - val showTopBarActionButton = canSummarizeCurrentMode || uiState.mode == TodoListMode.LIST + val showTopBarActionButton = canSummarizeCurrentMode || isListDetailScreen val fabPressed by fabInteractionSource.collectIsPressedAsState() val fabScale by animateFloatAsState( targetValue = if (fabPressed) 0.93f else 1f, @@ -1043,6 +1067,8 @@ fun TodoListScreen( editTargetTodoId = todo.id }, draggedTodo = sectionDraggedTodo, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = { openSwipeTaskId = it }, onDragTodoStart = if (canRescheduleTasks) { { position -> activeDropSectionKey = null @@ -1153,7 +1179,16 @@ fun TodoListScreen( RootFeedDock( activeTab = rootFeedTab, collapsed = dockCollapsed, - onTabSelected = onRootFeedTabSelected, + onTabSelected = { tab -> + if (tab == rootFeedTab && isRootFloaterScreen) { + screenScope.launch { + closeRootFloaterSearch() + listState.animateScrollToItem(index = 0, scrollOffset = 0) + } + } else { + onRootFeedTabSelected(tab) + } + }, modifier = Modifier .align(Alignment.BottomStart) .zIndex(8f), @@ -1320,7 +1355,7 @@ fun TodoListScreen( val selectedListId = listSettingsTargetId ?: uiState.listId if ( showListSettingsSheet && - uiState.mode == TodoListMode.LIST && + isListDetailScreen && !selectedListId.isNullOrBlank() ) { ListSettingsBottomSheet( @@ -1361,7 +1396,7 @@ fun TodoListScreen( val deleteConfirmationListId = selectedListId if ( showDeleteListConfirmation && - uiState.mode == TodoListMode.LIST && + isListDetailScreen && !deleteConfirmationListId.isNullOrBlank() ) { ListDeleteConfirmationDialog( @@ -1382,17 +1417,8 @@ private fun ListDeleteConfirmationDialog( ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val dialogContainerColor = if (isDarkTheme) { - colorScheme.surface.copy(alpha = 0.98f) - } else { - colorScheme.surface - } - val scrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.36f) - } + val dialogContainerColor = TdaySheetDefaults.surfaceColor() + val scrimColor = TdaySheetDefaults.scrimColor() Dialog( onDismissRequest = onDismissRequest, @@ -1419,7 +1445,8 @@ private fun ListDeleteConfirmationDialog( indication = null, onClick = {}, ), - shape = RoundedCornerShape(30.dp), + shape = TdaySheetDefaults.OverlayShape, + border = BorderStroke(1.dp, TdaySheetDefaults.cardStrokeColor()), colors = CardDefaults.cardColors(containerColor = dialogContainerColor), elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), ) { @@ -2066,22 +2093,10 @@ private fun SummaryBottomSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val colorScheme = MaterialTheme.colorScheme - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val sheetContainerColor = if (isDarkTheme) colorScheme.surface else colorScheme.background - val sheetScrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.40f) - } - ModalBottomSheet( + TdayModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - dragHandle = null, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), - containerColor = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, - scrimColor = sheetScrimColor, ) { Column( modifier = Modifier @@ -2090,25 +2105,13 @@ private fun SummaryBottomSheet( .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.todos_summary_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = colorScheme.onBackground, - ) - IconButton(onClick = onDismiss) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(R.string.todos_summary_close), - tint = colorScheme.onBackground, - ) - } - } + TdaySheetHeader( + title = stringResource(R.string.todos_summary_title), + leftIcon = Icons.Rounded.Close, + leftContentDescription = stringResource(R.string.todos_summary_close), + onLeftClick = onDismiss, + showConfirmAction = false, + ) if (isLoading) { Row( @@ -2129,11 +2132,8 @@ private fun SummaryBottomSheet( } if (!summaryText.isNullOrBlank()) { - Card( + TdaySheetCard( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(18.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { Text( modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), @@ -2215,22 +2215,10 @@ private fun ListSettingsBottomSheet( val selectedAccent = listAccentColor(listColor) val selectedIcon = listIconForKey(listIconKey) val canSave = listName.isNotBlank() - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val sheetContainerColor = if (isDarkTheme) colorScheme.surface else colorScheme.background - val sheetScrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.40f) - } - ModalBottomSheet( + TdayModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, - dragHandle = null, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), - containerColor = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, - scrimColor = sheetScrimColor, ) { Box( modifier = Modifier @@ -2244,54 +2232,26 @@ private fun ListSettingsBottomSheet( .padding(horizontal = 18.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - ListSettingsActionButton( - icon = Icons.Rounded.Close, - contentDescription = stringResource(R.string.action_close), - enabled = true, - accentColor = Color(0xFFE35A5A), - onClick = { - focusManager.clearFocus(force = true) - onDismiss() - }, - ) - - Text( - text = title, - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onBackground, - fontWeight = FontWeight.ExtraBold, - ) - - ListSettingsActionButton( - icon = Icons.Rounded.Check, - contentDescription = stringResource(R.string.todos_save_list_settings), - enabled = canSave, - accentColor = Color(0xFF2FA35B), - onClick = { - focusManager.clearFocus(force = true) - if (canSave) onSave() - }, - ) - } + TdaySheetHeader( + title = title, + leftIcon = Icons.Rounded.Close, + leftContentDescription = stringResource(R.string.action_close), + onLeftClick = { + focusManager.clearFocus(force = true) + onDismiss() + }, + confirmContentDescription = stringResource(R.string.todos_save_list_settings), + onConfirm = { + focusManager.clearFocus(force = true) + if (canSave) onSave() + }, + confirmEnabled = canSave, + ) - Text( + TdaySheetSectionTitle( text = stringResource(R.string.home_section_list), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { + TdaySheetCard { Column( modifier = Modifier .fillMaxWidth() @@ -2352,7 +2312,8 @@ private fun ListSettingsBottomSheet( modifier = Modifier .fillMaxWidth() .background( - colorScheme.surfaceVariant, RoundedCornerShape(16.dp) + TdaySheetDefaults.controlSurfaceColor(), + RoundedCornerShape(16.dp) ) .padding(horizontal = 14.dp, vertical = 12.dp), contentAlignment = Alignment.Center, @@ -2378,19 +2339,10 @@ private fun ListSettingsBottomSheet( } } - Text( + TdaySheetSectionTitle( text = stringResource(R.string.home_section_color), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { + TdaySheetCard { Row( modifier = Modifier .fillMaxWidth() @@ -2432,19 +2384,10 @@ private fun ListSettingsBottomSheet( } } - Text( + TdaySheetSectionTitle( text = stringResource(R.string.home_section_icon), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.ExtraBold, - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { + TdaySheetCard { Row( modifier = Modifier .fillMaxWidth() @@ -2463,7 +2406,7 @@ private fun ListSettingsBottomSheet( color = if (selected) { selectedAccent.copy(alpha = 0.2f) } else { - colorScheme.surfaceVariant + TdaySheetDefaults.controlSurfaceColor() }, shape = CircleShape, ) @@ -2533,7 +2476,11 @@ private fun ListSettingsDeleteButton( interactionSource = interactionSource, shape = RoundedCornerShape(24.dp), border = BorderStroke(1.5.dp, colorScheme.error.copy(alpha = 0.45f)), - colors = CardDefaults.cardColors(containerColor = colorScheme.errorContainer.copy(alpha = 0.22f)), + colors = CardDefaults.cardColors( + containerColor = colorScheme.error.copy( + alpha = if (TdaySheetDefaults.isDarkTheme()) 0.14f else 0.04f, + ), + ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp, pressedElevation = 0.dp), ) { Row( @@ -2558,78 +2505,6 @@ private fun ListSettingsDeleteButton( } } -@Composable -private fun ListSettingsActionButton( - icon: ImageVector, - contentDescription: String, - enabled: Boolean, - accentColor: Color, - onClick: () -> Unit, -) { - val view = LocalView.current - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed && enabled) 0.93f else 1f, - label = "listSettingsHeaderButtonScale", - ) - val elevation by animateDpAsState( - targetValue = when { - pressed && enabled -> 2.dp - enabled -> 8.dp - else -> 5.dp - }, - label = "listSettingsHeaderButtonElevation", - ) - val offsetY by animateDpAsState( - targetValue = if (pressed && enabled) 1.dp else 0.dp, - label = "listSettingsHeaderButtonOffsetY", - ) - val iconTint = colorScheme.onBackground.copy(alpha = if (enabled) 1f else 0.55f) - - Card( - modifier = Modifier - .size(54.dp) - .offset(y = offsetY) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .border( - width = 1.5.dp, - color = accentColor.copy(alpha = if (enabled) 0.55f else 0.3f), - shape = CircleShape, - ), - onClick = { - if (enabled) { - ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) - } - onClick() - }, - enabled = enabled, - interactionSource = interactionSource, - shape = CircleShape, - colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation( - defaultElevation = elevation, - pressedElevation = elevation, - ), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = iconTint, - modifier = Modifier.size(22.dp), - ) - } - } -} - @Composable private fun TimelineSectionHeader( modifier: Modifier = Modifier, @@ -2819,13 +2694,15 @@ private fun TimelineTaskDragPreview( color = colorScheme.onSurface, maxLines = 1, ) - Text( - text = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Floater", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.SemiBold, - color = colorScheme.onSurfaceVariant, - maxLines = 1, - ) + todo.due?.let(TODO_DUE_TIME_FORMATTER::format)?.let { dueText -> + Text( + text = dueText, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } } if (showListIndicator) { Icon( @@ -2861,6 +2738,8 @@ private fun TimelineTaskRow( onDelete: () -> Unit, onInfo: () -> Unit, draggedTodo: TodoItem? = null, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, onDragTodoStart: ((Offset) -> Unit)? = null, onDragTodoMove: (Offset) -> Unit = {}, onDragTodoEnd: (Offset?) -> Unit = {}, @@ -2886,6 +2765,8 @@ private fun TimelineTaskRow( onDragMove = onDragTodoMove, onDragEnd = onDragTodoEnd, onDragCancel = onDragTodoCancel, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = onOpenSwipeTaskIdChange, ) } else if ( useMinimalStyle && @@ -2915,6 +2796,8 @@ private fun TimelineTaskRow( onDragMove = onDragTodoMove, onDragEnd = onDragTodoEnd, onDragCancel = onDragTodoCancel, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = onOpenSwipeTaskIdChange, ) } else if (useMinimalStyle) { TodayTodoRow( @@ -3547,6 +3430,8 @@ private fun AllTaskSwipeRow( onDragMove: (Offset) -> Unit = {}, onDragEnd: (Offset?) -> Unit = {}, onDragCancel: () -> Unit = {}, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { SwipeTaskRow( todo = todo, @@ -3568,6 +3453,8 @@ private fun AllTaskSwipeRow( onDragMove = onDragMove, onDragEnd = onDragEnd, onDragCancel = onDragCancel, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = onOpenSwipeTaskIdChange, ) } @@ -3589,6 +3476,8 @@ private fun TodayTaskSwipeRow( onDragMove: (Offset) -> Unit = {}, onDragEnd: (Offset?) -> Unit = {}, onDragCancel: () -> Unit = {}, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { SwipeTaskRow( todo = todo, @@ -3610,6 +3499,8 @@ private fun TodayTaskSwipeRow( onDragMove = onDragMove, onDragEnd = onDragEnd, onDragCancel = onDragCancel, + openSwipeTaskId = openSwipeTaskId, + onOpenSwipeTaskIdChange = onOpenSwipeTaskIdChange, ) } @@ -3636,6 +3527,8 @@ private fun SwipeTaskRow( onDragMove: (Offset) -> Unit = {}, onDragEnd: (Offset?) -> Unit = {}, onDragCancel: () -> Unit = {}, + openSwipeTaskId: String?, + onOpenSwipeTaskIdChange: (String?) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val view = LocalView.current @@ -3648,6 +3541,19 @@ private fun SwipeTaskRow( var titleLayoutResult by remember(todo.id) { mutableStateOf(null) } var rowOriginInRoot by remember(todo.id) { mutableStateOf(Offset.Zero) } var dragPointerPosition by remember(todo.id) { mutableStateOf(null) } + val latestOpenSwipeTaskId = rememberUpdatedState(openSwipeTaskId) + fun claimSwipeSlot() { + if (latestOpenSwipeTaskId.value != todo.id) { + onOpenSwipeTaskIdChange(todo.id) + } + } + + fun closeSwipeSlot() { + swipeRevealState.close() + if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } + } val highlightAnim = remember(todo.id) { Animatable(0f) } val visuallyChecked = localChecked || (keepCompletedInline && todo.completed) val visuallyStruck = localStruck || (keepCompletedInline && todo.completed) @@ -3677,16 +3583,22 @@ private fun SwipeTaskRow( animationSpec = tween(durationMillis = 320, easing = FastOutSlowInEasing), label = "swipeTaskTitleStrikeProgress", ) - val dueTimeText = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Floater" - val dueDateTimeText = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) ?: "Floater" val isOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true - val dueBodyText = if (showDueDateInSubtitle) dueDateTimeText else dueTimeText - val dueSubtitleText = if (isOverdue) { - stringResource(R.string.todos_due_overdue_text, dueBodyText) - } else if (showDuePrefix) { - stringResource(R.string.todos_due_text, dueBodyText) - } else { - dueBodyText + val dueBodyText = todo.due?.let { + if (showDueDateInSubtitle) { + TODO_DUE_DATE_TIME_FORMATTER.format(it) + } else { + TODO_DUE_TIME_FORMATTER.format(it) + } + } + val dueSubtitleText = dueBodyText?.let { text -> + if (isOverdue) { + stringResource(R.string.todos_due_overdue_text, text) + } else if (showDuePrefix) { + stringResource(R.string.todos_due_text, text) + } else { + text + } } val rowShape = RoundedCornerShape(16.dp) val foregroundColor = colorScheme.background @@ -3726,9 +3638,14 @@ private fun SwipeTaskRow( val priorityIcon = priorityIconFor(todo.priority) val showPriorityIcon = priorityIcon != null val listIndicatorColor = listAccentColor(listMeta?.color) + LaunchedEffect(openSwipeTaskId, todo.id) { + if (openSwipeTaskId != null && openSwipeTaskId != todo.id && swipeRevealState.isOpenOrDragging) { + swipeRevealState.close() + } + } LaunchedEffect(flashHighlight) { if (!flashHighlight) return@LaunchedEffect - swipeRevealState.close() + closeSwipeSlot() highlightAnim.stop() highlightAnim.snapTo(0f) repeat(2) { pulseIndex -> @@ -3780,8 +3697,8 @@ private fun SwipeTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) + closeSwipeSlot() onInfo() - swipeRevealState.close() }, ) TaskSwipeActionButton( @@ -3797,8 +3714,8 @@ private fun SwipeTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) + closeSwipeSlot() onDelete() - swipeRevealState.close() }, ) } @@ -3815,7 +3732,7 @@ private fun SwipeTaskRow( Modifier.pointerInput(todo.id, dragEnabled) { detectDragGesturesAfterLongPress( onDragStart = { localOffset -> - swipeRevealState.close() + closeSwipeSlot() val startPosition = rowOriginInRoot + localOffset dragPointerPosition = startPosition onDragStart?.invoke(startPosition) @@ -3849,10 +3766,21 @@ private fun SwipeTaskRow( .draggable( orientation = Orientation.Horizontal, state = rememberDraggableState { delta -> + if (delta < 0f || swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } swipeRevealState.dragBy(delta) + if (!swipeRevealState.isOpenOrDragging && latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, onDragStopped = { velocity -> swipeRevealState.settle(velocity) + if (swipeRevealState.isOpenOrDragging) { + claimSwipeSlot() + } else if (latestOpenSwipeTaskId.value == todo.id) { + onOpenSwipeTaskIdChange(null) + } }, ) .clickable( @@ -3860,10 +3788,14 @@ private fun SwipeTaskRow( indication = null, ) { if (swipeRevealState.isOpenOrDragging) { - swipeRevealState.close() + closeSwipeSlot() } else if (!swipeRevealState.isHinting && !pendingCompletion && !dragging) { + claimSwipeSlot() coroutineScope.launch { swipeRevealState.playHint() + if (latestOpenSwipeTaskId.value == todo.id && !swipeRevealState.isOpenOrDragging) { + onOpenSwipeTaskIdChange(null) + } } } }, @@ -3913,7 +3845,7 @@ private fun SwipeTaskRow( view, HapticFeedbackConstantsCompat.CLOCK_TICK, ) - swipeRevealState.close() + closeSwipeSlot() localChecked = true pendingCompletion = true coroutineScope.launch { @@ -3962,7 +3894,7 @@ private fun SwipeTaskRow( maxLines = 2, onTextLayout = { titleLayoutResult = it }, ) - if (showDueText) { + if (showDueText && dueSubtitleText != null) { Text( text = dueSubtitleText, color = if (isOverdue) colorScheme.error else colorScheme.onSurfaceVariant.copy(alpha = 0.8f), @@ -4016,12 +3948,14 @@ private fun TodayTodoRow( onDelete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val dueText = todo.due?.let(TODO_DUE_TIME_FORMATTER::format) ?: "Floater" val isDetailOverdue = !todo.completed && todo.due?.isBefore(Instant.now()) == true - val detailDueText = if (isDetailOverdue) { - stringResource(R.string.todos_due_overdue_text, dueText) - } else { - dueText + val detailDueText = todo.due?.let { due -> + val dueText = TODO_DUE_TIME_FORMATTER.format(due) + if (isDetailOverdue) { + stringResource(R.string.todos_due_overdue_text, dueText) + } else { + dueText + } } Column( @@ -4058,11 +3992,15 @@ private fun TodayTodoRow( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.ExtraBold, ) - Text( - text = detailDueText, - color = if (isDetailOverdue) colorScheme.error else colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - style = MaterialTheme.typography.bodySmall, - ) + detailDueText?.let { text -> + Text( + text = text, + color = if (isDetailOverdue) colorScheme.error else colorScheme.onSurfaceVariant.copy( + alpha = 0.8f + ), + style = MaterialTheme.typography.bodySmall, + ) + } } } @@ -4090,7 +4028,7 @@ private fun TodoRow( onDelete: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val due = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) ?: "Floater" + val due = todo.due?.let(TODO_DUE_DATE_TIME_FORMATTER::format) Card( colors = CardDefaults.cardColors(containerColor = colorScheme.surfaceVariant), @@ -4124,11 +4062,13 @@ private fun TodoRow( style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.ExtraBold, ) - Text( - text = due, - color = colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) + due?.let { text -> + Text( + text = text, + color = colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + } } } @@ -4180,16 +4120,16 @@ private fun CircularCheckToggleIcon( @Composable private fun priorityColor(priority: String): Color { return when (priority.lowercase()) { - "high", "urgent", "important" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high", "urgent", "important" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } private fun priorityIconFor(priority: String): ImageVector? { return when (priority.trim().lowercase(Locale.getDefault())) { "medium" -> Icons.Rounded.Flag - "high", "urgent", "important" -> Icons.Rounded.PriorityHigh + "high", "urgent", "important" -> Icons.Rounded.Flag else -> null } } @@ -4214,7 +4154,10 @@ private fun modeAccentColor( TodoListMode.SCHEDULED -> Color(0xFFF29F38) TodoListMode.ALL -> Color(0xFF5E6878) TodoListMode.PRIORITY -> Color(0xFFE65E52) - TodoListMode.FLOATER -> Color(0xFF4D8F83) + TodoListMode.FLOATER -> listColorKey + ?.takeIf { it.isNotBlank() } + ?.let(::listAccentColor) + ?: Color(0xFF4D8F83) TodoListMode.LIST -> listAccentColor(listColorKey) } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt index ae1ee0c4..efbd0d91 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/widget/TodayTasksWidget.kt @@ -143,10 +143,9 @@ private fun TaskRow(task: CachedTodoRecord) { .withZone(ZoneId.systemDefault()) val dueText = task.dueEpochMs ?.let { timeFormatter.format(Instant.ofEpochMilli(it)) } - ?: "Floater" val priorityColor = when (task.priority.lowercase()) { - "high" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFE53935)) - "medium" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFFB8C00)) + "high" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFFF3B30)) + "medium" -> ColorProvider(androidx.compose.ui.graphics.Color(0xFFFF9500)) else -> GlanceTheme.colors.onSurfaceVariant } @@ -180,15 +179,17 @@ private fun TaskRow(task: CachedTodoRecord) { maxLines = 1, ) } - Spacer(modifier = GlanceModifier.width(6.dp)) - Text( - text = dueText, - style = TextStyle( - color = GlanceTheme.colors.onSurfaceVariant, - fontFamily = TdayWidgetFontFamily, - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - ), - ) + dueText?.let { text -> + Spacer(modifier = GlanceModifier.width(6.dp)) + Text( + text = text, + style = TextStyle( + color = GlanceTheme.colors.onSurfaceVariant, + fontFamily = TdayWidgetFontFamily, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + ), + ) + } } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt index a2a265fa..fe7e8c09 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt @@ -1,9 +1,9 @@ package com.ohmz.tday.compose.ui.component import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -13,7 +13,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -27,7 +26,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.ime 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.width @@ -74,7 +72,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector @@ -82,15 +79,12 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties -import androidx.core.view.HapticFeedbackConstantsCompat -import androidx.core.view.ViewCompat import com.ohmz.tday.compose.core.model.CreateTaskPayload import com.ohmz.tday.compose.core.model.ListSummary import com.ohmz.tday.compose.core.model.TodoItem @@ -123,8 +117,11 @@ private fun normalizePriorityValue(value: String?): String { } private const val DEFAULT_TASK_DURATION_MS = 60L * 60L * 1000L +private const val CREATE_TASK_SHEET_CREATE_HEIGHT_FRACTION = 0.74f +private const val CREATE_TASK_SHEET_FLOATER_CREATE_HEIGHT_FRACTION = 0.54f +private const val CREATE_TASK_SHEET_EDIT_HEIGHT_FRACTION = 0.76f +private const val CREATE_TASK_SHEET_FLOATER_EDIT_HEIGHT_FRACTION = 0.54f private const val CREATE_TASK_SHEET_MAX_HEIGHT_FRACTION = 0.86f -private const val CREATE_TASK_SHEET_NORMAL_HEIGHT_FRACTION = 0.70f private const val CREATE_TASK_SHEET_KEYBOARD_HEIGHT_FRACTION = 0.85f private const val CREATE_TASK_SHEET_MOTION_MS = 320 @@ -233,32 +230,38 @@ fun CreateTaskBottomSheet( } val canSubmit = title.isNotBlank() val colorScheme = MaterialTheme.colorScheme - val isDarkTheme = colorScheme.background.luminance() < 0.5f - val sheetContainerColor = if (isDarkTheme) { - lerp(colorScheme.background, colorScheme.surfaceVariant, 0.34f) - } else { - colorScheme.background - } - val sheetScrimColor = if (isDarkTheme) { - Color.Black.copy(alpha = 0.68f) - } else { - Color.Black.copy(alpha = 0.40f) - } + val sheetContainerColor = TdaySheetDefaults.containerColor() + val sheetScrimColor = TdaySheetDefaults.scrimColor() + val sheetTonalElevation = TdaySheetDefaults.tonalElevation() val screenHeight = LocalConfiguration.current.screenHeightDp.dp val density = LocalDensity.current val keyboardVisible = WindowInsets.ime.getBottom(density) > 0 val maxSheetHeight = screenHeight * CREATE_TASK_SHEET_MAX_HEIGHT_FRACTION - val sheetHeight by animateDpAsState( - targetValue = (screenHeight * if (keyboardVisible) { - CREATE_TASK_SHEET_KEYBOARD_HEIGHT_FRACTION - } else { - CREATE_TASK_SHEET_NORMAL_HEIGHT_FRACTION - }).coerceAtMost(maxSheetHeight), + val usesTallCreateModal = !isEditMode && showScheduleControls + val usesFloaterCreateModal = !isEditMode && !showScheduleControls + val usesScheduledEditModal = isEditMode && showScheduleControls + val usesFloaterEditModal = isEditMode && !showScheduleControls + val usesScrollSizedModal = usesTallCreateModal || + usesFloaterCreateModal || + usesScheduledEditModal || + usesFloaterEditModal + val createSheetHeight = (screenHeight * CREATE_TASK_SHEET_CREATE_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight) + val floaterCreateSheetHeight = (screenHeight * CREATE_TASK_SHEET_FLOATER_CREATE_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight) + val editSheetHeight = (screenHeight * CREATE_TASK_SHEET_EDIT_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight) + val floaterEditSheetHeight = (screenHeight * CREATE_TASK_SHEET_FLOATER_EDIT_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight) + val sheetFormScrollState = rememberScrollState() + val keyboardSheetHeight by animateDpAsState( + targetValue = (screenHeight * CREATE_TASK_SHEET_KEYBOARD_HEIGHT_FRACTION) + .coerceAtMost(maxSheetHeight), animationSpec = tween( durationMillis = CREATE_TASK_SHEET_MOTION_MS, easing = FastOutSlowInEasing, ), - label = "createTaskSheetHeight", + label = "createTaskKeyboardSheetHeight", ) LaunchedEffect(Unit) { @@ -329,127 +332,187 @@ fun CreateTaskBottomSheet( Surface( modifier = Modifier .fillMaxWidth() - .height(sheetHeight) + .then( + if (keyboardVisible) { + Modifier.height(keyboardSheetHeight) + } else if (usesTallCreateModal) { + Modifier.height(createSheetHeight) + } else if (usesScheduledEditModal) { + Modifier.height(editSheetHeight) + } else if (usesFloaterCreateModal) { + Modifier + .animateContentSize( + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + ) + .heightIn(min = floaterCreateSheetHeight, max = maxSheetHeight) + } else if (usesFloaterEditModal) { + Modifier + .animateContentSize( + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + ) + .heightIn(min = floaterEditSheetHeight, max = maxSheetHeight) + } else { + Modifier + .animateContentSize( + animationSpec = tween( + durationMillis = CREATE_TASK_SHEET_MOTION_MS, + easing = FastOutSlowInEasing, + ), + ) + .heightIn(max = maxSheetHeight) + }, + ) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) {}, - shape = RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp), + shape = TdaySheetDefaults.TopShape, color = sheetContainerColor, - tonalElevation = if (isDarkTheme) 10.dp else 0.dp, + tonalElevation = sheetTonalElevation, ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(start = 18.dp, top = 14.dp, end = 18.dp, bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - SheetHeader( - title = if (isEditMode) "Edit task" else "New task", - leftIcon = Icons.Rounded.Close, - leftContentDescription = "Close", - onLeftClick = { - dismissKeyboard() - onDismiss() - }, - onConfirm = { - dismissKeyboard() - if (canSubmit) { - submitTask() - } - }, - confirmEnabled = canSubmit, - ) + TdayCenteredSheetContent { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(start = 18.dp, top = 14.dp, end = 18.dp, bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TdaySheetHeader( + title = if (isEditMode) "Edit task" else "New task", + leftIcon = Icons.Rounded.Close, + leftContentDescription = "Close", + onLeftClick = { + dismissKeyboard() + onDismiss() + }, + confirmContentDescription = if (isEditMode) "Save task" else "Create task", + onConfirm = { + dismissKeyboard() + if (canSubmit) { + submitTask() + } + }, + confirmEnabled = canSubmit, + ) - TaskTextCard( - title = title, - notes = notes, - onTitleChange = { title = it }, - onNotesChange = { notes = it }, - onKeyboardDone = dismissKeyboard, - ) + val formModifier = if (keyboardVisible || usesScrollSizedModal) { + Modifier + .fillMaxWidth() + .weight(1f, fill = false) + .verticalScroll(sheetFormScrollState) + } else { + Modifier.fillMaxWidth() + } - if (showScheduleControls) { - SectionHeading("Schedule") - GroupCard { - ScheduleSwitchRow( - enabled = scheduleEnabled, - onEnabledChange = { enabled -> scheduleEnabled = enabled }, + Column( + modifier = formModifier, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + TaskTextCard( + title = title, + notes = notes, + onTitleChange = { title = it }, + onNotesChange = { notes = it }, + onKeyboardDone = dismissKeyboard, ) - AnimatedVisibility(visible = scheduleEnabled) { - Column { - RowDivider() - SplitDateTimeRow( - icon = Icons.Rounded.CalendarMonth, - title = "Due", - dateValue = dateOnlyFormatter.format( - Instant.ofEpochMilli( - dueEpochMs + + if (showScheduleControls) { + SectionHeading("Schedule") + GroupCard { + ScheduleSwitchRow( + enabled = scheduleEnabled, + onEnabledChange = { enabled -> + scheduleEnabled = enabled + }, + ) + AnimatedVisibility(visible = scheduleEnabled) { + Column { + RowDivider() + SplitDateTimeRow( + icon = Icons.Rounded.CalendarMonth, + title = "Due", + dateValue = dateOnlyFormatter.format( + Instant.ofEpochMilli( + dueEpochMs + ) + ), + timeValue = timeOnlyFormatter.format( + Instant.ofEpochMilli( + dueEpochMs + ) + ), + onDateClick = { dueDatePickerOpen = true }, + onTimeClick = { dueTimePickerOpen = true }, ) - ), - timeValue = timeOnlyFormatter.format( - Instant.ofEpochMilli( - dueEpochMs + } + } + } + } + + SectionHeading("Details") + GroupCard { + SheetDropdownRow( + icon = Icons.AutoMirrored.Rounded.List, + title = "List", + value = selectedListName, + options = listOf(null) + lists, + optionLabel = { option -> option?.name ?: "No list" }, + optionSwatchColor = { option -> + option?.let { + listColorSwatchForSelector( + raw = it.color, + fallback = colorScheme.primary.copy(alpha = 0.75f), ) - ), - onDateClick = { dueDatePickerOpen = true }, - onTimeClick = { dueTimePickerOpen = true }, + } ?: colorScheme.outlineVariant.copy(alpha = 0.95f) + }, + isSelected = { option -> option?.id == selectedListId }, + onOptionSelected = { option -> + selectedListId = option?.id + }, + ) + RowDivider() + SheetDropdownRow( + icon = Icons.Rounded.LowPriority, + title = "Priority", + value = selectedPriority, + options = listOf("Low", "Medium", "High"), + optionLabel = { option -> option }, + optionSwatchColor = { option -> prioritySwatchColor(option) }, + isSelected = { option -> selectedPriority == option }, + onOptionSelected = { option -> selectedPriority = option }, + ) + if (showScheduleControls) { + RowDivider() + SheetDropdownRow( + icon = Icons.Rounded.Repeat, + title = "Repeat", + value = repeatPreset.label, + options = if (scheduleEnabled) { + RepeatPreset.entries.toList() + } else { + listOf(RepeatPreset.NONE) + }, + optionLabel = { option -> option.label }, + optionSwatchColor = { option -> repeatSwatchColor(option) }, + isSelected = { option -> selectedRepeat == option.name }, + onOptionSelected = { option -> + selectedRepeat = option.name + }, ) } - } - } - } + } - SectionHeading("Details") - GroupCard { - SheetDropdownRow( - icon = Icons.AutoMirrored.Rounded.List, - title = "List", - value = selectedListName, - options = listOf(null) + lists, - optionLabel = { option -> option?.name ?: "No list" }, - optionSwatchColor = { option -> - option?.let { - listColorSwatchForSelector( - raw = it.color, - fallback = colorScheme.primary.copy(alpha = 0.75f), - ) - } ?: colorScheme.outlineVariant.copy(alpha = 0.95f) - }, - isSelected = { option -> option?.id == selectedListId }, - onOptionSelected = { option -> selectedListId = option?.id }, - ) - RowDivider() - SheetDropdownRow( - icon = Icons.Rounded.LowPriority, - title = "Priority", - value = selectedPriority, - options = listOf("Low", "Medium", "High"), - optionLabel = { option -> option }, - optionSwatchColor = { option -> prioritySwatchColor(option) }, - isSelected = { option -> selectedPriority == option }, - onOptionSelected = { option -> selectedPriority = option }, - ) - if (showScheduleControls) { - RowDivider() - SheetDropdownRow( - icon = Icons.Rounded.Repeat, - title = "Repeat", - value = repeatPreset.label, - options = if (scheduleEnabled) RepeatPreset.entries.toList() else listOf( - RepeatPreset.NONE - ), - optionLabel = { option -> option.label }, - optionSwatchColor = { option -> repeatSwatchColor(option) }, - isSelected = { option -> selectedRepeat == option.name }, - onOptionSelected = { option -> selectedRepeat = option.name }, - ) + Spacer(modifier = Modifier.height(4.dp)) + } } } - - Spacer(modifier = Modifier.height(4.dp)) - } } } } @@ -484,153 +547,16 @@ fun CreateTaskBottomSheet( } } -@Composable -private fun SheetHeader( - title: String, - leftIcon: ImageVector, - leftContentDescription: String, - onLeftClick: () -> Unit, - onConfirm: () -> Unit, - confirmEnabled: Boolean, -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CircleActionButton( - icon = leftIcon, - contentDescription = leftContentDescription, - onClick = onLeftClick, - enabled = true, - accentColor = Color(0xFFE35A5A), - ) - - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.ExtraBold, - ) - - CircleActionButton( - icon = Icons.Rounded.Check, - contentDescription = "Create task", - onClick = onConfirm, - enabled = confirmEnabled, - accentColor = Color(0xFF2FA35B), - ) - } -} - -@Composable -private fun CircleActionButton( - icon: ImageVector, - contentDescription: String, - onClick: () -> Unit, - enabled: Boolean, - accentColor: Color, -) { - val view = LocalView.current - val colorScheme = MaterialTheme.colorScheme - val interactionSource = remember { MutableInteractionSource() } - val pressed by interactionSource.collectIsPressedAsState() - val scale by animateFloatAsState( - targetValue = if (pressed && enabled) 0.93f else 1f, - label = "sheetHeaderButtonScale", - ) - val elevation by animateDpAsState( - targetValue = when { - pressed && enabled -> 2.dp - enabled -> 8.dp - else -> 5.dp - }, - label = "sheetHeaderButtonElevation", - ) - val offsetY by animateDpAsState( - targetValue = if (pressed && enabled) 1.dp else 0.dp, - label = "sheetHeaderButtonOffsetY", - ) - val containerColor = colorScheme.surfaceVariant - val iconTint = colorScheme.onBackground.copy(alpha = if (enabled) 1f else 0.55f) - val borderColor = if (enabled) { - accentColor.copy(alpha = 0.55f) - } else { - accentColor.copy(alpha = 0.3f) - } - - Card( - modifier = Modifier - .size(54.dp) - .offset(y = offsetY) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .border( - width = 1.5.dp, - color = borderColor, - shape = RoundedCornerShape(999.dp), - ), - shape = RoundedCornerShape(999.dp), - enabled = enabled, - onClick = { - if (enabled) { - performGentleHaptic(view) - } - onClick() - }, - interactionSource = interactionSource, - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation( - defaultElevation = elevation, - pressedElevation = elevation, - ), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = iconTint, - modifier = Modifier.size(22.dp), - ) - } - } -} - -private fun performGentleHaptic(view: android.view.View) { - ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) -} - @Composable private fun SectionHeading(text: String) { - Text( + TdaySheetSectionTitle( text = text, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), ) } @Composable private fun GroupCard(content: @Composable ColumnScope.() -> Unit) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(28.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - Column( - modifier = Modifier.fillMaxWidth(), - content = content, - ) - } + TdaySheetCard(content = content) } @Composable @@ -812,11 +738,12 @@ private fun ScheduleSwitchRow( modifier = Modifier .fillMaxWidth() .clickable { onEnabledChange(!enabled) } + .heightIn(min = 72.dp) .padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( - imageVector = Icons.Rounded.Schedule, + imageVector = Icons.Rounded.CalendarMonth, contentDescription = null, tint = colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp), @@ -824,16 +751,16 @@ private fun ScheduleSwitchRow( Spacer(modifier = Modifier.size(14.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "Due date", + text = "Schedule", style = MaterialTheme.typography.titleMedium, color = colorScheme.onSurface, fontWeight = FontWeight.ExtraBold, ) Text( - text = if (enabled) "Scheduled" else "Floater", + text = if (enabled) "Task has a due date" else "Floater task", style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurfaceVariant, - fontWeight = FontWeight.SemiBold, + color = colorScheme.onSurfaceVariant.copy(alpha = 0.78f), + fontWeight = FontWeight.Bold, ) } Switch( @@ -956,49 +883,61 @@ private fun CenteredSelectorDialog( onOptionSelected: (T) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val isDark = colorScheme.background.luminance() < 0.5f - val containerColor = if (isDark) { - lerp(colorScheme.surface, colorScheme.surfaceVariant, 0.18f) - } else { - colorScheme.surface - } + val containerColor = TdaySheetDefaults.surfaceColor() Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Card( + Box( modifier = Modifier - .fillMaxWidth(0.74f) - .heightIn(max = 380.dp), - shape = RoundedCornerShape(32.dp), - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), + .fillMaxSize() + .background(TdaySheetDefaults.scrimColor()) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, ) { - Column( + Card( modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(vertical = 10.dp), + .fillMaxWidth(0.74f) + .heightIn(max = 380.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + shape = TdaySheetDefaults.SelectorShape, + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = colorScheme.onSurfaceVariant, - fontWeight = FontWeight.ExtraBold, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - ) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(vertical = 10.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), + ) - options.forEachIndexed { index, option -> - if (index > 0) { - RowDivider() + options.forEachIndexed { index, option -> + if (index > 0) { + RowDivider() + } + CenteredSelectorRow( + title = optionLabel(option), + swatchColor = optionSwatchColor(option), + selected = isSelected(option), + onClick = { onOptionSelected(option) }, + ) } - CenteredSelectorRow( - title = optionLabel(option), - swatchColor = optionSwatchColor(option), - selected = isSelected(option), - onClick = { onOptionSelected(option) }, - ) } } } @@ -1080,9 +1019,9 @@ private fun listColorSwatchForSelector(raw: String?, fallback: Color): Color { private fun prioritySwatchColor(priority: String): Color { return when (priority.lowercase()) { - "high" -> Color(0xFFE56A6A) - "medium" -> Color(0xFFE3B368) - else -> Color(0xFF6FBF86) + "high" -> Color(0xFFFF3B30) + "medium" -> Color(0xFFFF9500) + else -> Color(0xFF007AFF) } } @@ -1150,8 +1089,8 @@ private fun ThemedDatePickerDialog( colorScheme.onSurface, if (isDark) 0.14f else 0.28f, ) - val dialogContainer = colorScheme.background - val calendarSurface = if (isDark) colorScheme.surface else Color.White + val dialogContainer = TdaySheetDefaults.containerColor() + val calendarSurface = TdaySheetDefaults.surfaceColor() val primaryText = colorScheme.onSurface val mutedText = colorScheme.onSurfaceVariant val selectedContentColor = if (pickerAccent.luminance() > 0.45f) colorScheme.surface else Color.White @@ -1222,8 +1161,8 @@ private fun ThemedTimePickerDialog( colorScheme.onSurface, if (isDark) 0.14f else 0.28f, ) - val dialogContainer = colorScheme.background - val pickerSurface = colorScheme.surface + val dialogContainer = TdaySheetDefaults.containerColor() + val pickerSurface = TdaySheetDefaults.surfaceColor() val primaryText = colorScheme.onSurface val mutedText = colorScheme.onSurfaceVariant val selectedContentColor = if (pickerAccent.luminance() > 0.45f) colorScheme.surface else Color.White @@ -1300,76 +1239,89 @@ private fun SpectrumPickerDialog( onConfirm: () -> Unit, content: @Composable () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val outerShape = RoundedCornerShape(34.dp) - val innerShape = RoundedCornerShape(28.dp) - Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Card( + Box( modifier = Modifier - .fillMaxWidth(dialogWidthFraction), - shape = outerShape, - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + .fillMaxSize() + .background(TdaySheetDefaults.scrimColor()) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, ) { - Column( - modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + Card( + modifier = Modifier + .fillMaxWidth(dialogWidthFraction) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + shape = TdaySheetDefaults.DialogShape, + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon( - imageVector = titleIcon, - contentDescription = null, - tint = mutedText, - modifier = Modifier.size(22.dp), - ) - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.ExtraBold, - color = primaryText, - ) - } - - Card( - modifier = Modifier - .fillMaxWidth(), - shape = innerShape, - colors = CardDefaults.cardColors(containerColor = panelColor), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - ) { - content() - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 2.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.padding(horizontal = 18.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - TextButton(onClick = onDismiss) { - Text( - text = "Cancel", - color = mutedText, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.ExtraBold, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = titleIcon, + contentDescription = null, + tint = mutedText, + modifier = Modifier.size(22.dp), ) - } - TextButton(onClick = onConfirm) { Text( - text = "Done", - color = primaryText, - style = MaterialTheme.typography.titleMedium, + text = title, + style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.ExtraBold, + color = primaryText, ) } + + Card( + modifier = Modifier + .fillMaxWidth(), + shape = TdaySheetDefaults.CardShape, + colors = CardDefaults.cardColors(containerColor = panelColor), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + content() + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 2.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { + Text( + text = "Cancel", + color = mutedText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + ) + } + TextButton(onClick = onConfirm) { + Text( + text = "Done", + color = primaryText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + ) + } + } } } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt new file mode 100644 index 00000000..c3af86d7 --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt @@ -0,0 +1,318 @@ +package com.ohmz.tday.compose.ui.component + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalView +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.dp +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat +import com.ohmz.tday.compose.ui.theme.TdayDimens + +object TdaySheetDefaults { + val CloseAccent = Color(0xFFE35A5A) + val ConfirmAccent = Color(0xFF2FA35B) + val TopShape = + RoundedCornerShape(topStart = TdayDimens.RadiusSheet, topEnd = TdayDimens.RadiusSheet) + val DialogShape = RoundedCornerShape(TdayDimens.RadiusSheet) + val CardShape = RoundedCornerShape(28.dp) + val OverlayShape = RoundedCornerShape(30.dp) + val SelectorShape = RoundedCornerShape(32.dp) + val ControlShape = CircleShape + + val HorizontalPadding: Dp = TdayDimens.ContentPaddingHorizontal + val VerticalPadding: Dp = TdayDimens.ContentPaddingVertical + val SectionSpacing: Dp = TdayDimens.SpacingXl + val ActionSize: Dp = TdayDimens.FabSize + val ActionIconSize: Dp = 22.dp + val ActionBorderWidth: Dp = TdayDimens.BorderWidthThick + val MaxContentWidth: Dp = 520.dp + + @Composable + fun isDarkTheme(): Boolean = MaterialTheme.colorScheme.background.luminance() < 0.5f + + @Composable + fun containerColor(): Color { + val colorScheme = MaterialTheme.colorScheme + return if (isDarkTheme()) { + lerp(colorScheme.background, colorScheme.surfaceVariant, 0.34f) + } else { + colorScheme.background + } + } + + @Composable + fun surfaceColor(): Color { + val colorScheme = MaterialTheme.colorScheme + return if (isDarkTheme()) { + lerp(colorScheme.surface, colorScheme.surfaceVariant, 0.18f) + } else { + colorScheme.surface + } + } + + @Composable + fun controlSurfaceColor(): Color = MaterialTheme.colorScheme.surfaceVariant + + @Composable + fun scrimColor(): Color = Color.Black.copy(alpha = if (isDarkTheme()) 0.68f else 0.40f) + + @Composable + fun tonalElevation(): Dp = if (isDarkTheme()) TdayDimens.BottomSheetTonalElevationDark else 0.dp + + @Composable + fun cardStrokeColor(): Color { + return if (isDarkTheme()) { + Color.White.copy(alpha = 0.10f) + } else { + Color.White.copy(alpha = 0.45f) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TdayModalBottomSheet( + onDismissRequest: () -> Unit, + sheetState: SheetState, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + dragHandle = null, + shape = TdaySheetDefaults.TopShape, + containerColor = TdaySheetDefaults.containerColor(), + tonalElevation = TdaySheetDefaults.tonalElevation(), + scrimColor = TdaySheetDefaults.scrimColor(), + modifier = modifier, + ) { + TdayCenteredSheetContent(content = content) + } +} + +@Composable +fun TdayCenteredSheetContent( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.TopCenter, + ) { + val contentWidth = if (maxWidth < TdaySheetDefaults.MaxContentWidth) { + maxWidth + } else { + TdaySheetDefaults.MaxContentWidth + } + + Column( + modifier = Modifier.width(contentWidth), + content = content, + ) + } +} + +@Composable +fun TdaySheetHeader( + title: String, + leftIcon: ImageVector, + leftContentDescription: String, + onLeftClick: () -> Unit, + confirmContentDescription: String = "", + onConfirm: () -> Unit = {}, + confirmEnabled: Boolean = false, + modifier: Modifier = Modifier, + confirmIcon: ImageVector = Icons.Rounded.Check, + showConfirmAction: Boolean = true, +) { + Box( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + TdaySheetActionButton( + icon = leftIcon, + contentDescription = leftContentDescription, + enabled = true, + accentColor = TdaySheetDefaults.CloseAccent, + onClick = onLeftClick, + ) + + Spacer(modifier = Modifier.weight(1f)) + + if (showConfirmAction) { + TdaySheetActionButton( + icon = confirmIcon, + contentDescription = confirmContentDescription, + enabled = confirmEnabled, + accentColor = TdaySheetDefaults.ConfirmAccent, + onClick = onConfirm, + ) + } else { + Spacer(modifier = Modifier.size(TdaySheetDefaults.ActionSize)) + } + } + + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.ExtraBold, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth() + .padding(horizontal = TdaySheetDefaults.ActionSize + 14.dp), + ) + } +} + +@Composable +fun TdaySheetActionButton( + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + accentColor: Color, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val view = LocalView.current + val colorScheme = MaterialTheme.colorScheme + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed && enabled) 0.93f else 1f, + label = "tdaySheetActionButtonScale", + ) + val elevation by animateDpAsState( + targetValue = when { + pressed && enabled -> 2.dp + enabled -> 8.dp + else -> 5.dp + }, + label = "tdaySheetActionButtonElevation", + ) + val offsetY by animateDpAsState( + targetValue = if (pressed && enabled) 1.dp else 0.dp, + label = "tdaySheetActionButtonOffsetY", + ) + + Card( + modifier = modifier + .size(TdaySheetDefaults.ActionSize) + .offset(y = offsetY) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .border( + width = TdaySheetDefaults.ActionBorderWidth, + color = accentColor.copy(alpha = if (enabled) 0.55f else 0.30f), + shape = TdaySheetDefaults.ControlShape, + ), + onClick = { + if (enabled) { + ViewCompat.performHapticFeedback(view, HapticFeedbackConstantsCompat.CLOCK_TICK) + } + onClick() + }, + enabled = enabled, + interactionSource = interactionSource, + shape = TdaySheetDefaults.ControlShape, + colors = CardDefaults.cardColors(containerColor = TdaySheetDefaults.controlSurfaceColor()), + elevation = CardDefaults.cardElevation( + defaultElevation = elevation, + pressedElevation = elevation, + ), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = colorScheme.onBackground.copy(alpha = if (enabled) 1f else 0.55f), + modifier = Modifier.size(TdaySheetDefaults.ActionIconSize), + ) + } + } +} + +@Composable +fun TdaySheetSectionTitle( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.ExtraBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(horizontal = 4.dp), + ) +} + +@Composable +fun TdaySheetCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + modifier = modifier.fillMaxWidth(), + shape = TdaySheetDefaults.CardShape, + colors = CardDefaults.cardColors(containerColor = TdaySheetDefaults.surfaceColor()), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + content = content, + ) + } +} diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index 39501dfe..eeab2351 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -10,6 +10,8 @@ struct AppRootView: View { @State private var isLaunchSplashHeld = false @State private var rootFeedTab: RootFeedTab = .home @State private var rootCreateTaskRequestID = 0 + @State private var rootHomeScrollToTopRequestID = 0 + @State private var rootFloaterScrollToTopRequestID = 0 @State private var rootDockCollapsed = false @State private var rootControlsVisible = true @Environment(\.scenePhase) private var scenePhase @@ -42,6 +44,7 @@ struct AppRootView: View { onRootFeedTabSelected: handleRootFeedTabSelection, showsRootControls: false, createTaskRequestID: rootCreateTaskRequestID, + scrollToTopRequestID: rootHomeScrollToTopRequestID, onRootDockCollapsedChange: { rootDockCollapsed = $0 }, onRootControlsVisibleChange: { rootControlsVisible = $0 } ) { route in @@ -59,6 +62,7 @@ struct AppRootView: View { showsRootControls: false, usesRootFeedHeader: true, createTaskRequestID: rootCreateTaskRequestID, + scrollToTopRequestID: rootFloaterScrollToTopRequestID, onRootDockCollapsedChange: { rootDockCollapsed = $0 }, onRootControlsVisibleChange: { rootControlsVisible = $0 }, onOpenFloaterList: { listId, listName in @@ -113,7 +117,9 @@ struct AppRootView: View { listId: listId, listName: listName, highlightedTodoId: nil, - usesRootFeedHeader: true + onListDeleted: { + handleRoute(.floaterTodos) + } ) case let .listTodos(listId, listName): TodoListScreen( @@ -264,6 +270,10 @@ struct AppRootView: View { } private func handleRootFeedTabSelection(_ tab: RootFeedTab) { + if tab == rootFeedTab { + requestRootFeedScrollToTop(for: tab) + return + } selectRootFeedTab(tab) } @@ -272,6 +282,15 @@ struct AppRootView: View { appViewModel.navigationPath = [] } + private func requestRootFeedScrollToTop(for tab: RootFeedTab) { + switch tab { + case .home: + rootHomeScrollToTopRequestID += 1 + case .floater: + rootFloaterScrollToTopRequestID += 1 + } + } + private var rootNavigationPath: Binding<[AppRoute]> { Binding( get: { sanitizedNavigationPath(appViewModel.navigationPath) }, diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 14a644ef..a1d888c8 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -166,6 +166,7 @@ struct CalendarScreen: View { @State private var activeDropDate: Date? @State private var dateDropTargetFrames: [String: CalendarDateDropTargetFrame] = [:] @State private var pendingRescheduleDrop: CalendarTaskRescheduleDrop? + @State private var openSwipeTaskID: String? init(container: AppContainer) { _viewModel = State(initialValue: CalendarViewModel(container: container)) @@ -289,7 +290,12 @@ struct CalendarScreen: View { list: todo.listId.flatMap { listId in viewModel.lists.first(where: { $0.id == listId }) }, - onComplete: { Task { await viewModel.complete(todo) } } + onComplete: { + if openSwipeTaskID == todo.id { + openSwipeTaskID = nil + } + Task { await viewModel.complete(todo) } + } ) .opacity(draggedTodo?.id == todo.id && activeDropDate != nil ? 0.55 : 1) .background(colors.background) @@ -304,6 +310,8 @@ struct CalendarScreen: View { ) ) .todoTrailingSwipeActions( + rowID: todo.id, + openRowID: $openSwipeTaskID, onEdit: { editingTodo = todo }, @@ -336,6 +344,9 @@ struct CalendarScreen: View { .environment(\.defaultMinListRowHeight, 1) .disableVerticalScrollBounce() .background(colors.background) + .tdayPullToRefresh(isRefreshing: viewModel.isLoading) { + await viewModel.refresh() + } .onPreferenceChange(CalendarDateDropTargetFramePreferenceKey.self) { frames in dateDropTargetFrames = frames } @@ -344,6 +355,10 @@ struct CalendarScreen: View { cancelInAppDrag() } } + .onChange(of: viewModel.items.map(\.id)) { _, ids in + guard let openSwipeTaskID, !ids.contains(openSwipeTaskID) else { return } + self.openSwipeTaskID = nil + } .overlay(alignment: .topLeading) { GeometryReader { proxy in if let inAppDrag { @@ -603,6 +618,7 @@ struct CalendarScreen: View { } private func beginInAppDrag(_ todo: TodoItem, at location: CGPoint) { + openSwipeTaskID = nil if draggedTodo?.id != todo.id { UIImpactFeedbackGenerator(style: .light).impactOccurred() } @@ -2527,10 +2543,12 @@ private struct CalendarTaskDragPreview: View { .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Floater") - .font(.tdayRounded(size: 12, weight: .semibold)) - .foregroundStyle(colors.onSurfaceVariant) - .lineLimit(1) + if let due = todo.due { + Text(due.formatted(date: .omitted, time: .shortened)) + .font(.tdayRounded(size: 12, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + } } Spacer(minLength: 0) @@ -2607,9 +2625,11 @@ private struct CalendarPendingTaskRow: View { strikeColor: colors.onSurface.opacity(0.65) ) - Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Floater") - .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) - .foregroundStyle(colors.onSurfaceVariant.opacity(0.8)) + if let due = todo.due { + Text(due.formatted(date: .omitted, time: .shortened)) + .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant.opacity(0.8)) + } } Spacer(minLength: 0) diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index a80af8f5..ec120c76 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -15,6 +15,7 @@ struct CompletedScreen: View { @State private var editingItem: CompletedItem? @State private var timelineScrollOffset: CGFloat = 0 @State private var collapsedSectionIDs: Set = [] + @State private var openSwipeTaskID: String? init(container: AppContainer) { _viewModel = State(initialValue: CompletedViewModel(container: container)) @@ -44,6 +45,9 @@ struct CompletedScreen: View { var body: some View { completedTimelineContent + .tdayPullToRefresh(isRefreshing: viewModel.isLoading) { + await viewModel.refresh() + } .background(colors.background) .overlay { if viewModel.items.isEmpty, !viewModel.isLoading { @@ -75,6 +79,10 @@ struct CompletedScreen: View { action: nil ) } + .onChange(of: viewModel.items.map(\.id)) { _, ids in + guard let openSwipeTaskID, !ids.contains(openSwipeTaskID) else { return } + self.openSwipeTaskID = nil + } .sheet(item: $editingItem) { item in CreateTaskSheet( lists: viewModel.lists, @@ -258,7 +266,8 @@ struct CompletedScreen: View { }, onEdit: { editingItem = item - } + }, + openSwipeTaskID: $openSwipeTaskID ) } } @@ -269,6 +278,7 @@ private struct CompletedTimelineRow: View { let onUncomplete: () async -> Void let onDelete: () async -> Void let onEdit: () -> Void + @Binding var openSwipeTaskID: String? @Environment(\.tdayColors) private var colors @State private var restorePhase = CompletedRestorePhase.completed @@ -371,6 +381,8 @@ private struct CompletedTimelineRow: View { .transition(.opacity.combined(with: .scale(scale: 0.985))) .allowsHitTesting(!isRestoring) .todoTrailingSwipeActions( + rowID: item.id, + openRowID: $openSwipeTaskID, enabled: !isRestoring, onEdit: onEdit, onDelete: { @@ -383,6 +395,9 @@ private struct CompletedTimelineRow: View { guard restorePhase == .completed else { return } + if openSwipeTaskID == item.id { + openSwipeTaskID = nil + } UIImpactFeedbackGenerator(style: .light).impactOccurred() Task { @MainActor in diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 4de09d3e..e2e91fc8 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -79,10 +79,13 @@ private struct CreateListSheetHeaderHeightKey: PreferenceKey { } } +private let homeScrollTopID = "home-scroll-top" + struct HomeScreen: View { let onRootFeedTabSelected: (RootFeedTab) -> Void let showsRootControls: Bool let createTaskRequestID: Int + let scrollToTopRequestID: Int let onRootDockCollapsedChange: (Bool) -> Void let onRootControlsVisibleChange: (Bool) -> Void let onNavigate: (AppRoute) -> Void @@ -100,12 +103,14 @@ struct HomeScreen: View { @State private var showingCreateList = false @State private var editingTodo: TodoItem? @State private var homeScrollOffset: CGFloat = 0 + @State private var openSwipeTaskID: String? init( container: AppContainer, onRootFeedTabSelected: @escaping (RootFeedTab) -> Void = { _ in }, showsRootControls: Bool = true, createTaskRequestID: Int = 0, + scrollToTopRequestID: Int = 0, onRootDockCollapsedChange: @escaping (Bool) -> Void = { _ in }, onRootControlsVisibleChange: @escaping (Bool) -> Void = { _ in }, onNavigate: @escaping (AppRoute) -> Void @@ -113,6 +118,7 @@ struct HomeScreen: View { self.onRootFeedTabSelected = onRootFeedTabSelected self.showsRootControls = showsRootControls self.createTaskRequestID = createTaskRequestID + self.scrollToTopRequestID = scrollToTopRequestID self.onRootDockCollapsedChange = onRootDockCollapsedChange self.onRootControlsVisibleChange = onRootControlsVisibleChange self.onNavigate = onNavigate @@ -176,131 +182,142 @@ struct HomeScreen: View { await viewModel.refresh() } ) { - ScrollView(showsIndicators: false) { - LazyVStack(alignment: .leading, spacing: HomeMetrics.sectionSpacing) { - TimelineScrollOffsetObserver { homeScrollOffset = $0 } - .frame(height: 0) - .allowsHitTesting(false) - - HomeTopBar( - totalWidth: proxy.size.width - (HomeMetrics.screenPadding * 2), - searchExpanded: $searchExpanded, - searchQuery: $searchQuery, - searchFieldFocused: $searchFieldFocused, - onSearchClose: { - closeSearch() - }, - onCreateList: { - closeSearch() - showingCreateList = true - }, - onOpenSettings: { - closeSearch() - onNavigate(.settings) - } - ) - .onTopPartialScrollSnap( - anchorDistance: HomeMetrics.titleAnchorDistance, - isDisabled: searchExpanded - ) + ScrollViewReader { scrollProxy in + ScrollView(showsIndicators: false) { + LazyVStack(alignment: .leading, spacing: HomeMetrics.sectionSpacing) { + TimelineScrollOffsetObserver { homeScrollOffset = $0 } + .frame(height: 0) + .allowsHitTesting(false) + .id(homeScrollTopID) + + HomeTopBar( + totalWidth: proxy.size.width - (HomeMetrics.screenPadding * 2), + searchExpanded: $searchExpanded, + searchQuery: $searchQuery, + searchFieldFocused: $searchFieldFocused, + onSearchClose: { + closeSearch() + }, + onCreateList: { + closeSearch() + showingCreateList = true + }, + onOpenSettings: { + closeSearch() + onNavigate(.settings) + } + ) + .onTopPartialScrollSnap( + anchorDistance: HomeMetrics.titleAnchorDistance, + isDisabled: searchExpanded + ) - HomeTodayCard( - count: viewModel.summary.todayCount, - action: { - closeSearch() - onNavigate(.todayTodos) - } - ) + HomeTodayCard( + count: viewModel.summary.todayCount, + action: { + closeSearch() + onNavigate(.todayTodos) + } + ) - if !viewModel.todayTodos.isEmpty { - VStack(spacing: 0) { - ForEach(viewModel.todayTodos) { todo in - homeTodayTaskRow(todo) - .transition(.opacity.combined(with: .move(edge: .top))) + if !viewModel.todayTodos.isEmpty { + VStack(spacing: 0) { + ForEach(viewModel.todayTodos) { todo in + homeTodayTaskRow(todo) + .transition(.opacity.combined(with: .move(edge: .top))) + } } + .animation( + .spring(response: 0.34, dampingFraction: 0.9), + value: viewModel.todayTodos.map(\.id) + ) } - .animation( - .spring(response: 0.34, dampingFraction: 0.9), - value: viewModel.todayTodos.map(\.id) + + HomeCategoryBoard( + overdueCount: overdueCount, + scheduledCount: viewModel.summary.scheduledCount, + allCount: viewModel.summary.allCount, + priorityCount: viewModel.summary.priorityCount, + completedCount: viewModel.summary.completedCount, + calendarCount: viewModel.summary.scheduledCount, + onOpenOverdue: { + closeSearch() + onNavigate(.overdueTodos) + }, + onOpenScheduled: { + closeSearch() + onNavigate(.scheduledTodos) + }, + onOpenAll: { + closeSearch() + onNavigate(.allTodos(highlightTodoId: nil)) + }, + onOpenPriority: { + closeSearch() + onNavigate(.priorityTodos) + }, + onOpenCompleted: { + closeSearch() + onNavigate(.completed) + }, + onOpenCalendar: { + closeSearch() + onNavigate(.calendar) + } ) - } - HomeCategoryBoard( - overdueCount: overdueCount, - scheduledCount: viewModel.summary.scheduledCount, - allCount: viewModel.summary.allCount, - priorityCount: viewModel.summary.priorityCount, - completedCount: viewModel.summary.completedCount, - calendarCount: viewModel.summary.scheduledCount, - onOpenOverdue: { - closeSearch() - onNavigate(.overdueTodos) - }, - onOpenScheduled: { - closeSearch() - onNavigate(.scheduledTodos) - }, - onOpenAll: { - closeSearch() - onNavigate(.allTodos(highlightTodoId: nil)) - }, - onOpenPriority: { - closeSearch() - onNavigate(.priorityTodos) - }, - onOpenCompleted: { - closeSearch() - onNavigate(.completed) - }, - onOpenCalendar: { - closeSearch() - onNavigate(.calendar) + if !viewModel.summary.lists.isEmpty { + HomeListsSection( + lists: viewModel.summary.lists, + displayName: displayName(for:) + ) { list, name in + closeSearch() + onNavigate(.listTodos(listId: list.id, listName: name)) + } } - ) - if !viewModel.summary.lists.isEmpty { - HomeListsSection( - lists: viewModel.summary.lists, - displayName: displayName(for:) - ) { list, name in - closeSearch() - onNavigate(.listTodos(listId: list.id, listName: name)) + if let errorMessage = viewModel.errorMessage { + ErrorRetryView(message: errorMessage) { + Task { await viewModel.refresh() } + } } - } - if let errorMessage = viewModel.errorMessage { - ErrorRetryView(message: errorMessage) { - Task { await viewModel.refresh() } - } } + .padding(.horizontal, HomeMetrics.screenPadding) + .padding(.top, HomeMetrics.screenPadding) } - .padding(.horizontal, HomeMetrics.screenPadding) - .padding(.top, HomeMetrics.screenPadding) - } - .scrollBounceBehavior(.always, axes: .vertical) - .safeAreaInset(edge: .bottom) { - if showsRootControls { - HStack(alignment: .bottom) { - RootFeedDock( - activeTab: .home, - collapsed: shouldCollapseRootDock, - onSelect: onRootFeedTabSelected - ) - .padding(.leading, 18) - .padding(.vertical, 8) + .scrollBounceBehavior(.always, axes: .vertical) + .safeAreaInset(edge: .bottom) { + if showsRootControls { + HStack(alignment: .bottom) { + RootFeedDock( + activeTab: .home, + collapsed: shouldCollapseRootDock, + onSelect: onRootFeedTabSelected + ) + .padding(.leading, 18) + .padding(.vertical, 8) - Spacer(minLength: 12) + Spacer(minLength: 12) - TaskFloatingActionButton { - closeSearch() - showingCreateTask = true + TaskFloatingActionButton { + closeSearch() + showingCreateTask = true + } + .padding(.trailing, 18) + .padding(.vertical, 8) } - .padding(.trailing, 18) - .padding(.vertical, 8) + } else { + Color.clear.frame(height: 80) + } + } + .onChange(of: scrollToTopRequestID) { _, requestID in + guard requestID > 0 else { return } + closeSearch() + withAnimation(.easeInOut(duration: 0.34)) { + scrollProxy.scrollTo(homeScrollTopID, anchor: .top) } - } else { - Color.clear.frame(height: 80) } } } @@ -366,6 +383,10 @@ struct HomeScreen: View { closeSearch() showingCreateTask = true } + .onChange(of: viewModel.todayTodos.map(\.id)) { _, ids in + guard let openSwipeTaskID, !ids.contains(openSwipeTaskID) else { return } + self.openSwipeTaskID = nil + } .onAppear { onRootControlsVisibleChange(!searchExpanded) onRootDockCollapsedChange(shouldCollapseRootDock) @@ -429,7 +450,8 @@ struct HomeScreen: View { lists: viewModel.lists, onComplete: { await viewModel.complete(todo) }, onDelete: { Task { await viewModel.delete(todo) } }, - onEdit: { editingTodo = todo } + onEdit: { editingTodo = todo }, + openSwipeTaskID: $openSwipeTaskID ) } @@ -624,6 +646,7 @@ private struct HomeTodayTaskRow: View { let onComplete: () async -> Void let onDelete: () -> Void let onEdit: () -> Void + @Binding var openSwipeTaskID: String? @Environment(\.tdayColors) private var colors @@ -635,8 +658,11 @@ private struct HomeTodayTaskRow: View { private var priorityIcon: String? { priorityIndicatorSymbolName(todo.priority) } private var isOverdue: Bool { !todo.completed && (todo.due ?? .distantFuture) < Date() } - private var dueText: String { todo.due?.formatted(date: .omitted, time: .shortened) ?? "" } - private var subtitleText: String { isOverdue ? "Overdue, \(dueText)" : "Due \(dueText)" } + private var dueText: String? { todo.due?.formatted(date: .omitted, time: .shortened) } + private var subtitleText: String? { + guard let dueText else { return nil } + return isOverdue ? "Overdue, \(dueText)" : "Due \(dueText)" + } private var subtitleColor: Color { isOverdue ? colors.error : colors.onSurfaceVariant.opacity(0.8) } private var isCompleting: Bool { completionPhase != .active } private var isFading: Bool { completionPhase == .fading } @@ -649,6 +675,8 @@ private struct HomeTodayTaskRow: View { var body: some View { rowContent .todoTrailingSwipeActions( + rowID: todo.id, + openRowID: $openSwipeTaskID, enabled: !isCompleting, onEdit: onEdit, onDelete: onDelete @@ -679,9 +707,11 @@ private struct HomeTodayTaskRow: View { strikeColor: colors.onSurface.opacity(0.65) ) - Text(subtitleText) - .font(.tdayRounded(size: 13, weight: .semibold)) - .foregroundStyle(subtitleColor) + if let subtitleText { + Text(subtitleText) + .font(.tdayRounded(size: 13, weight: .semibold)) + .foregroundStyle(subtitleColor) + } } Spacer(minLength: 0) @@ -709,6 +739,9 @@ private struct HomeTodayTaskRow: View { private func startCompletion() { guard completionPhase == .active else { return } + if openSwipeTaskID == todo.id { + openSwipeTaskID = nil + } withAnimation(.easeInOut(duration: 0.18)) { completionPhase = .checked @@ -789,23 +822,15 @@ private struct HomeTodayCard: View { ) ) - 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) - } + Text(dateLabel) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.85) + Spacer() + Text("\(count)") .font(.tdayRounded(size: 34, weight: .black)) .foregroundStyle(.white) @@ -1436,16 +1461,17 @@ struct CreateListSheet: View { var body: some View { VStack(spacing: 0) { - CreateListSheetHeader( - canCreate: canCreate, + TdaySheetHeader( + title: "New list", + closeAccessibilityLabel: "Close", + confirmAccessibilityLabel: "Create list", + isConfirmEnabled: canCreate, onClose: { dismiss() }, onConfirm: { onSubmit(trimmedName, color, iconKey) dismiss() } ) - .padding(.horizontal, 18) - .padding(.top, 14) .background( GeometryReader { proxy in Color.clear @@ -1455,7 +1481,7 @@ struct CreateListSheet: View { ScrollView(showsIndicators: false) { VStack(spacing: 14) { - CreateListSheetCard { + TdaySheetCard { VStack(spacing: 18) { ZStack { Circle() @@ -1491,8 +1517,8 @@ struct CreateListSheet: View { .padding(.vertical, 18) } - CreateListSheetSectionTitle(text: "Color") - CreateListSheetCard { + TdaySheetSectionTitle(text: "Color") + TdaySheetCard { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(homeListColorOptions, id: \.key) { option in @@ -1525,8 +1551,8 @@ struct CreateListSheet: View { } } - CreateListSheetSectionTitle(text: "Icon") - CreateListSheetCard { + TdaySheetSectionTitle(text: "Icon") + TdaySheetCard { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(homeListIconOptions, id: \.key) { option in @@ -1606,103 +1632,6 @@ struct CreateListSheet: View { } } -private struct CreateListSheetHeader: View { - let canCreate: Bool - let onClose: () -> Void - let onConfirm: () -> Void - - @Environment(\.tdayColors) private var colors - - var body: some View { - HStack { - CreateListSheetActionButton( - icon: "xmark", - accentColor: Color(hex: 0xE35A5A), - enabled: true, - action: onClose - ) - - Spacer() - - Text("New list") - .font(.tdayRounded(size: 24, weight: .heavy)) - .foregroundStyle(colors.onSurface) - .lineLimit(1) - .minimumScaleFactor(0.82) - - Spacer() - - CreateListSheetActionButton( - icon: "checkmark", - accentColor: Color(hex: 0x2FA35B), - enabled: canCreate, - action: onConfirm - ) - } - } -} - -private struct CreateListSheetActionButton: View { - let icon: String - let accentColor: Color - let enabled: Bool - let action: () -> Void - - @Environment(\.tdayColors) private var colors - - var body: some View { - Button(action: action) { - Image(systemName: icon) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(colors.onSurface.opacity(enabled ? 1 : 0.55)) - .frame(width: 56, height: 56) - .background(colors.bottomSheetControlSurface) - .clipShape(Circle()) - .overlay( - Circle() - .stroke(accentColor.opacity(enabled ? 0.55 : 0.3), lineWidth: 1.5) - ) - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: enabled ? 0.16 : 0.06 - ) - ) - .disabled(!enabled) - } -} - -private struct CreateListSheetSectionTitle: View { - let text: String - - @Environment(\.tdayColors) private var colors - - var body: some View { - Text(text) - .font(.tdayRounded(size: 22, weight: .bold)) - .foregroundStyle(colors.onSurfaceVariant) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 4) - } -} - -private struct CreateListSheetCard: View { - @Environment(\.tdayColors) private var colors - - @ViewBuilder let content: Content - - var body: some View { - content - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(colors.bottomSheetSurface) - ) - } -} - private let homeListColorOptions: [HomeListColorOption] = [ HomeListColorOption(key: "PINK", color: Color(hex: 0xC987A5)), HomeListColorOption(key: "GOLD", color: Color(hex: 0xC7AA63)), diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index e514f603..ce30c2a7 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -4,6 +4,7 @@ import UniformTypeIdentifiers private let todoDragContentTypes = [UTType.plainText.identifier, UTType.text.identifier] private let todoTimelineDragCoordinateSpace = "todoTimelineDragCoordinateSpace" +private let todoTimelineScrollTopID = "todo-timeline-scroll-top" private final class TodoTaskDragSession { static let shared = TodoTaskDragSession() @@ -55,6 +56,8 @@ enum TodoTimelineMetrics { static let sectionHeaderBottomPadding: CGFloat = 2 static let titleCollapseDistance: CGFloat = 64 static let rootFeedTitleTopInset: CGFloat = 32 + static let timelineBottomSpacerHeight: CGFloat = 120 + static let rootFloaterBottomSpacerHeight: CGFloat = 12 static let rootDockCollapseThreshold: CGFloat = 44 static let topBarRowHeight: CGFloat = 56 static let topBarButtonFrame: CGFloat = 56 @@ -564,6 +567,7 @@ struct TodoListScreen: View { let showsRootControls: Bool let usesRootFeedHeader: Bool let createTaskRequestID: Int + let scrollToTopRequestID: Int let onRootDockCollapsedChange: (Bool) -> Void let onRootControlsVisibleChange: (Bool) -> Void let onOpenFloaterList: (String, String) -> Void @@ -591,6 +595,7 @@ struct TodoListScreen: View { @State private var rootFloaterSearchExpanded = false @State private var rootFloaterSearchQuery = "" @State private var openingRootFloaterSearchResultID: String? + @State private var openSwipeTaskID: String? init( container: AppContainer, @@ -603,6 +608,7 @@ struct TodoListScreen: View { showsRootControls: Bool = true, usesRootFeedHeader: Bool = false, createTaskRequestID: Int = 0, + scrollToTopRequestID: Int = 0, onRootDockCollapsedChange: @escaping (Bool) -> Void = { _ in }, onRootControlsVisibleChange: @escaping (Bool) -> Void = { _ in }, onOpenFloaterList: @escaping (String, String) -> Void = { _, _ in }, @@ -616,6 +622,7 @@ struct TodoListScreen: View { self.showsRootControls = showsRootControls self.usesRootFeedHeader = usesRootFeedHeader self.createTaskRequestID = createTaskRequestID + self.scrollToTopRequestID = scrollToTopRequestID self.onRootDockCollapsedChange = onRootDockCollapsedChange self.onRootControlsVisibleChange = onRootControlsVisibleChange self.onOpenFloaterList = onOpenFloaterList @@ -649,6 +656,11 @@ struct TodoListScreen: View { viewModel.mode == .floater && viewModel.listId == nil } + private var isListDetailScreen: Bool { + viewModel.mode == .list || + (viewModel.mode == .floater && !(viewModel.listId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)) + } + private var floaterListByID: [String: ListSummary] { Dictionary(viewModel.lists.map { ($0.id, $0) }, uniquingKeysWith: { _, latest in latest }) } @@ -720,6 +732,10 @@ struct TodoListScreen: View { max(timelineScrollOffset, 0) > TodoTimelineMetrics.rootDockCollapseThreshold } + private var minimalTimelineBottomSpacerHeight: CGFloat { + isRootFloaterScreen ? TodoTimelineMetrics.rootFloaterBottomSpacerHeight : TodoTimelineMetrics.timelineBottomSpacerHeight + } + private var timelineItemAnimationKey: String { let itemIDs = viewModel.items.map(\.id).joined(separator: "|") let completingIDs = completionPhases.keys.sorted().joined(separator: "|") @@ -739,7 +755,7 @@ struct TodoListScreen: View { action: presentSummary ) } - if viewModel.mode == .list { + if isListDetailScreen { return TimelineTopBarAction( systemName: "ellipsis", usesCircularChrome: true, @@ -761,6 +777,9 @@ struct TodoListScreen: View { var body: some View { modeContent + .tdayPullToRefresh(isRefreshing: viewModel.isLoading) { + await viewModel.refresh() + } .coordinateSpace(name: todoTimelineDragCoordinateSpace) .background(colors.background) .onPreferenceChange(TodoDropTargetFramePreferenceKey.self) { frames in @@ -835,7 +854,7 @@ struct TodoListScreen: View { .onChange(of: viewModel.items) { handleItemsChanged() } - .safeAreaInset(edge: .bottom) { + .safeAreaInset(edge: .bottom, spacing: 0) { if showsRootControls { floatingActionButtonDock } else { @@ -929,7 +948,7 @@ struct TodoListScreen: View { } } } - if viewModel.mode == .list { + if isListDetailScreen { ToolbarItem(placement: .topBarTrailing) { Button { showingListSettings = true @@ -1082,38 +1101,46 @@ struct TodoListScreen: View { } private var summarySheetContent: some View { - NavigationStack { + VStack(spacing: 0) { + TdaySheetHeader( + title: "AI Summary", + closeAccessibilityLabel: "Close", + confirmSystemName: nil, + onClose: { showingSummary = false } + ) + ScrollView { - VStack(alignment: .leading, spacing: 12) { - if viewModel.isSummarizing { - ProgressView() - } else if let summaryText = viewModel.summaryText { - Text(summaryText) - .font(.tdayRounded(.body, weight: .bold)) - .frame(maxWidth: .infinity, alignment: .leading) - } else if viewModel.summaryConnectivityError { - ErrorRetryView(message: "Summary needs a network connection.") { - Task { await viewModel.summarizeCurrentMode() } + TdaySheetCard { + VStack(alignment: .leading, spacing: 12) { + if viewModel.isSummarizing { + ProgressView() + } else if let summaryText = viewModel.summaryText { + Text(summaryText) + .font(.tdayRounded(.body, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + } else if viewModel.summaryConnectivityError { + ErrorRetryView(message: "Summary needs a network connection.") { + Task { await viewModel.summarizeCurrentMode() } + } + } else if let summaryError = viewModel.summaryError { + Text(summaryError) + .foregroundStyle(colors.error) + } else { + Text("No summary available.") } - } else if let summaryError = viewModel.summaryError { - Text(summaryError) - .foregroundStyle(colors.error) - } else { - Text("No summary available.") } + .padding(18) } - .padding(20) + .padding(.horizontal, 18) + .padding(.top, 14) + .padding(.bottom, 24) } - .background(colors.bottomSheetBackground) - .navigationTitle("AI Summary") .disableVerticalScrollBounce() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { showingSummary = false } - } - } } + .background(colors.bottomSheetBackground.ignoresSafeArea()) .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + .presentationCornerRadius(34) .presentationBackground { colors.bottomSheetBackground .ignoresSafeArea(.container, edges: .bottom) @@ -1141,6 +1168,9 @@ struct TodoListScreen: View { inAppDrag = nil dropTargetFrames = [:] TodoTaskDragSession.shared.todo = nil + if let openSwipeTaskID, !viewModel.items.contains(where: { $0.id == openSwipeTaskID }) { + self.openSwipeTaskID = nil + } if viewModel.mode == .all, highlightedTodoId != nil { collapsedSectionIDs = [] } @@ -1207,6 +1237,7 @@ struct TodoListScreen: View { } private func beginInAppDrag(_ todo: TodoItem, at location: CGPoint) { + openSwipeTaskID = nil if draggedTodo?.id != todo.id { UIImpactFeedbackGenerator(style: .light).impactOccurred() } @@ -1570,7 +1601,7 @@ struct TodoListScreen: View { } Color.clear - .frame(height: 120) + .frame(height: TodoTimelineMetrics.timelineBottomSpacerHeight) .listRowInsets(EdgeInsets()) .listRowBackground(colors.background) .listRowSeparator(.hidden) @@ -1593,6 +1624,7 @@ struct TodoListScreen: View { ZStack { List { timelineHeroTitleRow + .id(todoTimelineScrollTopID) if showRootFloaterSearchResults { FloaterSearchResultsCard( @@ -1661,7 +1693,7 @@ struct TodoListScreen: View { } Color.clear - .frame(height: 120) + .frame(height: minimalTimelineBottomSpacerHeight) .listRowInsets(EdgeInsets()) .listRowBackground(colors.background) .listRowSeparator(.hidden) @@ -1687,6 +1719,13 @@ struct TodoListScreen: View { .onChange(of: viewModel.items) { scrollToHighlightedTodo(using: scrollProxy) } + .onChange(of: scrollToTopRequestID) { _, requestID in + guard requestID > 0, isRootFloaterScreen else { return } + closeRootFloaterSearch() + withAnimation(.easeInOut(duration: 0.34)) { + scrollProxy.scrollTo(todoTimelineScrollTopID, anchor: .top) + } + } } } @@ -1711,10 +1750,12 @@ struct TodoListScreen: View { .foregroundStyle(colors.tertiary) } } - HStack(spacing: 6) { - Text(todo.due?.formatted(date: .abbreviated, time: .shortened) ?? "Floater") - .font(.tdayRounded(size: 12, weight: .semibold)) - .foregroundStyle(colors.onSurfaceVariant) + if let due = todo.due { + HStack(spacing: 6) { + Text(due.formatted(date: .abbreviated, time: .shortened)) + .font(.tdayRounded(size: 12, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + } } if let description = todo.description, !description.isEmpty { Text(description) @@ -1730,6 +1771,8 @@ struct TodoListScreen: View { .opacity(draggedTodo?.id == todo.id && activeDropSectionId != nil ? 0.55 : 1) .allowsHitTesting(!isCompleting) .todoTrailingSwipeActions( + rowID: todo.id, + openRowID: $openSwipeTaskID, enabled: !isCompleting, onEdit: { editingTodo = todo @@ -1814,9 +1857,11 @@ struct TodoListScreen: View { strikeColor: colors.onSurface.opacity(0.65) ) - Text(subtitleText) - .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) - .foregroundStyle(subtitleColor) + if let subtitleText { + Text(subtitleText) + .font(.tdayRounded(size: TodoTimelineMetrics.minimalRowSubtitleSize, weight: .semibold)) + .foregroundStyle(subtitleColor) + } } Spacer(minLength: 0) @@ -1848,6 +1893,8 @@ struct TodoListScreen: View { .transition(.opacity.combined(with: .scale(scale: 0.985))) .modifier(TimelineTaskFlashHighlight(active: flashHighlight)) .todoTrailingSwipeActions( + rowID: todo.id, + openRowID: $openSwipeTaskID, enabled: !isCompleting, onEdit: { editingTodo = todo @@ -1884,6 +1931,9 @@ struct TodoListScreen: View { guard completionPhases[todo.id] == nil else { return } + if openSwipeTaskID == todo.id { + openSwipeTaskID = nil + } withAnimation(.easeInOut(duration: 0.16)) { completionPhases[todo.id] = .checked } @@ -2081,9 +2131,9 @@ struct TodoListScreen: View { return .asymmetric(insertion: insertion, removal: removal) } - private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String { + private func minimalTimelineSubtitle(for todo: TodoItem, in section: TodoTimelineSection) -> String? { guard let due = todo.due else { - return "Floater" + return nil } let timeText = due.formatted(date: .omitted, time: .shortened) let dueBodyText = if section.id == "earlier" && @@ -2114,7 +2164,7 @@ struct TodoListScreen: View { } return "Due \(dueBodyText)" case .floater: - return "Floater" + return nil case .list: if !todo.completed && due < Date() { return "Overdue, \(dueBodyText)" @@ -2531,10 +2581,12 @@ private struct TodoDragPreview: View { .font(.tdayRounded(size: 16, weight: .bold)) .foregroundStyle(colors.onSurface) .lineLimit(1) - Text(todo.due?.formatted(date: .omitted, time: .shortened) ?? "Floater") - .font(.tdayRounded(size: 12, weight: .semibold)) - .foregroundStyle(colors.onSurfaceVariant) - .lineLimit(1) + if let due = todo.due { + Text(due.formatted(date: .omitted, time: .shortened)) + .font(.tdayRounded(size: 12, weight: .semibold)) + .foregroundStyle(colors.onSurfaceVariant) + .lineLimit(1) + } } Spacer(minLength: 0) @@ -2944,56 +2996,49 @@ private struct ListDeleteConfirmationOverlay: View { .contentShape(Rectangle()) .onTapGesture(perform: onCancel) - VStack(alignment: .leading, spacing: 22) { - VStack(alignment: .leading, spacing: 14) { - Text("Delete list?") - .font(.tdayRounded(.title2, weight: .black)) - .foregroundStyle(colors.onSurface) - .lineLimit(1) - .minimumScaleFactor(0.82) + TdaySheetOverlayCard { + VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 14) { + Text("Delete list?") + .font(.tdayRounded(.title2, weight: .black)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.82) - Text("This will delete this list, every task in it, and completed history for those tasks.") - .font(.tdayRounded(.body, weight: .heavy)) - .foregroundStyle(colors.onSurfaceVariant) - .lineSpacing(3) - .fixedSize(horizontal: false, vertical: true) - } + Text("This will delete this list, every task in it, and completed history for those tasks.") + .font(.tdayRounded(.body, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + } - HStack(spacing: 24) { - Spacer(minLength: 0) + HStack(spacing: 24) { + Spacer(minLength: 0) - Button(action: onCancel) { - Text("Cancel") - .font(.tdayRounded(.headline, weight: .heavy)) - .foregroundStyle(colors.primary) - .padding(.horizontal, 4) - .padding(.vertical, 8) - } - .buttonStyle(.plain) - - Button(role: .destructive, action: onDelete) { - Text("Delete") - .font(.tdayRounded(.headline, weight: .heavy)) - .foregroundStyle(colors.error) - .padding(.horizontal, 4) - .padding(.vertical, 8) + Button(action: onCancel) { + Text("Cancel") + .font(.tdayRounded(.headline, weight: .heavy)) + .foregroundStyle(colors.primary) + .padding(.horizontal, 4) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + + Button(role: .destructive, action: onDelete) { + Text("Delete") + .font(.tdayRounded(.headline, weight: .heavy)) + .foregroundStyle(colors.error) + .padding(.horizontal, 4) + .padding(.vertical, 8) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 20) + .frame(maxWidth: 330, alignment: .leading) } - .padding(.horizontal, 24) - .padding(.top, 24) - .padding(.bottom, 20) - .frame(maxWidth: 330, alignment: .leading) - .background( - colors.bottomSheetSurface, - in: RoundedRectangle(cornerRadius: 30, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 30, style: .continuous) - .stroke(colors.cardStroke, lineWidth: 1) - } - .shadow(color: Color.black.opacity(colors.isDark ? 0.34 : 0.14), radius: 24, x: 0, y: 12) .padding(.horizontal, 34) .contentShape(RoundedRectangle(cornerRadius: 30, style: .continuous)) .onTapGesture {} @@ -3003,6 +3048,28 @@ private struct ListDeleteConfirmationOverlay: View { } } +private enum ListSettingsSheetMetrics { + static let initialSheetHeight: CGFloat = 760 + static let maximumHeightFraction: CGFloat = 0.94 + static let bottomContentPadding: CGFloat = 24 +} + +private struct ListSettingsSheetHeaderHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private struct ListSettingsSheetContentHeightKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + private struct ListSettingsSheet: View { let list: ListSummary? let onSubmit: (String, String?, String?) -> Void @@ -3014,6 +3081,8 @@ private struct ListSettingsSheet: View { @State private var name = "" @State private var color = "PINK" @State private var iconKey = "inbox" + @State private var headerHeight: CGFloat = 84 + @State private var contentHeight: CGFloat = ListSettingsSheetMetrics.initialSheetHeight - 84 private var trimmedName: String { name.trimmingCharacters(in: .whitespacesAndNewlines) @@ -3031,18 +3100,39 @@ private struct ListSettingsSheet: View { todoListSymbolName(for: iconKey) } + private var maximumSheetHeight: CGFloat { + max(1, UIScreen.main.bounds.height * ListSettingsSheetMetrics.maximumHeightFraction) + } + + private var measuredSheetHeight: CGFloat { + min(max(headerHeight + contentHeight, 1), maximumSheetHeight) + } + + private var contentNeedsScrolling: Bool { + headerHeight + contentHeight > maximumSheetHeight + } + var body: some View { VStack(spacing: 0) { - ListSettingsSheetHeader( - canSave: canSave, + TdaySheetHeader( + title: "List settings", + closeAccessibilityLabel: "Cancel", + confirmAccessibilityLabel: "Save", + isConfirmEnabled: canSave, onClose: { dismiss() }, onConfirm: submit ) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: ListSettingsSheetHeaderHeightKey.self, value: ceil(proxy.size.height)) + } + ) ScrollView(showsIndicators: false) { VStack(spacing: 14) { - ListSettingsSheetSectionTitle(text: "List") - ListSettingsSheetCard { + TdaySheetSectionTitle(text: "List") + TdaySheetCard { VStack(spacing: 18) { ZStack { Circle() @@ -3084,8 +3174,8 @@ private struct ListSettingsSheet: View { .padding(.vertical, 18) } - ListSettingsSheetSectionTitle(text: "Color") - ListSettingsSheetCard { + TdaySheetSectionTitle(text: "Color") + TdaySheetCard { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 12) { ForEach(todoListSettingsColorKeys, id: \.self) { colorKey in @@ -3122,8 +3212,8 @@ private struct ListSettingsSheet: View { } } - ListSettingsSheetSectionTitle(text: "Icon") - ListSettingsSheetCard { + TdaySheetSectionTitle(text: "Icon") + TdaySheetCard { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 10) { ForEach(todoListSettingsIconKeys, id: \.self) { optionKey in @@ -3172,14 +3262,21 @@ private struct ListSettingsSheet: View { } .padding(.horizontal, 18) .padding(.top, 14) - .padding(.bottom, 24) + .padding(.bottom, ListSettingsSheetMetrics.bottomContentPadding) + .background( + GeometryReader { proxy in + Color.clear + .preference(key: ListSettingsSheetContentHeightKey.self, value: ceil(proxy.size.height)) + } + ) } + .scrollDisabled(!contentNeedsScrolling) .scrollDismissesKeyboard(.interactively) .disableVerticalScrollBounce() } .frame(maxWidth: .infinity, alignment: .top) .background(tdayColors.bottomSheetBackground.ignoresSafeArea()) - .presentationDetents([.fraction(0.8)]) + .presentationDetents([.height(measuredSheetHeight)]) .presentationDragIndicator(.hidden) .presentationCornerRadius(34) .presentationBackground { @@ -3192,6 +3289,13 @@ private struct ListSettingsSheet: View { color = normalizedTodoListColorKey(list?.color) iconKey = normalizedTodoListIconKey(list?.iconKey) } + .onPreferenceChange(ListSettingsSheetHeaderHeightKey.self) { height in + headerHeight = max(height, 1) + } + .onPreferenceChange(ListSettingsSheetContentHeightKey.self) { height in + contentHeight = max(height, 1) + } + .animation(.snappy(duration: 0.24), value: measuredSheetHeight) } private func submit() { @@ -3210,77 +3314,6 @@ private struct ListSettingsSheet: View { } } -private struct ListSettingsSheetHeader: View { - let canSave: Bool - let onClose: () -> Void - let onConfirm: () -> Void - - @Environment(\.tdayColors) private var colors - - var body: some View { - HStack { - ListSettingsSheetActionButton( - icon: "xmark", - accessibilityLabel: "Cancel", - accentColor: Color(red: 227.0 / 255.0, green: 90.0 / 255.0, blue: 90.0 / 255.0), - enabled: true, - action: onClose - ) - - Spacer(minLength: 0) - - Text("List settings") - .font(.tdayRounded(size: 22, weight: .heavy)) - .foregroundStyle(colors.onSurface) - .lineLimit(1) - .minimumScaleFactor(0.82) - - Spacer(minLength: 0) - - ListSettingsSheetActionButton( - icon: "checkmark", - accessibilityLabel: "Save", - accentColor: Color(red: 47.0 / 255.0, green: 163.0 / 255.0, blue: 91.0 / 255.0), - enabled: canSave, - action: onConfirm - ) - } - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, 14) - .background(colors.bottomSheetBackground) - } -} - -private struct ListSettingsSheetSectionTitle: View { - let text: String - - @Environment(\.tdayColors) private var colors - - var body: some View { - Text(text) - .font(.tdayRounded(size: 22, weight: .bold)) - .foregroundStyle(colors.onSurfaceVariant) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 4) - } -} - -private struct ListSettingsSheetCard: View { - @Environment(\.tdayColors) private var colors - - @ViewBuilder let content: Content - - var body: some View { - content - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 28, style: .continuous) - .fill(colors.bottomSheetSurface) - ) - } -} - private struct ListSettingsSheetDeleteButton: View { let action: () -> Void @@ -3325,41 +3358,6 @@ private struct ListSettingsSheetDeleteButton: View { } } -private struct ListSettingsSheetActionButton: View { - let icon: String - let accessibilityLabel: String - let accentColor: Color - let enabled: Bool - let action: () -> Void - - @Environment(\.tdayColors) private var colors - - var body: some View { - Button(action: action) { - Image(systemName: icon) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(colors.onSurface.opacity(enabled ? 1 : 0.55)) - .frame(width: 54, height: 54) - .background(colors.bottomSheetControlSurface, in: Circle()) - .overlay { - Circle() - .stroke(accentColor.opacity(enabled ? 0.55 : 0.3), lineWidth: 1.5) - } - .contentShape(Circle()) - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: enabled ? 0.16 : 0.06 - ) - ) - .disabled(!enabled) - .accessibilityLabel(accessibilityLabel) - .accessibilityAddTraits(.isButton) - } -} - private struct TodoTimelineSection: Identifiable, Hashable { let id: String let title: String @@ -3914,7 +3912,7 @@ func priorityIndicatorSymbolName(_ priority: String) -> String? { case "medium": return "flag.fill" case "high", "urgent", "important": - return "exclamationmark.circle.fill" + return "flag.fill" default: return nil } @@ -3971,6 +3969,9 @@ private func todoModeAccentColor(_ mode: TodoListMode, listColorKey: String?) -> case .priority: return todoHexColor(0xE65E52) case .floater: + if let listColorKey, !listColorKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return todoListAccentColor(for: listColorKey) + } return todoHexColor(0x4D8F83) case .list: return todoListAccentColor(for: listColorKey) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 568c7f1c..51cb8a1d 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -132,15 +132,16 @@ struct CreateTaskSheet: View { var body: some View { VStack(spacing: 0) { - CreateTaskSheetHeader( + TdaySheetHeader( title: titleText, - submitAccessibilityLabel: submitText, - isSubmitEnabled: !isSubmitting && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - onCancel: { + closeAccessibilityLabel: "Cancel", + confirmAccessibilityLabel: submitText, + isConfirmEnabled: !isSubmitting && !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + onClose: { onDismiss() dismiss() }, - onSubmit: { + onConfirm: { Task { await submit() } @@ -158,14 +159,14 @@ struct CreateTaskSheet: View { CreateTaskSheetTextCard(title: $title, notes: $notes) if showScheduleControls { - CreateTaskSheetSectionTitle(text: "Schedule") - CreateTaskSheetGroupCard { + TdaySheetSectionTitle(text: "Schedule") + TdaySheetCard { CreateTaskSheetScheduleToggleRow( isOn: $scheduleEnabled ) if scheduleEnabled { - CreateTaskSheetDivider() + TdaySheetDivider() CreateTaskSheetDueRow( dueDate: $dueDate, @@ -177,8 +178,8 @@ struct CreateTaskSheet: View { } } - CreateTaskSheetSectionTitle(text: "Details") - CreateTaskSheetGroupCard { + TdaySheetSectionTitle(text: "Details") + TdaySheetCard { CreateTaskSheetSelectorTriggerRow( iconName: "list.bullet", title: "List", @@ -186,7 +187,7 @@ struct CreateTaskSheet: View { onTap: { activeSelector = .list } ) - CreateTaskSheetDivider() + TdaySheetDivider() CreateTaskSheetSelectorTriggerRow( iconName: "text.badge.checkmark", @@ -196,7 +197,7 @@ struct CreateTaskSheet: View { ) if showScheduleControls { - CreateTaskSheetDivider() + TdaySheetDivider() CreateTaskSheetSelectorTriggerRow( iconName: "repeat", @@ -339,7 +340,7 @@ struct CreateTaskSheet: View { } ForEach(lists) { list in - CreateTaskSheetSelectorDivider() + TdaySheetDivider(horizontalPadding: 20, opacity: 0.16) CreateTaskSheetSelectorRow( title: list.name, swatchColor: createTaskSheetListSwatchColor(list.color), @@ -353,7 +354,7 @@ struct CreateTaskSheet: View { case .priority: ForEach(Array(priorityOptions.enumerated()), id: \.element) { index, option in if index > 0 { - CreateTaskSheetSelectorDivider() + TdaySheetDivider(horizontalPadding: 20, opacity: 0.16) } CreateTaskSheetSelectorRow( title: option, @@ -368,7 +369,7 @@ struct CreateTaskSheet: View { case .recurrence: ForEach(Array(repeatOptions.enumerated()), id: \.element.label) { index, option in if index > 0 { - CreateTaskSheetSelectorDivider() + TdaySheetDivider(horizontalPadding: 20, opacity: 0.16) } CreateTaskSheetSelectorRow( title: option.label, @@ -437,14 +438,14 @@ private struct CreateTaskSheetTextCard: View { @Environment(\.tdayColors) private var colors var body: some View { - CreateTaskSheetGroupCard { + TdaySheetCard { CreateTaskSheetTextField( placeholder: "Title", text: $title, lineLimit: 1 ... 1 ) - CreateTaskSheetDivider() + TdaySheetDivider() CreateTaskSheetTextField( placeholder: "Notes", @@ -500,34 +501,6 @@ private struct CreateTaskSheetTextField: View { } } -private struct CreateTaskSheetSectionTitle: View { - let text: String - - @Environment(\.tdayColors) private var colors - - var body: some View { - Text(text) - .font(.tdayRounded(size: 22, weight: .bold)) - .foregroundStyle(colors.onSurfaceVariant) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 4) - } -} - -private struct CreateTaskSheetGroupCard: View { - @ViewBuilder let content: Content - - @Environment(\.tdayColors) private var colors - - var body: some View { - VStack(spacing: 0) { - content - } - .frame(maxWidth: .infinity) - .background(colors.bottomSheetSurface, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) - } -} - private struct CreateTaskSheetScheduleToggleRow: View { @Binding var isOn: Bool @@ -768,20 +741,20 @@ private struct CreateTaskSheetSelectorCard: View { @Environment(\.tdayColors) private var colors var body: some View { - VStack(alignment: .leading, spacing: 0) { - Text(title) - .font(.tdayRounded(size: 18, weight: .heavy)) - .foregroundStyle(colors.onSurfaceVariant) - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 12) + TdaySheetOverlayCard { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 12) - content + content + } + .padding(.bottom, 14) + .frame(maxWidth: 330) } - .padding(.bottom, 14) - .frame(maxWidth: 330) - .background(colors.bottomSheetSurface, in: RoundedRectangle(cornerRadius: 32, style: .continuous)) - .shadow(color: Color.black.opacity(0.18), radius: 24, x: 0, y: 18) } } @@ -824,107 +797,6 @@ private struct CreateTaskSheetSelectorRow: View { } } -private struct CreateTaskSheetSelectorDivider: View { - @Environment(\.tdayColors) private var colors - - var body: some View { - Rectangle() - .fill(colors.onSurfaceVariant.opacity(0.16)) - .frame(height: 1) - .padding(.horizontal, 20) - } -} - -private struct CreateTaskSheetDivider: View { - @Environment(\.tdayColors) private var colors - - var body: some View { - Rectangle() - .fill(colors.onSurfaceVariant.opacity(0.18)) - .frame(height: 1) - .padding(.horizontal, 18) - } -} - -private struct CreateTaskSheetHeader: View { - let title: String - let submitAccessibilityLabel: String - let isSubmitEnabled: Bool - let onCancel: () -> Void - let onSubmit: () -> Void - - @Environment(\.tdayColors) private var colors - - var body: some View { - HStack { - CreateTaskSheetHeaderButton( - systemName: "xmark", - accessibilityLabel: "Cancel", - accentColor: Color(red: 227.0 / 255.0, green: 90.0 / 255.0, blue: 90.0 / 255.0), - isEnabled: true, - action: onCancel - ) - - Spacer(minLength: 0) - - Text(title) - .font(.tdayRounded(size: 24, weight: .heavy)) - .foregroundStyle(colors.onSurface) - .lineLimit(1) - .minimumScaleFactor(0.78) - - Spacer(minLength: 0) - - CreateTaskSheetHeaderButton( - systemName: "checkmark", - accessibilityLabel: submitAccessibilityLabel, - accentColor: Color(red: 47.0 / 255.0, green: 163.0 / 255.0, blue: 91.0 / 255.0), - isEnabled: isSubmitEnabled, - action: onSubmit - ) - } - .padding(.horizontal, 18) - .padding(.top, 14) - .padding(.bottom, 14) - .background(colors.bottomSheetBackground) - } -} - -private struct CreateTaskSheetHeaderButton: View { - let systemName: String - let accessibilityLabel: String - let accentColor: Color - let isEnabled: Bool - let action: () -> Void - - @Environment(\.tdayColors) private var colors - - var body: some View { - Button(action: action) { - Image(systemName: systemName) - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(colors.onSurface.opacity(isEnabled ? 1 : 0.55)) - .frame(width: 56, height: 56) - .background(colors.bottomSheetControlSurface, in: Circle()) - .overlay { - Circle() - .stroke(accentColor.opacity(isEnabled ? 0.55 : 0.3), lineWidth: 1.5) - } - .contentShape(Circle()) - } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.04, - normalShadowOpacity: isEnabled ? 0.16 : 0.06 - ) - ) - .disabled(!isEnabled) - .accessibilityLabel(accessibilityLabel) - .accessibilityAddTraits(.isButton) - } -} - private func createTaskSheetListSwatchColor(_ raw: String?) -> Color { switch raw?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { case "PINK": diff --git a/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift b/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift index 62235625..2478e12d 100644 --- a/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift +++ b/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift @@ -23,6 +23,17 @@ struct PullToRefreshContainer: View { } } +extension View { + func tdayPullToRefresh( + isRefreshing: Bool, + action: @escaping @Sendable () async -> Void + ) -> some View { + PullToRefreshContainer(isRefreshing: isRefreshing, action: action) { + self + } + } +} + private struct RefreshContainerBody: View { let isRefreshing: Bool let action: @Sendable () async -> Void @@ -237,15 +248,22 @@ private struct PullRefreshOffsetObserver: UIViewRepresentable { } } - final class Coordinator { + final class Coordinator: NSObject { var onChange: (CGFloat) -> Void private weak var observedScrollView: UIScrollView? private var observation: NSKeyValueObservation? + private var overscrollDistance: CGFloat = 0 + private var gesturePullDistance: CGFloat = 0 + private var pullStartTranslationY: CGFloat? init(onChange: @escaping (CGFloat) -> Void) { self.onChange = onChange } + deinit { + observedScrollView?.panGestureRecognizer.removeTarget(self, action: #selector(handlePan(_:))) + } + func attach(to view: UIView) { guard let scrollView = view.nearestScrollView() else { return @@ -257,17 +275,56 @@ private struct PullRefreshOffsetObserver: UIViewRepresentable { return } + observedScrollView?.panGestureRecognizer.removeTarget(self, action: #selector(handlePan(_:))) observedScrollView = scrollView + scrollView.panGestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) observation = scrollView.observe(\.contentOffset, options: [.initial, .new]) { [weak self] scrollView, _ in self?.hideNativeRefreshControl(in: scrollView) let normalizedOffset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top - let pullDistance = max(-normalizedOffset, 0) - if Thread.isMainThread { - self?.onChange(pullDistance) - } else { - DispatchQueue.main.async { - self?.onChange(pullDistance) + self?.overscrollDistance = max(-normalizedOffset, 0) + self?.emitPullDistance() + } + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard let scrollView = observedScrollView else { + return + } + + let normalizedOffset = scrollView.contentOffset.y + scrollView.adjustedContentInset.top + let translationY = gesture.translation(in: scrollView).y + + switch gesture.state { + case .began: + pullStartTranslationY = nil + gesturePullDistance = 0 + case .changed: + if normalizedOffset <= 1, translationY > 0 { + if pullStartTranslationY == nil { + pullStartTranslationY = translationY } + gesturePullDistance = max(translationY - (pullStartTranslationY ?? translationY), 0) + } else if normalizedOffset > 1 || translationY <= 0 { + pullStartTranslationY = nil + gesturePullDistance = 0 + } + case .ended, .cancelled, .failed: + pullStartTranslationY = nil + gesturePullDistance = 0 + default: + break + } + + emitPullDistance() + } + + private func emitPullDistance() { + let pullDistance = max(overscrollDistance, gesturePullDistance) + if Thread.isMainThread { + onChange(pullDistance) + } else { + DispatchQueue.main.async { + self.onChange(pullDistance) } } } diff --git a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift index 054272d7..6816215f 100644 --- a/ios-swiftUI/Tday/UI/Component/SwipeActions.swift +++ b/ios-swiftUI/Tday/UI/Component/SwipeActions.swift @@ -31,12 +31,16 @@ extension View { } func todoTrailingSwipeActions( + rowID: String, + openRowID: Binding, enabled: Bool = true, onEdit: @escaping () -> Void, onDelete: @escaping () -> Void ) -> some View { modifier( TodoTrailingSwipeActionsModifier( + rowID: rowID, + openRowID: openRowID, enabled: enabled, onEdit: onEdit, onDelete: onDelete @@ -46,6 +50,8 @@ extension View { } private struct TodoTrailingSwipeActionsModifier: ViewModifier { + let rowID: String + @Binding var openRowID: String? let enabled: Bool let onEdit: () -> Void let onDelete: () -> Void @@ -68,6 +74,8 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { .background( HorizontalSwipePanObserver( enabled: enabled, + rowID: rowID, + openRowID: $openRowID, revealWidth: revealWidth, openVelocityThreshold: openVelocityThreshold, offsetX: $offsetX @@ -81,6 +89,11 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { revealHint() } } + .onChange(of: openRowID) { _, activeID in + if activeID != rowID && offsetX != 0 { + closeActions(clearOpenRow: false) + } + } .onChange(of: enabled) { _, isEnabled in if !isEnabled { closeActions() @@ -116,16 +129,26 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { } } - private func closeActions() { + private func claimRow() { + if openRowID != rowID { + openRowID = rowID + } + } + + private func closeActions(clearOpenRow: Bool = true) { withAnimation(.interactiveSpring(response: 0.26, dampingFraction: 0.86)) { offsetX = 0 } + if clearOpenRow && openRowID == rowID { + openRowID = nil + } } private func revealHint() { guard !isHinting else { return } isHinting = true + claimRow() Task { @MainActor in withAnimation(.spring(response: 0.26, dampingFraction: 0.78)) { offsetX = -28 @@ -136,18 +159,23 @@ private struct TodoTrailingSwipeActionsModifier: ViewModifier { } try? await Task.sleep(nanoseconds: 340_000_000) isHinting = false + if openRowID == rowID && offsetX == 0 { + openRowID = nil + } } } } private struct HorizontalSwipePanObserver: UIViewRepresentable { let enabled: Bool + let rowID: String + @Binding var openRowID: String? let revealWidth: CGFloat let openVelocityThreshold: CGFloat @Binding var offsetX: CGFloat func makeCoordinator() -> Coordinator { - Coordinator(offsetX: $offsetX) + Coordinator(rowID: rowID, openRowID: $openRowID, offsetX: $offsetX) } func makeUIView(context: Context) -> UIView { @@ -159,6 +187,8 @@ private struct HorizontalSwipePanObserver: UIViewRepresentable { func updateUIView(_ uiView: UIView, context: Context) { context.coordinator.enabled = enabled + context.coordinator.rowID = rowID + context.coordinator.openRowID = $openRowID context.coordinator.revealWidth = revealWidth context.coordinator.openVelocityThreshold = openVelocityThreshold context.coordinator.offsetX = $offsetX @@ -169,6 +199,8 @@ private struct HorizontalSwipePanObserver: UIViewRepresentable { final class Coordinator: NSObject, UIGestureRecognizerDelegate { var enabled = true + var rowID: String + var openRowID: Binding var revealWidth: CGFloat = 152 var openVelocityThreshold: CGFloat = -180 var offsetX: Binding @@ -185,7 +217,9 @@ private struct HorizontalSwipePanObserver: UIViewRepresentable { return recognizer }() - init(offsetX: Binding) { + init(rowID: String, openRowID: Binding, offsetX: Binding) { + self.rowID = rowID + self.openRowID = openRowID self.offsetX = offsetX } @@ -241,18 +275,30 @@ private struct HorizontalSwipePanObserver: UIViewRepresentable { switch recognizer.state { case .began: dragStartOffsetX = offsetX.wrappedValue + if dragStartOffsetX != 0 { + openRowID.wrappedValue = rowID + } case .changed: let translation = recognizer.translation(in: scrollView) let proposed = dragStartOffsetX + translation.x if proposed < 0 { + openRowID.wrappedValue = rowID offsetX.wrappedValue = max(-revealWidth * 1.12, min(0, proposed)) } else { offsetX.wrappedValue = 0 + if openRowID.wrappedValue == rowID { + openRowID.wrappedValue = nil + } } case .ended, .cancelled, .failed: let velocityX = recognizer.velocity(in: scrollView).x let shouldOpen = offsetX.wrappedValue < -(revealWidth * 0.32) || velocityX < openVelocityThreshold + if shouldOpen { + openRowID.wrappedValue = rowID + } else if openRowID.wrappedValue == rowID { + openRowID.wrappedValue = nil + } withAnimation(.interactiveSpring(response: 0.34, dampingFraction: 0.82)) { offsetX.wrappedValue = shouldOpen ? -revealWidth : 0 } diff --git a/ios-swiftUI/Tday/UI/Component/TdaySheetChrome.swift b/ios-swiftUI/Tday/UI/Component/TdaySheetChrome.swift new file mode 100644 index 00000000..1eb85f73 --- /dev/null +++ b/ios-swiftUI/Tday/UI/Component/TdaySheetChrome.swift @@ -0,0 +1,166 @@ +import SwiftUI + +enum TdaySheetMetrics { + static let horizontalPadding: CGFloat = 18 + static let verticalPadding: CGFloat = 14 + static let sectionSpacing: CGFloat = 14 + static let actionSize: CGFloat = 56 + static let actionIconSize: CGFloat = 22 + static let cardCornerRadius: CGFloat = 28 + static let overlayCornerRadius: CGFloat = 30 + static let selectorCornerRadius: CGFloat = 32 + static let sheetCornerRadius: CGFloat = 34 + static let closeAccent = Color(red: 227.0 / 255.0, green: 90.0 / 255.0, blue: 90.0 / 255.0) + static let confirmAccent = Color(red: 47.0 / 255.0, green: 163.0 / 255.0, blue: 91.0 / 255.0) +} + +struct TdaySheetHeader: View { + let title: String + var closeSystemName = "xmark" + var closeAccessibilityLabel = "Close" + var confirmSystemName: String? = "checkmark" + var confirmAccessibilityLabel = "Done" + var isConfirmEnabled = true + let onClose: () -> Void + var onConfirm: () -> Void = {} + + @Environment(\.tdayColors) private var colors + + var body: some View { + HStack { + TdaySheetActionButton( + systemName: closeSystemName, + accessibilityLabel: closeAccessibilityLabel, + accentColor: TdaySheetMetrics.closeAccent, + isEnabled: true, + action: onClose + ) + + Spacer(minLength: 0) + + Text(title) + .font(.tdayRounded(size: 24, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.78) + + Spacer(minLength: 0) + + if let confirmSystemName { + TdaySheetActionButton( + systemName: confirmSystemName, + accessibilityLabel: confirmAccessibilityLabel, + accentColor: TdaySheetMetrics.confirmAccent, + isEnabled: isConfirmEnabled, + action: onConfirm + ) + } else { + Color.clear + .frame(width: TdaySheetMetrics.actionSize, height: TdaySheetMetrics.actionSize) + } + } + .padding(.horizontal, TdaySheetMetrics.horizontalPadding) + .padding(.top, TdaySheetMetrics.verticalPadding) + .padding(.bottom, TdaySheetMetrics.verticalPadding) + .background(colors.bottomSheetBackground) + } +} + +struct TdaySheetActionButton: View { + let systemName: String + let accessibilityLabel: String + let accentColor: Color + let isEnabled: Bool + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: TdaySheetMetrics.actionIconSize, weight: .semibold)) + .foregroundStyle(colors.onSurface.opacity(isEnabled ? 1 : 0.55)) + .frame(width: TdaySheetMetrics.actionSize, height: TdaySheetMetrics.actionSize) + .background(colors.bottomSheetControlSurface, in: Circle()) + .overlay { + Circle() + .stroke(accentColor.opacity(isEnabled ? 0.55 : 0.3), lineWidth: 1.5) + } + .contentShape(Circle()) + } + .buttonStyle( + TdayPressButtonStyle( + shadowColor: Color.black, + pressedShadowOpacity: 0.04, + normalShadowOpacity: isEnabled ? 0.16 : 0.06 + ) + ) + .disabled(!isEnabled) + .accessibilityLabel(accessibilityLabel) + } +} + +struct TdaySheetSectionTitle: View { + let text: String + + @Environment(\.tdayColors) private var colors + + var body: some View { + Text(text) + .font(.tdayRounded(size: 22, weight: .bold)) + .foregroundStyle(colors.onSurfaceVariant) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 4) + } +} + +struct TdaySheetCard: View { + @ViewBuilder let content: Content + + @Environment(\.tdayColors) private var colors + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + content + } + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: TdaySheetMetrics.cardCornerRadius, style: .continuous) + .fill(colors.bottomSheetSurface) + ) + .clipShape(RoundedRectangle(cornerRadius: TdaySheetMetrics.cardCornerRadius, style: .continuous)) + } +} + +struct TdaySheetOverlayCard: View { + @ViewBuilder let content: Content + + @Environment(\.tdayColors) private var colors + + var body: some View { + content + .background( + colors.bottomSheetSurface, + in: RoundedRectangle(cornerRadius: TdaySheetMetrics.overlayCornerRadius, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: TdaySheetMetrics.overlayCornerRadius, style: .continuous) + .stroke(colors.cardStroke, lineWidth: 1) + } + .shadow(color: Color.black.opacity(colors.isDark ? 0.34 : 0.14), radius: 24, x: 0, y: 12) + } +} + +struct TdaySheetDivider: View { + var horizontalPadding: CGFloat = 18 + var opacity: Double = 0.18 + + @Environment(\.tdayColors) private var colors + + var body: some View { + Rectangle() + .fill(colors.onSurfaceVariant.opacity(opacity)) + .frame(height: 1) + .padding(.horizontal, horizontalPadding) + } +} diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 71f5dfe4..3ebf40e5 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -48,6 +48,7 @@ 5DB03218BE341B7C186F01AE /* CalendarPagingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E5A511171F96ABDEC46926 /* CalendarPagingScrollView.swift */; }; 64E0A2B205D80764F2BF52FB /* CalendarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EFEAE1EE18AB86477A56BC /* CalendarViewModel.swift */; }; 701E03BE9BBC8792CAD5919C /* CreateTaskSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFA6E39FBCB063E54C61AF7 /* CreateTaskSheet.swift */; }; + F2C4D6E8A1B3456790ABCDE0 /* TdaySheetChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2C4D6E8A1B3456790ABCDEF /* TdaySheetChrome.swift */; }; 72304EA28CF49303A8CCB6B0 /* TodayTasksWidgetSnapshotStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC80072C8B232C0C09CE3139 /* TodayTasksWidgetSnapshotStore.swift */; }; 765ED719B3CCBB90176C55EB /* DomainModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A6D006E491513605369E7D5 /* DomainModels.swift */; }; 846AE66C58EF435FB506E3E6 /* OnboardingWizardOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D617DF23936179DDFF13A36D /* OnboardingWizardOverlay.swift */; }; @@ -159,6 +160,7 @@ AD41A49221B1A947AF8A94D0 /* ThemeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStore.swift; sourceTree = ""; }; AE3D896A638869384B4CF1EF /* ErrorRetryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorRetryView.swift; sourceTree = ""; }; AEFA6E39FBCB063E54C61AF7 /* CreateTaskSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateTaskSheet.swift; sourceTree = ""; }; + F2C4D6E8A1B3456790ABCDEF /* TdaySheetChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TdaySheetChrome.swift; sourceTree = ""; }; AF0DB445B9FEE9B047FBB708 /* TodoListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoListViewModel.swift; sourceTree = ""; }; B3F7D3043A1EFC1A4EBBDA02 /* Tday.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tday.entitlements; sourceTree = ""; }; C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderPreferenceStore.swift; sourceTree = ""; }; @@ -425,6 +427,7 @@ AEFA6E39FBCB063E54C61AF7 /* CreateTaskSheet.swift */, 32538D5E9182018AFEAB8DEC /* PullToRefresh.swift */, 84798381A9E2DF3C9468ED8F /* SwipeActions.swift */, + F2C4D6E8A1B3456790ABCDEF /* TdaySheetChrome.swift */, ); path = Component; sourceTree = ""; @@ -709,6 +712,7 @@ CFD25493E8027AACCC39FE21 /* CompletedViewModel.swift in Sources */, 01C535860D5694F91772B940 /* CookieStore.swift in Sources */, 701E03BE9BBC8792CAD5919C /* CreateTaskSheet.swift in Sources */, + F2C4D6E8A1B3456790ABCDE0 /* TdaySheetChrome.swift in Sources */, 58BD5E982FB87AE6986E3E79 /* CreateTodoUseCase.swift in Sources */, 535CABAC18AD47FEF15D2C11 /* CredentialEnvelope.swift in Sources */, 765ED719B3CCBB90176C55EB /* DomainModels.swift in Sources */, From de54ffbe9ec446461b30e1609cefdc9a123e56a6 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 12:04:45 -0400 Subject: [PATCH 05/11] feat(ui): implement icon-to-text transitions for `RootFeedDock` on Android and iOS Update the `RootFeedDock` component to support a morphing transition between icons and text labels based on the dock's expansion state. When collapsed, the selected tab displays an icon; when expanded, tabs transition to show text labels. - **Shared UI Changes**: - Redefined dock dimensions: set collapsed width to match height (square aspect) and increased tab width to 102dp/pts to better accommodate text. - Added specialized icons for "Home" and "Floater" tabs. - **Android (Compose)**: - Updated `RootFeedDock.kt` to include `Icon` and `Text` layers within tab items. - Integrated `expansionProgress` to cross-fade alpha and apply subtle scale transforms during the transition from icon to text. - Added `RootFeedTab.icon()` helper to map tabs to `Icons.Rounded` vectors. - **iOS (SwiftUI)**: - Refactored `RootFeedDock.swift` to use a `ZStack` for toggling between `Image`/`RootFeedFloaterIcon` and `Text` based on `isExpanded`. - Created `RootFeedFloaterIcon`, a custom drawing of a bubble-style icon using `GeometryReader` and `Circle` shapes. - Improved accessibility by adding explicit labels to dock tab buttons. - Swapped the "Floater" system image from `tray.full.fill` to `circle.dotted`. --- .../tday/compose/ui/component/RootFeedDock.kt | 34 +++++++-- ios-swiftUI/Tday/Core/UI/RootFeedDock.swift | 71 ++++++++++++++++--- 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt index ea63e94f..c3f26fed 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/RootFeedDock.kt @@ -28,6 +28,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.BubbleChart +import androidx.compose.material.icons.rounded.Home import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -46,6 +48,7 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -66,10 +69,10 @@ enum class RootFeedTab { private val RootFeedTabs = listOf(RootFeedTab.HOME, RootFeedTab.FLOATER) private val RootFeedSliderAccent = Color(0xFF7D67B6) -private val RootFeedDockCollapsedWidth = 112.dp private val RootFeedDockHeight = 58.dp +private val RootFeedDockCollapsedWidth = RootFeedDockHeight private val RootFeedDockInnerPadding = 5.dp -private val RootFeedDockTabWidth = RootFeedDockCollapsedWidth - (RootFeedDockInnerPadding * 2) +private val RootFeedDockTabWidth = 102.dp private val RootFeedDockExpandedWidth = (RootFeedDockTabWidth * RootFeedTabs.size) + (RootFeedDockInnerPadding * 2) private val RootFeedDockShape = RoundedCornerShape(22.dp) @@ -82,6 +85,13 @@ private fun RootFeedTab.label(): String { } } +private fun RootFeedTab.icon(): ImageVector { + return when (this) { + RootFeedTab.HOME -> Icons.Rounded.Home + RootFeedTab.FLOATER -> Icons.Rounded.BubbleChart + } +} + @Composable fun RootCreateTaskButton( onClick: () -> Unit, @@ -359,6 +369,21 @@ fun RootFeedDock( ), contentAlignment = Alignment.Center, ) { + val textAlpha = if (selected) expansionProgress else 1f + val iconAlpha = if (selected) 1f - expansionProgress else 0f + + Icon( + imageVector = tab.icon(), + contentDescription = null, + tint = animatedContentColor, + modifier = Modifier + .size(22.dp) + .graphicsLayer { + alpha = iconAlpha * tabAlpha + scaleX = contentScale * (1f - (0.08f * expansionProgress)) + scaleY = contentScale * (1f - (0.08f * expansionProgress)) + }, + ) Text( text = tab.label(), style = MaterialTheme.typography.titleSmall, @@ -368,8 +393,9 @@ fun RootFeedDock( overflow = TextOverflow.Ellipsis, softWrap = false, modifier = Modifier.graphicsLayer { - scaleX = contentScale - scaleY = contentScale + alpha = textAlpha + scaleX = contentScale * (0.94f + (0.06f * textAlpha)) + scaleY = contentScale * (0.94f + (0.06f * textAlpha)) }, ) } diff --git a/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift b/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift index 79773f19..83934e06 100644 --- a/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift +++ b/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift @@ -18,7 +18,7 @@ enum RootFeedTab: Hashable { case .home: return "house.fill" case .floater: - return "tray.full.fill" + return "circle.dotted" } } } @@ -82,6 +82,7 @@ struct RootFeedDock: View { ForEach(Array(tabs.enumerated()), id: \.element) { index, tab in let selected = tab == activeTab + let foregroundColor = selected ? colors.onSurface : colors.onSurfaceVariant.opacity(0.82) let expandedX = CGFloat(index) * tabWidth let hiddenX = index < activeIndex ? -tabWidth : innerWidth let tabX = selected ? selectorX : (isExpanded ? expandedX : hiddenX) @@ -93,16 +94,35 @@ struct RootFeedDock: View { onSelect(tab) } } label: { - Text(tab.title) - .font(.tdayRounded(size: 13, weight: selected ? .black : .bold)) - .foregroundStyle(selected ? colors.onSurface : colors.onSurfaceVariant.opacity(0.82)) - .lineLimit(1) - .minimumScaleFactor(0.82) - .frame(width: tabWidth, height: innerHeight) - .contentShape(RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous)) + ZStack { + Group { + if tab == .floater { + RootFeedFloaterIcon(color: foregroundColor) + .frame(width: 22, height: 22) + } else { + Image(systemName: tab.systemImage) + .font(.system(size: 18, weight: selected ? .black : .bold, design: .rounded)) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(foregroundColor) + } + } + .opacity(selected && !isExpanded ? 1 : 0) + .scaleEffect(isExpanded ? 0.92 : 1) + + Text(tab.title) + .font(.tdayRounded(size: 13, weight: selected ? .black : .bold)) + .foregroundStyle(foregroundColor) + .lineLimit(1) + .minimumScaleFactor(0.82) + .opacity(isExpanded ? 1 : 0) + .scaleEffect(isExpanded ? 1 : 0.94) + } + .frame(width: tabWidth, height: innerHeight) + .contentShape(RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous)) } .buttonStyle(.plain) .disabled(!isExpanded && !selected) + .accessibilityLabel(Text(tab.title)) .opacity(selected ? 1 : (isExpanded ? 1 : 0)) .frame(width: tabWidth, height: innerHeight) .offset(x: tabX) @@ -134,11 +154,42 @@ struct RootFeedDock: View { } } +private struct RootFeedFloaterIcon: View { + let color: Color + + var body: some View { + GeometryReader { proxy in + let side = min(proxy.size.width, proxy.size.height) + let scale = side / 24 + + ZStack { + Circle() + .fill(color) + .frame(width: 6.4 * scale, height: 6.4 * scale) + .position(x: 7.2 * scale, y: 14.4 * scale) + + Circle() + .fill(color) + .frame(width: 4.0 * scale, height: 4.0 * scale) + .position(x: 14.8 * scale, y: 18.0 * scale) + + Circle() + .fill(color) + .frame(width: 9.6 * scale, height: 9.6 * scale) + .position(x: 15.2 * scale, y: 8.8 * scale) + } + .frame(width: side, height: side) + .position(x: proxy.size.width / 2, y: proxy.size.height / 2) + } + .aspectRatio(1, contentMode: .fit) + } +} + private enum RootFeedDockMetrics { - static let collapsedWidth: CGFloat = 112 static let height: CGFloat = 52 + static let collapsedWidth: CGFloat = height static let innerPadding: CGFloat = 5 - static let tabWidth: CGFloat = collapsedWidth - (innerPadding * 2) + static let tabWidth: CGFloat = 102 static let expandedWidth: CGFloat = (tabWidth * 2) + (innerPadding * 2) static let cornerRadius: CGFloat = 22 static let selectorCornerRadius: CGFloat = 18 From 6b123306ff775a99f43706c320a1e0237aae510e Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 12:10:57 -0400 Subject: [PATCH 06/11] refactor(ui): centralize toolbar button styling and effects on iOS Introduce a standardized `TdayToolbarButtonStyle` and `tdayToolbarButtonEffect` to unify button interactions, replacing manual shadow and animation configurations across the app. - **Core UI Components**: - Implement `TdayToolbarButtonStyle` and `TdayToolbarButtonEffectModifier` to handle ripple effects, scaling, and double-layered shadow animations (ambient and key shadows). - Add a `shadowsEnabled` toggle to the new effect modifier to support flat styles in compact layouts. - **Screen Refactors**: - **TodoListScreen**: Replace hardcoded `TdayPressButtonStyle` instances with the new `TdayToolbarButtonStyle` for search and filter buttons. - **HomeScreen**: Migrate `HomeToolbarButtonStyle` to use the centralized `.tdayToolbarButtonEffect()`, simplifying the shadow management logic. --- .../Core/UI/TaskFloatingActionButton.swift | 79 +++++++++++++++++++ .../Tday/Feature/Home/HomeScreen.swift | 6 +- .../Tday/Feature/Todos/TodoListScreen.swift | 14 +--- 3 files changed, 83 insertions(+), 16 deletions(-) diff --git a/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift b/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift index 07897b4d..ad6da847 100644 --- a/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift +++ b/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift @@ -98,6 +98,18 @@ struct TdayPressButtonStyle: ButtonStyle { } } +struct TdayToolbarButtonStyle: ButtonStyle { + var shadowsEnabled = true + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .tdayToolbarButtonEffect( + isPressed: configuration.isPressed, + shadowsEnabled: shadowsEnabled + ) + } +} + extension View { func tdayPressEffect( isPressed: Bool, @@ -115,6 +127,18 @@ extension View { ) } + func tdayToolbarButtonEffect( + isPressed: Bool, + shadowsEnabled: Bool = true + ) -> some View { + modifier( + TdayToolbarButtonEffectModifier( + isPressed: isPressed, + shadowsEnabled: shadowsEnabled + ) + ) + } + func tdayRippleEffect( isPressed: Bool, rippleColor: Color? = nil @@ -128,6 +152,61 @@ extension View { } } +private struct TdayToolbarButtonEffectModifier: ViewModifier { + let isPressed: Bool + let shadowsEnabled: Bool + + func body(content: Content) -> some View { + content + .tdayRippleEffect(isPressed: isPressed) + .scaleEffect(isPressed ? 0.95 : 1) + .offset(y: isPressed ? 1 : 0) + .shadow( + color: Color.black.opacity(ambientShadowOpacity), + radius: ambientShadowRadius, + x: 0, + y: ambientShadowOffsetY + ) + .shadow( + color: Color.black.opacity(keyShadowOpacity), + radius: keyShadowRadius, + x: 0, + y: keyShadowOffsetY + ) + .animation(.easeOut(duration: 0.14), value: isPressed) + } + + private var ambientShadowOpacity: Double { + guard shadowsEnabled else { return 0 } + return isPressed ? 0.035 : 0.08 + } + + private var keyShadowOpacity: Double { + guard shadowsEnabled else { return 0 } + return isPressed ? 0.03 : 0.045 + } + + private var ambientShadowRadius: CGFloat { + guard shadowsEnabled else { return 0 } + return isPressed ? 8 : 24 + } + + private var keyShadowRadius: CGFloat { + guard shadowsEnabled else { return 0 } + return isPressed ? 3 : 6 + } + + private var ambientShadowOffsetY: CGFloat { + guard shadowsEnabled else { return 0 } + return isPressed ? 4 : 12 + } + + private var keyShadowOffsetY: CGFloat { + guard shadowsEnabled else { return 0 } + return isPressed ? 2 : 4 + } +} + private struct TdayPressEffectModifier: ViewModifier { let isPressed: Bool let shadowColor: Color diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index e2e91fc8..8978b41b 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -1317,11 +1317,9 @@ private struct HomeIconButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .tdayPressEffect( + .tdayToolbarButtonEffect( isPressed: configuration.isPressed, - shadowColor: Color.black, - pressedShadowOpacity: compact ? 0 : 0.14, - normalShadowOpacity: compact ? 0 : 0.24 + shadowsEnabled: !compact ) } } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index ce30c2a7..cc907efb 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -320,11 +320,7 @@ private struct RootFeedSearchTitleRow: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.08, - normalShadowOpacity: 0.16 - ) + TdayToolbarButtonStyle() ) .opacity(searchExpanded ? 0 : 1) .allowsHitTesting(!searchExpanded) @@ -400,13 +396,7 @@ private struct RootFeedHeaderIconButton: View { .stroke(colors.onSurface.opacity(0.34), lineWidth: 1) } } - .buttonStyle( - TdayPressButtonStyle( - shadowColor: Color.black, - pressedShadowOpacity: 0.08, - normalShadowOpacity: 0.16 - ) - ) + .buttonStyle(TdayToolbarButtonStyle()) } } From 8c82ceb05c47450d9a64c23d8fbe889618755c55 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 12:19:50 -0400 Subject: [PATCH 07/11] refactor(ios): promote selector components and refine UI visual styles Centralize and promote internal selector components to shared UI utilities and tune several visual metrics across the iOS application. - **Component Refactoring**: - Extract `CreateTaskSheetSelectorCard` and `CreateTaskSheetSelectorRow` from `CreateTaskSheet.swift` to `TdaySheetChrome.swift`, renaming them to `TdayCenteredSelectorCard` and `TdayCenteredSelectorRow` for broader reuse. - Update `CreateTaskSheet` to utilize these new shared components for list, priority, and repeat rule selection. - **UI & Layout Refinements**: - **TodoList**: Adjust `rootFeedTitleBottomInset` from 0 to 12 in `TodoListScreen` for better spacing. - **RootFeedDock**: Update background materials and opacities to improve layering and translucency in both light and dark modes. - **FAB Shadows**: Soften the shadow effects on `TaskFloatingActionButton` by reducing radii, offsets, and opacities for a more subtle elevation. - **Android (Compose)**: - Add missing imports to `TdaySheetChrome.kt` (no functional changes in the provided diff beyond setup for future expansion). --- .../compose/ui/component/TdaySheetChrome.kt | 141 ++++++++++++++++++ ios-swiftUI/Tday/Core/UI/RootFeedDock.swift | 12 +- .../Core/UI/TaskFloatingActionButton.swift | 12 +- .../Tday/Feature/Todos/TodoListScreen.swift | 3 +- .../Tday/UI/Component/CreateTaskSheet.swift | 73 +-------- .../Tday/UI/Component/TdaySheetChrome.swift | 63 ++++++++ 6 files changed, 226 insertions(+), 78 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt index c3af86d7..8b3b67f8 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdaySheetChrome.kt @@ -2,9 +2,12 @@ package com.ohmz.tday.compose.ui.component import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -13,12 +16,15 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.Card @@ -45,6 +51,8 @@ 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.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.view.HapticFeedbackConstantsCompat import androidx.core.view.ViewCompat import com.ohmz.tday.compose.ui.theme.TdayDimens @@ -154,6 +162,139 @@ fun TdayCenteredSheetContent( } } +@Composable +fun TdayCenteredSelectorDialog( + title: String, + options: List, + optionLabel: (T) -> String, + optionSwatchColor: (T) -> Color, + isSelected: (T) -> Boolean, + onDismiss: () -> Unit, + onOptionSelected: (T) -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + val containerColor = TdaySheetDefaults.surfaceColor() + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(TdaySheetDefaults.scrimColor()) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onDismiss, + ), + contentAlignment = Alignment.Center, + ) { + Card( + modifier = Modifier + .fillMaxWidth(0.74f) + .heightIn(max = 380.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = {}, + ), + shape = TdaySheetDefaults.SelectorShape, + colors = CardDefaults.cardColors(containerColor = containerColor), + elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(vertical = 10.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurfaceVariant, + fontWeight = FontWeight.ExtraBold, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), + ) + + options.forEachIndexed { index, option -> + if (index > 0) { + TdayCenteredSelectorDivider() + } + TdayCenteredSelectorRow( + title = optionLabel(option), + swatchColor = optionSwatchColor(option), + selected = isSelected(option), + onClick = { onOptionSelected(option) }, + ) + } + } + } + } + } +} + +@Composable +private fun TdayCenteredSelectorDivider() { + Box( + modifier = Modifier + .padding(horizontal = 18.dp) + .fillMaxWidth() + .height(1.dp) + .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f)), + ) +} + +@Composable +private fun TdayCenteredSelectorRow( + title: String, + swatchColor: Color, + selected: Boolean, + onClick: () -> Unit, +) { + val colorScheme = MaterialTheme.colorScheme + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(10.dp) + .background( + color = swatchColor, + shape = RoundedCornerShape(999.dp), + ), + ) + + Spacer(modifier = Modifier.width(14.dp)) + + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = colorScheme.onSurface, + fontWeight = FontWeight.ExtraBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } else { + Spacer(modifier = Modifier.size(20.dp)) + } + } +} + @Composable fun TdaySheetHeader( title: String, diff --git a/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift b/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift index 83934e06..7810cc26 100644 --- a/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift +++ b/ios-swiftUI/Tday/Core/UI/RootFeedDock.swift @@ -54,10 +54,11 @@ struct RootFeedDock: View { ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: RootFeedDockMetrics.cornerRadius, style: .continuous) - .fill(.ultraThinMaterial) + .fill(colors.surfaceVariant.opacity(colors.isDark ? 0.62 : 0.50)) .background( RoundedRectangle(cornerRadius: RootFeedDockMetrics.cornerRadius, style: .continuous) - .fill(colors.surfaceVariant.opacity(colors.isDark ? 0.76 : 0.68)) + .fill(.ultraThinMaterial) + .opacity(colors.isDark ? 0.44 : 0.34) ) .overlay( RoundedRectangle(cornerRadius: RootFeedDockMetrics.cornerRadius, style: .continuous) @@ -66,7 +67,12 @@ struct RootFeedDock: View { ZStack(alignment: .topLeading) { RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous) - .fill(colors.isDark ? colors.background.opacity(0.90) : colors.surface.opacity(0.98)) + .fill(colors.isDark ? colors.background.opacity(0.82) : colors.surface.opacity(0.90)) + .background( + RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + .opacity(colors.isDark ? 0.30 : 0.24) + ) .overlay( RoundedRectangle(cornerRadius: RootFeedDockMetrics.selectorCornerRadius, style: .continuous) .fill(accentColor.opacity(colors.isDark ? 0.04 : 0.06)) diff --git a/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift b/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift index ad6da847..25b8bc32 100644 --- a/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift +++ b/ios-swiftUI/Tday/Core/UI/TaskFloatingActionButton.swift @@ -178,32 +178,32 @@ private struct TdayToolbarButtonEffectModifier: ViewModifier { private var ambientShadowOpacity: Double { guard shadowsEnabled else { return 0 } - return isPressed ? 0.035 : 0.08 + return isPressed ? 0.025 : 0.045 } private var keyShadowOpacity: Double { guard shadowsEnabled else { return 0 } - return isPressed ? 0.03 : 0.045 + return isPressed ? 0.02 : 0.026 } private var ambientShadowRadius: CGFloat { guard shadowsEnabled else { return 0 } - return isPressed ? 8 : 24 + return isPressed ? 5 : 10 } private var keyShadowRadius: CGFloat { guard shadowsEnabled else { return 0 } - return isPressed ? 3 : 6 + return isPressed ? 2 : 3 } private var ambientShadowOffsetY: CGFloat { guard shadowsEnabled else { return 0 } - return isPressed ? 4 : 12 + return isPressed ? 2 : 4 } private var keyShadowOffsetY: CGFloat { guard shadowsEnabled else { return 0 } - return isPressed ? 2 : 4 + return isPressed ? 1 : 2 } } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index cc907efb..96631dc3 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -56,6 +56,7 @@ enum TodoTimelineMetrics { static let sectionHeaderBottomPadding: CGFloat = 2 static let titleCollapseDistance: CGFloat = 64 static let rootFeedTitleTopInset: CGFloat = 32 + static let rootFeedTitleBottomInset: CGFloat = 12 static let timelineBottomSpacerHeight: CGFloat = 120 static let rootFloaterBottomSpacerHeight: CGFloat = 12 static let rootDockCollapseThreshold: CGFloat = 44 @@ -1014,7 +1015,7 @@ struct TodoListScreen: View { EdgeInsets( top: TodoTimelineMetrics.rootFeedTitleTopInset, leading: TodoTimelineMetrics.horizontalPadding, - bottom: 0, + bottom: TodoTimelineMetrics.rootFeedTitleBottomInset, trailing: TodoTimelineMetrics.horizontalPadding ) ) diff --git a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift index 51cb8a1d..2d40f420 100644 --- a/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift +++ b/ios-swiftUI/Tday/UI/Component/CreateTaskSheet.swift @@ -327,10 +327,10 @@ struct CreateTaskSheet: View { activeSelector = nil } - CreateTaskSheetSelectorCard(title: selector.title) { + TdayCenteredSelectorCard(title: selector.title) { switch selector { case .list: - CreateTaskSheetSelectorRow( + TdayCenteredSelectorRow( title: "No list", swatchColor: colors.onSurfaceVariant.opacity(0.35), selected: selectedListID == nil @@ -341,7 +341,7 @@ struct CreateTaskSheet: View { ForEach(lists) { list in TdaySheetDivider(horizontalPadding: 20, opacity: 0.16) - CreateTaskSheetSelectorRow( + TdayCenteredSelectorRow( title: list.name, swatchColor: createTaskSheetListSwatchColor(list.color), selected: selectedListID == list.id @@ -356,7 +356,7 @@ struct CreateTaskSheet: View { if index > 0 { TdaySheetDivider(horizontalPadding: 20, opacity: 0.16) } - CreateTaskSheetSelectorRow( + TdayCenteredSelectorRow( title: option, swatchColor: createTaskSheetPrioritySwatchColor(option), selected: priority == option @@ -371,7 +371,7 @@ struct CreateTaskSheet: View { if index > 0 { TdaySheetDivider(horizontalPadding: 20, opacity: 0.16) } - CreateTaskSheetSelectorRow( + TdayCenteredSelectorRow( title: option.label, swatchColor: createTaskSheetRepeatSwatchColor(option.value), selected: repeatRule == option.value @@ -734,69 +734,6 @@ private struct CreateTaskSheetSelectorTriggerRow: View { } } -private struct CreateTaskSheetSelectorCard: View { - let title: String - @ViewBuilder let content: Content - - @Environment(\.tdayColors) private var colors - - var body: some View { - TdaySheetOverlayCard { - VStack(alignment: .leading, spacing: 0) { - Text(title) - .font(.tdayRounded(size: 18, weight: .heavy)) - .foregroundStyle(colors.onSurfaceVariant) - .padding(.horizontal, 20) - .padding(.top, 20) - .padding(.bottom, 12) - - content - } - .padding(.bottom, 14) - .frame(maxWidth: 330) - } - } -} - -private struct CreateTaskSheetSelectorRow: View { - let title: String - let swatchColor: Color - let selected: Bool - let action: () -> Void - - @Environment(\.tdayColors) private var colors - - var body: some View { - Button(action: action) { - HStack(spacing: 14) { - Circle() - .fill(swatchColor) - .frame(width: 10, height: 10) - - Text(title) - .font(.tdayRounded(size: 18, weight: .heavy)) - .foregroundStyle(colors.onSurface) - .lineLimit(1) - - Spacer(minLength: 12) - - if selected { - Image(systemName: "checkmark") - .font(.system(size: 18, weight: .bold)) - .foregroundStyle(colors.primary) - } else { - Color.clear - .frame(width: 18, height: 18) - } - } - .padding(.horizontal, 20) - .padding(.vertical, 14) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - private func createTaskSheetListSwatchColor(_ raw: String?) -> Color { switch raw?.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() { case "PINK": diff --git a/ios-swiftUI/Tday/UI/Component/TdaySheetChrome.swift b/ios-swiftUI/Tday/UI/Component/TdaySheetChrome.swift index 1eb85f73..7928dbb1 100644 --- a/ios-swiftUI/Tday/UI/Component/TdaySheetChrome.swift +++ b/ios-swiftUI/Tday/UI/Component/TdaySheetChrome.swift @@ -151,6 +151,69 @@ struct TdaySheetOverlayCard: View { } } +struct TdayCenteredSelectorCard: View { + let title: String + @ViewBuilder let content: Content + + @Environment(\.tdayColors) private var colors + + var body: some View { + TdaySheetOverlayCard { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurfaceVariant) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 12) + + content + } + .padding(.bottom, 14) + .frame(maxWidth: 330) + } + } +} + +struct TdayCenteredSelectorRow: View { + let title: String + let swatchColor: Color + let selected: Bool + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: action) { + HStack(spacing: 14) { + Circle() + .fill(swatchColor) + .frame(width: 10, height: 10) + + Text(title) + .font(.tdayRounded(size: 18, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .lineLimit(1) + + Spacer(minLength: 12) + + if selected { + Image(systemName: "checkmark") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(colors.primary) + } else { + Color.clear + .frame(width: 18, height: 18) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} + struct TdaySheetDivider: View { var horizontalPadding: CGFloat = 18 var opacity: Double = 0.18 From bcf3029a1fa765513db0df91f2c0068526bbf95d Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 12:34:12 -0400 Subject: [PATCH 08/11] refactor: unify reminder selector UI and optimize calendar layout across platforms This update standardizes the reminder selection experience by replacing platform-native menus with a custom centered selector overlay/dialog on both iOS and Android. It also refactors the calendar view logic to improve layout stability during transitions. - **Settings & Reminders (iOS & Android)**: - Replaced the standard `Menu` (iOS) and `DropdownMenu` (Android) for default reminder selection with a custom `TdayCenteredSelectorDialog` / `SettingsReminderSelectorOverlay`. - Implemented a unified color swatch logic for reminder options to provide consistent visual cues. - Added a scale and opacity transition for the new selector overlay on iOS. - **iOS Calendar UI**: - Refactored `CalendarScreen` to group the calendar mode card, error views, and task rows into a single `calendarModeAndTaskSection`. - Moved the task list logic into a dedicated `pendingTaskRows` ViewBuilder to simplify the main body. - Optimized layout transitions by applying the `calendarModeResizeAnimation` to the entire container rather than individual list rows, reducing visual jitter when switching display modes. - **Shared Components (Android)**: - Renamed and promoted `CenteredSelectorDialog` to `TdayCenteredSelectorDialog` within `CreateTaskBottomSheet.kt` to facilitate reuse across the application. --- .../feature/settings/SettingsScreen.kt | 71 ++++----- .../ui/component/CreateTaskBottomSheet.kt | 125 +-------------- .../Feature/Calendar/CalendarScreen.swift | 149 ++++++++++-------- .../Feature/Settings/SettingsScreen.swift | 87 ++++++++-- 4 files changed, 185 insertions(+), 247 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt index c543a796..ec88397a 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt @@ -25,15 +25,12 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.ChevronLeft import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -68,6 +65,7 @@ import com.ohmz.tday.compose.core.data.server.VersionCheckResult import com.ohmz.tday.compose.core.model.SessionUser import com.ohmz.tday.compose.core.notification.ReminderOption import com.ohmz.tday.compose.core.ui.rememberScrollCollapsingTitleScrollBehavior +import com.ohmz.tday.compose.ui.component.TdayCenteredSelectorDialog import com.ohmz.tday.compose.ui.component.TdaySegmentedSlider import com.ohmz.tday.compose.ui.theme.AppThemeMode import com.ohmz.tday.compose.ui.theme.TdayDimens @@ -602,41 +600,38 @@ private fun ReminderSelector( } } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - ReminderOption.entries.forEach { option -> - val isSelected = option == selectedReminder - DropdownMenuItem( - text = { - Text( - text = option.label, - fontWeight = FontWeight.ExtraBold, - ) - }, - onClick = { - ViewCompat.performHapticFeedback( - view, - HapticFeedbackConstantsCompat.CLOCK_TICK, - ) - onReminderSelected(option) - expanded = false - }, - trailingIcon = if (isSelected) { - { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.secondary, - modifier = Modifier.size(18.dp), - ) - } - } else { - null - }, - ) - } + if (expanded) { + TdayCenteredSelectorDialog( + title = "Default reminder", + options = ReminderOption.entries, + optionLabel = { option -> option.label }, + optionSwatchColor = { option -> reminderSwatchColor(option) }, + isSelected = { option -> option == selectedReminder }, + onDismiss = { expanded = false }, + onOptionSelected = { option -> + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.CLOCK_TICK, + ) + onReminderSelected(option) + expanded = false + }, + ) } } } + +private fun reminderSwatchColor(option: ReminderOption): Color { + return when (option) { + ReminderOption.NONE -> Color(0xFFB7BCC8) + ReminderOption.AT_TIME -> Color(0xFF6EA8E1) + ReminderOption.MINUTES_5 -> Color(0xFF7088C8) + ReminderOption.MINUTES_10 -> Color(0xFF7D67B6) + ReminderOption.MINUTES_15 -> Color(0xFFC7AA63) + ReminderOption.MINUTES_30 -> Color(0xFFD39A82) + ReminderOption.HOURS_1 -> Color(0xFF8DBB73) + ReminderOption.HOURS_2 -> Color(0xFF67AAA7) + ReminderOption.DAYS_1 -> Color(0xFF9A86CF) + ReminderOption.DAYS_2 -> Color(0xFFC98299) + } +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt index fe7e8c09..2cfb4c02 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/CreateTaskBottomSheet.kt @@ -38,7 +38,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.List import androidx.compose.material.icons.rounded.CalendarMonth -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.ExpandMore import androidx.compose.material.icons.rounded.LowPriority @@ -856,7 +855,7 @@ private fun SheetDropdownRow( ) if (selectorOpen) { - CenteredSelectorDialog( + TdayCenteredSelectorDialog( title = title, options = options, optionLabel = optionLabel, @@ -872,128 +871,6 @@ private fun SheetDropdownRow( } } -@Composable -private fun CenteredSelectorDialog( - title: String, - options: List, - optionLabel: (T) -> String, - optionSwatchColor: (T) -> Color, - isSelected: (T) -> Boolean, - onDismiss: () -> Unit, - onOptionSelected: (T) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - val containerColor = TdaySheetDefaults.surfaceColor() - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Box( - modifier = Modifier - .fillMaxSize() - .background(TdaySheetDefaults.scrimColor()) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onDismiss, - ), - contentAlignment = Alignment.Center, - ) { - Card( - modifier = Modifier - .fillMaxWidth(0.74f) - .heightIn(max = 380.dp) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = {}, - ), - shape = TdaySheetDefaults.SelectorShape, - colors = CardDefaults.cardColors(containerColor = containerColor), - elevation = CardDefaults.cardElevation(defaultElevation = 18.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(vertical = 10.dp), - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = colorScheme.onSurfaceVariant, - fontWeight = FontWeight.ExtraBold, - modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp), - ) - - options.forEachIndexed { index, option -> - if (index > 0) { - RowDivider() - } - CenteredSelectorRow( - title = optionLabel(option), - swatchColor = optionSwatchColor(option), - selected = isSelected(option), - onClick = { onOptionSelected(option) }, - ) - } - } - } - } - } -} - -@Composable -private fun CenteredSelectorRow( - title: String, - swatchColor: Color, - selected: Boolean, - onClick: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding(horizontal = 18.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(10.dp) - .background( - color = swatchColor, - shape = RoundedCornerShape(999.dp), - ), - ) - - Spacer(modifier = Modifier.width(14.dp)) - - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - color = colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - - if (selected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } else { - Spacer(modifier = Modifier.size(20.dp)) - } - } -} - private fun listColorSwatchForSelector(raw: String?, fallback: Color): Color { if (raw.isNullOrBlank()) return fallback return when (raw.trim().uppercase()) { diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index a1d888c8..026e6ef2 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -260,76 +260,11 @@ struct CalendarScreen: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) - animatedCalendarModeCard - .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: CalendarModeCardMetrics.shadowBleed, trailing: TodoTimelineMetrics.horizontalPadding)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - - if let errorMessage = viewModel.errorMessage { - Section { - ErrorRetryView(message: errorMessage) { - Task { await viewModel.refresh() } - } - .listRowBackground(Color.clear) - } - } - - 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: { - if openSwipeTaskID == todo.id { - openSwipeTaskID = nil - } - Task { await viewModel.complete(todo) } - } - ) - .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( - rowID: todo.id, - openRowID: $openSwipeTaskID, - onEdit: { - editingTodo = todo - }, - onDelete: { - Task { await viewModel.delete(todo) } - } - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - } - .animation( - .spring(response: 0.34, dampingFraction: 0.9), - value: pendingItems.map(\.id) - ) - .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) - .listRowBackground(colors.background) - .listRowSeparator(.hidden) - .listSectionSeparator(.hidden) + calendarModeAndTaskSection + .listRowInsets(EdgeInsets(top: 0, leading: TodoTimelineMetrics.horizontalPadding, bottom: 0, trailing: TodoTimelineMetrics.horizontalPadding)) + .listRowBackground(colors.background) + .listRowSeparator(.hidden) + .listSectionSeparator(.hidden) } } .listRowBackground(colors.background) @@ -467,6 +402,80 @@ struct CalendarScreen: View { ) } + private var calendarModeAndTaskSection: some View { + VStack(alignment: .leading, spacing: 0) { + animatedCalendarModeCard + .padding(.bottom, CalendarModeCardMetrics.shadowBleed) + + if let errorMessage = viewModel.errorMessage { + ErrorRetryView(message: errorMessage) { + Task { await viewModel.refresh() } + } + .padding(.bottom, 12) + } + + Text("Tasks due \(selectedDateHeaderText)") + .font(.tdayRounded(size: 22, weight: .heavy)) + .foregroundStyle(colors.onSurface) + .textCase(nil) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + .padding(.bottom, 4) + + pendingTaskRows + } + .animation(calendarModeResizeAnimation, value: calendarCardHeight) + } + + @ViewBuilder + private var pendingTaskRows: some View { + 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: { + if openSwipeTaskID == todo.id { + openSwipeTaskID = nil + } + Task { await viewModel.complete(todo) } + } + ) + .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( + rowID: todo.id, + openRowID: $openSwipeTaskID, + onEdit: { + editingTodo = todo + }, + onDelete: { + Task { await viewModel.delete(todo) } + } + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .animation( + .spring(response: 0.34, dampingFraction: 0.9), + value: pendingItems.map(\.id) + ) + } + } + private var animatedCalendarModeCard: some View { calendarModeContent(for: displayMode) .transaction { transaction in diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index 441dffb0..86f0127b 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -8,6 +8,7 @@ struct SettingsScreen: View { @Environment(\.dismiss) private var dismiss @Environment(\.tdayColors) private var colors @State private var settingsScrollOffset: CGFloat = 0 + @State private var showingReminderSelector = false private var isAdminUser: Bool { (viewModel.user?.role ?? "").uppercased() == "ADMIN" @@ -44,6 +45,18 @@ struct SettingsScreen: View { action: nil ) } + .overlay { + if showingReminderSelector { + SettingsReminderSelectorOverlay( + selectedReminder: viewModel.selectedReminder, + onSelect: viewModel.setDefaultReminder, + onDismiss: { + showingReminderSelector = false + } + ) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + } + } .task { await viewModel.refreshAdminAiSummarySetting() await viewModel.refreshVersionInfo() @@ -65,6 +78,7 @@ struct SettingsScreen: View { } message: { Text(viewModel.aiSummaryValidationError ?? "") } + .animation(.spring(response: 0.24, dampingFraction: 0.9), value: showingReminderSelector) } private var settingsContent: some View { @@ -86,7 +100,9 @@ struct SettingsScreen: View { SettingsSectionTitle("Reminders") SettingsReminderSelector( selectedReminder: viewModel.selectedReminder, - onSelect: viewModel.setDefaultReminder + onOpen: { + showingReminderSelector = true + } ) } } @@ -271,24 +287,12 @@ private struct SettingsThemeSelector: View { private struct SettingsReminderSelector: View { let selectedReminder: ReminderOption - let onSelect: (ReminderOption) -> Void + let onOpen: () -> Void @Environment(\.tdayColors) private var colors var body: some View { - Menu { - ForEach(ReminderOption.allCases) { option in - Button { - onSelect(option) - } label: { - if option == selectedReminder { - Label(option.label, systemImage: "checkmark") - } else { - Text(option.label) - } - } - } - } label: { + Button(action: onOpen) { SettingsRowLabel( title: "Default reminder", value: selectedReminder.label, @@ -300,6 +304,59 @@ private struct SettingsReminderSelector: View { } } +private struct SettingsReminderSelectorOverlay: View { + let selectedReminder: ReminderOption + let onSelect: (ReminderOption) -> Void + let onDismiss: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + ZStack { + colors.bottomSheetScrim + .ignoresSafeArea() + .onTapGesture(perform: onDismiss) + + TdayCenteredSelectorCard(title: "Default reminder") { + ForEach(Array(ReminderOption.allCases.enumerated()), id: \.element.id) { index, option in + if index > 0 { + TdaySheetDivider(horizontalPadding: 20, opacity: 0.16) + } + + TdayCenteredSelectorRow( + title: option.label, + swatchColor: reminderSwatchColor(option, colors: colors), + selected: option == selectedReminder + ) { + onSelect(option) + onDismiss() + } + } + } + .padding(.horizontal, 54) + } + } + + private func reminderSwatchColor(_ option: ReminderOption, colors: TdayColors) -> Color { + switch option { + case .none: + return colors.onSurfaceVariant.opacity(0.35) + case .atTime: + return Color.tdayTodayBlue + case .fiveMinutes: + return Color(red: 0.44, green: 0.53, blue: 0.78) + case .fifteenMinutes: + return Color(red: 0.78, green: 0.58, blue: 0.40) + case .oneHour: + return Color(red: 0.56, green: 0.70, blue: 0.48) + case .oneDay: + return Color(red: 0.61, green: 0.54, blue: 0.82) + case .twoDays: + return Color(red: 0.78, green: 0.48, blue: 0.58) + } + } +} + private struct SettingsAiSummaryRow: View { let viewModel: AppViewModel From 910273f9efa09bde3f840059340f582b0a79eb7a Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 13:17:03 -0400 Subject: [PATCH 09/11] feat(ux): implement "Local Mode" for offline-only usage on iOS and Android This update introduces a "Local Mode" (or "This device" mode) that allows users to use the app without a server connection or account. It refines the onboarding process, disables synchronization logic when in local mode, and updates the UI to reflect the available feature set. - **Onboarding & Authentication**: - **UI/UX**: Redesigned the onboarding wizard with a new "Mode" step to choose between "Self-hosted" and "This device" setups. Improved visual styling with hero tiles, updated chips, and themed iconography. - **Logic**: Added `AppDataMode` (`unset`, `server`, `local`) to track the workspace type. Implemented `useLocalMode` in view models to initialize a local-only environment by clearing server config and halting sync loops. - **Data & Sync Management**: - **Local State**: Updated `SyncManager`, `TodoRepository`, `ListRepository`, and `CompletedRepository` to bypass API calls and synchronization logic when `isLocalMode` is active. - **Persistence**: Enhanced `SecureStore` (iOS) and `SecureConfigStore` (Android) to persist the selected data mode. - **Cache**: Modified `OfflineCacheManager` to normalize state in local mode by stripping pending mutations and sync timestamps. - **UI Refinements & Feature Gating**: - **Gating**: Disabled server-dependent features in Local Mode, including AI summaries, NLP title parsing, admin settings, and the sign-out option. - **UX**: - Disabled pull-to-refresh across all screens (Home, TodoList, Calendar, Completed) when in local mode. - Suppressed the `OfflineBanner` and version check alerts for local workspaces. - Updated `SettingsScreen` to hide profile cards and server version info in local mode. - **Platform Specifics**: - **Android**: Updated `TdayApp` navigation logic and `OnboardingWizardOverlay` with Compose-based UI enhancements. - **iOS**: Updated `AppRootView` navigation logic and `OnboardingWizardOverlay` with SwiftUI-based UI enhancements; updated project files to include new `AppDataMode` files. --- .../java/com/ohmz/tday/compose/TdayApp.kt | 28 +- .../tday/compose/core/data/AppDataMode.kt | 7 + .../compose/core/data/SecureConfigStore.kt | 30 +- .../core/data/cache/OfflineCacheManager.kt | 17 +- .../data/completed/CompletedRepository.kt | 10 + .../core/data/list/FloaterListRepository.kt | 12 + .../compose/core/data/list/ListRepository.kt | 12 + .../data/server/ServerConfigRepository.kt | 13 +- .../core/data/settings/SettingsRepository.kt | 11 + .../compose/core/data/sync/SyncManager.kt | 23 +- .../compose/core/data/todo/TodoRepository.kt | 25 + .../tday/compose/feature/app/AppViewModel.kt | 103 +++- .../tday/compose/feature/home/HomeScreen.kt | 2 + .../onboarding/OnboardingWizardOverlay.kt | 461 +++++++++++++++--- .../feature/settings/SettingsScreen.kt | 33 +- .../compose/feature/todos/TodoListScreen.kt | 2 + .../compose/ui/component/TdayPullRefresh.kt | 10 + .../app/src/main/res/values/strings.xml | 17 +- .../compose/feature/app/AppViewModelTest.kt | 38 ++ ios-swiftUI/Tday/Core/Data/AppContainer.swift | 6 +- ios-swiftUI/Tday/Core/Data/AppDataMode.swift | 7 + .../Core/Data/Cache/OfflineCacheManager.swift | 41 +- .../Data/Completed/CompletedRepository.swift | 11 + .../Data/List/FloaterListRepository.swift | 14 + .../Tday/Core/Data/List/ListRepository.swift | 14 + ios-swiftUI/Tday/Core/Data/SecureStore.swift | 24 + .../Data/Server/ServerConfigRepository.swift | 18 + .../Data/Settings/SettingsRepository.swift | 21 +- .../Tday/Core/Data/Sync/SyncManager.swift | 23 +- .../Tday/Core/Data/Todo/TodoRepository.swift | 43 +- .../Tday/Feature/App/AppRootView.swift | 38 +- .../Tday/Feature/App/AppViewModel.swift | 80 ++- .../Feature/Calendar/CalendarScreen.swift | 6 +- .../Feature/Completed/CompletedScreen.swift | 6 +- .../Tday/Feature/Home/HomeScreen.swift | 4 + .../Onboarding/OnboardingWizardOverlay.swift | 313 ++++++++++-- .../Feature/Settings/SettingsScreen.swift | 32 +- .../Tday/Feature/Todos/TodoListScreen.swift | 5 +- .../Tday/UI/Component/PullToRefresh.swift | 12 +- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 + .../ServerURLPersistenceTests.swift | 13 + 41 files changed, 1361 insertions(+), 228 deletions(-) create mode 100644 android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/AppDataMode.kt create mode 100644 ios-swiftUI/Tday/Core/Data/AppDataMode.swift 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 33f5ed73..bd4ab807 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 @@ -314,7 +314,7 @@ fun TdayApp( ) { val authViewModel: AuthViewModel = hiltViewModel() val authUiState by authViewModel.uiState.collectAsStateWithLifecycle() - val showOnboardingWizard = !appUiState.authenticated + val showOnboardingWizard = !appUiState.isWorkspaceAvailable Box(modifier = Modifier.fillMaxSize()) { Box( @@ -328,7 +328,7 @@ fun TdayApp( }, ), ) { - if (appUiState.authenticated) { + if (appUiState.isWorkspaceAvailable) { Box(modifier = Modifier.fillMaxSize()) { when (rootFeedTab) { RootFeedTab.HOME -> { @@ -341,6 +341,7 @@ fun TdayApp( HomeScreen( uiState = homeUiState, onRefresh = homeViewModel::refresh, + pullRefreshEnabled = !appUiState.isLocalMode, onOpenToday = { navController.navigate(AppRoute.TodayTodos.route) }, onOpenOverdue = { navController.navigate(AppRoute.OverdueTodos.route) }, onOpenScheduled = { navController.navigate(AppRoute.ScheduledTodos.route) }, @@ -414,6 +415,7 @@ fun TdayApp( mode = TodoListMode.FLOATER, onBack = { rootFeedTab = RootFeedTab.HOME }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, onOpenFloaterList = { id, name -> navController.navigate( AppRoute.FloaterListTodos.create( @@ -503,6 +505,11 @@ fun TdayApp( serverCanResetTrust = appUiState.canResetServerTrust, pendingApprovalMessage = appUiState.pendingApprovalMessage, authUiState = authUiState, + onUseLocalMode = { + authViewModel.clearStatus() + appViewModel.clearPendingApprovalNotice() + appViewModel.useLocalMode() + }, onConnectServer = { rawUrl, onResult -> appViewModel.saveServerUrl( rawUrl = rawUrl, @@ -559,6 +566,7 @@ fun TdayApp( val authenticatedVersionCheck = appUiState.versionCheckResult if (appUiState.authenticated && + !appUiState.isLocalMode && (authenticatedVersionCheck is com.ohmz.tday.compose.core.data.server.VersionCheckResult.AppUpdateRequired || authenticatedVersionCheck is com.ohmz.tday.compose.core.data.server.VersionCheckResult.ServerUpdateRequired) ) { @@ -594,6 +602,7 @@ fun TdayApp( mode = TodoListMode.TODAY, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -605,6 +614,7 @@ fun TdayApp( mode = TodoListMode.OVERDUE, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -616,6 +626,7 @@ fun TdayApp( mode = TodoListMode.SCHEDULED, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -646,6 +657,7 @@ fun TdayApp( highlightTodoId = highlightTodoId, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -657,6 +669,7 @@ fun TdayApp( mode = TodoListMode.PRIORITY, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, ) } @@ -678,6 +691,7 @@ fun TdayApp( listName = listName, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, onListDeleted = { navController.navigate(AppRoute.Home.route) { popUpTo(AppRoute.Home.route) { inclusive = false } @@ -705,6 +719,7 @@ fun TdayApp( listName = listName, onBack = { navController.popBackStack() }, onTaskDeleted = ::showTaskDeletedToast, + pullRefreshEnabled = !appUiState.isLocalMode, onListDeleted = { rootFeedTab = RootFeedTab.FLOATER navController.navigate(AppRoute.Home.route) { @@ -782,6 +797,7 @@ fun TdayApp( } SettingsScreen( user = appUiState.user, + isLocalMode = appUiState.isLocalMode, selectedThemeMode = appUiState.themeMode, selectedReminder = appUiState.selectedReminder, adminAiSummaryEnabled = appUiState.adminAiSummaryEnabled, @@ -822,7 +838,7 @@ fun TdayApp( } OfflineBanner( - visible = appUiState.isOffline && appUiState.authenticated, + visible = appUiState.isOffline && appUiState.authenticated && !appUiState.isLocalMode, pendingMutationCount = appUiState.pendingMutationCount, noticeKey = appUiState.offlineNoticeId, modifier = Modifier.align(Alignment.TopCenter), @@ -879,14 +895,14 @@ private fun HandleStartupNavigation( ) { LaunchedEffect( appUiState.loading, - appUiState.authenticated, + appUiState.isWorkspaceAvailable, currentRoute, isStartupSplashHeld, ) { if (appUiState.loading) return@LaunchedEffect if (isStartupSplashHeld) return@LaunchedEffect - if (appUiState.authenticated) { + if (appUiState.isWorkspaceAvailable) { val unauthenticatedRoutes = setOf( AppRoute.Splash.route, AppRoute.Login.route, @@ -987,6 +1003,7 @@ private fun TodosRoute( scrollToTopRequestKey: Int = 0, onRootDockCollapsedChange: (Boolean) -> Unit = {}, onRootControlsVisibleChange: (Boolean) -> Unit = {}, + pullRefreshEnabled: Boolean = true, ) { val viewModel: TodoListViewModel = hiltViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -1036,6 +1053,7 @@ private fun TodosRoute( onRootFeedTabSelected = onRootFeedTabSelected, showRootFeedDock = showRootFeedDock, showCreateTaskButton = showCreateTaskButton, + pullRefreshEnabled = pullRefreshEnabled, usesRootFeedHeader = usesRootFeedHeader, createTaskRequestKey = createTaskRequestKey, scrollToTopRequestKey = scrollToTopRequestKey, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/AppDataMode.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/AppDataMode.kt new file mode 100644 index 00000000..d3d39787 --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/AppDataMode.kt @@ -0,0 +1,7 @@ +package com.ohmz.tday.compose.core.data + +enum class AppDataMode { + UNSET, + SERVER, + LOCAL, +} diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt index 03bfa1d5..20160fde 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/SecureConfigStore.kt @@ -32,6 +32,25 @@ class SecureConfigStore @Inject constructor( fun hasServerUrl(): Boolean = !getServerUrl().isNullOrBlank() + fun getAppDataMode(): AppDataMode { + val persisted = prefs.getString(KEY_APP_DATA_MODE, null) + ?.let { raw -> + runCatching { AppDataMode.valueOf(raw) }.getOrNull() + } + if (persisted != null) return persisted + return if (!getServerUrl().isNullOrBlank()) AppDataMode.SERVER else AppDataMode.UNSET + } + + fun isLocalMode(): Boolean = getAppDataMode() == AppDataMode.LOCAL + + fun setAppDataMode(mode: AppDataMode) { + prefs.edit().putString(KEY_APP_DATA_MODE, mode.name).apply() + } + + fun clearAppDataMode() { + prefs.edit().remove(KEY_APP_DATA_MODE).apply() + } + fun getServerUrl(): String? { val inMemory = runtimeServerUrl?.takeIf { it.isNotBlank() } if (inMemory != null) return inMemory @@ -53,7 +72,10 @@ class SecureConfigStore @Inject constructor( runtimeServerUrl = normalized if (persist) { - prefs.edit().putString(KEY_SERVER_URL, normalized).apply() + prefs.edit() + .putString(KEY_SERVER_URL, normalized) + .putString(KEY_APP_DATA_MODE, AppDataMode.SERVER.name) + .apply() } else { prefs.edit().remove(KEY_SERVER_URL).apply() } @@ -65,7 +87,10 @@ class SecureConfigStore @Inject constructor( ?: return Result.failure(IllegalStateException("Server URL is not configured")) runtimeServerUrl = current - prefs.edit().putString(KEY_SERVER_URL, current).apply() + prefs.edit() + .putString(KEY_SERVER_URL, current) + .putString(KEY_APP_DATA_MODE, AppDataMode.SERVER.name) + .apply() return Result.success(current) } @@ -254,5 +279,6 @@ class SecureConfigStore @Inject constructor( const val KEY_LIST_ICON_MAP = "list_icon_map" const val KEY_OFFLINE_SYNC_STATE = "offline_sync_state_v1" const val KEY_CACHED_SESSION_USER = "cached_session_user_v1" + const val KEY_APP_DATA_MODE = "app_data_mode_v1" } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt index 4144fb45..78d4eef2 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/cache/OfflineCacheManager.kt @@ -107,11 +107,20 @@ class OfflineCacheManager @Inject constructor( fun saveOfflineState(state: OfflineSyncState) { ensureMigrated() val previous = lastPersistedState ?: loadOfflineState() - if (previous == state) return + val normalizedState = if (secureConfigStore.isLocalMode()) { + state.copy( + lastSuccessfulSyncEpochMs = 0L, + lastSyncAttemptEpochMs = 0L, + pendingMutations = emptyList(), + ) + } else { + state + } + if (previous == normalizedState) return - persistStateToDaos(state) - lastPersistedState = state - if (hasUiDataChanges(previous, state)) { + persistStateToDaos(normalizedState) + lastPersistedState = normalizedState + if (hasUiDataChanges(previous, normalizedState)) { cacheDataVersionMutable.value = cacheDataVersionMutable.value + 1L } } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt index adde90d2..36a5c8ac 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/completed/CompletedRepository.kt @@ -69,6 +69,8 @@ class CompletedRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (originalTodoId.startsWith(LOCAL_TODO_PREFIX)) return runCatching { @@ -158,6 +160,8 @@ class CompletedRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + requireApiBody( api.patchCompletedTodoByBody( UpdateCompletedTodoRequest( @@ -207,6 +211,8 @@ class CompletedRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (resolvedCompletedId.startsWith(LOCAL_COMPLETED_PREFIX)) return requireApiBody( @@ -222,6 +228,10 @@ class CompletedRepository @Inject constructor( canonicalTodoId: String?, instanceDateEpochMs: Long?, ): String { + if (syncManager.isLocalMode()) { + return currentCompletedId + } + if (!currentCompletedId.startsWith(LOCAL_COMPLETED_PREFIX)) { return currentCompletedId } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt index 8f7171fa..910521e9 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/FloaterListRepository.kt @@ -68,6 +68,8 @@ class FloaterListRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + runCatching { requireApiBody( api.createFloaterList( @@ -158,6 +160,7 @@ class FloaterListRepository @Inject constructor( iconKey?.takeIf { it.isNotBlank() }?.let { secureConfigStore.saveListIcon(listId, it) } + if (syncManager.isLocalMode()) return syncManager.syncCachedData(force = true, replayPendingMutations = true) return } @@ -190,6 +193,13 @@ class FloaterListRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) { + iconKey?.takeIf { it.isNotBlank() }?.let { + secureConfigStore.saveListIcon(listId, it) + } + return + } + val immediateError = runCatching { requireApiBody( api.patchFloaterListByBody( @@ -269,6 +279,8 @@ class FloaterListRepository @Inject constructor( onOptimisticDelete() + if (syncManager.isLocalMode()) return + if (isLocalOnly) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt index ced37323..77860885 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/list/ListRepository.kt @@ -74,6 +74,8 @@ class ListRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + runCatching { requireApiBody( api.createList( @@ -164,6 +166,7 @@ class ListRepository @Inject constructor( iconKey?.takeIf { it.isNotBlank() }?.let { secureConfigStore.saveListIcon(listId, it) } + if (syncManager.isLocalMode()) return syncManager.syncCachedData(force = true, replayPendingMutations = true) Log.d(LOG_TAG, "updateList local-list path finished listId=$listId") return @@ -197,6 +200,13 @@ class ListRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) { + iconKey?.takeIf { it.isNotBlank() }?.let { + secureConfigStore.saveListIcon(listId, it) + } + return + } + Log.d(LOG_TAG, "updateList patch /api/list listId=$listId") val pendingMutation = PendingMutationRecord( mutationId = mutationId, @@ -286,6 +296,8 @@ class ListRepository @Inject constructor( onOptimisticDelete() + if (syncManager.isLocalMode()) return + if (isLocalOnly) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/server/ServerConfigRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/server/ServerConfigRepository.kt index 9662a5ba..21ffa2ae 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/server/ServerConfigRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/server/ServerConfigRepository.kt @@ -2,10 +2,10 @@ package com.ohmz.tday.compose.core.data.server import android.util.Log import com.ohmz.tday.compose.BuildConfig +import com.ohmz.tday.compose.core.data.AppDataMode import com.ohmz.tday.compose.core.data.SecureConfigStore import com.ohmz.tday.compose.core.data.ServerProbeException import com.ohmz.tday.compose.core.data.extractApiErrorMessage -import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.network.TdayApiService import com.ohmz.tday.compose.core.security.ProbeDecryptor import kotlinx.coroutines.withTimeout @@ -22,10 +22,21 @@ class ServerConfigRepository @Inject constructor( private val api: TdayApiService, private val secureConfigStore: SecureConfigStore, ) { + fun getAppDataMode(): AppDataMode = secureConfigStore.getAppDataMode() + + fun isLocalMode(): Boolean = secureConfigStore.isLocalMode() + fun hasServerConfigured(): Boolean = secureConfigStore.hasServerUrl() fun getServerUrl(): String? = secureConfigStore.getServerUrl() + fun enableLocalMode() { + secureConfigStore.clearServerUrl() + secureConfigStore.clearCachedSessionUser() + secureConfigStore.clearLastEmail() + secureConfigStore.setAppDataMode(AppDataMode.LOCAL) + } + data class ProbeResult( val serverUrl: String, val versionCheck: VersionCheckResult, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/settings/SettingsRepository.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/settings/SettingsRepository.kt index 7ff6fa03..9d158a35 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/settings/SettingsRepository.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/settings/SettingsRepository.kt @@ -1,5 +1,6 @@ package com.ohmz.tday.compose.core.data.settings +import com.ohmz.tday.compose.core.data.SecureConfigStore import com.ohmz.tday.compose.core.data.cache.OfflineCacheManager import com.ohmz.tday.compose.core.data.requireApiBody import com.ohmz.tday.compose.core.model.AdminSettingsResponse @@ -12,12 +13,16 @@ import javax.inject.Singleton class SettingsRepository @Inject constructor( private val api: TdayApiService, private val cacheManager: OfflineCacheManager, + private val secureConfigStore: SecureConfigStore, ) { fun isAiSummaryEnabledSnapshot(): Boolean { + if (secureConfigStore.isLocalMode()) return false return cacheManager.loadOfflineState().aiSummaryEnabled } suspend fun refreshAiSummaryEnabled(): Boolean { + if (secureConfigStore.isLocalMode()) return false + return runCatching { val enabled = requireApiBody( api.getAppSettings(), @@ -33,6 +38,8 @@ class SettingsRepository @Inject constructor( } suspend fun fetchAdminAiSummaryEnabled(): Boolean { + if (secureConfigStore.isLocalMode()) return false + val enabled = requireApiBody( api.getAdminSettings(), "Could not load admin settings", @@ -44,6 +51,10 @@ class SettingsRepository @Inject constructor( } suspend fun updateAdminAiSummaryEnabled(enabled: Boolean): AdminSettingsResponse { + if (secureConfigStore.isLocalMode()) { + throw IllegalStateException("Admin settings are unavailable in local mode") + } + val response = requireApiBody( api.patchAdminSettings( UpdateAdminSettingsRequest(aiSummaryEnabled = enabled), 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 c2200507..a775360c 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 @@ -82,7 +82,9 @@ class SyncManager @Inject constructor( val offlineSyncSuccesses: SharedFlow = offlineSyncSuccessMutable.asSharedFlow() fun hasPendingMutations(): Boolean = - cacheManager.loadOfflineState().pendingMutations.isNotEmpty() + !isLocalMode() && cacheManager.loadOfflineState().pendingMutations.isNotEmpty() + + fun isLocalMode(): Boolean = secureConfigStore.isLocalMode() suspend fun syncCachedData( force: Boolean = false, @@ -90,6 +92,25 @@ class SyncManager @Inject constructor( notifyOfflineFailure: Boolean = true, connectionProbeTimeoutMs: Long? = null, ): Result { + if (isLocalMode()) { + cacheManager.updateOfflineState { state -> + if (state.pendingMutations.isEmpty() && + state.lastSuccessfulSyncEpochMs == 0L && + state.lastSyncAttemptEpochMs == 0L + ) { + state + } else { + state.copy( + lastSuccessfulSyncEpochMs = 0L, + lastSyncAttemptEpochMs = 0L, + pendingMutations = emptyList(), + ) + } + } + runCatching { TodayTasksWidget().updateAll(context) } + return Result.success(Unit) + } + val result = runCatching { var contactedServer = false if (connectionProbeTimeoutMs != 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 50695148..313f7e20 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 @@ -144,6 +144,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( LOCAL_FLOATER_LIST_PREFIX ) @@ -231,6 +233,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( LOCAL_FLOATER_LIST_PREFIX ) @@ -345,6 +349,7 @@ class TodoRepository @Inject constructor( }, ) } + if (syncManager.isLocalMode()) return syncManager.syncCachedData(force = true, replayPendingMutations = true) return } @@ -377,6 +382,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith( LOCAL_FLOATER_LIST_PREFIX ) @@ -497,6 +504,7 @@ class TodoRepository @Inject constructor( }, ) } + if (syncManager.isLocalMode()) return syncManager.syncCachedData(force = true, replayPendingMutations = true) return } @@ -521,6 +529,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (!normalizedListId.isNullOrBlank() && normalizedListId.startsWith(LOCAL_LIST_PREFIX)) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return @@ -630,6 +640,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (isLocalOnly) { syncManager.syncCachedData(force = true, replayPendingMutations = true) return @@ -719,6 +731,8 @@ class TodoRepository @Inject constructor( } } + if (syncManager.isLocalMode()) return + if (canonicalId.startsWith(LOCAL_TODO_PREFIX)) return runCatching { @@ -782,6 +796,8 @@ class TodoRepository @Inject constructor( } } + if (syncManager.isLocalMode()) return + if (canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) return runCatching { @@ -851,6 +867,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (todo.canonicalId.startsWith(LOCAL_TODO_PREFIX)) return runCatching { @@ -919,6 +937,8 @@ class TodoRepository @Inject constructor( ) } + if (syncManager.isLocalMode()) return + if (floater.canonicalId.startsWith(LOCAL_FLOATER_PREFIX)) return runCatching { @@ -937,6 +957,10 @@ class TodoRepository @Inject constructor( mode: TodoListMode, listId: String? = null, ): TodoSummaryResponse { + if (syncManager.isLocalMode()) { + throw IllegalStateException("AI summary is unavailable in local mode") + } + val modeValue = when (mode) { TodoListMode.TODAY -> "today" TodoListMode.OVERDUE -> throw IllegalStateException( @@ -964,6 +988,7 @@ class TodoRepository @Inject constructor( ): TodoTitleNlpResponse? { val trimmedText = text.trim() if (trimmedText.isBlank()) return null + if (syncManager.isLocalMode()) return null val timezoneOffsetMinutes = ZoneId.systemDefault() .rules 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 73b39f7e..1e8d273b 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 @@ -3,6 +3,7 @@ package com.ohmz.tday.compose.feature.app import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ohmz.tday.compose.core.data.ApiCallException +import com.ohmz.tday.compose.core.data.AppDataMode import com.ohmz.tday.compose.core.data.ServerProbeException import com.ohmz.tday.compose.core.data.ThemePreferenceStore import com.ohmz.tday.compose.core.data.auth.AuthRepository @@ -49,6 +50,7 @@ data class AppUiState( val requiresServerSetup: Boolean = false, val requiresLogin: Boolean = false, val serverUrl: String? = null, + val dataMode: AppDataMode = AppDataMode.UNSET, val themeMode: AppThemeMode = AppThemeMode.SYSTEM, val user: SessionUser? = null, val error: String? = null, @@ -68,7 +70,13 @@ data class AppUiState( val backendVersion: String? = null, val requiredUpdateRelease: GitHubRelease? = null, val isCheckingUpdateRelease: Boolean = false, -) +) { + val isLocalMode: Boolean + get() = dataMode == AppDataMode.LOCAL + + val isWorkspaceAvailable: Boolean + get() = authenticated || isLocalMode +} internal const val OFFLINE_NOTICE_COOLDOWN_MS = 10 * 60 * 1000L @@ -141,6 +149,12 @@ class AppViewModel @Inject constructor( fun bootstrap() { viewModelScope.launch { _uiState.update { it.copy(loading = true, error = null, isManualSyncing = false) } + val dataMode = serverConfigRepository.getAppDataMode() + if (dataMode == AppDataMode.LOCAL) { + enterLocalWorkspace() + return@launch + } + if (!serverConfigRepository.hasServerConfigured()) { authRepository.clearAllLocalUserDataForUnauthenticatedState() _uiState.update { @@ -150,6 +164,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = true, requiresLogin = false, serverUrl = null, + dataMode = AppDataMode.UNSET, user = null, error = null, canResetServerTrust = false, @@ -159,6 +174,12 @@ class AppViewModel @Inject constructor( isAdminAiSummaryLoading = false, isAdminAiSummarySaving = false, adminAiSummaryError = null, + isOffline = false, + pendingMutationCount = 0, + versionCheckResult = VersionCheckResult.Compatible, + backendVersion = null, + requiredUpdateRelease = null, + isCheckingUpdateRelease = false, ) } ensureResyncLoop(authenticated = false) @@ -187,6 +208,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = false, serverUrl = serverConfigRepository.getServerUrl(), + dataMode = AppDataMode.SERVER, user = sessionUser, error = null, canResetServerTrust = false, @@ -228,6 +250,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = false, serverUrl = serverConfigRepository.getServerUrl(), + dataMode = AppDataMode.SERVER, user = null, error = null, canResetServerTrust = false, @@ -255,6 +278,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = true, serverUrl = serverConfigRepository.getServerUrl(), + dataMode = AppDataMode.SERVER, user = null, error = null, canResetServerTrust = false, @@ -270,6 +294,58 @@ class AppViewModel @Inject constructor( } } + fun useLocalMode() { + viewModelScope.launch { + runCatching { authRepository.clearAllLocalUserDataForUnauthenticatedState() } + runCatching { systemCredentialService.clearCredentialState() } + runCatching { reminderScheduler.cancelAll() } + serverConfigRepository.enableLocalMode() + enterLocalWorkspace() + } + } + + private fun enterLocalWorkspace() { + ensureResyncLoop(authenticated = false) + runCatching { + cacheManager.updateOfflineState { state -> + state.copy( + lastSuccessfulSyncEpochMs = 0L, + lastSyncAttemptEpochMs = 0L, + pendingMutations = emptyList(), + ) + } + } + _uiState.update { + it.copy( + loading = false, + authenticated = false, + requiresServerSetup = false, + requiresLogin = false, + serverUrl = null, + dataMode = AppDataMode.LOCAL, + user = null, + error = null, + canResetServerTrust = false, + pendingApprovalMessage = null, + isManualSyncing = false, + adminAiSummaryEnabled = null, + isAdminAiSummaryLoading = false, + isAdminAiSummarySaving = false, + adminAiSummaryError = null, + aiSummaryValidationError = null, + isOffline = false, + pendingMutationCount = 0, + versionCheckResult = VersionCheckResult.Compatible, + backendVersion = null, + requiredUpdateRelease = null, + isCheckingUpdateRelease = false, + ) + } + viewModelScope.launch(Dispatchers.Default) { + runCatching { reminderScheduler.rescheduleAll() } + } + } + private suspend fun restoreSessionAndPrimeData(): SessionBootstrapResult? { val restored = authRepository.restoreSessionForBootstrap() ?: return null val user = restored.user @@ -304,7 +380,7 @@ class AppViewModel @Inject constructor( fun refreshAdminAiSummarySetting() { val current = _uiState.value - if (!isAdmin(current.user)) { + if (current.isLocalMode || !isAdmin(current.user)) { _uiState.update { it.copy( adminAiSummaryEnabled = null, @@ -348,7 +424,7 @@ class AppViewModel @Inject constructor( fun setAdminAiSummaryEnabled(enabled: Boolean) { val current = _uiState.value - if (!isAdmin(current.user) || current.isAdminAiSummarySaving) return + if (current.isLocalMode || !isAdmin(current.user) || current.isAdminAiSummarySaving) return viewModelScope.launch { _uiState.update { @@ -410,6 +486,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = !isBlocking, serverUrl = probeResult.serverUrl, + dataMode = AppDataMode.SERVER, error = null, canResetServerTrust = false, pendingApprovalMessage = null, @@ -438,6 +515,7 @@ class AppViewModel @Inject constructor( } fun recheckVersion() { + if (_uiState.value.isLocalMode) return viewModelScope.launch { appVersionManager.refreshServerCompatibility() if (appVersionManager.state.value.versionCheckResult is VersionCheckResult.Compatible && @@ -450,7 +528,11 @@ class AppViewModel @Inject constructor( fun refreshVersionInfo() { viewModelScope.launch { - appVersionManager.refreshAll() + if (_uiState.value.isLocalMode) { + appVersionManager.refreshGitHubReleases() + } else { + appVersionManager.refreshAll() + } } } @@ -496,6 +578,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = true, requiresLogin = false, serverUrl = null, + dataMode = AppDataMode.UNSET, user = null, error = null, loading = false, @@ -506,13 +589,20 @@ class AppViewModel @Inject constructor( isAdminAiSummaryLoading = false, isAdminAiSummarySaving = false, adminAiSummaryError = null, + aiSummaryValidationError = null, + isOffline = false, + pendingMutationCount = 0, + versionCheckResult = VersionCheckResult.Compatible, + backendVersion = null, + requiredUpdateRelease = null, + isCheckingUpdateRelease = false, ) } } } fun syncNow() { - if (!_uiState.value.authenticated) return + if (!_uiState.value.authenticated || _uiState.value.isLocalMode) return if (_uiState.value.isManualSyncing) return viewModelScope.launch { @@ -561,7 +651,7 @@ class AppViewModel @Inject constructor( } fun reconnectAfterForeground() { - if (!_uiState.value.authenticated) return + if (!_uiState.value.authenticated || _uiState.value.isLocalMode) return if (foregroundReconnectJob?.isActive == true) return foregroundReconnectJob = viewModelScope.launch { @@ -720,6 +810,7 @@ class AppViewModel @Inject constructor( requiresServerSetup = false, requiresLogin = false, serverUrl = serverConfigRepository.getServerUrl(), + dataMode = AppDataMode.SERVER, user = restoredSession.user, error = null, pendingApprovalMessage = null, diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/home/HomeScreen.kt index 33307a5d..d263081e 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 @@ -252,6 +252,7 @@ fun HomeScreen( onUpdateTask: (todo: com.ohmz.tday.compose.core.model.TodoItem, payload: CreateTaskPayload) -> Unit, showRootFeedDock: Boolean = true, showCreateTaskButton: Boolean = true, + pullRefreshEnabled: Boolean = true, createTaskRequestKey: Int = 0, scrollToTopRequestKey: Int = 0, onRootDockCollapsedChange: (Boolean) -> Unit = {}, @@ -443,6 +444,7 @@ fun HomeScreen( TdayPullToRefreshBox( isRefreshing = uiState.isLoading, onRefresh = onRefresh, + enabled = pullRefreshEnabled, modifier = Modifier .fillMaxSize() .padding(padding), 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 8e73ff42..4bd55c92 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 @@ -20,6 +20,8 @@ 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.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -28,8 +30,9 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Language -import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PhoneAndroid +import androidx.compose.material.icons.rounded.WbSunny import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -60,10 +63,12 @@ import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalAutofill @@ -85,11 +90,13 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch private enum class WizardStep { + MODE, SERVER, LOGIN, } private enum class WizardViewState { + MODE, SERVER, CONNECTING, LOGIN, @@ -121,6 +128,7 @@ fun OnboardingWizardOverlay( onRequestSavedCredential: suspend (Context, String?) -> SystemCredential?, onRequestSavedServerUrl: suspend (Context) -> String?, onSaveServerUrlCredential: suspend (Context, String) -> Unit, + onUseLocalMode: () -> Unit, onClearAuthStatus: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -132,7 +140,7 @@ fun OnboardingWizardOverlay( val credentialCoordinator = remember { LoginCredentialCoordinator() } var step by rememberSaveable(initialServerUrl) { - mutableStateOf(if (initialServerUrl.isNullOrBlank()) WizardStep.SERVER else WizardStep.LOGIN) + mutableStateOf(if (initialServerUrl.isNullOrBlank()) WizardStep.MODE else WizardStep.LOGIN) } var serverUrl by rememberSaveable { mutableStateOf(initialServerUrl.orEmpty()) } var email by rememberSaveable { mutableStateOf("") } @@ -345,7 +353,8 @@ fun OnboardingWizardOverlay( isConnecting -> WizardViewState.CONNECTING authUiState.isLoading -> WizardViewState.AUTHENTICATING step == WizardStep.LOGIN -> WizardViewState.LOGIN - else -> WizardViewState.SERVER + step == WizardStep.SERVER -> WizardViewState.SERVER + else -> WizardViewState.MODE } val fieldColors = OutlinedTextFieldDefaults.colors( @@ -358,6 +367,9 @@ fun OnboardingWizardOverlay( cursorColor = colorScheme.onSurface, focusedPlaceholderColor = colorScheme.onSurface.copy(alpha = 0.4f), unfocusedPlaceholderColor = colorScheme.onSurface.copy(alpha = 0.4f), + focusedContainerColor = colorScheme.surface, + unfocusedContainerColor = colorScheme.surface, + errorContainerColor = colorScheme.surface, ) BoxWithConstraints( @@ -384,9 +396,10 @@ fun OnboardingWizardOverlay( Card( modifier = Modifier .width(cardWidth), - shape = RoundedCornerShape(32.dp), - colors = CardDefaults.cardColors(containerColor = colorScheme.surface), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + shape = RoundedCornerShape(34.dp), + colors = CardDefaults.cardColors(containerColor = colorScheme.background), + elevation = CardDefaults.cardElevation(defaultElevation = 12.dp), + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.08f)), ) { Box( modifier = Modifier @@ -395,8 +408,8 @@ fun OnboardingWizardOverlay( val tint = colorScheme.onSurface val wash = Brush.linearGradient( colors = listOf( - tint.copy(alpha = 0.06f), - tint.copy(alpha = 0.02f), + Color.White.copy(alpha = 0.18f), + tint.copy(alpha = 0.015f), Color.Transparent, ), ) @@ -407,52 +420,113 @@ fun OnboardingWizardOverlay( } .padding(WIZARD_CARD_CONTENT_PADDING), ) { - Icon( - imageVector = if (step == WizardStep.SERVER) Icons.Rounded.Language else Icons.Rounded.Lock, - contentDescription = null, - tint = lerp(colorScheme.surface, colorScheme.primary, 0.3f).copy(alpha = 0.25f), - modifier = Modifier - .align(Alignment.BottomEnd) - .size(WIZARD_WATERMARK_SIZE), - ) - Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Text( - text = stringResource(R.string.onboarding_title), - style = MaterialTheme.typography.headlineSmall, - color = colorScheme.onSurface, - fontWeight = FontWeight.ExtraBold, - ) - Text( - text = stringResource(R.string.onboarding_subtitle), - style = MaterialTheme.typography.bodySmall, - color = colorScheme.onSurface.copy(alpha = 0.6f), - ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.WbSunny, + contentDescription = null, + tint = Color(0xFFF4C542), + modifier = Modifier.size(27.dp), + ) + Text( + text = "T'Day", + style = MaterialTheme.typography.headlineMedium, + color = colorScheme.onSurface, + fontWeight = FontWeight.Black, + ) + } Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WizardStepChip( + modifier = Modifier.weight(1f), + title = stringResource(R.string.onboarding_step_mode), + imageVector = Icons.Rounded.PhoneAndroid, + color = Color(0xFF7FB78A), + active = step == WizardStep.MODE, + ) WizardStepChip( modifier = Modifier.weight(1f), title = stringResource(R.string.onboarding_step_server), - isServerStep = true, + imageVector = Icons.Rounded.Language, color = Color(0xFF6EA8E1), active = step == WizardStep.SERVER, ) WizardStepChip( modifier = Modifier.weight(1f), title = stringResource(R.string.onboarding_step_login), - isServerStep = false, + imageVector = Icons.Rounded.Person, color = Color(0xFFD48A8C), active = step == WizardStep.LOGIN, ) } + Text( + text = stringResource(R.string.onboarding_subtitle), + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.62f), + fontWeight = FontWeight.Bold, + ) + AnimatedContent(targetState = viewState, label = "wizardState") { state -> when (state) { + WizardViewState.MODE -> { + Column(verticalArrangement = Arrangement.spacedBy(11.dp)) { + WizardHeroTile( + title = stringResource(R.string.onboarding_mode_title), + subtitle = stringResource(R.string.onboarding_mode_subtitle), + imageVector = Icons.Rounded.PhoneAndroid, + color = Color(0xFF6EA8E1), + ) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + WizardModeChoiceButton( + modifier = Modifier.weight(1f), + title = stringResource(R.string.onboarding_mode_server_short_title), + subtitle = stringResource(R.string.onboarding_mode_server_short_subtitle), + imageVector = Icons.Rounded.Language, + color = Color(0xFF6EA8E1), + enabled = !isResettingTrust, + onClick = { + step = WizardStep.SERVER + serverError = null + localAuthError = null + onClearAuthStatus() + }, + ) + WizardModeChoiceButton( + modifier = Modifier.weight(1f), + title = stringResource(R.string.onboarding_mode_local_short_title), + subtitle = stringResource(R.string.onboarding_mode_local_short_subtitle), + imageVector = Icons.Rounded.PhoneAndroid, + color = Color(0xFF719F84), + enabled = !isResettingTrust, + onClick = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + serverError = null + localAuthError = null + onClearAuthStatus() + onUseLocalMode() + }, + ) + } + } + } + WizardViewState.SERVER -> { - Column { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + WizardHeroTile( + title = stringResource(R.string.onboarding_mode_server_title), + subtitle = stringResource(R.string.onboarding_server_hero_subtitle), + imageVector = Icons.Rounded.Language, + color = Color(0xFF6EA8E1), + ) OutlinedTextField( modifier = Modifier.fillMaxWidth(), value = serverUrl, @@ -469,6 +543,7 @@ fun OnboardingWizardOverlay( onGo = { connectToServer() }, onDone = { connectToServer() }, ), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) @@ -529,7 +604,7 @@ fun OnboardingWizardOverlay( Button( modifier = Modifier .fillMaxWidth() - .padding(top = 14.dp), + .height(48.dp), enabled = serverUrl.isNotBlank() && !isResettingTrust, onClick = connectToServer, colors = ButtonDefaults.buttonColors( @@ -539,6 +614,23 @@ fun OnboardingWizardOverlay( ) { Text(stringResource(R.string.onboarding_connect)) } + + TextButton( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + enabled = !isResettingTrust, + onClick = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + serverError = null + localAuthError = null + onClearAuthStatus() + step = WizardStep.MODE + }, + ) { + Text(stringResource(R.string.onboarding_change_setup)) + } } } @@ -553,9 +645,16 @@ fun OnboardingWizardOverlay( Column { when (authMode) { AuthPanelMode.SIGN_IN -> { + WizardHeroTile( + title = stringResource(R.string.onboarding_sign_in), + subtitle = stringResource(R.string.onboarding_login_hero_subtitle), + imageVector = Icons.Rounded.Person, + color = Color(0xFFC97880), + ) OutlinedTextField( modifier = Modifier .fillMaxWidth() + .padding(top = 10.dp) .tdayAutofill( autofillTypes = listOf( AutofillType.Username, @@ -578,6 +677,7 @@ fun OnboardingWizardOverlay( keyboardActions = KeyboardActions( onNext = { passwordFocusRequester.requestFocus() }, ), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) OutlinedTextField( @@ -605,6 +705,7 @@ fun OnboardingWizardOverlay( onDone = { signIn() }, ), visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) @@ -646,7 +747,8 @@ fun OnboardingWizardOverlay( Button( modifier = Modifier .fillMaxWidth() - .padding(top = 14.dp), + .padding(top = 4.dp) + .height(48.dp), enabled = email.isNotBlank() && password.isNotBlank() && !authUiState.isLoading, onClick = signIn, colors = ButtonDefaults.buttonColors( @@ -673,20 +775,28 @@ fun OnboardingWizardOverlay( } TextButton( onClick = { - step = WizardStep.SERVER + step = WizardStep.MODE canRequestSavedLoginCredential = false localAuthError = null onClearAuthStatus() }, ) { - Text(stringResource(R.string.onboarding_change_server)) + Text(stringResource(R.string.onboarding_change_setup)) } } } AuthPanelMode.CREATE_ACCOUNT -> { + WizardHeroTile( + title = stringResource(R.string.onboarding_create_account), + subtitle = stringResource(R.string.onboarding_register_hero_subtitle), + imageVector = Icons.Rounded.Person, + color = Color(0xFFC97880), + ) OutlinedTextField( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp), value = firstName, onValueChange = { firstName = it @@ -699,6 +809,7 @@ fun OnboardingWizardOverlay( keyboardActions = KeyboardActions( onNext = { passwordFocusRequester.requestFocus() }, ), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) OutlinedTextField( @@ -728,6 +839,7 @@ fun OnboardingWizardOverlay( keyboardActions = KeyboardActions( onNext = { registerPasswordFocusRequester.requestFocus() }, ), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) OutlinedTextField( @@ -755,6 +867,7 @@ fun OnboardingWizardOverlay( onNext = { registerConfirmFocusRequester.requestFocus() }, ), visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) OutlinedTextField( @@ -782,6 +895,7 @@ fun OnboardingWizardOverlay( onDone = { createAccount() }, ), visualTransformation = PasswordVisualTransformation(), + shape = RoundedCornerShape(22.dp), colors = fieldColors, ) @@ -813,7 +927,8 @@ fun OnboardingWizardOverlay( Button( modifier = Modifier .fillMaxWidth() - .padding(top = 14.dp), + .padding(top = 4.dp) + .height(48.dp), enabled = firstName.isNotBlank() && email.isNotBlank() && registerPassword.isNotBlank() && @@ -851,14 +966,14 @@ fun OnboardingWizardOverlay( } TextButton( onClick = { - step = WizardStep.SERVER + step = WizardStep.MODE authMode = AuthPanelMode.SIGN_IN canRequestSavedLoginCredential = false localAuthError = null onClearAuthStatus() }, ) { - Text(stringResource(R.string.onboarding_change_server)) + Text(stringResource(R.string.onboarding_change_setup)) } } } @@ -939,6 +1054,7 @@ private fun WizardLoading( title: String, subtitle: String, ) { + val colorScheme = MaterialTheme.colorScheme val transition = rememberInfiniteTransition(label = "wizardLoading") val rotation by transition.animateFloat( initialValue = 0f, @@ -947,36 +1063,216 @@ private fun WizardLoading( label = "wizardRotation", ) - Column( + Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 14.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(10.dp), + .padding(vertical = 4.dp), + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors(containerColor = colorScheme.surface), + border = BorderStroke(1.dp, colorScheme.onSurface.copy(alpha = 0.08f)), ) { - Icon( - imageVector = Icons.Rounded.Language, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + Column( modifier = Modifier - .size(34.dp) - .graphicsLayer(rotationZ = rotation), - ) - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.5.dp, - ) - Text( - text = title, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.ExtraBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), - ) + .fillMaxWidth() + .padding(vertical = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Icon( + imageVector = Icons.Rounded.Language, + contentDescription = null, + tint = colorScheme.primary, + modifier = Modifier + .size(34.dp) + .graphicsLayer(rotationZ = rotation), + ) + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.5.dp, + ) + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.ExtraBold, + color = colorScheme.onSurface, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = colorScheme.onSurface.copy(alpha = 0.6f), + ) + } + } +} + +@Composable +private fun WizardHeroTile( + title: String, + subtitle: String, + imageVector: ImageVector, + color: Color, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier + .fillMaxWidth() + .height(WIZARD_HERO_TILE_HEIGHT), + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors(containerColor = color), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val glow = Brush.radialGradient( + colors = listOf( + Color.White.copy(alpha = 0.24f), + Color.White.copy(alpha = 0.08f), + Color.Transparent, + ), + center = Offset(size.width * 0.18f, size.height * 0.18f), + radius = size.width * 0.72f, + ) + onDrawWithContent { + drawRect(glow) + drawContent() + } + }, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = Color.White.copy(alpha = 0.2f), + modifier = Modifier + .align(Alignment.CenterEnd) + .size(86.dp) + .offset(x = 22.dp, y = 12.dp), + ) + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 14.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(42.dp) + .background(Color.White.copy(alpha = 0.18f), RoundedCornerShape(16.dp)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(23.dp), + ) + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.ExtraBold, + color = Color.White, + maxLines = 1, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = Color.White.copy(alpha = 0.82f), + maxLines = 2, + ) + } + } + } + } +} + +@Composable +private fun WizardModeChoiceButton( + modifier: Modifier = Modifier, + title: String, + subtitle: String, + imageVector: ImageVector, + color: Color, + enabled: Boolean, + onClick: () -> Unit, +) { + Card( + modifier = modifier + .height(WIZARD_MODE_TILE_HEIGHT) + .clickable(enabled = enabled, onClick = onClick), + shape = RoundedCornerShape(26.dp), + colors = CardDefaults.cardColors( + containerColor = color.copy(alpha = if (enabled) 1f else 0.55f), + ), + elevation = CardDefaults.cardElevation(defaultElevation = if (enabled) 8.dp else 0.dp), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .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.18f), + radius = size.maxDimension * 0.9f, + ) + val wash = Brush.linearGradient( + colors = listOf(Color.White.copy(alpha = 0.12f), Color.Transparent), + ) + onDrawWithContent { + drawRect(glow) + drawRect(wash) + drawContent() + } + }, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = Color.White.copy(alpha = 0.22f), + modifier = Modifier + .align(Alignment.BottomEnd) + .size(76.dp) + .offset(x = 18.dp, y = 16.dp), + ) + Column( + modifier = Modifier + .fillMaxSize() + .padding(13.dp), + verticalArrangement = Arrangement.SpaceBetween, + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.ExtraBold, + color = Color.White, + maxLines = 2, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = Color.White.copy(alpha = 0.82f), + maxLines = 2, + ) + } + } } } @@ -984,20 +1280,21 @@ private fun WizardLoading( private fun WizardStepChip( modifier: Modifier, title: String, - isServerStep: Boolean, + imageVector: ImageVector, color: Color, active: Boolean, ) { val scale by animateFloatAsState( - targetValue = if (active) 1.04f else 1f, + targetValue = if (active) 1.02f else 1f, animationSpec = tween(durationMillis = 180), label = "wizardStepChipScale", ) val borderWidth by animateDpAsState( - targetValue = if (active) 2.dp else 0.dp, + targetValue = 1.dp, animationSpec = tween(durationMillis = 180), label = "wizardStepChipBorderWidth", ) + val colorScheme = MaterialTheme.colorScheme val ringColor = lerp(color, MaterialTheme.colorScheme.onSurface, 0.35f) Card( @@ -1005,24 +1302,27 @@ private fun WizardStepChip( scaleX = scale, scaleY = scale, ), - shape = RoundedCornerShape(20.dp), - colors = CardDefaults.cardColors(containerColor = color), - elevation = CardDefaults.cardElevation(defaultElevation = if (active) 12.dp else 8.dp), - border = if (active) BorderStroke(borderWidth, ringColor) else null, + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = if (active) color else colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = if (active) 8.dp else 0.dp), + border = BorderStroke( + borderWidth, + if (active) ringColor.copy(alpha = 0.62f) else colorScheme.onSurface.copy(alpha = 0.08f), + ), ) { - Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Icon( - imageVector = if (isServerStep) Icons.Rounded.Language else Icons.Rounded.Person, + imageVector = imageVector, contentDescription = null, - tint = Color.White, - modifier = Modifier.size(14.dp), + tint = if (active) Color.White else colorScheme.onSurface.copy(alpha = 0.68f), + modifier = Modifier.size(13.dp), ) Text( text = title, - color = Color.White, + color = if (active) Color.White else colorScheme.onSurface.copy(alpha = 0.68f), style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.ExtraBold, + fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 6.dp), ) } @@ -1037,4 +1337,5 @@ 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 +private val WIZARD_HERO_TILE_HEIGHT = 78.dp +private val WIZARD_MODE_TILE_HEIGHT = 116.dp diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt index ec88397a..b0ac848c 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/settings/SettingsScreen.kt @@ -73,6 +73,7 @@ import com.ohmz.tday.compose.ui.theme.TdayDimens @Composable fun SettingsScreen( user: SessionUser?, + isLocalMode: Boolean = false, selectedThemeMode: AppThemeMode, selectedReminder: ReminderOption, adminAiSummaryEnabled: Boolean?, @@ -120,9 +121,11 @@ fun SettingsScreen( .padding(horizontal = 18.dp, vertical = 2.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - SettingsProfileCard( - user = user, - ) + if (!isLocalMode) { + SettingsProfileCard( + user = user, + ) + } SettingsSectionCard { SettingsSectionTitle(title = stringResource(R.string.settings_appearance)) @@ -138,7 +141,7 @@ fun SettingsScreen( ) } - if (isAdminUser) { + if (!isLocalMode && isAdminUser) { SettingsSectionCard { SettingsSectionTitle(title = stringResource(R.string.settings_feature_toggle)) Row( @@ -206,7 +209,7 @@ fun SettingsScreen( fontWeight = FontWeight.ExtraBold, ) } - if (backendVersion != null) { + if (!isLocalMode && backendVersion != null) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -242,15 +245,17 @@ fun SettingsScreen( } } } - SettingsDivider() - SettingsListRow( - title = stringResource(R.string.action_sign_out), - value = null, - onClick = onLogout, - titleColor = colorScheme.error, - trailingTint = colorScheme.error.copy(alpha = 0.72f), - showChevron = false, - ) + if (!isLocalMode) { + SettingsDivider() + SettingsListRow( + title = stringResource(R.string.action_sign_out), + value = null, + onClick = onLogout, + titleColor = colorScheme.error, + trailingTint = colorScheme.error.copy(alpha = 0.72f), + showChevron = false, + ) + } } Spacer(modifier = Modifier.height(24.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 82af95a4..af776511 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 @@ -289,6 +289,7 @@ fun TodoListScreen( onRootFeedTabSelected: ((RootFeedTab) -> Unit)? = null, showRootFeedDock: Boolean = true, showCreateTaskButton: Boolean = true, + pullRefreshEnabled: Boolean = true, usesRootFeedHeader: Boolean = false, createTaskRequestKey: Int = 0, scrollToTopRequestKey: Int = 0, @@ -799,6 +800,7 @@ fun TodoListScreen( TdayPullToRefreshBox( isRefreshing = uiState.isLoading, onRefresh = onRefresh, + enabled = pullRefreshEnabled, modifier = Modifier .fillMaxSize() .padding(padding), diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt index 8f8f34b5..676ec5ff 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/ui/component/TdayPullRefresh.kt @@ -57,8 +57,18 @@ fun TdayPullToRefreshBox( onRefresh: () -> Unit, modifier: Modifier = Modifier, contentAlignment: Alignment = Alignment.TopStart, + enabled: Boolean = true, content: @Composable BoxScope.() -> Unit, ) { + if (!enabled) { + Box( + modifier = modifier, + contentAlignment = contentAlignment, + content = content, + ) + return + } + val state = rememberPullToRefreshState() val pullProgress = state.distanceFraction.coerceIn(0f, 1f) val contentPullProgress = state.distanceFraction.coerceIn(0f, 1.25f) diff --git a/android-compose/app/src/main/res/values/strings.xml b/android-compose/app/src/main/res/values/strings.xml index cf82050c..abbca56f 100644 --- a/android-compose/app/src/main/res/values/strings.xml +++ b/android-compose/app/src/main/res/values/strings.xml @@ -65,9 +65,20 @@ Set Up T\'Day - Secure onboarding wizard + Set up your workspace + Mode Server Login + Choose your setup + Pick where T\'Day keeps your tasks. + Self-hosted server + Use accounts, sync, and your backend. + Self-hosted + Accounts and sync + This device + No login + No login. Data stays in app storage. + Connect your T\'Day endpoint. Server URL https://app.example.com Reset trusted server @@ -75,11 +86,15 @@ Connect Connecting to server… Checking endpoint, TLS, and workspace settings + Use this device only Email Password Sign in + Open your synced workspace. Create account + Create your server account. Change server + Change setup First name Confirm password Creating account… diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt index 45367c98..e4231049 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/app/AppViewModelTest.kt @@ -2,6 +2,7 @@ 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.AppDataMode 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 @@ -22,8 +23,10 @@ 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.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -75,6 +78,7 @@ class AppViewModelTest { every { reminderPreferenceStore.getDefaultReminder() } returns ReminderOption.DEFAULT every { appVersionManager.state } returns versionState coEvery { appVersionManager.refreshServerCompatibility() } returns Unit + every { serverConfigRepository.getAppDataMode() } returns AppDataMode.SERVER every { serverConfigRepository.hasServerConfigured() } returns true every { serverConfigRepository.getServerUrl() } returns "https://tday.example.com" every { cacheManager.loadOfflineState() } returns OfflineSyncState() @@ -92,6 +96,40 @@ class AppViewModelTest { every { reminderScheduler.cancelAll() } returns Unit } + @Test + fun `local bootstrap opens workspace without server session or sync`() = runTest { + every { serverConfigRepository.getAppDataMode() } returns AppDataMode.LOCAL + every { cacheManager.updateOfflineState(any()) } answers { + firstArg<(OfflineSyncState) -> OfflineSyncState>().invoke( + OfflineSyncState( + pendingMutations = listOf( + com.ohmz.tday.compose.core.data.PendingMutationRecord( + mutationId = "mutation-1", + kind = com.ohmz.tday.compose.core.data.MutationKind.CREATE_TODO, + targetId = "local-todo-1", + timestampEpochMs = 1L, + ), + ), + ), + ) + } + + val viewModel = makeViewModel() + runCurrent() + + assertTrue(viewModel.uiState.value.isLocalMode) + assertTrue(viewModel.uiState.value.isWorkspaceAvailable) + assertFalse(viewModel.uiState.value.authenticated) + assertFalse(viewModel.uiState.value.requiresServerSetup) + assertFalse(viewModel.uiState.value.requiresLogin) + assertEquals(0, viewModel.uiState.value.pendingMutationCount) + + coVerify(exactly = 0) { authRepository.restoreSessionForBootstrap() } + coVerify(exactly = 0) { appVersionManager.refreshServerCompatibility() } + coVerify(exactly = 0) { syncManager.syncCachedData(any(), any(), any(), any()) } + verify(exactly = 0) { realtimeClient.connect() } + } + @Test fun `foreground reconnect retries sync after restoring session`() = runTest { val restoredSession = AuthRepository.RestoredSession( diff --git a/ios-swiftUI/Tday/Core/Data/AppContainer.swift b/ios-swiftUI/Tday/Core/Data/AppContainer.swift index d85bdb48..93ab175f 100644 --- a/ios-swiftUI/Tday/Core/Data/AppContainer.swift +++ b/ios-swiftUI/Tday/Core/Data/AppContainer.swift @@ -55,7 +55,7 @@ final class AppContainer { PendingMutationEntity.self, SyncMetadataEntity.self ) - cacheManager = OfflineCacheManager(modelContainer: modelContainer) + cacheManager = OfflineCacheManager(modelContainer: modelContainer, secureStore: secureStore) serverConfigRepository = ServerConfigRepository( secureStore: secureStore, serverURLState: serverURLState, @@ -71,12 +71,12 @@ final class AppContainer { themeStore: themeStore, reminderPreferenceStore: reminderPreferenceStore ) - syncManager = SyncManager(api: apiService, cacheManager: cacheManager) + syncManager = SyncManager(api: apiService, cacheManager: cacheManager, secureStore: secureStore) todoRepository = TodoRepository(api: apiService, cacheManager: cacheManager, syncManager: syncManager) listRepository = ListRepository(api: apiService, cacheManager: cacheManager, syncManager: syncManager) floaterListRepository = FloaterListRepository(api: apiService, cacheManager: cacheManager, syncManager: syncManager) completedRepository = CompletedRepository(api: apiService, cacheManager: cacheManager, syncManager: syncManager) - settingsRepository = SettingsRepository(api: apiService, cacheManager: cacheManager) + settingsRepository = SettingsRepository(api: apiService, cacheManager: cacheManager, secureStore: secureStore) realtimeClient = RealtimeClient(configuration: networkConfiguration) reminderScheduler = TaskReminderScheduler(reminderPreferenceStore: reminderPreferenceStore) snackbarManager = SnackbarManager() diff --git a/ios-swiftUI/Tday/Core/Data/AppDataMode.swift b/ios-swiftUI/Tday/Core/Data/AppDataMode.swift new file mode 100644 index 00000000..d705d775 --- /dev/null +++ b/ios-swiftUI/Tday/Core/Data/AppDataMode.swift @@ -0,0 +1,7 @@ +import Foundation + +enum AppDataMode: String { + case unset + case server + case local +} diff --git a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift index c901e693..9d923467 100644 --- a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift @@ -22,12 +22,14 @@ actor AsyncLock { final class OfflineCacheManager { let modelContainer: ModelContainer private let modelContext: ModelContext + private let secureStore: SecureStore private let syncLock = AsyncLock() private(set) var cacheDataVersion = 0 private var lastState = OfflineSyncState() - init(modelContainer: ModelContainer) { + init(modelContainer: ModelContainer, secureStore: SecureStore) { self.modelContainer = modelContainer + self.secureStore = secureStore modelContext = ModelContext(modelContainer) lastState = loadOfflineState() } @@ -154,7 +156,18 @@ final class OfflineCacheManager { } func saveOfflineState(_ state: OfflineSyncState) { - if state == lastState { + let normalizedState: OfflineSyncState + if secureStore.isLocalMode() { + var localState = state + localState.lastSuccessfulSyncEpochMs = 0 + localState.lastSyncAttemptEpochMs = 0 + localState.pendingMutations = [] + normalizedState = localState + } else { + normalizedState = state + } + + if normalizedState == lastState { return } @@ -167,24 +180,24 @@ final class OfflineCacheManager { replaceAll(PendingMutationEntity.self) replaceAll(SyncMetadataEntity.self) - state.todos.forEach { modelContext.insert(CachedTodoEntity(from: $0)) } - state.floaters.forEach { modelContext.insert(CachedFloaterEntity(from: $0)) } - state.lists.forEach { modelContext.insert(CachedListEntity(from: $0)) } - state.floaterLists.forEach { modelContext.insert(CachedFloaterListEntity(from: $0)) } - state.completedItems.forEach { modelContext.insert(CachedCompletedEntity(from: $0)) } - state.completedFloaters.forEach { modelContext.insert(CachedCompletedFloaterEntity(from: $0)) } - state.pendingMutations.forEach { modelContext.insert(PendingMutationEntity(from: $0)) } + normalizedState.todos.forEach { modelContext.insert(CachedTodoEntity(from: $0)) } + normalizedState.floaters.forEach { modelContext.insert(CachedFloaterEntity(from: $0)) } + normalizedState.lists.forEach { modelContext.insert(CachedListEntity(from: $0)) } + normalizedState.floaterLists.forEach { modelContext.insert(CachedFloaterListEntity(from: $0)) } + normalizedState.completedItems.forEach { modelContext.insert(CachedCompletedEntity(from: $0)) } + normalizedState.completedFloaters.forEach { modelContext.insert(CachedCompletedFloaterEntity(from: $0)) } + normalizedState.pendingMutations.forEach { modelContext.insert(PendingMutationEntity(from: $0)) } modelContext.insert( SyncMetadataEntity( - lastSuccessfulSyncEpochMs: state.lastSuccessfulSyncEpochMs, - lastSyncAttemptEpochMs: state.lastSyncAttemptEpochMs, - aiSummaryEnabled: state.aiSummaryEnabled + lastSuccessfulSyncEpochMs: normalizedState.lastSuccessfulSyncEpochMs, + lastSyncAttemptEpochMs: normalizedState.lastSyncAttemptEpochMs, + aiSummaryEnabled: normalizedState.aiSummaryEnabled ) ) try? modelContext.save() - lastState = state - TodayTasksWidgetSnapshotStore.saveTodayTasks(from: state) + lastState = normalizedState + TodayTasksWidgetSnapshotStore.saveTodayTasks(from: normalizedState) cacheDataVersion += 1 NotificationCenter.default.post(name: .offlineCacheDidChange, object: nil) } diff --git a/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift b/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift index a1bb0755..92663b87 100644 --- a/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Completed/CompletedRepository.swift @@ -68,6 +68,9 @@ final class CompletedRepository { } return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -106,6 +109,10 @@ final class CompletedRepository { return nextState } + if syncManager.isLocalMode { + return + } + do { _ = try await api.patchCompletedTodoByBody( payload: UpdateCompletedTodoRequest( @@ -132,6 +139,10 @@ final class CompletedRepository { return nextState } + if syncManager.isLocalMode { + return + } + guard !item.id.hasPrefix(LOCAL_COMPLETED_PREFIX) else { return } diff --git a/ios-swiftUI/Tday/Core/Data/List/FloaterListRepository.swift b/ios-swiftUI/Tday/Core/Data/List/FloaterListRepository.swift index 0d4eeb6b..48e021d0 100644 --- a/ios-swiftUI/Tday/Core/Data/List/FloaterListRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/List/FloaterListRepository.swift @@ -65,6 +65,10 @@ final class FloaterListRepository { return nextState } + if syncManager.isLocalMode { + return + } + do { let response = try await api.createFloaterList( payload: CreateFloaterListRequest(name: normalizedName, color: color, iconKey: iconKey) @@ -155,6 +159,9 @@ final class FloaterListRepository { } return nextState } + if syncManager.isLocalMode { + return + } _ = await syncManager.syncCachedData(force: true, replayPendingMutations: true) return } @@ -196,6 +203,9 @@ final class FloaterListRepository { ) return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -253,6 +263,10 @@ final class FloaterListRepository { onOptimisticDelete() + if syncManager.isLocalMode { + return + } + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error diff --git a/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift b/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift index 67ea62fd..0dac6222 100644 --- a/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/List/ListRepository.swift @@ -64,6 +64,10 @@ final class ListRepository { return nextState } + if syncManager.isLocalMode { + return + } + do { let response = try await api.createList( payload: CreateListRequest(name: normalizedName, color: color, iconKey: iconKey) @@ -149,6 +153,9 @@ final class ListRepository { } return nextState } + if syncManager.isLocalMode { + return + } _ = await syncManager.syncCachedData(force: true, replayPendingMutations: true) return } @@ -190,6 +197,9 @@ final class ListRepository { ) return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -250,6 +260,10 @@ final class ListRepository { onOptimisticDelete() + if syncManager.isLocalMode { + return + } + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error diff --git a/ios-swiftUI/Tday/Core/Data/SecureStore.swift b/ios-swiftUI/Tday/Core/Data/SecureStore.swift index a4699d33..a48b019a 100644 --- a/ios-swiftUI/Tday/Core/Data/SecureStore.swift +++ b/ios-swiftUI/Tday/Core/Data/SecureStore.swift @@ -24,6 +24,27 @@ final class SecureStore { case persistedAuthSessionCookie = "persisted-auth-session-cookie" case cachedSessionUser = "cached-session-user" case savedServerURLSuggestion = "saved-server-url-suggestion" + case appDataMode = "app-data-mode-v1" + } + + func appDataMode() -> AppDataMode { + if let rawValue = loadString(for: .appDataMode), + let mode = AppDataMode(rawValue: rawValue) { + return mode + } + return (loadRuntimeServerURL() == nil && loadPersistedServerURL() == nil) ? .unset : .server + } + + func isLocalMode() -> Bool { + appDataMode() == .local + } + + func setAppDataMode(_ mode: AppDataMode) { + saveString(mode.rawValue, for: .appDataMode) + } + + func clearAppDataMode() { + deleteValue(for: .appDataMode) } func loadPersistedServerURL() -> URL? { @@ -35,6 +56,7 @@ final class SecureStore { func savePersistedServerURL(_ url: URL) { saveString(url.absoluteString, for: .persistedServerURL) + setAppDataMode(.server) } func clearPersistedServerURL() { @@ -125,6 +147,7 @@ final class SecureStore { func clearAllUserValues(preservingServerURL: Bool = false) { if !preservingServerURL { clearPersistedServerURL() + clearAppDataMode() } clearPersistedAuthSessionCookie() clearCachedSessionUser() @@ -191,6 +214,7 @@ final class SecureStore { } clearPersistedServerURL() + clearAppDataMode() clearPersistedAuthSessionCookie() clearCachedSessionUser() clearLastEmail() diff --git a/ios-swiftUI/Tday/Core/Data/Server/ServerConfigRepository.swift b/ios-swiftUI/Tday/Core/Data/Server/ServerConfigRepository.swift index 83009a71..e6ba1d5e 100644 --- a/ios-swiftUI/Tday/Core/Data/Server/ServerConfigRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Server/ServerConfigRepository.swift @@ -35,6 +35,14 @@ final class ServerConfigRepository { serverURLState.currentURL != nil || secureStore.loadPersistedServerURL() != nil } + func appDataMode() -> AppDataMode { + secureStore.appDataMode() + } + + func isLocalMode() -> Bool { + secureStore.isLocalMode() + } + func getServerURL() -> URL? { serverURLState.currentURL ?? secureStore.loadPersistedServerURL() } @@ -123,9 +131,19 @@ final class ServerConfigRepository { func clearServerConfiguration() { serverURLState.currentURL = nil secureStore.clearPersistedServerURL() + secureStore.clearAppDataMode() secureStore.clearAllTrustedFingerprints() } + func enableLocalMode() { + serverURLState.currentURL = nil + secureStore.clearPersistedServerURL() + secureStore.clearCachedSessionUser() + secureStore.clearLastEmail() + secureStore.clearPersistedAuthSessionCookie() + secureStore.setAppDataMode(.local) + } + func buildAbsoluteAppURL(_ path: String) -> URL? { getServerURL()?.appending(path: path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) } diff --git a/ios-swiftUI/Tday/Core/Data/Settings/SettingsRepository.swift b/ios-swiftUI/Tday/Core/Data/Settings/SettingsRepository.swift index dc7f2dad..e73eb9d6 100644 --- a/ios-swiftUI/Tday/Core/Data/Settings/SettingsRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Settings/SettingsRepository.swift @@ -4,17 +4,26 @@ import Foundation final class SettingsRepository { private let api: TdayAPIService private let cacheManager: OfflineCacheManager + private let secureStore: SecureStore - init(api: TdayAPIService, cacheManager: OfflineCacheManager) { + init(api: TdayAPIService, cacheManager: OfflineCacheManager, secureStore: SecureStore) { self.api = api self.cacheManager = cacheManager + self.secureStore = secureStore } func isAiSummaryEnabledSnapshot() -> Bool { - cacheManager.loadOfflineState().aiSummaryEnabled + if secureStore.isLocalMode() { + return false + } + return cacheManager.loadOfflineState().aiSummaryEnabled } func refreshAiSummaryEnabled() async -> Bool { + if secureStore.isLocalMode() { + return false + } + do { let enabled = try await api.getAppSettings().aiSummaryEnabled _ = try await cacheManager.updateOfflineState { state in @@ -29,6 +38,10 @@ final class SettingsRepository { } func fetchAdminAiSummaryEnabled() async throws -> Bool { + if secureStore.isLocalMode() { + return false + } + let enabled = try await api.getAdminSettings().aiSummaryEnabled _ = try await cacheManager.updateOfflineState { state in var nextState = state @@ -39,6 +52,10 @@ final class SettingsRepository { } func updateAdminAiSummaryEnabled(_ enabled: Bool) async throws -> AdminSettingsResponse { + if secureStore.isLocalMode() { + throw APIError(message: "Admin settings are unavailable in local mode", statusCode: nil) + } + let response = try await api.patchAdminSettings(payload: UpdateAdminSettingsRequest(aiSummaryEnabled: enabled)) _ = try await cacheManager.updateOfflineState { state in var nextState = state diff --git a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift index 91e90a5e..54829cf2 100644 --- a/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Sync/SyncManager.swift @@ -97,17 +97,23 @@ func mergeCompletedFloaterRecordsWithPendingOverrides( final class SyncManager { private let api: TdayAPIService private let cacheManager: OfflineCacheManager + private let secureStore: SecureStore private let offlineResyncIntervalMs: Int64 = 5 * 60 * 1_000 private let minForceSyncIntervalMs: Int64 = 1_200 - init(api: TdayAPIService, cacheManager: OfflineCacheManager) { + init(api: TdayAPIService, cacheManager: OfflineCacheManager, secureStore: SecureStore) { self.api = api self.cacheManager = cacheManager + self.secureStore = secureStore } func hasPendingMutations() -> Bool { - !cacheManager.loadOfflineState().pendingMutations.isEmpty + !isLocalMode && !cacheManager.loadOfflineState().pendingMutations.isEmpty + } + + var isLocalMode: Bool { + secureStore.isLocalMode() } func syncCachedData( @@ -116,6 +122,19 @@ final class SyncManager { notifyOfflineFailure: Bool = true, connectionProbeTimeoutSeconds: TimeInterval? = nil ) async -> Result { + if isLocalMode { + if let localState = try? await cacheManager.updateOfflineState({ state in + var nextState = state + nextState.lastSuccessfulSyncEpochMs = 0 + nextState.lastSyncAttemptEpochMs = 0 + nextState.pendingMutations = [] + return nextState + }) { + TodayTasksWidgetSnapshotStore.saveTodayTasks(from: localState) + } + return .success(()) + } + do { var contactedServer = false if let connectionProbeTimeoutSeconds { diff --git a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift index 4c15e7d5..28e99d06 100644 --- a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift @@ -82,6 +82,10 @@ final class TodoRepository { return nextState } + if syncManager.isLocalMode { + return + } + if normalizedListID?.hasPrefix(LOCAL_FLOATER_LIST_PREFIX) == true { _ = await syncManager.syncCachedData(force: true, replayPendingMutations: true) return @@ -173,6 +177,10 @@ final class TodoRepository { return nextState } + if syncManager.isLocalMode { + return + } + if normalizedListID?.hasPrefix(LOCAL_LIST_PREFIX) == true { _ = await syncManager.syncCachedData(force: true, replayPendingMutations: true) return @@ -266,6 +274,9 @@ final class TodoRepository { ) return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -321,6 +332,9 @@ final class TodoRepository { ) return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -408,6 +422,10 @@ final class TodoRepository { return nextState } + if syncManager.isLocalMode { + return + } + let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -444,6 +462,9 @@ final class TodoRepository { } return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -480,6 +501,9 @@ final class TodoRepository { } return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -541,6 +565,9 @@ final class TodoRepository { ) return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -604,6 +631,9 @@ final class TodoRepository { ) return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error @@ -619,7 +649,11 @@ final class TodoRepository { } func summarizeTodos(mode: TodoListMode, listId: String? = nil) async throws -> TodoSummaryResponse { - try await api.summarizeTodos( + if syncManager.isLocalMode { + throw APIError(message: "AI summary is unavailable in local mode", statusCode: nil) + } + + return try await api.summarizeTodos( payload: TodoSummaryRequest( mode: mode.rawValue, listId: listId, @@ -629,6 +663,10 @@ final class TodoRepository { } func parseTodoTitleNlp(text: String, referenceDueEpochMs: Int64) async -> TodoTitleNlpResponse? { + if syncManager.isLocalMode { + return nil + } + let timezoneOffsetMinutes = TimeZone.current.secondsFromGMT() / 60 return try? await api.parseTodoTitleNlp( payload: TodoTitleNlpRequest( @@ -704,6 +742,9 @@ final class TodoRepository { ) return nextState } + if syncManager.isLocalMode { + return + } let result = await syncManager.syncCachedData(force: true, replayPendingMutations: true) if case let .failure(error) = result, isLikelyUnrecoverableMutationError(error) { throw error diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index eeab2351..a3d2b688 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -30,7 +30,7 @@ struct AppRootView: View { if !appViewModel.hasCompletedInitialBootstrap || isLaunchSplashHeld { AppLaunchSplashView(isHeld: $isLaunchSplashHeld) } else { - let showOnboardingOverlay = !appViewModel.authenticated && appViewModel.versionCheckResult == .compatible + let showOnboardingOverlay = !appViewModel.isWorkspaceAvailable && appViewModel.versionCheckResult == .compatible NavigationStack( path: rootNavigationPath @@ -46,7 +46,8 @@ struct AppRootView: View { createTaskRequestID: rootCreateTaskRequestID, scrollToTopRequestID: rootHomeScrollToTopRequestID, onRootDockCollapsedChange: { rootDockCollapsed = $0 }, - onRootControlsVisibleChange: { rootControlsVisible = $0 } + onRootControlsVisibleChange: { rootControlsVisible = $0 }, + pullRefreshEnabled: !appViewModel.isLocalMode ) { route in handleRoute(route) } @@ -60,6 +61,7 @@ struct AppRootView: View { rootFeedTab: .floater, onRootFeedTabSelected: handleRootFeedTabSelection, showsRootControls: false, + pullRefreshEnabled: !appViewModel.isLocalMode, usesRootFeedHeader: true, createTaskRequestID: rootCreateTaskRequestID, scrollToTopRequestID: rootFloaterScrollToTopRequestID, @@ -74,7 +76,7 @@ struct AppRootView: View { ) } - if appViewModel.authenticated, rootControlsVisible { + if appViewModel.isWorkspaceAvailable, rootControlsVisible { rootFloatingControls } } @@ -89,20 +91,21 @@ struct AppRootView: View { case .home: HomeScreen( container: container, - onRootFeedTabSelected: handleRootFeedTabSelection + onRootFeedTabSelected: handleRootFeedTabSelection, + pullRefreshEnabled: !appViewModel.isLocalMode ) { nextRoute in handleRoute(nextRoute) } case .todayTodos: - TodoListScreen(container: container, mode: .today, listId: nil, listName: nil, highlightedTodoId: nil) + TodoListScreen(container: container, mode: .today, listId: nil, listName: nil, highlightedTodoId: nil, pullRefreshEnabled: !appViewModel.isLocalMode) case .overdueTodos: - TodoListScreen(container: container, mode: .overdue, listId: nil, listName: nil, highlightedTodoId: nil) + TodoListScreen(container: container, mode: .overdue, listId: nil, listName: nil, highlightedTodoId: nil, pullRefreshEnabled: !appViewModel.isLocalMode) case .scheduledTodos: - TodoListScreen(container: container, mode: .scheduled, listId: nil, listName: nil, highlightedTodoId: nil) + TodoListScreen(container: container, mode: .scheduled, listId: nil, listName: nil, highlightedTodoId: nil, pullRefreshEnabled: !appViewModel.isLocalMode) case let .allTodos(highlightTodoId): - TodoListScreen(container: container, mode: .all, listId: nil, listName: nil, highlightedTodoId: highlightTodoId) + TodoListScreen(container: container, mode: .all, listId: nil, listName: nil, highlightedTodoId: highlightTodoId, pullRefreshEnabled: !appViewModel.isLocalMode) case .priorityTodos: - TodoListScreen(container: container, mode: .priority, listId: nil, listName: nil, highlightedTodoId: nil) + TodoListScreen(container: container, mode: .priority, listId: nil, listName: nil, highlightedTodoId: nil, pullRefreshEnabled: !appViewModel.isLocalMode) case .floaterTodos: Color.clear .navigationBarBackButtonHidden(true) @@ -117,6 +120,7 @@ struct AppRootView: View { listId: listId, listName: listName, highlightedTodoId: nil, + pullRefreshEnabled: !appViewModel.isLocalMode, onListDeleted: { handleRoute(.floaterTodos) } @@ -128,14 +132,15 @@ struct AppRootView: View { listId: listId, listName: listName, highlightedTodoId: nil, + pullRefreshEnabled: !appViewModel.isLocalMode, onListDeleted: { appViewModel.navigate(to: .home) } ) case .completed: - CompletedScreen(container: container) + CompletedScreen(container: container, pullRefreshEnabled: !appViewModel.isLocalMode) case .calendar: - CalendarScreen(container: container) + CalendarScreen(container: container, pullRefreshEnabled: !appViewModel.isLocalMode) case .settings: SettingsScreen(viewModel: appViewModel) case .latestRelease: @@ -147,7 +152,7 @@ struct AppRootView: View { } .overlay(alignment: .top) { OfflineBanner( - visible: appViewModel.authenticated && appViewModel.isOffline, + visible: appViewModel.authenticated && !appViewModel.isLocalMode && appViewModel.isOffline, pendingMutationCount: appViewModel.pendingMutationCount, noticeID: appViewModel.offlineNoticeID ) @@ -168,7 +173,7 @@ struct AppRootView: View { } } .overlay { - if !appViewModel.authenticated { + if !appViewModel.isWorkspaceAvailable { let isVersionBlocking = appViewModel.versionCheckResult != .compatible if isVersionBlocking { @@ -206,6 +211,11 @@ struct AppRootView: View { } return success }, + onUseLocalMode: { + authViewModel.clearStatus() + appViewModel.clearPendingApprovalNotice() + await appViewModel.useLocalMode() + }, onClearAuthStatus: { authViewModel.clearStatus() appViewModel.clearPendingApprovalNotice() @@ -214,7 +224,7 @@ struct AppRootView: View { } } - if appViewModel.authenticated && appViewModel.versionCheckResult != .compatible { + if appViewModel.authenticated && !appViewModel.isLocalMode && appViewModel.versionCheckResult != .compatible { UpdateRequiredView( versionCheckResult: appViewModel.versionCheckResult, onRetry: { diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index 1f08fc40..6b066407 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -35,6 +35,7 @@ final class AppViewModel { var requiresServerSetup = false var requiresLogin = false var serverURL: String? + var dataMode: AppDataMode = .unset var themeMode: AppThemeMode var user: SessionUser? var error: String? @@ -63,6 +64,14 @@ final class AppViewModel { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" } + var isLocalMode: Bool { + dataMode == .local + } + + var isWorkspaceAvailable: Bool { + authenticated || isLocalMode + } + var hasUpdate: Bool { guard let remote = latestVersionName else { return false } return Self.compareVersions(remote, currentVersionName) > 0 @@ -102,12 +111,18 @@ final class AppViewModel { error = nil isManualSyncing = false + if container.serverConfigRepository.appDataMode() == .local { + await enterLocalWorkspace() + return + } + if !container.serverConfigRepository.hasServerConfigured() { container.authRepository.clearAllLocalUserDataForUnauthenticatedState() authenticated = false requiresServerSetup = true requiresLogin = false serverURL = nil + dataMode = .unset user = nil error = nil canResetServerTrust = false @@ -125,6 +140,7 @@ final class AppViewModel { } serverURL = container.serverConfigRepository.getServerURL()?.absoluteString + dataMode = .server let sessionResult = await container.bootstrapSession() if let sessionResult, sessionResult.user.id != nil { let session = sessionResult.user @@ -141,6 +157,7 @@ final class AppViewModel { authenticated = true requiresServerSetup = false requiresLogin = false + dataMode = .server user = session error = nil pendingApprovalMessage = nil @@ -164,6 +181,7 @@ final class AppViewModel { authenticated = false requiresServerSetup = false requiresLogin = true + dataMode = .server user = nil error = nil pendingApprovalMessage = nil @@ -182,10 +200,51 @@ final class AppViewModel { await bootstrap() } + func useLocalMode() async { + container.authRepository.clearAllLocalUserDataForUnauthenticatedState() + container.serverConfigRepository.enableLocalMode() + await enterLocalWorkspace() + } + + private func enterLocalWorkspace() async { + stopRealtime() + stopSyncLoop() + _ = try? await container.cacheManager.updateOfflineState { state in + var nextState = state + nextState.lastSuccessfulSyncEpochMs = 0 + nextState.lastSyncAttemptEpochMs = 0 + nextState.pendingMutations = [] + return nextState + } + authenticated = false + requiresServerSetup = false + requiresLogin = false + serverURL = nil + dataMode = .local + user = nil + error = nil + canResetServerTrust = false + pendingApprovalMessage = nil + isManualSyncing = false + adminAiSummaryEnabled = nil + isAdminAiSummaryLoading = false + isAdminAiSummarySaving = false + adminAiSummaryError = nil + aiSummaryValidationError = nil + isOffline = false + pendingMutationCount = 0 + versionCheckResult = .compatible + backendVersion = nil + await container.reminderScheduler.requestAuthorization() + await rescheduleReminders() + finishBootstrap() + } + func connectServer(rawURL: String) async -> Result { do { let probeResult = try await container.serverConfigRepository.probeAndSave(rawURL) serverURL = probeResult.serverURL + dataMode = .server versionCheckResult = probeResult.versionCheck backendVersion = probeResult.backendVersion let isBlocking = probeResult.versionCheck != .compatible @@ -203,6 +262,9 @@ final class AppViewModel { } func recheckVersion() async { + guard !isLocalMode else { + return + } let result = await container.serverConfigRepository.recheckVersion() versionCheckResult = result switch result { @@ -220,6 +282,7 @@ final class AppViewModel { _ = try await container.serverConfigRepository.resetTrustedServer(rawURL: rawURL) let savedServerURL = container.serverConfigRepository.getServerURL()?.absoluteString ?? rawURL serverURL = savedServerURL + dataMode = .server requiresServerSetup = false requiresLogin = true error = nil @@ -237,7 +300,7 @@ final class AppViewModel { } func refreshAdminAiSummarySetting() async { - guard isAdmin(user) else { + guard !isLocalMode, isAdmin(user) else { adminAiSummaryEnabled = nil isAdminAiSummaryLoading = false isAdminAiSummarySaving = false @@ -258,7 +321,7 @@ final class AppViewModel { } func setAdminAiSummaryEnabled(_ enabled: Bool) async { - guard isAdmin(user), !isAdminAiSummarySaving else { + guard !isLocalMode, isAdmin(user), !isAdminAiSummarySaving else { return } isAdminAiSummarySaving = true @@ -293,6 +356,9 @@ final class AppViewModel { } func manualSync() async { + guard !isLocalMode else { + return + } isManualSyncing = true let result = await container.syncAndRefresh( force: true, @@ -306,7 +372,7 @@ final class AppViewModel { } func reconnectAfterForeground() async { - guard authenticated, !isForegroundReconnectInFlight else { + guard authenticated, !isLocalMode, !isForegroundReconnectInFlight else { return } @@ -355,6 +421,10 @@ final class AppViewModel { } private func refreshPendingMutationCount() { + guard !isLocalMode else { + pendingMutationCount = 0 + return + } pendingMutationCount = container.cacheManager.loadOfflineState().pendingMutations.count } @@ -633,7 +703,9 @@ final class AppViewModel { } func refreshVersionInfo() async { - await recheckVersion() + if !isLocalMode { + await recheckVersion() + } await refreshGitHubReleases() } diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 026e6ef2..cba2b6e2 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -147,6 +147,7 @@ private struct CalendarTaskRescheduleDrop: Equatable { } struct CalendarScreen: View { + private let pullRefreshEnabled: Bool @State private var viewModel: CalendarViewModel @Environment(\.tdayColors) private var colors @Environment(\.dismiss) private var dismiss @@ -168,7 +169,8 @@ struct CalendarScreen: View { @State private var pendingRescheduleDrop: CalendarTaskRescheduleDrop? @State private var openSwipeTaskID: String? - init(container: AppContainer) { + init(container: AppContainer, pullRefreshEnabled: Bool = true) { + self.pullRefreshEnabled = pullRefreshEnabled _viewModel = State(initialValue: CalendarViewModel(container: container)) } @@ -279,7 +281,7 @@ struct CalendarScreen: View { .environment(\.defaultMinListRowHeight, 1) .disableVerticalScrollBounce() .background(colors.background) - .tdayPullToRefresh(isRefreshing: viewModel.isLoading) { + .tdayPullToRefresh(isRefreshing: viewModel.isLoading, isEnabled: pullRefreshEnabled) { await viewModel.refresh() } .onPreferenceChange(CalendarDateDropTargetFramePreferenceKey.self) { frames in diff --git a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift index ec120c76..2e9b1907 100644 --- a/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift +++ b/ios-swiftUI/Tday/Feature/Completed/CompletedScreen.swift @@ -9,6 +9,7 @@ private enum CompletedRestorePhase { } struct CompletedScreen: View { + private let pullRefreshEnabled: Bool @State private var viewModel: CompletedViewModel @Environment(\.tdayColors) private var colors @Environment(\.dismiss) private var dismiss @@ -17,7 +18,8 @@ struct CompletedScreen: View { @State private var collapsedSectionIDs: Set = [] @State private var openSwipeTaskID: String? - init(container: AppContainer) { + init(container: AppContainer, pullRefreshEnabled: Bool = true) { + self.pullRefreshEnabled = pullRefreshEnabled _viewModel = State(initialValue: CompletedViewModel(container: container)) } @@ -45,7 +47,7 @@ struct CompletedScreen: View { var body: some View { completedTimelineContent - .tdayPullToRefresh(isRefreshing: viewModel.isLoading) { + .tdayPullToRefresh(isRefreshing: viewModel.isLoading, isEnabled: pullRefreshEnabled) { await viewModel.refresh() } .background(colors.background) diff --git a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift index 8978b41b..3a6067b1 100644 --- a/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift +++ b/ios-swiftUI/Tday/Feature/Home/HomeScreen.swift @@ -88,6 +88,7 @@ struct HomeScreen: View { let scrollToTopRequestID: Int let onRootDockCollapsedChange: (Bool) -> Void let onRootControlsVisibleChange: (Bool) -> Void + let pullRefreshEnabled: Bool let onNavigate: (AppRoute) -> Void @State private var viewModel: HomeViewModel @@ -113,6 +114,7 @@ struct HomeScreen: View { scrollToTopRequestID: Int = 0, onRootDockCollapsedChange: @escaping (Bool) -> Void = { _ in }, onRootControlsVisibleChange: @escaping (Bool) -> Void = { _ in }, + pullRefreshEnabled: Bool = true, onNavigate: @escaping (AppRoute) -> Void ) { self.onRootFeedTabSelected = onRootFeedTabSelected @@ -121,6 +123,7 @@ struct HomeScreen: View { self.scrollToTopRequestID = scrollToTopRequestID self.onRootDockCollapsedChange = onRootDockCollapsedChange self.onRootControlsVisibleChange = onRootControlsVisibleChange + self.pullRefreshEnabled = pullRefreshEnabled self.onNavigate = onNavigate _viewModel = State(initialValue: HomeViewModel(container: container)) } @@ -178,6 +181,7 @@ struct HomeScreen: View { ZStack(alignment: .topLeading) { PullToRefreshContainer( isRefreshing: viewModel.isLoading, + isEnabled: pullRefreshEnabled, action: { await viewModel.refresh() } diff --git a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift index 2b4a981e..74c9c118 100644 --- a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift +++ b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift @@ -1,7 +1,13 @@ import SwiftUI import UIKit +private func isOnboardingDaytime(_ date: Date) -> Bool { + let hour = Calendar.current.component(.hour, from: date) + return (6..<18).contains(hour) +} + enum OnboardingStep: Equatable { + case mode case server case login } @@ -9,15 +15,18 @@ enum OnboardingStep: Equatable { struct OnboardingWizardOverlay: View { fileprivate enum Metrics { static let overlayPadding: CGFloat = 18 - static let cardMaxWidth: CGFloat = 460 - static let cardCornerRadius: CGFloat = 32 + static let cardMaxWidth: CGFloat = 430 + static let cardCornerRadius: CGFloat = 34 static let cardPadding: CGFloat = 18 - static let sectionSpacing: CGFloat = 12 - static let chipSpacing: CGFloat = 10 - static let chipCornerRadius: CGFloat = 20 - static let inputHeight: CGFloat = 56 - static let inputCornerRadius: CGFloat = 8 - static let buttonHeight: CGFloat = 40 + static let sectionSpacing: CGFloat = 14 + static let chipSpacing: CGFloat = 8 + static let chipCornerRadius: CGFloat = 18 + static let inputHeight: CGFloat = 54 + static let inputCornerRadius: CGFloat = 22 + static let buttonHeight: CGFloat = 48 + static let tileCornerRadius: CGFloat = 26 + static let tileHeight: CGFloat = 116 + static let heroHeight: CGFloat = 78 static let watermarkSize: CGFloat = 130 } @@ -31,6 +40,7 @@ struct OnboardingWizardOverlay: View { let onResetServerTrust: (String) async -> Result let onLogin: (String, String, LoginCredentialSource) async -> Bool let onRegister: (String, String, String) async -> Bool + let onUseLocalMode: () async -> Void let onClearAuthStatus: () -> Void @Environment(\.tdayColors) private var colors @@ -71,17 +81,17 @@ struct OnboardingWizardOverlay: View { } .onAppear { serverURL = initialServerURL ?? "" - step = (initialServerURL?.isEmpty == false) ? .login : .server + step = (initialServerURL?.isEmpty == false) ? .login : .mode if step == .login { requestSavedCredentialIfAvailable() - } else { + } else if step == .server { requestSavedServerURLIfAvailable() } } .onChange(of: step) { _, newStep in if newStep == .login { requestSavedCredentialIfAvailable() - } else { + } else if newStep == .server { requestSavedServerURLIfAvailable() } } @@ -149,17 +159,26 @@ struct OnboardingWizardOverlay: View { private var wizardCard: some View { VStack(alignment: .leading, spacing: Metrics.sectionSpacing) { - VStack(alignment: .leading, spacing: 4) { - Text("Set Up T'Day") - .font(.tdayRounded(size: 23, weight: .bold)) + HStack(spacing: 8) { + Image(systemName: isOnboardingDaytime(Date()) ? "sun.max.fill" : "moon.stars.fill") + .font(.system(size: 25, weight: .regular)) + .foregroundStyle(Color(red: 0.96, green: 0.77, blue: 0.26)) + + Text("T'Day") + .font(.tdayRounded(size: 31, weight: .heavy)) .foregroundStyle(colors.onSurface) - Text("Secure onboarding wizard") - .font(.tdayRounded(size: 12, weight: .bold)) - .foregroundStyle(colors.onSurface.opacity(0.6)) + Spacer(minLength: 0) } HStack(spacing: Metrics.chipSpacing) { + WizardStepChip( + title: "Mode", + systemImage: "iphone", + tint: Color(red: 0.5, green: 0.72, blue: 0.54), + active: step == .mode + ) + WizardStepChip( title: "Server", systemImage: "globe", @@ -175,6 +194,11 @@ struct OnboardingWizardOverlay: View { ) } + Text("Set up your workspace") + .font(.tdayRounded(size: 13, weight: .bold)) + .foregroundStyle(colors.onSurface.opacity(0.62)) + .padding(.top, -4) + Group { if isConnecting { WizardLoadingPanel( @@ -188,6 +212,8 @@ struct OnboardingWizardOverlay: View { title: authLoadingTitle, subtitle: authLoadingSubtitle ) + } else if step == .mode { + modeStepContent } else if step == .server { serverStepContent } else { @@ -198,41 +224,68 @@ struct OnboardingWizardOverlay: View { .frame(maxWidth: Metrics.cardMaxWidth, alignment: .leading) .padding(Metrics.cardPadding) .background { - ZStack(alignment: .bottomTrailing) { + ZStack { RoundedRectangle(cornerRadius: Metrics.cardCornerRadius, style: .continuous) - .fill(colors.surface.opacity(1)) + .fill(colors.background) .overlay( RoundedRectangle(cornerRadius: Metrics.cardCornerRadius, style: .continuous) - .fill(Color.white.opacity(colors.isDark ? 0.045 : 0.18)) + .fill(Color.white.opacity(colors.isDark ? 0.035 : 0.34)) ) .overlay( RoundedRectangle(cornerRadius: Metrics.cardCornerRadius, style: .continuous) - .stroke(Color.white.opacity(colors.isDark ? 0.11 : 0.88), lineWidth: 1) + .stroke(colors.onSurface.opacity(colors.isDark ? 0.12 : 0.08), lineWidth: 1) ) + } + } + .shadow(color: Color.black.opacity(colors.isDark ? 0.34 : 0.14), radius: 14, x: 0, y: 10) + } - LinearGradient( - colors: [ - Color.white.opacity(colors.isDark ? 0.06 : 0.2), - colors.onSurface.opacity(0.025), - .clear, - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - .clipShape(RoundedRectangle(cornerRadius: Metrics.cardCornerRadius, style: .continuous)) + private var modeStepContent: some View { + VStack(alignment: .leading, spacing: 11) { + WizardHeroTile( + title: "Choose your setup", + subtitle: "Pick where T'Day keeps your tasks.", + systemImage: "sparkles", + tint: Color(red: 0.43, green: 0.66, blue: 0.88) + ) + + HStack(spacing: 10) { + WizardModeChoiceButton( + title: "Self-hosted", + subtitle: "Accounts and sync", + systemImage: "globe", + tint: Color(red: 0.43, green: 0.66, blue: 0.88) + ) { + localError = nil + onClearAuthStatus() + step = .server + } - Image(systemName: step == .server ? "globe.americas.fill" : "lock.fill") - .font(.system(size: Metrics.watermarkSize, weight: .regular)) - .foregroundStyle(colors.primary.opacity(0.18)) - .padding(.trailing, 18) - .padding(.bottom, isLoginStep ? 18 : 10) + WizardModeChoiceButton( + title: "This device", + subtitle: "No login", + systemImage: "iphone", + tint: Color(red: 0.45, green: 0.62, blue: 0.52) + ) { + localError = nil + onClearAuthStatus() + Task { + await onUseLocalMode() + } + } } } - .shadow(color: Color.black.opacity(colors.isDark ? 0.34 : 0.16), radius: 10, x: 0, y: 8) } private var serverStepContent: some View { VStack(alignment: .leading, spacing: 12) { + WizardHeroTile( + title: "Self-hosted server", + subtitle: "Connect your T'Day endpoint.", + systemImage: "globe", + tint: Color(red: 0.43, green: 0.66, blue: 0.88) + ) + WizardInputField( title: "Server URL", text: $serverURL, @@ -280,11 +333,29 @@ struct OnboardingWizardOverlay: View { await connectServer() } } + + Button("Change setup") { + localError = nil + onClearAuthStatus() + step = .mode + } + .buttonStyle(WizardTextButtonStyle()) + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.primary) + .frame(maxWidth: .infinity, alignment: .center) + .disabled(isConnecting) } } private var loginStepContent: some View { VStack(alignment: .leading, spacing: 11) { + WizardHeroTile( + title: isCreatingAccount ? "Create account" : "Sign in", + subtitle: isCreatingAccount ? "Create your server account." : "Open your synced workspace.", + systemImage: isCreatingAccount ? "person.badge.plus.fill" : "person.fill", + tint: Color(red: 0.79, green: 0.47, blue: 0.50) + ) + if isCreatingAccount { WizardInputField(title: "First name", text: $firstName, autocapitalization: .words, submitLabel: .next) } @@ -366,12 +437,12 @@ struct OnboardingWizardOverlay: View { Spacer(minLength: 16) - Button("Change server") { + Button("Change setup") { localError = nil onClearAuthStatus() isCompletingAuthentication = false isCreatingAccount = false - step = .server + step = .mode } .buttonStyle(WizardTextButtonStyle()) .font(.tdayRounded(size: 15, weight: .bold)) @@ -606,6 +677,8 @@ private struct WizardStepChip: View { let tint: Color let active: Bool + @Environment(\.tdayColors) private var colors + var body: some View { let ringColor = Color( red: min(1, tint.components.red * 0.75), @@ -615,26 +688,25 @@ private struct WizardStepChip: View { HStack(spacing: 8) { Image(systemName: systemImage) - .font(.system(size: 14, weight: .bold)) + .font(.system(size: 13, weight: .bold)) Text(title) - .font(.tdayRounded(size: 14, weight: .bold)) + .font(.tdayRounded(size: 13, weight: .bold)) .lineLimit(1) } - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 10) + .foregroundStyle(active ? .white : colors.onSurface.opacity(0.68)) + .padding(.horizontal, 10) + .padding(.vertical, 8) .frame(maxWidth: .infinity) .background( RoundedRectangle(cornerRadius: OnboardingWizardOverlay.Metrics.chipCornerRadius, style: .continuous) - .fill(tint) + .fill(active ? tint : colors.surface) ) .overlay( RoundedRectangle(cornerRadius: OnboardingWizardOverlay.Metrics.chipCornerRadius, style: .continuous) - .stroke(active ? ringColor.opacity(0.85) : .clear, lineWidth: 2) + .stroke(active ? ringColor.opacity(0.62) : colors.onSurface.opacity(0.08), lineWidth: 1) ) - .scaleEffect(active ? 1.04 : 1) - .shadow(color: tint.opacity(active ? 0.22 : 0.14), radius: active ? 10 : 8, x: 0, y: 6) + .shadow(color: tint.opacity(active ? 0.18 : 0), radius: active ? 8 : 0, x: 0, y: 5) } } @@ -668,15 +740,16 @@ private struct WizardInputField: View { .frame(height: OnboardingWizardOverlay.Metrics.inputHeight) .background { RoundedRectangle(cornerRadius: OnboardingWizardOverlay.Metrics.inputCornerRadius, style: .continuous) - .fill(colors.surface.opacity(0.9)) + .fill(colors.surface) .overlay( RoundedRectangle(cornerRadius: OnboardingWizardOverlay.Metrics.inputCornerRadius, style: .continuous) .stroke( - colors.onSurface.opacity(isFocused ? 0.92 : 0.3), + isFocused ? colors.primary.opacity(0.82) : colors.onSurface.opacity(0.14), lineWidth: isFocused ? 1.1 : 1 ) ) } + .shadow(color: Color.black.opacity(colors.isDark ? 0.08 : 0.04), radius: 7, x: 0, y: 4) .background(PasswordRulesConfigurator(rulesDescriptor: passwordRulesDescriptor)) .accessibilityLabel(title) .frame(maxWidth: .infinity) @@ -709,6 +782,130 @@ private enum TdayPasswordRules { static let descriptor = "allowed: ascii-printable; minlength: 8; required: upper; required: special;" } +private struct WizardHeroTile: View { + let title: String + let subtitle: String + let systemImage: String + let tint: Color + + var body: some View { + ZStack(alignment: .trailing) { + RoundedRectangle(cornerRadius: OnboardingWizardOverlay.Metrics.tileCornerRadius, style: .continuous) + .fill(tint) + .overlay( + RadialGradient( + colors: [Color.white.opacity(0.24), Color.white.opacity(0.08), .clear], + center: UnitPoint(x: 0.18, y: 0.18), + startRadius: 0, + endRadius: 210 + ) + ) + + Image(systemName: systemImage) + .font(.system(size: 82, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.2)) + .offset(x: 20, y: 12) + + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 23, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 42, height: 42) + .background(Color.white.opacity(0.18), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.tdayRounded(size: 21, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.82) + + Text(subtitle) + .font(.tdayRounded(size: 13, weight: .bold)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(2) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + } + .frame(maxWidth: .infinity) + .frame(height: OnboardingWizardOverlay.Metrics.heroHeight) + .clipShape(RoundedRectangle(cornerRadius: OnboardingWizardOverlay.Metrics.tileCornerRadius, style: .continuous)) + .shadow(color: tint.opacity(0.16), radius: 9, x: 0, y: 7) + } +} + +private struct WizardModeChoiceButton: View { + let title: String + let subtitle: String + let systemImage: String + let tint: Color + let action: () -> Void + + @Environment(\.tdayColors) private var colors + + var body: some View { + Button(action: action) { + ZStack(alignment: .bottomTrailing) { + RoundedRectangle(cornerRadius: OnboardingWizardOverlay.Metrics.tileCornerRadius, style: .continuous) + .fill(tint) + .overlay( + RadialGradient( + colors: [Color.white.opacity(0.24), Color.white.opacity(0.08), .clear], + center: UnitPoint(x: 0.22, y: 0.18), + startRadius: 0, + endRadius: 140 + ) + ) + .overlay( + LinearGradient( + colors: [ + Color.white.opacity(0.12), + Color(red: 0.91, green: 0.96, blue: 1.0).opacity(0.08), + .clear, + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + Image(systemName: systemImage) + .font(.system(size: 70, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.22)) + .offset(x: 14, y: 18) + + VStack(alignment: .leading, spacing: 8) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(.white) + + Spacer(minLength: 0) + + Text(title) + .font(.tdayRounded(size: 16, weight: .bold)) + .foregroundStyle(.white) + .lineLimit(2) + + Text(subtitle) + .font(.tdayRounded(size: 12, weight: .bold)) + .foregroundStyle(.white.opacity(0.82)) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(13) + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: OnboardingWizardOverlay.Metrics.tileHeight) + .clipShape(RoundedRectangle(cornerRadius: OnboardingWizardOverlay.Metrics.tileCornerRadius, style: .continuous)) + } + .buttonStyle(WizardPressButtonStyle()) + .shadow(color: tint.opacity(colors.isDark ? 0.18 : 0.16), radius: 9, x: 0, y: 7) + } +} + private struct PasswordRulesConfigurator: UIViewRepresentable { let rulesDescriptor: String? @@ -770,13 +967,23 @@ private struct WizardPrimaryButton: View { .foregroundStyle(enabled ? colors.onPrimary : colors.onSurfaceVariant.opacity(0.65)) .frame(maxWidth: .infinity) .frame(height: OnboardingWizardOverlay.Metrics.buttonHeight) - .background( + .background { Capsule(style: .continuous) .fill(enabled ? colors.primary : colors.surfaceVariant.opacity(0.95)) - ) + .overlay( + Capsule(style: .continuous) + .fill( + LinearGradient( + colors: [Color.white.opacity(enabled ? 0.16 : 0), .clear], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + } } .buttonStyle(WizardPressButtonStyle()) - .shadow(color: enabled ? colors.primary.opacity(0.16) : .clear, radius: 12, x: 0, y: 8) + .shadow(color: enabled ? colors.primary.opacity(0.18) : .clear, radius: 11, x: 0, y: 8) .opacity(enabled ? 1 : 0.72) .disabled(!enabled) } diff --git a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift index 86f0127b..19808f1e 100644 --- a/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift +++ b/ios-swiftUI/Tday/Feature/Settings/SettingsScreen.swift @@ -11,7 +11,7 @@ struct SettingsScreen: View { @State private var showingReminderSelector = false private var isAdminUser: Bool { - (viewModel.user?.role ?? "").uppercased() == "ADMIN" + !viewModel.isLocalMode && (viewModel.user?.role ?? "").uppercased() == "ADMIN" } private var titleCollapseProgress: CGFloat { @@ -85,8 +85,10 @@ struct SettingsScreen: View { List { settingsHeroTitleRow - settingsListRow { - SettingsProfileCard(user: viewModel.user) + if !viewModel.isLocalMode { + settingsListRow { + SettingsProfileCard(user: viewModel.user) + } } settingsListRow { @@ -132,24 +134,26 @@ struct SettingsScreen: View { .foregroundStyle(colors.secondary) } - if let backendVersion = viewModel.backendVersion { + if !viewModel.isLocalMode, let backendVersion = viewModel.backendVersion { SettingsServerVersionRow( backendVersion: backendVersion, versionCheckResult: viewModel.versionCheckResult ) } - SettingsDivider() + if !viewModel.isLocalMode { + SettingsDivider() - SettingsListRow( - title: "Sign out", - value: nil, - titleColor: colors.error, - showChevron: false, - action: { - Task { await viewModel.logout() } - } - ) + SettingsListRow( + title: "Sign out", + value: nil, + titleColor: colors.error, + showChevron: false, + action: { + Task { await viewModel.logout() } + } + ) + } } } diff --git a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift index 96631dc3..6a85ad25 100644 --- a/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift +++ b/ios-swiftUI/Tday/Feature/Todos/TodoListScreen.swift @@ -561,6 +561,7 @@ struct TodoListScreen: View { let scrollToTopRequestID: Int let onRootDockCollapsedChange: (Bool) -> Void let onRootControlsVisibleChange: (Bool) -> Void + let pullRefreshEnabled: Bool let onOpenFloaterList: (String, String) -> Void let onOpenSettings: () -> Void @State private var viewModel: TodoListViewModel @@ -597,6 +598,7 @@ struct TodoListScreen: View { rootFeedTab: RootFeedTab? = nil, onRootFeedTabSelected: ((RootFeedTab) -> Void)? = nil, showsRootControls: Bool = true, + pullRefreshEnabled: Bool = true, usesRootFeedHeader: Bool = false, createTaskRequestID: Int = 0, scrollToTopRequestID: Int = 0, @@ -611,6 +613,7 @@ struct TodoListScreen: View { self.rootFeedTab = rootFeedTab self.onRootFeedTabSelected = onRootFeedTabSelected self.showsRootControls = showsRootControls + self.pullRefreshEnabled = pullRefreshEnabled self.usesRootFeedHeader = usesRootFeedHeader self.createTaskRequestID = createTaskRequestID self.scrollToTopRequestID = scrollToTopRequestID @@ -768,7 +771,7 @@ struct TodoListScreen: View { var body: some View { modeContent - .tdayPullToRefresh(isRefreshing: viewModel.isLoading) { + .tdayPullToRefresh(isRefreshing: viewModel.isLoading, isEnabled: pullRefreshEnabled) { await viewModel.refresh() } .coordinateSpace(name: todoTimelineDragCoordinateSpace) diff --git a/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift b/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift index 2478e12d..fda1b51e 100644 --- a/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift +++ b/ios-swiftUI/Tday/UI/Component/PullToRefresh.swift @@ -3,21 +3,28 @@ import UIKit struct PullToRefreshContainer: View { let isRefreshing: Bool + let isEnabled: Bool let action: @Sendable () async -> Void private let content: Content init( isRefreshing: Bool, + isEnabled: Bool = true, action: @escaping @Sendable () async -> Void, @ViewBuilder content: () -> Content ) { self.isRefreshing = isRefreshing + self.isEnabled = isEnabled self.action = action self.content = content() } var body: some View { - RefreshContainerBody(isRefreshing: isRefreshing, action: action) { + if isEnabled { + RefreshContainerBody(isRefreshing: isRefreshing, action: action) { + content + } + } else { content } } @@ -26,9 +33,10 @@ struct PullToRefreshContainer: View { extension View { func tdayPullToRefresh( isRefreshing: Bool, + isEnabled: Bool = true, action: @escaping @Sendable () async -> Void ) -> some View { - PullToRefreshContainer(isRefreshing: isRefreshing, action: action) { + PullToRefreshContainer(isRefreshing: isRefreshing, isEnabled: isEnabled, action: action) { self } } diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 3ebf40e5..a257e690 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 041712E33446975E528F050E /* RealtimeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC7B17CE45842F8DF7D522B /* RealtimeClient.swift */; }; 04EFD8B7BFDF9261BE3D88BE /* CacheMappers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3CD3233C5F5D5FC1E058708 /* CacheMappers.swift */; }; 0536D5C87C017F1DAC4F316C /* ThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD41A49221B1A947AF8A94D0 /* ThemeStore.swift */; }; + 07D1F4A0C9434C54853E9D29 /* AppDataMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E4F3B64B40942F48202C32E /* AppDataMode.swift */; }; 0A6188D6B8C9FC92A9927F1C /* SettingsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DE0A20E66DEF6FD2146DB10 /* SettingsRepository.swift */; }; 0BA0A83365B9CF5E63B5A9AA /* SnackbarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133161412A6BD2A0C04F745A /* SnackbarManager.swift */; }; 1618367E71392D77BC8C61B6 /* NavigationBackHistoryTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15B9BC191869EBEA7E8340E6 /* NavigationBackHistoryTitle.swift */; }; @@ -105,6 +106,7 @@ 0380EFC9ADD5303B83B5BD91 /* ProbeDecryptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProbeDecryptor.swift; sourceTree = ""; }; 055D0F0501B287B70456AF4B /* TodoRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoRepository.swift; sourceTree = ""; }; 0DE4A047CF237AD9F605D02E /* TaskFloatingActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskFloatingActionButton.swift; sourceTree = ""; }; + 0E4F3B64B40942F48202C32E /* AppDataMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDataMode.swift; sourceTree = ""; }; A9F10101A9F10101A9F10101 /* RootFeedDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootFeedDock.swift; sourceTree = ""; }; 133161412A6BD2A0C04F745A /* SnackbarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackbarManager.swift; sourceTree = ""; }; 15B9BC191869EBEA7E8340E6 /* NavigationBackHistoryTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBackHistoryTitle.swift; sourceTree = ""; }; @@ -435,6 +437,7 @@ 967A6B5FC45694B849B1972E /* Data */ = { isa = PBXGroup; children = ( + 0E4F3B64B40942F48202C32E /* AppDataMode.swift */, 6CF6FD5D2845A10D7052467D /* AppContainer.swift */, E867FA22FEB66EDCE837CF6D /* SecureStore.swift */, D24DBD8CA3EE648D4560B880 /* ServerURLState.swift */, @@ -695,6 +698,7 @@ buildActionMask = 2147483647; files = ( B648A67EAD215BE5B74F7460 /* ApiModels.swift in Sources */, + 07D1F4A0C9434C54853E9D29 /* AppDataMode.swift in Sources */, 5B82443B89507719EDD7215C /* AppContainer.swift in Sources */, A316B4B3BB1AEA5FDF9998DF /* AppRootView.swift in Sources */, D1EC81F59DDFE7F8A10D1D30 /* AppRoute.swift in Sources */, diff --git a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift index b8014491..0c65cb08 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift @@ -40,6 +40,19 @@ final class ServerURLPersistenceTests: XCTestCase { XCTAssertNil(defaults.string(forKey: SecureStore.Key.persistedServerURL.rawValue)) } + func testAppDataModePersistsLocalAndInfersServerFromSavedURL() { + XCTAssertEqual(secureStore.appDataMode(), .unset) + + secureStore.savePersistedServerURL(URL(string: "https://tday.ohmz.cloud")!) + XCTAssertEqual(secureStore.appDataMode(), .server) + + secureStore.setAppDataMode(.local) + XCTAssertEqual(secureStore.appDataMode(), .local) + + secureStore.clearAllUserValues() + XCTAssertEqual(secureStore.appDataMode(), .unset) + } + func testUserCleanupCanPreservePersistedServerURL() { let url = URL(string: "https://tday.ohmz.cloud")! secureStore.savePersistedServerURL(url) From a057c1bfb0c8c038a62c6cf56ad2a4c0aa9a9b73 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 15:26:05 -0400 Subject: [PATCH 10/11] docs: update project documentation to reflect Local Mode, Floater tasks, and mobile parity Comprehensive update of the repository documentation to align with recent architectural shifts, specifically focusing on the introduction of Local Mode, Floater/Anytime tasks, and synchronized mobile data models across Android (Room) and iOS (SwiftData). - **New Documentation**: - Created `docs/PRODUCT_DIRECTION.md`: Defines the "north star" for T'Day, including the separation of scheduled tasks vs. floaters and the priority of mobile parity. - Created `docs/DATA_MODEL.md`: Centralizes the schema for backend tables, shared KMP DTOs, and mirrored mobile cache entities (Room/SwiftData). - Created `docs/REPO_HOUSEKEEPING.md`: Establishes maintenance expectations, markdown audit history, and rules for generated files. - **Mobile Alignment**: - Updated `android-compose/README.md` and `ios-swiftUI/README.md` to reflect identical feature surfaces: Local Mode, Server Mode, `RootFeedDock` navigation, and persistent cache logic. - Standardized "Mobile Parity" rules in `CONTRIBUTING.md` and `AGENTS.md`, requiring behavior synchronization between platforms even if implementations differ. - **Architecture & API**: - Updated `docs/ARCHITECTURE.md` to detail the local-first sync flow and the transition from encrypted JSON snapshots to Room/SwiftData persistence. - Updated `docs/API_GUIDELINES.md` with new endpoints for Floaters, Floater Lists, and mobile server discovery (`/api/mobile/probe`). - Refined `docs/CODING_STANDARDS.md` and `docs/TESTING.md` to include Swift/iOS patterns and enforcement of data contract consistency. - **Security & Telemetry**: - Updated `SECURITY.md` and `docs/TELEMETRY.md` to explicitly state that Local Mode data remains on-device and is excluded from server sync and crash reporting. --- .github/ISSUE_TEMPLATE/bug_report.md | 8 +- .github/ISSUE_TEMPLATE/feature_request.md | 8 + .github/PULL_REQUEST_TEMPLATE.md | 12 +- AGENTS.md | 38 ++++- CONTRIBUTING.md | 73 ++++++++- README.md | 96 +++++++++--- SECURITY.md | 9 +- android-compose/README.md | 100 ++++++++++-- docs/API_GUIDELINES.md | 74 +++++++-- docs/ARCHITECTURE.md | 108 +++++++++---- docs/CODING_STANDARDS.md | 93 +++++++++-- docs/DATA_MODEL.md | 140 +++++++++++++++++ docs/DEPLOYMENT.md | 13 +- docs/PRODUCT_DIRECTION.md | 96 ++++++++++++ docs/REMOTE_ACCESS.md | 2 + docs/REPO_HOUSEKEEPING.md | 144 ++++++++++++++++++ docs/TELEMETRY.md | 5 + docs/TESTING.md | 72 ++++++++- ...001-next-js-monolith-with-native-mobile.md | 6 +- docs/adr/002-postgresql-with-exposed.md | 3 +- docs/adr/003-jwe-jwt-sessions.md | 2 + docs/adr/004-local-ai-via-ollama.md | 1 + .../005-offline-first-android-with-sync.md | 20 ++- docs/adr/006-rfc5545-recurrence.md | 2 +- docs/remote-access/cloudflare-tunnel.md | 2 + docs/remote-access/frp.md | 2 + docs/remote-access/ngrok.md | 2 + docs/remote-access/ssh-tunnel.md | 2 + docs/remote-access/tailscale.md | 2 + docs/remote-access/wireguard.md | 2 + docs/security/cloudflare-auth-hardening.md | 2 + docs/security/operations-hardening.md | 2 + ios-swiftUI/README.md | 91 ++++++++--- .../guardrails/dependency-hygiene.test.ts | 4 + 34 files changed, 1095 insertions(+), 141 deletions(-) create mode 100644 docs/DATA_MODEL.md create mode 100644 docs/PRODUCT_DIRECTION.md create mode 100644 docs/REPO_HOUSEKEEPING.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index d3d1d72f..0cac59b5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -26,12 +26,18 @@ assignees: '' ## Environment -- **Platform**: Web / Android +- **Platform**: Web / Android / iOS / Backend +- **Workspace mode**: Local Mode / Server Mode / Not sure - **Browser** (if web): - **Device/Emulator** (if Android): +- **Simulator/Device** (if iOS): - **T'Day version**: - **OS**: +## Data / Sync Context + + + ## Screenshots / Logs diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 0605c33d..139e544a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -14,10 +14,18 @@ assignees: '' +## Product Surface + + + ## Alternatives Considered +## Parity / Data Notes + + + ## Additional Context diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 1205a69e..03954982 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,6 +8,10 @@ - +## Product / Data Impact + + + ## Type of Change - [ ] Feature (new functionality) @@ -15,6 +19,7 @@ - [ ] Refactor (no behavior change) - [ ] Chore (deps, CI, docs, tooling) - [ ] Breaking change (existing behavior altered) +- [ ] Documentation-only ## Pre-Merge Checklist @@ -24,9 +29,14 @@ - [ ] No AI tool attribution in commits or PR description — no `Co-authored-by`, `Made-with`, or any trailer/text referencing Cursor, Codex, Copilot, ChatGPT, Claude, etc. - [ ] Backward compatibility maintained (or migration provided) - [ ] Flyway migration reviewed (if schema changed) +- [ ] Shared DTOs / Android Room / iOS SwiftData / sync mappers reviewed (if data shape changed) +- [ ] Local Mode behavior reviewed (if mobile behavior changed) +- [ ] Android/iOS parity checked (if mobile UI changed) +- [ ] Relevant docs updated (`README`, product/data/API/architecture/testing/platform docs) - [ ] Error handling and logging added where needed -- [ ] API changes follow [API Guidelines](docs/API_GUIDELINES.md) +- [ ] API changes follow [API Guidelines](../docs/API_GUIDELINES.md) - [ ] Android changes tested on emulator or device (if applicable) +- [ ] iOS changes tested on simulator or device (if applicable) ## Testing diff --git a/AGENTS.md b/AGENTS.md index 1d17721b..d65c4a52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # T'Day Agent Guide -This file is the working agreement for AI agents contributing to T'Day. Read it with `README.md`, `docs/ARCHITECTURE.md`, `docs/CODING_STANDARDS.md`, and `docs/TESTING.md`. +This file is the working agreement for AI agents contributing to T'Day. Read it with `README.md`, `docs/PRODUCT_DIRECTION.md`, `docs/DATA_MODEL.md`, `docs/ARCHITECTURE.md`, `docs/CODING_STANDARDS.md`, `docs/TESTING.md`, and `docs/REPO_HOUSEKEEPING.md`. ## Project Shape @@ -8,12 +8,20 @@ T'Day is a private, self-hosted personal task planner with: - `tday-web/`: Vite, React, TypeScript, Tailwind, i18next. - `tday-backend/`: Ktor, Exposed, Flyway, PostgreSQL, JWE sessions. -- `shared/`: Kotlin Multiplatform DTOs, enums, and validators consumed by backend, Android, and iOS. -- `android-compose/`: Native Android app using Kotlin, Jetpack Compose, Hilt, Retrofit, offline cache and sync. -- `ios-swiftUI/`: Native iOS app using SwiftUI, SwiftData, Observation, URLSession, Keychain/cookie handling. +- `shared/`: Kotlin Multiplatform DTOs, enums, validators, and route constants consumed by backend/Android and mirrored by iOS contract models. +- `android-compose/`: Native Android app using Kotlin, Jetpack Compose, Hilt, Retrofit, Room offline cache, reminders, widgets, and sync. +- `ios-swiftUI/`: Native iOS app using SwiftUI, SwiftData, Observation, URLSession, Keychain/cookie handling, reminders, and widget snapshots. The native mobile apps should feel like one product expressed through two platform-native implementations. +Current product direction: + +- Scheduled `Todo` items have due-date, recurrence, reminder, calendar, and scheduled-list semantics. +- `Floater` items are unscheduled Anytime tasks with separate floater lists and completed history. +- Local Mode is a first-class mobile workspace and must not silently upload local-only data to a server workspace. +- Server Mode uses local optimistic writes plus pending mutation replay. +- Documentation is part of the deliverable when behavior, structure, API, data shape, or verification changes. + ## How To Work In This Repo - Start by checking `git status --short --branch`. The worktree may already contain user changes. @@ -23,6 +31,7 @@ The native mobile apps should feel like one product expressed through two platfo - When the user asks for implementation, implement it, verify it, then report clearly. - When the user asks for a PR, push the active branch and open/update the PR they requested. - When resolving merge conflicts into an outdated base, prefer the active/latest branch behavior unless the user explicitly says otherwise. +- When the user asks for documentation, inspect the real project state and recent commits before writing broad claims. ## Git And Attribution @@ -44,6 +53,7 @@ Before finishing a mobile UI task, ask: - Do labels, task counts, date rules, empty states, and disabled states match? - Do navigation rules match, including lower bounds and edge cases? - Does the interaction feel platform-native on each OS? +- Does Local Mode hide/disable server-only affordances consistently? - Is one platform now clearly nicer? If yes, bring the other platform up to the same product quality. Do not blindly copy implementation details across platforms. Copy behavior, interaction rules, information architecture, and visual intent while using native APIs and established local patterns. @@ -63,6 +73,7 @@ Core rules: - Task counts come from pending scheduled items grouped by local start-of-day. - Day and week task counts cap display at `9+`. - The task section title stays in the form `Tasks due EEE, MMM d`. +- Floaters do not appear on the calendar unless a future product decision gives them scheduled semantics. Interaction rules: @@ -90,6 +101,7 @@ T'Day is a task app, not a marketing site. Mobile screens should feel quiet, use - Text must fit in compact mobile layouts without overlap or truncation that hides meaning. - Empty states should be calm and short. - Preserve dark mode. +- Root feed behavior should stay aligned: Home and Floater/Anytime are sibling root feeds controlled by `RootFeedDock`, with the create action available from the root controls. ## Design Tokens And Strings @@ -102,30 +114,46 @@ T'Day is a task app, not a marketing site. Mobile screens should feel quiet, use ## Architecture Expectations +Across the repo, keep changes shaped around readable boundaries: a file or type should have a clear reason to exist, dependencies should flow from UI to state to services/repositories to storage/network, and helpers should start local before being promoted to shared. + Backend: - Keep request/response contracts aligned with `shared/` models when possible. - Services return typed errors and avoid leaking internals. - Preserve tenant isolation in all data access. +- Keep scheduled-task routes, floater routes, scheduled-list routes, and floater-list routes distinct. +- Routes translate HTTP and validation; services own business decisions; Exposed table/query code stays out of UI-facing layers. Android: - Use MVVM with `@HiltViewModel`, `StateFlow`, repositories, and app services. - Keep mutable state private and expose read-only state. -- Respect offline-first cache/sync behavior. +- Respect Room-backed offline-first cache/sync behavior and Local Mode. - Use Compose idioms and Material 3. +- ViewModels depend on injected repositories/services, not Retrofit, Room DAOs, or storage details directly. iOS: - Use SwiftUI, Observation, SwiftData, and URLSession patterns already present in the app. - Keep feature code inside `Feature//` unless it is truly shared. - Prefer small local helpers before creating broad abstractions. +- Keep `AppContainer` wiring explicit and update SwiftData/cache mappers with data model changes. +- Views render state and invoke actions; repositories/cache managers own persistence and sync details. Web: - Use React Query for server state. - Use the shared API client, not raw backend `fetch` calls from components. - Use Tailwind semantic tokens and locale keys. +- Keep feature modules cohesive and move repeated logic into `src/lib/`, `src/hooks/`, or feature-scoped helpers only when that reduces real duplication. + +Docs: + +- Update `README.md` for project shape and documentation-map changes. +- Update `docs/DATA_MODEL.md` for table, DTO, local cache, and pending mutation changes. +- Update `docs/API_GUIDELINES.md` for route changes. +- Update `docs/ARCHITECTURE.md` for data flow, module, and platform architecture changes. +- Update platform READMEs for Android/iOS setup, storage, sync, or feature-surface changes. ## Verification Commands diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 490a360d..66653515 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,9 +5,11 @@ This document covers everything a developer needs to know before writing code, o ## Table of Contents - [Development Setup](#development-setup) +- [Product Direction](#product-direction) - [Branch Strategy](#branch-strategy) - [Commit Messages](#commit-messages) - [Pull Request Process](#pull-request-process) +- [Documentation Expectations](#documentation-expectations) - [Coding Conventions](#coding-conventions) - [Linting and Formatting](#linting-and-formatting) - [Testing](#testing) @@ -39,9 +41,16 @@ bash scripts/install-hooks.sh # install git hooks (required, one-time) 1. Open `android-compose/` in Android Studio. 2. Ensure Android SDK 35 is installed. -3. Set the server URL at first launch to point to your local or remote T'Day instance. +3. Choose Local Mode for offline-only testing, or set the server URL at first launch to point to your local or remote T'Day instance. 4. Run on emulator or physical device. +### iOS (SwiftUI) + +1. Open `ios-swiftUI/TdayApp.xcodeproj` in Xcode. +2. Select the `Tday` scheme. +3. Run on an iOS 17+ simulator or physical device. +4. Choose Local Mode for offline-only testing, or connect to a self-hosted server. + ### Database PostgreSQL 15 is required. Use Docker or a local installation: @@ -59,6 +68,17 @@ docker compose up -d --build docker exec -it tday_ollama ollama pull qwen2.5:0.5b ``` +## Product Direction + +Read [`docs/PRODUCT_DIRECTION.md`](docs/PRODUCT_DIRECTION.md) before changing product behavior. The short version: + +- T'Day is a quiet private planner with web, backend, Android, and iOS surfaces. +- Mobile is local-first and cross-platform parity matters. +- Scheduled `Todo` items and unscheduled `Floater` items are separate domain concepts. +- Local Mode is a first-class Android/iOS workspace that does not require a server. +- Server Mode writes optimistically to local cache and replays pending mutations to the backend. +- Documentation should move with behavior, data shape, architecture, and verification changes. + ## Branch Strategy | Branch | Purpose | Deploys to | @@ -119,10 +139,12 @@ docs: add architecture decision record for offline sync 2. Make your changes following [coding standards](docs/CODING_STANDARDS.md). 3. Ensure lint passes: `cd tday-web && npm run lint`. 4. Ensure tests pass: `cd tday-web && npm run test` and `./gradlew :tday-backend:test`. -5. Open a PR against `develop` using the [PR template](.github/PULL_REQUEST_TEMPLATE.md). -6. Request review from at least one maintainer. -7. Address all review comments. -8. Squash-merge when approved. +5. For Android changes, run `cd android-compose && ./gradlew :app:compileDebugKotlin` and targeted tests when practical. +6. For iOS changes, run the `Tday` scheme build/test in Xcode or the `xcodebuild` command from [`docs/TESTING.md`](docs/TESTING.md). +7. Open a PR against `develop` using the [PR template](.github/PULL_REQUEST_TEMPLATE.md). +8. Request review from at least one maintainer. +9. Address all review comments. +10. Squash-merge when approved. **CI enforcement:** PRs to `master` run lint and the full test suite automatically. The Docker image will **not** be built or released unless all tests pass. See [Deployment > Test-Before-Build Policy](docs/DEPLOYMENT.md#test-before-build-policy). @@ -131,6 +153,7 @@ docs: add architecture decision record for offline sync - Aim for < 400 lines changed per PR. - Split large features into incremental PRs. - Refactoring PRs should not include behavior changes. +- Cross-platform mobile changes should stay behaviorally paired even when implementation differs. ### Review Checklist (for reviewers) @@ -139,13 +162,31 @@ docs: add architecture decision record for offline sync - [ ] Error handling covers failure paths. - [ ] New API endpoints follow [API guidelines](docs/API_GUIDELINES.md). - [ ] Database changes include a Flyway migration and are backward-compatible. +- [ ] Shared DTO, Android Room, iOS SwiftData, and sync mappers are updated if the data shape changed. - [ ] Tests cover the happy path and at least one error path. - [ ] No console.log / Log.d left from debugging. +## Documentation Expectations + +Documentation is part of the change when a future contributor would otherwise have to rediscover the rule. + +| Change | Docs to update | +|--------|----------------| +| Product surface, navigation, Local Mode, or cross-platform UX rule | `README.md`, `docs/PRODUCT_DIRECTION.md`, platform READMEs | +| Backend table, shared DTO, local cache record, mutation kind | `docs/DATA_MODEL.md`, `docs/ARCHITECTURE.md`, `docs/API_GUIDELINES.md` | +| Route or response contract | `docs/API_GUIDELINES.md`, shared models, tests | +| Coding pattern or guardrail | `docs/CODING_STANDARDS.md`, guardrail tests | +| Verification workflow or CI expectation | `docs/TESTING.md`, `.github/PULL_REQUEST_TEMPLATE.md` | +| Deployment, versioning, signing, ingress, telemetry, security | The matching doc under `docs/`, `SECURITY.md`, or platform README | + +Use [`docs/REPO_HOUSEKEEPING.md`](docs/REPO_HOUSEKEEPING.md) for generated-file rules, markdown maintenance, and cleanup expectations. + ## Coding Conventions See [`docs/CODING_STANDARDS.md`](docs/CODING_STANDARDS.md) for the full rules. Key highlights: +Across all codebases, prefer readable, narrow units with explicit names and clear dependency direction. UI renders state, ViewModels/controllers coordinate, repositories/services own data work, and transport/storage details stay behind injected collaborators. + ### TypeScript (Frontend) - Strict mode is enabled — no `any` unless absolutely unavoidable. @@ -161,6 +202,13 @@ See [`docs/CODING_STANDARDS.md`](docs/CODING_STANDARDS.md) for the full rules. K - Use `runCatching` for operations that can fail. - Constants in `companion object` with `UPPER_SNAKE_CASE`. +### Swift (iOS) + +- Use SwiftUI, Observation, SwiftData, URLSession, Keychain/cookie handling, and the existing `AppContainer` dependency graph. +- Keep feature code in `ios-swiftUI/Tday/Feature//` and shared app logic in `Core/`. +- Mirror product behavior with Android while keeping platform-native UI, gestures, and system integrations. +- Update SwiftData entities and cache mappers whenever offline state changes. + ## Linting and Formatting ### Frontend @@ -212,6 +260,16 @@ npm run test # all Vitest suites - Tests go in `app/src/test/` (unit) and `app/src/androidTest/` (instrumented). - Test naming: `should when `. +### iOS + +```bash +xcodebuild test -project ios-swiftUI/TdayApp.xcodeproj -scheme Tday -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' +``` + +- Tests live in `ios-swiftUI/Tests/`. +- Prefer focused tests for repository/cache mapping, sync behavior, and core helpers. +- For UI polish without tests, build the app and do a simulator/device spot check when practical. + ## Before Merging Checklist Every PR must satisfy these before merge: @@ -219,13 +277,16 @@ Every PR must satisfy these before merge: - [ ] `cd tday-web && npm run lint` passes with no warnings. - [ ] `cd tday-web && npm run test` passes with no failures (including guardrail tests). - [ ] `./gradlew :tday-backend:test` passes with no failures. +- [ ] Android build/tests run for Android changes, or the skip reason is documented. +- [ ] iOS build/tests run for iOS changes, or the skip reason is documented. - [ ] CI pipeline passes (lint + tests are enforced automatically on PRs to `master`). - [ ] No secrets or credentials in the diff. - [ ] No AI tool attribution in commits or PR description — no `Co-authored-by`, `Made-with`, or any trailer/text referencing Cursor, Codex, Copilot, ChatGPT, Claude, etc. - [ ] Backward compatibility maintained (or migration provided). - [ ] Flyway migration SQL and corresponding Exposed table changes reviewed if schema changed. +- [ ] Shared DTOs, Android Room, iOS SwiftData, and sync mappers reviewed if data shape changed. - [ ] Error handling and logging added where appropriate. - [ ] API changes documented in the PR description. -- [ ] Android changes tested on emulator or device. +- [ ] Android/iOS parity checked for mobile user-facing changes. **Note:** The release pipeline will not build or push a Docker image unless all tests pass. Broken tests block the entire release. diff --git a/README.md b/README.md index bd8a5028..6e5a17ae 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,17 @@ # T'Day -Personal task planner — self-hosted, private, multilingual. +Private, self-hosted personal task planning with native mobile apps and local-first behavior. -- Tasks with priorities, pinning, drag-and-drop reordering, and RFC 5545 recurrence -- Calendar with month, week, and day views -- Lists (projects) for organization with colors and icons -- Completion history and AI-powered task summaries (Ollama) -- 11 languages via i18next -- Native Android client (Kotlin + Jetpack Compose) -- Native iOS client (SwiftUI + SwiftData) +T'Day is designed to be a quiet daily planner, not a generic productivity platform. The end goal is a single product expressed across web, Android, and iOS: + +- Scheduled tasks with priorities, pinning, drag-and-drop reordering, reminders, and RFC 5545 recurrence. +- Floater/Anytime tasks for unscheduled work, with their own lists and completed history. +- Calendar with month, week, and day views, anchored headers, bounded navigation, overdue visibility, and cross-platform paging rules. +- Local Mode on Android and iOS for offline-only use without server setup or login. +- Server Mode for self-hosted sync, realtime updates, encrypted sessions, and private PostgreSQL storage. +- Local-first mobile data backed by Room on Android and SwiftData on iOS. +- Completion history, list metadata preservation, task search, widgets, in-app update/version compatibility, and AI-powered summaries via Ollama. +- 11 web locales via i18next, with mobile strings handled through platform-local patterns. ## Tech Stack @@ -18,11 +21,18 @@ Personal task planner — self-hosted, private, multilingual. | Backend | Ktor (Kotlin), Exposed ORM, Flyway migrations | | Database | PostgreSQL 15 | | Auth | Rolling JWE cookie sessions, PBKDF2 credentials, credential envelope encryption | +| Shared Contracts | Kotlin Multiplatform DTOs, enums, validators, and route constants | | AI | Ollama (local, e.g. qwen2.5:0.5b) | -| Android | Kotlin, Jetpack Compose, Hilt, Retrofit, Material 3 | -| iOS | SwiftUI, SwiftData, URLSession, Observation | +| Android | Kotlin, Jetpack Compose, Hilt, Retrofit, Room, WorkManager, Glance widgets, Material 3 | +| iOS | SwiftUI, SwiftData, URLSession, Observation, Keychain/cookie handling, WidgetKit-ready snapshots | | Infra | Docker Compose, GitHub Actions CI/CD, GHCR | +## Documentation Currency + +Markdown files were audited on **2026-05-29** using git history. The docs were mostly last touched between March and May 2026, while recent commits added Local Mode, Floater/Anytime tasks, RootFeedDock, Room/SwiftData cache parity, mobile sheet/swipe/calendar polish, and offline sync refinements. This refresh updates the project docs around that current direction. + +See [`docs/REPO_HOUSEKEEPING.md`](docs/REPO_HOUSEKEEPING.md) for the markdown audit summary and maintenance expectations. + ## Quick Start ### Docker (recommended) @@ -68,11 +78,11 @@ Requires a running PostgreSQL instance. The Ktor backend applies Flyway migratio ### Android -Open `android-compose/` in Android Studio (SDK 35 required) and run on device or emulator. See [`android-compose/README.md`](android-compose/README.md) for first-launch behavior and persistence details. +Open `android-compose/` in Android Studio (SDK 35 required) and run on a device or emulator. The app can start in Local Mode or connect to a self-hosted server. See [`android-compose/README.md`](android-compose/README.md) for structure, first-launch behavior, persistence, sync, and release notes. ### iOS -Open `ios-swiftUI/` in Xcode on macOS and use the `Tday/` source tree for the native SwiftUI app. See [`ios-swiftUI/README.md`](ios-swiftUI/README.md) for the current structure and environment notes. +Open `ios-swiftUI/TdayApp.xcodeproj` in Xcode on macOS and run the `Tday` scheme. The app supports Local Mode, server workspaces, SwiftData cache, reminders, and the shared mobile feature surface. See [`ios-swiftUI/README.md`](ios-swiftUI/README.md) for structure and environment notes. ## Project Structure @@ -101,15 +111,42 @@ Tday/ │ ├── routes/ # API route handlers │ ├── security/ # Auth, encryption, throttling │ └── services/ # Business logic -├── shared/ # Shared Kotlin Multiplatform code -├── android-compose/ # Native Android client (Kotlin + Compose) -├── ios-swiftUI/ # Native iOS client (SwiftUI) -├── scripts/ # Git hooks -├── docs/ # Architecture, coding standards, guides +├── shared/ # KMP DTOs, enums, validators, and route constants +├── android-compose/ # Native Android client (Compose, Room, Hilt) +├── ios-swiftUI/ # Native iOS client (SwiftUI, SwiftData, Observation) +├── scripts/ # Git hooks, version sync, operational helpers +├── docs/ # Product, architecture, data, coding, testing, deployment ├── Dockerfile.backend # Multi-stage Docker build (Vite + Ktor) └── docker-compose.yaml # Full stack orchestration ``` +## Product Model + +| Concept | Purpose | +|---------|---------| +| Scheduled task | Due-date task used by Today, Scheduled, Calendar, reminders, recurrence, and scheduled-task lists | +| Floater | Unscheduled Anytime task with priority, pinning, ordering, list membership, and completion | +| List | Project/group for scheduled tasks | +| Floater list | Project/group for floaters | +| Completed history | Completed todo and completed floater records, preserving list metadata where possible | +| Local Mode | Mobile-only workspace that never requires server setup or login | +| Server Mode | Authenticated self-hosted workspace with local optimistic writes and sync replay | + +The detailed data contract lives in [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md). + +## Product Direction + +Future coding should preserve these expectations: + +- Mobile UX parity matters. Android and iOS should expose the same feature surface while using native implementation patterns. +- Local cache is the mobile screen source of truth. Network sync updates the cache; screens observe cache changes. +- Scheduled tasks and floaters are separate domain concepts. Do not represent unscheduled work by making `Todo.due` nullable. +- Server APIs must remain stable for mobile clients, with explicit compatibility behavior before breaking changes. +- Code should stay easy to reason about: narrow responsibilities, clear dependency direction, named concepts over cleverness, and no shared abstraction until it has a real second use. +- Documentation is updated with the behavior it describes. + +The fuller north star is in [`docs/PRODUCT_DIRECTION.md`](docs/PRODUCT_DIRECTION.md). + ## Documentation | Document | Purpose | @@ -117,6 +154,9 @@ Tday/ | [`AGENTS.md`](AGENTS.md) | AI agent workflow, git expectations, and cross-platform UX parity rules | | [`CONTRIBUTING.md`](CONTRIBUTING.md) | Developer setup, conventions, PR process | | [`SECURITY.md`](SECURITY.md) | Security practices and responsible disclosure | +| [`docs/PRODUCT_DIRECTION.md`](docs/PRODUCT_DIRECTION.md) | Product goal, mobile rules, Local Mode, Floater/Anytime direction | +| [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md) | Backend tables, shared DTOs, mobile cache records, mutation queue, data-change checklist | +| [`docs/REPO_HOUSEKEEPING.md`](docs/REPO_HOUSEKEEPING.md) | Markdown audit, generated-file rules, docs maintenance, repo hygiene | | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) | System design, domain boundaries, data flow | | [`docs/CODING_STANDARDS.md`](docs/CODING_STANDARDS.md) | Code quality rules, naming, patterns | | [`docs/API_GUIDELINES.md`](docs/API_GUIDELINES.md) | REST API contracts and conventions | @@ -126,6 +166,28 @@ Tday/ | [`docs/TELEMETRY.md`](docs/TELEMETRY.md) | What crash reporting collects (and doesn't) — no PII, no analytics | | [`docs/adr/`](docs/adr/) | Architecture Decision Records | +## Verification + +Run the smallest meaningful checks for your change, then broaden when a change crosses boundaries. + +```bash +# Web +cd tday-web && npm run lint +cd tday-web && npm run test + +# Backend +./gradlew :tday-backend:test + +# Android +cd android-compose && ./gradlew :app:compileDebugKotlin +cd android-compose && ./gradlew :app:testDebugUnitTest + +# iOS +xcodebuild test -project ios-swiftUI/TdayApp.xcodeproj -scheme Tday -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' +``` + +For user-facing mobile changes, do a parity pass on both platforms even when only one platform was edited. + ## License Private repository. All rights reserved. diff --git a/SECURITY.md b/SECURITY.md index 75d096ac..a517f1cd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,7 @@ # Security Policy +For product/data context, see [`docs/PRODUCT_DIRECTION.md`](docs/PRODUCT_DIRECTION.md) and [`docs/DATA_MODEL.md`](docs/DATA_MODEL.md). Security rules apply to Server Mode; Local Mode stays on-device unless a future explicit migration/import flow is designed. + ## Reporting Vulnerabilities If you discover a security vulnerability, please report it privately. Do **not** open a public issue. @@ -91,8 +93,11 @@ Every response includes (via `SecurityHeaders.kt`): ### Mobile Client Security -- Server URL and credentials stored in Android `EncryptedSharedPreferences`. -- Session cookies persist in an encrypted cookie store. +- Android server URL and credentials are stored in `EncryptedSharedPreferences`. +- iOS server config, credentials, cookies, and mode state are stored through Keychain-backed `SecureStore`. +- Session cookies persist in encrypted platform stores. +- Android task data is cached in Room; iOS task data is cached in SwiftData. Both caches are local app data and are cleared through logout/session invalidation flows where appropriate. +- Local Mode does not send task/list/floater data to a server and should not silently migrate local-only data into Server Mode. - Optional public-key fingerprint pinning for self-hosted servers. - All local user data (credentials, cache, cookies) is wiped on logout or session invalidation. - Custom client headers (`X-Tday-Client`, `X-Tday-App-Version`, `X-Tday-Device-Id`) for audit trails. diff --git a/android-compose/README.md b/android-compose/README.md index 6ba1eccd..8f2f3c86 100644 --- a/android-compose/README.md +++ b/android-compose/README.md @@ -2,30 +2,100 @@ Native Kotlin + Jetpack Compose Android client for T'Day. -## Goals -- Replace old web-wrapper APK approach with a real native app. -- Keep feature parity with backend domains: auth, todos, calendar feed, completed, notes, projects, preferences/profile. -- iOS Reminders-inspired UI direction for mobile UX. +## Product Role + +The Android app is a primary T'Day client, not a web wrapper. It should stay behaviorally aligned +with the iOS SwiftUI app while using Android-native implementation patterns. + +Current feature surface: + +- Local Mode for offline-only planning without server setup. +- Server Mode with JWE cookie auth, optimistic local writes, realtime refresh, and pending mutation + replay. +- Home and Floater/Anytime root feeds controlled by `RootFeedDock`. +- Scheduled tasks, floaters, scheduled-task lists, floater lists, completed history, calendar, + search, settings, reminders, widgets, and in-app APK updates. +- Room-backed local cache with a one-time migration from the older encrypted JSON cache. ## Module + - `app`: Android application module. +## Structure + +```text +android-compose/app/src/main/java/com/ohmz/tday/compose/ +├── core/ +│ ├── data/ # Repositories, Room cache, sync, auth/server stores +│ ├── model/ # API/domain models and UI-facing data +│ ├── navigation/ # AppRoute +│ ├── network/ # Retrofit, cookies, realtime, connectivity +│ ├── notification/ # Reminders, boot receiver, workers +│ ├── security/ # Probe/decryption helpers +│ └── ui/ # Shared app UI helpers +├── feature/ +│ ├── app/ # Bootstrap, Local/Server Mode, sync, version state +│ ├── auth/ # Login/register and credential handoff +│ ├── home/ # Home root feed +│ ├── todos/ # Todo/Floater/List screens +│ ├── calendar/ # Month/week/day calendar +│ ├── completed/ # Completed todo/floater history +│ ├── settings/ # Settings and admin toggles +│ ├── release/ # Latest release and APK installer +│ └── widget/ # Today tasks widget +└── ui/ + ├── component/ # RootFeedDock, sheets, pull refresh, controls + └── theme/ # Colors, typography, dimensions +``` + ## Run -1. Open `/home/ohmz/StudioProjects/Tday/android-compose` in Android Studio. + +1. Open `android-compose/` in Android Studio. 2. Ensure Android SDK 35 is installed. 3. Run on emulator/device. -First launch behavior: -- The app shows a server URL dialog before login. -- Enter your host (app.example.com). -- URL is normalized and kept in memory for the current auth flow. +Useful command-line checks: + +```bash +cd android-compose +./gradlew :app:compileDebugKotlin +./gradlew :app:testDebugUnitTest +``` + +## First Launch + +The onboarding overlay offers two workspace paths: + +- **Local Mode**: start immediately with local data only. Pull-to-refresh, server sync, realtime + updates, and admin server settings are not active. +- **Server Mode**: configure a self-hosted T'Day URL, verify the mobile probe/version compatibility, + then login/register. + +Server URLs are normalized and persisted only after successful authenticated setup. Server URL +credentials use Android Credential Manager where available, while real login credentials and cookies +are stored through encrypted local stores. + +## Persistence and Sync + +- Room stores todos, floaters, lists, floater lists, completed records, pending mutations, and sync + metadata. +- `OfflineCacheManager` exposes `cacheDataVersion`; ViewModels reload from cache when it changes. +- Repositories write optimistically to Room first. +- In Server Mode, `SyncManager` replays pending mutations and refreshes snapshots. +- In Local Mode, pending mutations are cleared/ignored because there is no remote target. +- Logout or invalid session clears session/user data according to the auth flow. + +See [`../docs/DATA_MODEL.md`](../docs/DATA_MODEL.md) for the shared cache model. + +## Mobile Parity + +For user-facing Android changes, compare the iOS implementation in `ios-swiftUI/Tday/Feature/` and +`ios-swiftUI/Tday/Core/`. Match behavior, counts, empty states, Local Mode affordances, and +navigation rules while keeping Android Compose idioms. -Persistence: -- Server URL is persisted only after a successful authenticated login. -- Login credentials are stored encrypted only after successful login. -- If no valid authenticated session exists, local user data is wiped (server URL, credentials, offline cache, cookies, and encrypted local prefs). -- After logout or expired/invalid session, the app requires server setup and login again. +## Theme -Theme: - Theme mode can be changed in Settings: `System`, `Light`, or `Dark`. - Selection is applied immediately and is cleared when unauthenticated data wipe runs. +- New shared colors/dimensions belong in `ui/theme/Color.kt` or `ui/theme/Dimens.kt` before screen + code uses them. diff --git a/docs/API_GUIDELINES.md b/docs/API_GUIDELINES.md index 1b71636c..c8099ba7 100644 --- a/docs/API_GUIDELINES.md +++ b/docs/API_GUIDELINES.md @@ -1,15 +1,15 @@ # API Guidelines -Conventions for the T'Day REST API served by the Ktor backend. +Conventions for the T'Day REST API served by the Ktor backend. Keep this file aligned with `shared/`, backend routes, mobile Retrofit/URLSession clients, and [`DATA_MODEL.md`](DATA_MODEL.md). ## Base URL -All API routes live under `/api/`. The web SPA consumes them via same-origin requests (Vite proxy in development, same container in production). The Android and iOS clients target them at the user-configured server URL. +All API routes live under `/api/`. The web SPA consumes them via same-origin requests (Vite proxy in development, same container in production). Android and iOS clients target them at the user-configured server URL in Server Mode. Local Mode does not call the API. ## Authentication - All routes require a valid JWE session unless listed as public. -- Public routes: `/api/auth/*` (CSRF, register, login-challenge, credentials-key, callback), `/api/mobile/probe`, `/health`. +- Public routes: `/api/auth/*` (CSRF, register, login-challenge, credentials-key, callback), `/api/mobile/probe`, `/.well-known/apple-app-site-association`, `/health`. - Authentication is enforced by a **Ktor pipeline intercept** in `Security.kt`: 1. Reads a JWE token from `Authorization: Bearer` header or session cookies. 2. Decodes and validates claims (expiry, `tokenVersion`, role, approval status). @@ -194,8 +194,25 @@ Services return `Either` (Arrow) for typed error handling. Routes f | POST | `/api/todo/nlp` | Natural language date/title parsing | | POST | `/api/todo/summary` | AI-powered task summary | +### Floaters + +Floaters are unscheduled Anytime tasks. They are not scheduled todos with a nullable due date. + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/floater` | List all active floaters | +| POST | `/api/floater` | Create a floater | +| PATCH | `/api/floater` | Update a floater | +| DELETE | `/api/floater` | Delete a floater | +| PATCH | `/api/floater/complete` | Complete a floater | +| PATCH | `/api/floater/uncomplete` | Restore a completed floater to active | +| PATCH | `/api/floater/prioritize` | Change floater priority | +| PATCH | `/api/floater/reorder` | Reorder floaters | + ### Lists +Lists group scheduled tasks. + | Method | Path | Purpose | |--------|------|---------| | GET | `/api/list` | List all lists | @@ -204,6 +221,18 @@ Services return `Either` (Arrow) for typed error handling. Routes f | DELETE | `/api/list` | Delete a list | | GET | `/api/list/{id}` | Get list with its todos | +### Floater Lists + +Floater lists group floaters. + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/floaterList` | List all floater lists | +| POST | `/api/floaterList` | Create a floater list | +| PATCH | `/api/floaterList` | Update a floater list | +| DELETE | `/api/floaterList` | Delete one or many floater lists | +| GET | `/api/floaterList/{id}` | Get floater list with its floaters | + ### User | Method | Path | Purpose | @@ -235,8 +264,16 @@ Services return `Either` (Arrow) for typed error handling. Routes f | Method | Path | Purpose | |--------|------|---------| | GET | `/api/completedTodo` | List completed todos | -| DELETE | `/api/completedTodo` | Delete all completed todos | -| PATCH | `/api/completedTodo` | Remove a single completed todo | +| DELETE | `/api/completedTodo` | Delete all completed todos, or delete one when an `id` body is supplied | +| PATCH | `/api/completedTodo` | Update a completed todo, or remove it when no update fields are supplied | + +### Completed Floaters + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/api/completedFloater` | List completed floaters | +| DELETE | `/api/completedFloater` | Delete all or one completed floater | +| PATCH | `/api/completedFloater` | Update or remove a completed floater | ### Timezone @@ -254,7 +291,13 @@ Services return `Either` (Arrow) for typed error handling. Routes f | Method | Path | Purpose | Auth | |--------|------|---------|------| -| GET | `/api/mobile/probe` | Server discovery | Public | +| GET | `/api/mobile/probe` | Server discovery, compatibility/version metadata, optional encrypted probe payload | Public | + +### Apple App Site Association + +| Method | Path | Purpose | Auth | +|--------|------|---------|------| +| GET | `/.well-known/apple-app-site-association` | iOS webcredentials/deep-link association | Public | ## Cache Headers @@ -264,11 +307,14 @@ Services return `Either` (Arrow) for typed error handling. Routes f ## Adding a New Endpoint -1. Add a route function in `routes/.kt` (or create a new file for a new domain). -2. Use `call.withAuth { }` for authenticated routes. -3. Validate input using Konform validators or shared model validation. -4. Delegate to a service in `services/`. -5. Filter data by `userID` for tenant isolation. -6. Use appropriate HTTP status codes. -7. Add tests in `tday-backend/src/test/kotlin/` if the endpoint involves security or complex logic. -8. Update this document with the new route. +1. Add or update shared request/response models in `shared/` when the endpoint is consumed outside the backend. +2. Add a route function in `routes/.kt` (or create a new file for a new domain). +3. Use `call.withAuth { }` for authenticated routes. +4. Validate input using Konform validators or shared model validation. +5. Delegate to a service in `services/`. +6. Filter data by `userID` for tenant isolation. +7. Use appropriate HTTP status codes. +8. Update Android Retrofit and iOS URLSession clients when mobile consumes it. +9. Update local cache/sync models if the route changes mobile persisted data. +10. Add tests in `tday-backend/src/test/kotlin/` if the endpoint involves security or complex logic. +11. Update this document with the new route. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 98085ed7..b8e63090 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,17 +1,17 @@ # Architecture -This document describes the high-level system design, domain boundaries, and key technical decisions for T'Day. +This document describes the high-level system design, domain boundaries, and key technical decisions for T'Day. Product intent lives in [`PRODUCT_DIRECTION.md`](PRODUCT_DIRECTION.md); durable data shape lives in [`DATA_MODEL.md`](DATA_MODEL.md). ## System Overview -T'Day is a **monorepo application** with a Kotlin/Ktor backend, a React SPA frontend, shared Kotlin Multiplatform code, and native mobile clients: +T'Day is a **monorepo application** with a Kotlin/Ktor backend, a React SPA frontend, shared Kotlin Multiplatform code, and native local-first mobile clients: ``` ┌─────────────────────────────────────────────────────────────┐ │ Clients │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ Web (React) │ │ Android App │ │ iOS App │ │ -│ │ Vite SPA │ │ Compose/Hilt │ │ SwiftUI │ │ +│ │ Vite SPA │ │ Compose/Room │ │ SwiftUI/Cache │ │ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────────┘ │ │ │ │ │ │ └─────────┼─────────────────┼──────────────────┼───────────────┘ @@ -41,9 +41,20 @@ T'Day is a **monorepo application** with a Kotlin/Ktor backend, a React SPA fron └─────────────────┘ └─────────────┘ ``` +### Architectural Direction + +- Web is the desktop/admin/broad-access client. +- Backend owns auth, persistence, tenant isolation, server compatibility, AI summaries, and realtime events. +- Android and iOS own the primary mobile experience and render from local cache first. +- Local Mode is a mobile-only workspace with no server dependency. +- Server Mode uses optimistic local writes plus pending mutation replay. +- Scheduled tasks and floaters are separate concepts; do not make `Todo.due` nullable to represent Anytime work. +- Boundaries should stay readable and directional: clients render state, presentation layers coordinate work, repositories/services own domain/data operations, and transport/storage details sit at the edges. +- Shared abstractions are introduced only when they reduce real duplication or clarify a cross-platform contract. + ### Shared Kotlin Multiplatform Module -The `shared/` module is a Kotlin Multiplatform (KMP) library targeting JVM, Android, and iOS. It provides a single source of truth for: +The `shared/` module is a Kotlin Multiplatform (KMP) library targeting JVM, Android, and iOS frameworks. It provides the source of truth for: - Serializable DTOs (request/response models) - Domain enums (`Priority`, `UserRole`, `ApprovalStatus`, `SortBy`, `GroupBy`, `ProjectColor`, etc.) @@ -53,7 +64,7 @@ The `shared/` module is a Kotlin Multiplatform (KMP) library targeting JVM, Andr |----------|--------|-------------------| | Backend (`tday-backend`) | JVM | Gradle `project(":shared")` | | Android (`android-compose`) | Android | Gradle `project(":shared")` | -| iOS (`ios-swiftUI`) | iOS framework (`TdayShared`) | Swift Package import | +| iOS (`ios-swiftUI`) | Swift models | Mirrored manually in `Core/Model/ApiModels.swift` and checked with contract tests | ## Domain Model @@ -62,12 +73,15 @@ The application is organized around these core domains: | Domain | Description | Models | |--------|------------|--------| | **Auth** | Registration, login, sessions, approval, admin | `User`, `Account`, `AuthThrottle`, `AuthSignal` | -| **Todos** | Task CRUD, RFC 5545 recurrence, priorities, ordering | `Todo`, `TodoInstance`, `CompletedTodo` | -| **Lists** | Project grouping with colors and icons | `List` | +| **Todos** | Scheduled task CRUD, RFC 5545 recurrence, priorities, ordering | `Todo`, `TodoInstance`, `CompletedTodo` | +| **Floaters** | Unscheduled Anytime task CRUD, priorities, ordering | `Floater`, `CompletedFloater` | +| **Lists** | Scheduled-task project grouping with colors and icons | `List` | +| **Floater Lists** | Floater project grouping with colors and icons | `FloaterList` | | **Files** | S3-backed file storage | `File` | | **Preferences** | Sort/group/direction settings per user | `UserPreferences` | | **Admin** | App configuration, user management | `AppConfig` | | **Operations** | Cron jobs, event logging | `CronLog`, `eventLog` | +| **Mobile Sync** | Local cache metadata and pending replay state | Android Room entities, iOS SwiftData entities, `PendingMutationRecord` | ## Backend Architecture (Ktor) @@ -128,8 +142,12 @@ tday-backend/src/main/kotlin/com/ohmz/tday/ │ └── response/ # Response-specific DTOs ├── plugins/ │ ├── Routing.kt # /health, /api/*, /ws, static SPA serving +│ ├── CallLogging.kt # Structured request logging +│ ├── Cors.kt # CORS policy +│ ├── RateLimiting.kt # App-layer request throttling │ ├── Security.kt # JWE bearer + cookie auth, pipeline intercept │ ├── SecurityHeaders.kt # CSP, HSTS, X-Frame-Options, etc. +│ ├── SentryPlugin.kt # Sentry JVM configuration │ ├── Serialization.kt # kotlinx.serialization JSON config │ └── StatusPages.kt # AppError → JSON ApiError mapping ├── routes/ # HTTP route handlers by domain @@ -145,10 +163,12 @@ tday-backend/src/main/kotlin/com/ohmz/tday/ | **Koin** | Dependency injection (config, security, service modules) | | **WebSockets** | Real-time domain event streaming per authenticated user | | **ContentNegotiation** | JSON request/response via kotlinx.serialization | -| **DefaultHeaders** | Security headers (CSP, HSTS in production, X-Frame-Options, etc.) | +| **DefaultHeaders / SecurityHeaders** | Security headers (CSP, HSTS in production, X-Frame-Options, etc.) | | **StatusPages** | Maps `AppError` / generic errors to JSON `ApiError` responses | | **Authentication** | Bearer token provider + pipeline intercept for JWE/cookie auth | | **Routing** | API routes, health check, WebSocket, optional static SPA | +| **CallLogging** | Structured request logging without sensitive payloads | +| **RateLimiting** | App-layer request throttling before handlers | ### Error Handling (Backend) @@ -233,14 +253,14 @@ The web SPA communicates with the Ktor backend via a thin `fetch` wrapper: │ ┌──────────────────────┴────────────────────────────┐ │ Data / App Services │ -│ TodoRepository, ListRepository, CompletedRepo, │ -│ AuthRepository, SettingsRepository, SyncManager, │ -│ OfflineCacheManager, TaskReminderScheduler │ +│ TodoRepository, ListRepository, FloaterListRepo, │ +│ CompletedRepo, AuthRepository, SettingsRepo, │ +│ SyncManager, OfflineCacheManager, Reminder APIs │ └──────┬───────────────────────────────┬────────────┘ │ │ ┌──────┴──────┐ ┌─────────┴─────────┐ -│ Retrofit │ │ EncryptedPrefs │ -│ (Network) │ │ (Local Cache) │ +│ Retrofit │ │ Room + Encrypted │ +│ (Network) │ │ prefs for secrets │ └─────────────┘ └───────────────────┘ ``` @@ -248,11 +268,13 @@ The web SPA communicates with the Ktor backend via a thin `fetch` wrapper: - **MVVM without an Android use-case layer**: ViewModels coordinate repositories and app services directly instead of routing through Android-specific `UseCase` wrappers. - **Domain-specific repositories**: Data access is split by concern (`TodoRepository`, `ListRepository`, `CompletedRepository`, `AuthRepository`, `SettingsRepository`) instead of a single catch-all repository. -- **Offline-first sync**: `OfflineCacheManager` stores the local source of truth, while `SyncManager` replays pending mutations and refreshes remote snapshots. +- **Local-first sync**: `OfflineCacheManager` stores the local source of truth in Room, while `SyncManager` replays pending mutations and refreshes remote snapshots in Server Mode. +- **Local Mode**: Mobile can run without server setup. Server-only operations are hidden or disabled and pending mutations are not retained for replay. - **Cache invalidation**: `OfflineCacheManager.cacheDataVersion` is observed by ViewModels so screens can hydrate from cache when local data changes. - **Auth compatibility**: The Android client implements the JWE credential flow (CSRF token fetch → credential callback → session cookie) using Retrofit + an encrypted cookie store. - **Navigation**: Programmatic Compose Navigation (`NavHost`) with `sealed class AppRoute` — no XML navigation graphs. - **Server discovery**: Runtime server URL configuration with optional certificate fingerprint pinning for self-hosted instances. +- **Root feeds**: `RootFeedDock` switches between Home and Floater/Anytime, with a shared root create action. ### Package Structure (Android) @@ -260,22 +282,26 @@ The web SPA communicates with the Ktor backend via a thin `fetch` wrapper: com.ohmz.tday.compose/ ├── core/ │ ├── data/ # Repositories, OfflineCacheManager, SyncManager, stores +│ │ └── db/ # Room entities, DAOs, and database │ ├── model/ # ApiModels (DTOs), DomainModels (UI types) │ ├── navigation/ # AppRoute sealed class │ ├── network/ # Hilt NetworkModule, TdayApiService, EncryptedCookieStore -│ └── notification/ # Alarms, WorkManager, receivers +│ ├── notification/ # Alarms, WorkManager, receivers +│ ├── security/ # Probe/decryption helpers +│ └── ui/ # Shared non-feature app UI helpers ├── feature/ │ ├── app/ # AppViewModel (bootstrap, sync, session) │ ├── auth/ # AuthViewModel │ ├── home/ # HomeScreen + HomeViewModel -│ ├── todos/ # TodoListScreen + TodoListViewModel +│ ├── todos/ # Todo/Floater list screens + ViewModel │ ├── completed/ # CompletedScreen + CompletedViewModel │ ├── calendar/ # CalendarScreen + CalendarViewModel -│ ├── lists/ # ListsScreen + ListsViewModel │ ├── settings/ # SettingsScreen +│ ├── release/ # In-app update and latest release +│ ├── widget/ # TodayTasks widget │ └── onboarding/ # OnboardingWizardOverlay └── ui/ - ├── component/ # Shared composables (PullRefresh, CreateTaskBottomSheet) + ├── component/ # Shared composables (PullRefresh, CreateTaskBottomSheet, RootFeedDock) └── theme/ # Material 3 theme, colors, typography, dimensions ``` @@ -283,29 +309,41 @@ com.ohmz.tday.compose/ ### Stack -- **SwiftUI** targeting iOS 17+, managed via Swift Package Manager +- **SwiftUI** targeting iOS 17+ - **SwiftData** for local persistence -- Feature-based folder structure with `AppRootView` using TabView + NavigationStack +- **Observation** for ViewModels +- `AppRootView` using `NavigationStack`, root-feed state, onboarding overlay, update gating, deep links, and Local/Server Mode checks ### Structure ``` ios-swiftUI/Tday/ ├── Feature/ -│ ├── Home/ # Home screen -│ ├── Todos/ # Todo management +│ ├── App/ # AppRootView + AppViewModel +│ ├── Home/ # Home root feed +│ ├── Todos/ # Todo/Floater management │ ├── Calendar/ # Calendar views │ ├── Completed/ # Completion history │ ├── Settings/ # User settings │ ├── Auth/ # Login/register │ └── Onboarding/ # First-launch flow ├── Core/ +│ ├── Data/ # AppContainer, repositories, SwiftData cache, sync +│ ├── Domain/ # Focused use cases +│ ├── Model/ # API/domain/offline models +│ ├── Navigation/ # AppRoute │ ├── Network/ # TdayAPIService, RealtimeClient (URLSession + cookies) -│ └── Data/ # Repositories, SwiftData models -└── AppRootView.swift # TabView + NavigationStack with AppRoute destinations +│ ├── Notification/ # Deep links and reminders +│ ├── Security/ # Probe/decryption helpers +│ ├── UI/ # Shared app UI helpers +│ └── Widget/ # TodayTasks snapshot store +├── UI/ +│ ├── Component/ # Shared SwiftUI controls and sheets +│ └── Theme/ # Colors and rounded typography +└── AppRootView.swift # NavigationStack, root feed state, overlays, deep links ``` -No third-party Swift dependencies — all native frameworks. +Sentry Cocoa is the only notable third-party runtime dependency; core app behavior uses native frameworks. ## Database Design @@ -313,8 +351,10 @@ No third-party Swift dependencies — all native frameworks. ``` User ──┬── Todo ──── TodoInstance - │ └──── List (Project) + │ └──── List (scheduled-task project) + ├── Floater ─── FloaterList ├── CompletedTodo + ├── CompletedFloater ├── File ├── UserPreferences ├── Account (OAuth) @@ -335,7 +375,8 @@ User ──┬── Todo ──── TodoInstance ### Key Patterns - **Soft completion**: Completed todos are moved to `CompletedTodo` with metadata (completion time, on-time status, days to complete). -- **RFC 5545 recurrence**: Todos support `rrule`, `dtstart`, `due`, `exdates`, and `durationMinutes`. Instances are materialized in `TodoInstance` for per-occurrence overrides. +- **Floater completion**: Completed floaters are moved to `CompletedFloater` with completion time, days-to-complete metadata, and floater-list metadata. +- **RFC 5545 recurrence**: Todos support `rrule`, `due`, `exdates`, and `durationMinutes`. Instances are materialized in `TodoInstance` for per-occurrence overrides. - **Tenant isolation**: All data queries filter by `userID`. There are no shared/public data models. - **Audit fields**: All major models include `createdAt` and `updatedAt`. @@ -361,7 +402,7 @@ Tokens are encrypted JWTs (JWE) via Nimbus JOSE JWT + BouncyCastle. The Ktor pip `SessionControl` bumps `tokenVersion` on the `User` row. Existing tokens fail on the next request when the intercept detects the version mismatch. -### Android Auth Flow +### Mobile Auth and Workspace Flow ``` App launch → Probe server (GET /api/mobile/probe) @@ -372,6 +413,8 @@ App launch → Probe server (GET /api/mobile/probe) → Subsequent requests include cookie automatically ``` +Local Mode skips server probe/auth and enters a local-only workspace immediately. + ## Real-Time Communication The backend exposes a `WS /ws` WebSocket endpoint for authenticated users. Domain events (`DomainEvent` sealed class) are streamed as JSON frames — covering todo and list changes. Each user has their own WebSocket channel. @@ -393,11 +436,14 @@ The backend exposes a `WS /ws` WebSocket endpoint for authenticated users. Domai ## Caching Strategy - **Web**: No application-level cache layer. API requests use `Cache-Control: no-store`. TanStack React Query provides client-side cache with 60-second stale time. -- **Android**: Offline JSON cache in encrypted shared preferences. Cache is the source of truth for list/todo screens; network sync updates the cache periodically and on pull-to-refresh. +- **Android**: Room-backed local cache for todos, floaters, lists, completed history, pending mutations, and sync metadata. Encrypted preferences still protect credentials, cookies, server config, trust data, theme/reminder preferences, and legacy cache migration input. Cache is the source of truth for screens; network sync updates it periodically, on foreground reconnect, realtime events, and user refresh in Server Mode. +- **iOS**: SwiftData-backed local cache with mirrored `OfflineSyncState` records. Keychain-backed stores protect server config, cookies, credentials, mode state, theme, and reminders. Cache changes notify ViewModels and widget snapshot storage. ## Background Jobs - **Android reminders**: `AlarmManager` for exact-time reminders, `WorkManager` for periodic reminder rescheduling, `BootRescheduleReceiver` for device restart recovery. +- **iOS reminders**: `UserNotifications` scheduling and notification deep-link routing. +- **Widgets**: Android Glance widget and iOS WidgetKit-ready snapshot storage focus on Today tasks. ## Production Deployment @@ -411,7 +457,7 @@ One JVM process serves both the REST API and the SPA. Docker Compose orchestrate ## Future Considerations -- Keep ViewModel orchestration focused on presentation concerns; if shared Android workflows become complex, prefer extracting them into repositories or platform services before adding another app-layer abstraction. -- Consider Room database if the Android cache grows beyond what the current encrypted snapshot approach handles comfortably. +- Keep ViewModel orchestration focused on presentation concerns; if shared mobile workflows become complex, prefer extracting them into repositories, platform services, or focused use cases before adding broad abstractions. +- Define an explicit import/export or migration experience before moving Local Mode data into a server workspace. - Consider Redis or in-memory caching if web API latency becomes a concern under load. - Evaluate SSE as an alternative to WebSocket for clients that don't need bidirectional streaming. diff --git a/docs/CODING_STANDARDS.md b/docs/CODING_STANDARDS.md index 199e863b..4ccd557d 100644 --- a/docs/CODING_STANDARDS.md +++ b/docs/CODING_STANDARDS.md @@ -1,18 +1,30 @@ # Coding Standards -Detailed code quality rules for the TypeScript (web), Kotlin (backend), and Kotlin (Android) codebases. +Detailed code quality rules for the TypeScript web app, Kotlin backend/shared code, Android Compose app, and iOS SwiftUI app. ## Table of Contents - [General Principles](#general-principles) - [TypeScript Standards](#typescript-standards) - [Kotlin Standards](#kotlin-standards) +- [Swift Standards](#swift-standards) - [Shared Rules](#shared-rules) --- ## General Principles +### Product and Documentation Hygiene + +The product direction is part of the coding standard. Before adding behavior, check: + +- [`PRODUCT_DIRECTION.md`](PRODUCT_DIRECTION.md) for Local Mode, Floater/Anytime, mobile parity, and final-product expectations. +- [`DATA_MODEL.md`](DATA_MODEL.md) for scheduled task vs floater semantics, local cache records, and mutation queue rules. +- [`ARCHITECTURE.md`](ARCHITECTURE.md) for module boundaries and data flow. +- [`REPO_HOUSEKEEPING.md`](REPO_HOUSEKEEPING.md) for generated-file and cleanup expectations. + +Update docs in the same change when code changes a rule a future contributor needs to know. + ### Git Commit Hygiene — No AI Trailers Some AI-assisted editors (Cursor, Copilot, etc.) **silently inject trailers** into commit messages — for example, `Made-with: Cursor`. These must never appear in this repository's history. @@ -34,12 +46,14 @@ Some AI-assisted editors (Cursor, Copilot, etc.) **silently inject trailers** in > **Never use `--no-verify`** to skip the hook. If the hook causes problems, fix the hook — don't bypass it. -### Clean Code +### Readable, Focused Code -- Functions do one thing. If a function needs a comment explaining what the "next section" does, extract it. -- Prefer explicit over clever. Readable code wins over terse code. -- No dead code. Delete unused imports, variables, functions, and commented-out blocks. -- No magic numbers. Use named constants. +- A function, component, ViewModel method, or service method should have a narrow job that can be named plainly. +- If a block needs a comment to explain the next phase of work, consider extracting that phase into a well-named helper. +- Prefer explicit control flow and domain names over clever compression. Future readers should not have to reverse-engineer intent. +- Delete unused imports, variables, functions, and commented-out blocks when the owning change makes them obsolete. +- Replace repeated literals and thresholds with named constants close to their domain. +- Keep files navigable. When a screen or service grows multiple independent concerns, split along feature, state, transport, validation, or rendering boundaries. ### DRY — Extract Reusable Logic into Utilities @@ -63,6 +77,9 @@ If a piece of logic appears (or could appear) in more than one place, extract it | **Android (shared)** | `core/` subpackages | Repository helpers, network utilities, model mapping | | **Android (UI shared)** | `ui/component/` | Reusable Composables (e.g., `TdayPullRefresh`, `CreateTaskBottomSheet`) | | **Android (feature-scoped)** | Within the feature package | Helpers used only by that feature | +| **iOS (shared)** | `Core/` subpackages | Repositories, network, cache, navigation, notification, model mapping | +| **iOS (UI shared)** | `UI/Component/`, `Core/UI/`, `UI/Theme/` | Reusable SwiftUI controls, app UI helpers, colors, typography | +| **iOS (feature-scoped)** | `Feature//` | Helpers used only by that feature | **Rules:** @@ -98,13 +115,14 @@ fun Instant?.formatDisplay(timeZone: String): String { val display = todo.due.formatDisplay(userTimeZone) ``` -### SOLID +### Change-Friendly Boundaries -- **S — Single Responsibility**: A module, class, or function should have one reason to change. -- **O — Open/Closed**: Extend behavior through composition or new types, not by modifying existing code. Use sealed classes/interfaces for domain variants. -- **L — Liskov Substitution**: Subclasses must be substitutable for their base types. Applies to error hierarchies (`AppError`, `ApiException`). -- **I — Interface Segregation**: Clients should not depend on methods they don't use. Keep Retrofit interface methods grouped but don't force a single God interface if it grows beyond ~30 methods. -- **D — Dependency Inversion**: High-level modules depend on injected collaborators, not concrete transport details. Android ViewModels depend on repositories and app services provided by Hilt, not on Retrofit directly. Backend services are injected via Koin. +- Keep each module responsible for a coherent slice of the product: route handlers translate HTTP, services own business rules, repositories own persistence/sync, and views render state. +- Add behavior by composing new collaborators, sealed variants, or feature-scoped helpers before widening an already overloaded type. +- Shared abstractions must preserve the expectations of every caller. A common interface is only useful when each implementation can be swapped without surprising its consumers. +- Do not make callers depend on operations they do not use. Split large service/repository/protocol surfaces when unrelated features start sharing one bag of methods. +- Higher-level code should depend on injected contracts and app services, not transport or storage details. Android ViewModels should not call Retrofit directly; backend routes should not build SQL; SwiftUI views should not mutate SwiftData entities directly. +- Keep dependency direction easy to explain: UI -> ViewModel/state -> repository/service -> network/database/cache. Avoid cycles and hidden global access. ### Null Safety, Type Safety, and Explicit Types @@ -142,6 +160,7 @@ Colors and spacing/sizing values must come from the project's centralized design - **Web**: Use Tailwind utility classes that map to CSS custom properties defined in `src/globals.css` (e.g., `bg-card`, `text-foreground`, `border-border`). Never write inline `style={{ color: "#2A6DC2" }}` or raw `hsl(...)` values. If a new semantic color is needed, add a CSS variable in `globals.css` under `:root` and `.dark`, map it in the `@theme inline` block, then use the Tailwind class. - **Android**: Use `MaterialTheme.colorScheme.*` for all colors in Composables. If a color is not in the Material scheme, add it as a named constant in `ui/theme/Color.kt` — never write `Color(0xFF...)` directly in a screen or component file. - **Android dimensions**: Use the centralized `TdayDimens` object (`ui/theme/Dimens.kt`) for all spacing, sizing, corner radius, and elevation values. Never write raw `.dp` literals like `padding(18.dp)` directly in screens — use `TdayDimens.SpacingMd` or similar. +- **iOS**: Use `tdayColors`, `TdayTheme`, shared metrics, and feature-scoped constants that already belong to the local component. New repeated colors/metrics should move into `UI/Theme/` or a narrow shared component metrics type. ```kotlin // Good: colors and dimensions from centralized sources @@ -173,6 +192,7 @@ All user-facing strings must live in a single centralized source — never inlin - **Web**: Use **i18next** translation keys backed by `tday-web/public/locales//translation.json`, with `tday-web/messages/en.json` kept as the bundled English fallback. Components access strings via `useTranslation()`. - **Android**: Use Android string resources (`res/values/strings.xml`). Screens access strings via `stringResource(R.string.*)`. +- **iOS**: Follow the current local SwiftUI string patterns until a broader localization layer exists. Avoid scattering repeated labels; extract repeated app language into narrow constants or shared helpers when it appears in multiple places. - Internal log messages and developer-facing error strings (not shown to users) are exempt. - When adding a new screen or feature, add its strings to the centralized source **first**, then reference the keys. @@ -443,6 +463,8 @@ sealed interface AuthResult { - Android API DTOs live in `core/model/ApiModels.kt`; UI-facing domain models in `core/model/DomainModels.kt`. - Never expose DTOs directly to the Android UI layer — map them in the repository. - Use `kotlinx.serialization` for all JSON serialization (no Gson, no Moshi, no Jackson). +- Scheduled `Todo` and unscheduled `Floater` models must remain distinct. Do not make a due date nullable to represent Anytime work. +- Android local cache shape lives in `core/data/OfflineSyncModels.kt` and Room entities under `core/data/db/`; keep both aligned with `docs/DATA_MODEL.md`. ### Colors and Dimensions (Android) @@ -544,6 +566,42 @@ Within a ViewModel file: --- +## Swift Standards + +These rules apply to the iOS SwiftUI codebase. + +### Architecture and State + +- Use SwiftUI, Observation, SwiftData, URLSession, and Keychain-backed storage patterns already present in the app. +- Keep dependency wiring explicit in `Core/Data/AppContainer.swift`. +- Keep feature screens and ViewModels in `Feature//`. +- Put reusable repositories, sync logic, models, navigation, network, notification, security, and UI helpers under `Core/`. +- Use `@Observable` ViewModels on the main actor for UI-facing state. +- Keep local cache writes centralized through repositories and `OfflineCacheManager`; views should not mutate SwiftData entities directly. + +### Local Mode and Sync + +- Respect `AppDataMode.local` as a first-class workspace. +- Hide or disable pull-to-refresh, manual sync, realtime reconnect expectations, and admin server settings in Local Mode. +- Mirror Android's `OfflineSyncState` when adding cached records or pending mutations. +- Update widget snapshot storage when Today-task cache semantics change. + +### SwiftUI UI Rules + +- Preserve dark mode and rounded typography. +- Prefer platform-native gestures and controls unless the product behavior requires custom handling. +- Keep root-feed behavior aligned with Android: Home and Floater/Anytime are sibling root feeds controlled by `RootFeedDock`. +- Use shared sheet chrome and swipe helpers before creating one-off variants. +- Keep text fitting in compact layouts with `lineLimit`, `minimumScaleFactor`, or layout changes where needed. + +### Error Handling + +- Convert technical failures into `userFacingMessage` before showing them. +- Do not expose raw backend, SQL, keychain, or URLSession internals in UI copy. +- For async work, keep cancellation and task lifetime explicit when work outlives a view update. + +--- + ## Shared Rules ### Folder and Module Structure @@ -551,9 +609,17 @@ Within a ViewModel file: - **Web**: group by technical layer at root (`src/lib/`, `src/components/`, `src/providers/`) and by feature domain in `src/features/`. - **Backend**: group by layer (`routes/`, `services/`, `db/`, `security/`, `domain/`, `config/`, `plugins/`). - **Android**: group by feature (`feature/home/`, `feature/todos/`), shared code in `core/` and `ui/`. -- **Shared KMP**: DTOs, enums, and validators in `shared/` consumed by all three platforms. +- **iOS**: group by feature (`Feature/Home/`, `Feature/Todos/`), shared app code in `Core/`, reusable UI in `UI/`. +- **Shared KMP**: DTOs, enums, validators, and route constants in `shared/` consumed by backend/Android and mirrored by iOS models/tests. - A new feature should create its own subdirectory, not grow an existing file. +### Cross-Platform Mobile Parity + +- Android and iOS should expose the same mobile feature surface. +- Match behavior, information architecture, counts, empty states, disabled states, Local Mode affordances, and navigation rules. +- Use native APIs and patterns on each platform; do not blindly copy implementation details. +- When one platform gets a better interaction, bring the other up to the same product quality. + ### Error Messages - User-facing error messages should be helpful but not leak internal details. @@ -567,6 +633,7 @@ Within a ViewModel file: - Review changelogs before major version bumps. - Keep Android dependencies version-locked in `build.gradle.kts`. - Backend dependencies use Ktor's BOM for server artifacts; pin explicit versions for other libraries. +- iOS dependencies should be added through Xcode/Swift Package Manager and documented in `ios-swiftUI/README.md` when they affect setup, privacy, or build behavior. ### Git Hygiene diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 00000000..268803dc --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,140 @@ +# Data Model + +This document describes the durable and local data structures that define T'Day. Keep it aligned with `shared/`, backend Exposed tables, Android Room entities, and iOS SwiftData entities. + +## Sources of Truth + +| Layer | Files | Purpose | +|-------|-------|---------| +| Shared contracts | `shared/src/commonMain/kotlin/com/ohmz/tday/shared/model/` | Serializable DTOs, request/response bodies, enums, and validators consumed across platforms | +| Backend tables | `tday-backend/src/main/kotlin/com/ohmz/tday/db/tables/` | PostgreSQL schema mapping through Exposed | +| Backend migrations | `tday-backend/src/main/resources/db/migration/` | Flyway SQL history and clean-install schema | +| Android cache | `android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/db/` and `core/data/OfflineSyncModels.kt` | Room entities plus cache records used by repositories | +| iOS cache | `ios-swiftUI/Tday/Core/Data/Database/` and `Core/Model/OfflineSyncModels.swift` | SwiftData entities plus cache records used by repositories | + +## Core Entities + +| Entity | Backend table | Shared/mobile DTOs | Notes | +|--------|---------------|--------------------|-------| +| User | `Users` | `SessionUser`, auth responses | Owns all private data through `userID`; includes role, approval, and `tokenVersion`. | +| Account | `Accounts` | Auth models | OAuth/account compatibility and credential metadata. | +| Todo | `Todos` | `TodoDto`, `CreateTodoRequest`, `UpdateTodoRequest` | Scheduled task with required `due`, optional `rrule`, priority, pinning, ordering, and optional scheduled-task list. | +| Todo instance | `TodoInstances` | `TodoInstancePatchRequest`, `TodoInstanceDeleteRequest` | Per-occurrence overrides/deletions for recurring tasks. | +| Completed todo | `CompletedTodos` | `CompletedTodoDto` | Completion history preserving original task/list details where possible. | +| List | `Lists` | `ListDto`, `ListDetailResponse` | Scheduled-task project/group with color and icon metadata. | +| Floater | `Floaters` | `FloaterDto`, `CreateFloaterRequest`, `UpdateFloaterRequest` | Unscheduled task for Anytime/Floater planning. No `due`. | +| Floater list | `FloaterLists` / `FloaterProject` | `FloaterListDto`, `FloaterListDetailResponse` | Project/group for floaters. Keep separate from scheduled-task lists. | +| Completed floater | `CompletedFloaters` | `CompletedFloaterDto` | Completion history for floaters. | +| Preferences | `UserPreferences` | `PreferencesDto`, `PreferencesResponse` | Per-user sorting/grouping/direction preferences. | +| App config | `AppConfigs` | `AppSettingsResponse`, `AdminSettingsResponse` | Public/admin app settings such as AI summary availability. | +| Event/auth logs | `EventLogs`, `AuthThrottles`, `AuthSignals`, `CronLogs` | Internal models | Security, throttling, diagnostics, and operational state. | + +## Scheduling Rules + +Scheduled tasks and floaters are intentionally different: + +- `Todo` requires a due timestamp and can participate in Today, Scheduled, Calendar, recurring instances, reminders, and scheduled-task lists. +- `Floater` has no due timestamp and belongs to the Anytime/Floater root feed. +- A task should not be made "unscheduled" by nulling `Todo.due`; use a floater instead. +- Completing a todo creates completed-todo history; completing a floater creates completed-floater history. +- List deletion must preserve completed history metadata (`listName`, `listColor`) where the backend/mobile model supports it. + +## Recurrence + +Recurring scheduled tasks use RFC 5545 RRULE strings. + +| Field | Meaning | +|-------|---------| +| `due` | Canonical due timestamp for the base task or occurrence. | +| `rrule` | RFC 5545 recurrence rule for the series. | +| `instanceDate` / `instanceDateEpochMs` | Occurrence identity for edits/completion/deletion. | +| `exdates` | Backend exclusion timestamps for skipped occurrences. | +| `durationMinutes` | Backend duration metadata for expanded instances. | + +Do not apply recurrence to floaters until a new product decision explicitly defines what "unscheduled recurrence" means. + +## Mobile Offline State + +Android and iOS mirror the same logical `OfflineSyncState`: + +```text +OfflineSyncState +├── todos +├── floaters +├── completedItems +├── completedFloaters +├── lists +├── floaterLists +├── pendingMutations +├── lastSuccessfulSyncEpochMs +├── lastSyncAttemptEpochMs +└── aiSummaryEnabled +``` + +Android stores this state in Room tables: + +- `cached_todos` +- `cached_floaters` +- `cached_lists` +- `cached_floater_lists` +- `cached_completed` +- `cached_completed_floaters` +- `pending_mutations` +- `sync_metadata` + +iOS stores the same logical records in SwiftData: + +- `CachedTodoEntity` +- `CachedFloaterEntity` +- `CachedListEntity` +- `CachedFloaterListEntity` +- `CachedCompletedEntity` +- `CachedCompletedFloaterEntity` +- `PendingMutationEntity` +- `SyncMetadataEntity` + +Android has a one-time migration path from the legacy encrypted JSON cache into Room. New cache work should target Room and SwiftData directly. + +## Local IDs + +Mobile optimistic writes create local IDs until the server returns canonical IDs. + +| Prefix | Meaning | +|--------|---------| +| `local-list-` | Scheduled-task list created locally. | +| `local-floater-list-` | Floater list created locally. | +| `local-todo-` | Scheduled task created locally. | +| `local-floater-` | Floater created locally. | +| `local-completed-` | Completed scheduled item created locally. | +| `local-completed-floater-` | Completed floater created locally. | + +When syncing in Server Mode, repositories must remap local IDs to server IDs and update references in todos, floaters, lists, completed history, and pending mutations. + +## Pending Mutations + +`PendingMutationRecord` preserves user intent while offline or while an immediate network call fails. + +Current mutation kinds: + +- List: `CREATE_LIST`, `UPDATE_LIST`, `DELETE_LIST` +- Floater list: `CREATE_FLOATER_LIST`, `UPDATE_FLOATER_LIST`, `DELETE_FLOATER_LIST` +- Scheduled todo: `CREATE_TODO`, `UPDATE_TODO`, `DELETE_TODO`, `SET_PINNED`, `SET_PRIORITY`, `COMPLETE_TODO`, `COMPLETE_TODO_INSTANCE`, `UNCOMPLETE_TODO` +- Floater: `CREATE_FLOATER`, `UPDATE_FLOATER`, `DELETE_FLOATER`, `COMPLETE_FLOATER`, `UNCOMPLETE_FLOATER` + +Server Mode replays pending mutations through `SyncManager`. Local Mode clears/ignores pending mutations because there is no remote target. + +## Tenant Isolation + +Every backend query that reads or writes private data must filter by the authenticated `userID`. Admin-only operations that touch other users must be behind centralized admin checks and should avoid returning private task content unless the endpoint explicitly requires it. + +## Data Change Checklist + +When changing data shape: + +- Update shared DTOs and validators first when the contract crosses platforms. +- Update Exposed tables and add a Flyway migration for backend persistence changes. +- Update Android Room entities, DAOs, mappers, cache records, and migration/version handling. +- Update iOS SwiftData entities, mappers, cache records, and widget snapshot logic if affected. +- Update REST docs in `docs/API_GUIDELINES.md`. +- Update architecture and platform READMEs if the data flow changes. +- Add or update tests for recurrence, tenant isolation, sync replay, local mode, and destructive operations. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 2d97a651..9ae417c8 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -1,6 +1,6 @@ # Deployment -How T'Day is built, deployed, and operated in production. +How T'Day is built, deployed, and operated in production. Product direction and data boundaries are documented in [`PRODUCT_DIRECTION.md`](PRODUCT_DIRECTION.md) and [`DATA_MODEL.md`](DATA_MODEL.md). ## Environments @@ -9,6 +9,8 @@ How T'Day is built, deployed, and operated in production. | Production | `master` | `tday.ohmz.cloud` | Auto via GitHub Actions → Docker image to GHCR | | Development | `develop` | Local only | `docker compose up` or local dev servers | +Mobile clients can also run in Local Mode without a deployed backend. Deployment work affects Server Mode, remote access, app compatibility checks, and server-backed sync. + ## Docker ### Architecture @@ -184,6 +186,8 @@ Every file that contains or controls a version number, grouped by platform. The `TDAY_APP_VERSION` environment variable tells the backend which app version it is compatible with. When `TDAY_UPDATE_REQUIRED=true`, clients that connect with a different version are shown an "Update Required" or "Server Update Needed" screen. +Local Mode does not require this probe. Server Mode Android and iOS clients use `/api/mobile/probe` plus the `X-Tday-App-Version` header to decide whether the installed app and server can safely sync. + | File | Purpose | Notes | |------|---------|-------| | `.env.docker` | **Live value** used by the running Docker container | This is the file that actually controls what the server reports. Update here and recreate the container to take effect. | @@ -209,6 +213,13 @@ Distributable Android release builds must use the same release keystore every ti - The Android app can download a release APK in-app and hand it directly to the system installer. The first sideloaded update still requires enabling "Install unknown apps" for T'Day in Android settings. - Historical note: GitHub Android APKs published before the stable signing fix on April 1, 2026 may have been signed with ephemeral debug certificates from CI runners. Devices on one of those installs must uninstall once and reinstall `v1.8.1` or newer before sideloaded updates will work again. +### iOS Signing and Associated Domains + +- The iOS app uses `ios-swiftUI/TdayApp.xcodeproj`, automatic signing, and the `Tday` scheme. +- `/.well-known/apple-app-site-association` is served by the backend for webcredentials/deep-link support. +- `CFBundleShortVersionString` is synced from `tday-web/package.json` by `scripts/sync-ios-version.sh` during `npm version`. +- `CFBundleVersion` remains the App Store build number and is incremented manually when needed. + ## Configuration ### Environment Variables diff --git a/docs/PRODUCT_DIRECTION.md b/docs/PRODUCT_DIRECTION.md new file mode 100644 index 00000000..915770ed --- /dev/null +++ b/docs/PRODUCT_DIRECTION.md @@ -0,0 +1,96 @@ +# Product Direction + +This document records the intended shape of T'Day so future work moves toward the same product instead of accreting unrelated screens. + +## Product Goal + +T'Day is a private, self-hosted personal task planner that should feel immediate on mobile, dependable offline, and quiet enough to use every day. The long-term product is: + +- A self-hosted planning system for one person or a small private group. +- A native Android and iOS app pair with the same feature surface and platform-native implementation. +- A web app that remains the administrative, desktop, and broad-access surface. +- A local-first mobile experience that can be used without a server, then safely syncs when a server workspace exists. +- A documented monorepo where data contracts, UI rules, verification, deployment, and housekeeping are discoverable before coding starts. + +## Current Product Surfaces + +| Surface | Role | +|---------|------| +| Web SPA | Desktop planner, admin settings, release/version surfaces, full API consumer, i18n reference implementation | +| Backend | Auth, tenant isolation, task/list/floater persistence, recurrence expansion, WebSocket events, AI summaries, mobile probe and version compatibility | +| Shared KMP | DTOs, enums, validators, and shared route constants for backend/Android alignment; iOS mirrors these contracts in Swift models | +| Android | Primary native mobile app using Compose, Room-backed local cache, Hilt, Retrofit, reminders, widgets, and in-app updates | +| iOS | Primary native mobile app using SwiftUI, SwiftData-backed local cache, Observation, URLSession, reminders, widgets, and password/keychain support | + +## Planning Model + +T'Day separates work by scheduling intent. + +| Concept | Meaning | +|---------|---------| +| Scheduled task | A task with a `due` date/time. It appears in Today, Scheduled, Calendar, lists, and recurrence-aware flows. | +| Floater | An unscheduled task for Anytime/Floater planning. It has title, description, priority, pinning, completion, ordering, and optional floater-list membership, but no due date. | +| List | A project/group for scheduled tasks. | +| Floater list | A project/group for floaters. Keep it distinct from scheduled-task lists because the data and UI semantics are different. | +| Completed item | Immutable-ish history created when scheduled tasks or floaters are completed, preserving list metadata where possible. | + +## Mobile Product Rules + +Mobile is now the center of the product experience. Any user-facing Android or iOS change should ask whether the other platform exposes the same behavior, language, counts, empty states, and edge cases. + +- Build Android and iOS as siblings, not clones. Copy behavior and intent; use native APIs and local conventions. +- Treat Home and Floater/Anytime as root feeds. `RootFeedDock` switches between them and collapses into a compact icon state while preserving quick creation. +- Keep pull-to-refresh disabled in Local Mode and enabled for server workspaces. +- Use local cache as the screen source of truth. Network sync updates the cache; screens observe cache changes. +- Keep offline notices calm and rate-limited. Do not interrupt normal use when cached data can satisfy the screen. +- Preserve dark mode, compact layouts, and text fit. Avoid explanatory UI copy when a familiar control can do the job. +- Treat calendar paging as a product contract: headers stay anchored, page content moves horizontally, today jumps keep the active mode, and previous-month navigation remains bounded. + +## Local Mode + +Local Mode is an offline-only workspace on Android and iOS. + +- It does not require server setup, login, or session cookies. +- Data is written directly to the local cache. +- Pending mutation queues are cleared/ignored because there is no remote target. +- Server-only features such as manual sync, remote updates, admin AI settings, and pull-to-refresh should be hidden or disabled. +- Local Mode data should not be silently uploaded later without an explicit migration/import design. + +Server Mode remains the authenticated self-hosted workspace: + +- Mobile writes optimistically to local storage first. +- Sync replays pending mutations and refreshes server snapshots. +- Realtime events and foreground reconnects should refresh cache state without destabilizing the UI. + +## UX Direction + +T'Day should feel like a focused task app, not a marketing surface. + +- Directly usable screens beat onboarding copy. +- Familiar icons, labels, haptics, and expected placement beat custom explanation. +- Cards are for actual grouped content or sheet chrome, not decoration. +- Empty states are short, calm, and consistent. +- Motion should explain continuity: list removals, root-feed switching, calendar paging, sheet transitions, and swipe actions should feel attached to the thing changing. +- If one mobile platform gets a nicer interaction, bring the other platform up to the same product quality. + +## Documentation Expectations + +Documentation is part of the product. + +- Update `README.md` when the project shape, setup, or document map changes. +- Update `docs/ARCHITECTURE.md` when module boundaries, data flow, or platform architecture changes. +- Update `docs/DATA_MODEL.md` when backend tables, shared DTOs, local cache records, or sync mutations change. +- Update `docs/API_GUIDELINES.md` when REST/WebSocket contracts change. +- Update `docs/CODING_STANDARDS.md` when local patterns or guardrails change. +- Update `docs/TESTING.md` when verification expectations change. +- Update platform READMEs when Android or iOS setup, storage, navigation, or feature surface changes. +- Update ADRs when a decision changes direction rather than merely adding implementation detail. + +## Near-Term Direction + +- Keep server/local data boundaries explicit and user-controlled. +- Continue converging Android and iOS around Home, Floater/Anytime, Calendar, Completed, Settings, reminders, and update flows. +- Make list and floater-list behavior clear in UI and contracts. +- Keep backend contracts stable for mobile clients; add compatibility handling before making breaking changes. +- Prefer focused cleanup that reduces future drift: shared DTOs, mirrored cache records, platform design tokens, and documented verification commands. +- Keep implementation pieces small enough to understand in isolation, with clear ownership and dependency direction from UI to state to data services. diff --git a/docs/REMOTE_ACCESS.md b/docs/REMOTE_ACCESS.md index 13f9d401..1927068f 100644 --- a/docs/REMOTE_ACCESS.md +++ b/docs/REMOTE_ACCESS.md @@ -2,6 +2,8 @@ How to reach your self-hosted T'Day instance from outside the local network. +Remote access is only required for Server Mode. Android and iOS can run in Local Mode without any tunnel, VPN, public URL, or backend deployment. + ## Background By default, Docker Compose binds the backend to **`127.0.0.1:2525`** (localhost only). This means no external device — phone, laptop on another network, or browser outside the LAN — can reach T'Day directly. You need an ingress method that bridges external clients to `localhost:2525` on the Docker host. diff --git a/docs/REPO_HOUSEKEEPING.md b/docs/REPO_HOUSEKEEPING.md new file mode 100644 index 00000000..e6bd711e --- /dev/null +++ b/docs/REPO_HOUSEKEEPING.md @@ -0,0 +1,144 @@ +# Repo Housekeeping + +This document captures maintenance expectations for T'Day so the repo stays easy to change. + +## Documentation Audit + +The markdown inventory was checked on 2026-05-29 with `git log -1 --date=short -- `. + +### Markdown Inventory Before This Refresh + +| File | Last commit date | Commit | +|------|------------------|--------| +| `.github/ISSUE_TEMPLATE/bug_report.md` | 2026-03-24 | `d6d55e7` Add comprehensive project documentation, coding guardrails, and CI enforcement | +| `.github/ISSUE_TEMPLATE/feature_request.md` | 2026-03-24 | `d6d55e7` Add comprehensive project documentation, coding guardrails, and CI enforcement | +| `.github/PULL_REQUEST_TEMPLATE.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `AGENTS.md` | 2026-05-19 | `11221e0` Add agent project guidance | +| `CONTRIBUTING.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `README.md` | 2026-05-19 | `11221e0` Add agent project guidance | +| `SECURITY.md` | 2026-04-02 | `825c6bd` feat: implement app-layer request rate limiting and security hardening | +| `android-compose/README.md` | 2026-03-24 | `d6d55e7` Add comprehensive project documentation, coding guardrails, and CI enforcement | +| `docs/API_GUIDELINES.md` | 2026-04-02 | `825c6bd` feat: implement app-layer request rate limiting and security hardening | +| `docs/ARCHITECTURE.md` | 2026-04-02 | `825c6bd` feat: implement app-layer request rate limiting and security hardening | +| `docs/CODING_STANDARDS.md` | 2026-04-02 | `8dca84e` feat(release): add structured web release metadata | +| `docs/DEPLOYMENT.md` | 2026-05-22 | `f18309d` Implement Android Credential Manager support for seamless login and registration. | +| `docs/REMOTE_ACCESS.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/TELEMETRY.md` | 2026-04-04 | `4b79a1b` feat: add Sentry telemetry across all platforms (v1.14.0) | +| `docs/TESTING.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `docs/adr/001-next-js-monolith-with-native-mobile.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `docs/adr/002-postgresql-with-exposed.md` | 2026-03-27 | `c13b1cc` fix: harden auth and lazy-load locales | +| `docs/adr/003-jwe-jwt-sessions.md` | 2026-04-01 | `6871121` fix(auth): add rolling web session renewal | +| `docs/adr/004-local-ai-via-ollama.md` | 2026-03-24 | `d6d55e7` Add comprehensive project documentation, coding guardrails, and CI enforcement | +| `docs/adr/005-offline-first-android-with-sync.md` | 2026-03-28 | `dad190d` Inline use-case logic into ViewModels and bump to v1.7.0 | +| `docs/adr/006-rfc5545-recurrence.md` | 2026-03-27 | `4bb1f60` docs: align all documentation with current Ktor/Vite/Exposed stack | +| `docs/remote-access/cloudflare-tunnel.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/frp.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/ngrok.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/ssh-tunnel.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/tailscale.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/wireguard.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/remote-access/zerotier.md` | 2026-03-30 | `d4b3e9e` docs: add remote access guides for all supported ingress methods | +| `docs/security/cloudflare-auth-hardening.md` | 2026-03-27 | `c13b1cc` fix: harden auth and lazy-load locales | +| `docs/security/operations-hardening.md` | 2026-04-02 | `825c6bd` feat: implement app-layer request rate limiting and security hardening | +| `ios-swiftUI/README.md` | 2026-05-22 | `a3a3c66` Fix mobile reminder deep links | + +### Summary By Area + +| Area | Last updated before this refresh | Notes | +|------|----------------------------------|-------| +| Root README / agent guide | 2026-05-19 | Behind Local Mode, RootFeedDock, Floater/Anytime, and recent mobile parity work. | +| Contributing / testing / ADR base | 2026-03-27 | Behind the current Ktor/Vite/mobile architecture and native iOS expectations. | +| Architecture / API / security hardening docs | 2026-04-02 | Missing floater APIs, Room cache, SwiftData parity, local mode, and current mobile data flow. | +| Deployment | 2026-05-22 | Mostly current for release/versioning, but now needs mobile local/server mode context. | +| Telemetry | 2026-04-04 | Still accurate in spirit; should mention Local Mode and mobile cache privacy boundaries. | +| Android README | 2026-03-24 | Behind Room cache, RootFeedDock, Floater, Local Mode, widgets, and update flow. | +| iOS README | 2026-05-22 | Behind Local Mode, RootFeedDock, Floater, and current app structure. | +| Issue/PR templates | 2026-03-24 to 2026-03-27 | Missing iOS, Local Mode, data contract, and docs-impact prompts. | +| Remote access guides | 2026-03-30 | Still scoped to ingress setup; update only when ports, host binding, or recommended ingress changes. | + +## Recent Product Changes Audited + +Recent commits after the older documentation introduced or refined: + +- Local Mode for offline-only Android and iOS workspaces. +- Floater/Anytime tasks, floater lists, completed floaters, and root feed navigation. +- `RootFeedDock` icon-to-text transitions across Android and iOS. +- Room-backed Android offline cache and SwiftData-backed iOS cache with mirrored `OfflineSyncState`. +- Unified reminder selectors, pull-to-refresh, empty states, sheet chrome, swipe actions, and task completion animations. +- Calendar paging, drag-and-drop rescheduling, overdue visibility, and cross-platform calendar polish. +- Mobile server credential handling, webcredentials/AASA support, in-app update/version compatibility, and offline notice cooldowns. + +## Git Expectations + +- Start with `git status --short --branch`. +- Keep work scoped and avoid opportunistic refactors. +- Do not revert user changes unless explicitly asked. +- Do not use destructive cleanup commands such as `git reset --hard` or `git checkout --` unless the user explicitly requests them. +- Commit as `ohmzi <6551272+ohmzi@users.noreply.github.com>` when attribution matters. +- Do not add AI attribution trailers or bypass hooks with `--no-verify`. + +## Generated Files and Local Artifacts + +Never commit: + +- `node_modules/` +- `tday-web/dist/` +- `coverage/` +- Gradle `build/`, `.gradle/`, `.kotlin/` +- iOS `.build/`, DerivedData, archives, and user-specific Xcode files +- Android Studio/IDE local metadata unless intentionally tracked +- `.env`, local secrets, signing keys, keystores, DSYM uploads, or generated credentials + +When adding a tool that creates new caches or outputs, update `.gitignore`, this document, and any guardrail test that enforces dependency hygiene. + +## Documentation Maintenance + +Documentation should change in the same PR as the behavior when: + +- A new product surface, route, table, DTO, local cache record, mutation kind, or app mode is added. +- Android and iOS behavior changes in a user-facing way. +- Setup, deployment, versioning, signing, telemetry, or security configuration changes. +- Verification commands, simulator requirements, or CI gates change. +- An implementation decision changes the direction captured by an ADR. + +Small bug fixes do not need broad documentation churn, but they should update docs when they reveal a rule future contributors need to know. + +## Cross-Platform Housekeeping + +Mobile features should be checked in pairs: + +- Android files under `android-compose/app/src/main/java/com/ohmz/tday/compose/feature//` +- iOS files under `ios-swiftUI/Tday/Feature//` +- Shared data contracts under `shared/` +- Backend routes/services/tables under `tday-backend/` when the feature is server-backed + +Before finishing a mobile change, compare: + +- Feature surface and labels +- Counts, empty states, and disabled states +- Local Mode behavior +- Offline/online transitions +- Navigation and deep links +- Dark mode +- Reminder/widget/update implications + +## Cleanup Policy + +- Prefer deleting dead code over preserving unused compatibility stubs. +- Keep the codebase easy to scan: narrow files, named concepts, plain control flow, and boundaries that match product/data ownership. +- Keep feature-specific helpers close to the feature until a second consumer exists. +- Promote repeated mobile styling into platform theme/component layers. +- Keep backend service methods small enough to preserve tenant isolation reviewability. +- Keep shared DTOs minimal; do not leak persistence-only fields into shared contracts unless clients need them. +- Keep dependencies flowing in one direction: UI calls state/actions, state coordinates services/repositories, services/repositories touch network/database/cache. +- When a migration or compatibility shim is temporary, document the removal condition in code or this document. + +## Pre-PR Housekeeping Checklist + +- `git status --short --branch` shows only intentional changes. +- Documentation and templates reflect new behavior. +- New generated outputs are ignored or intentionally tracked. +- Data changes include migration, DTO, local cache, and sync updates as needed. +- API changes are documented and backwards compatibility is considered. +- Android and iOS parity was checked for mobile UI work. +- Relevant verification commands ran, or the reason for skipping is recorded in the PR. diff --git a/docs/TELEMETRY.md b/docs/TELEMETRY.md index 931d2b0d..a592e98c 100644 --- a/docs/TELEMETRY.md +++ b/docs/TELEMETRY.md @@ -4,6 +4,8 @@ T'Day uses [Sentry](https://sentry.io) for crash reporting and performance monitoring across all four platforms (backend, web, Android, iOS). This document explains exactly what is collected, what is not, and why. +Telemetry is crash/performance reporting only. It is not a product analytics system and it does not change the Local Mode data boundary described in [`PRODUCT_DIRECTION.md`](PRODUCT_DIRECTION.md). + ## TL;DR - Sentry tells us **when the app crashes and where in the code it happened**. @@ -37,6 +39,7 @@ The following data is **never** sent to Sentry: | Excluded Data | How It's Enforced | |---------------|-------------------| | Task titles, descriptions, notes, or any user content | Sentry only receives stack traces and HTTP metadata — request/response bodies are never attached | +| Local Mode task/list/floater content | Local Mode data remains on-device; crash reports do not include local cache records | | Email addresses, usernames, display names | `sendDefaultPii = false` on every SDK; `beforeSend` callback strips any residual user fields | | IP addresses | Explicitly nulled in every `beforeSend` callback across all four platforms | | Cookies, auth tokens, session IDs | `sendDefaultPii = false` prevents header capture; sensitive headers are never attached | @@ -91,6 +94,8 @@ The relevant code paths: - Web: `main.tsx` → `Sentry.init({ ... })` - iOS: `SentryConfiguration.swift` → `SentrySDK.start { ... }` +When adding a new Sentry breadcrumb, tag, or transaction name, keep it structural: route names, screen names, status codes, durations, and release versions are allowed; user-created task/list/floater text is not. + ## Self-Hosted Users If you self-host T'Day and build from source, Sentry is **completely inactive** diff --git a/docs/TESTING.md b/docs/TESTING.md index 36266b73..09ca3727 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,12 +1,14 @@ # Testing Strategy -This document defines testing expectations, conventions, and tooling for the web, backend, and Android codebases. +This document defines testing expectations, conventions, and tooling for the web, backend, Android, and iOS codebases. ## Philosophy - Test behavior, not implementation details. - Security-critical code must always have tests. - Tests are first-class code — apply the same quality standards as production code. +- For mobile UI work, verify Android/iOS parity even when only one platform changed. +- For data model work, verify shared DTOs, backend persistence, mobile local cache, and sync replay together. ## Web Testing @@ -112,6 +114,7 @@ npm run test -- tests/guardrails/security # security guardrails only | Android `core/`, `feature/`, `ui/theme/` packages exist | Package structure follows the documented architecture | | Android theme files exist (`Color.kt`, `Theme.kt`, `Type.kt`, `Dimens.kt`) | Design tokens are centralized | | Android `build.gradle.kts` derives version from `package.json` | Single source of version truth | +| iOS docs and project structure are represented in repository docs | Native iOS remains a first-class surface | #### `api-guidelines.test.ts` — API Convention Enforcement @@ -161,6 +164,7 @@ npm run test -- tests/guardrails/security # security guardrails only | `install-hooks.sh` exists | Hook installation is documented and scriptable | | `CODING_STANDARDS.md` documents git commit hygiene | Rules are discoverable | | All required documentation files exist | Complete project documentation | +| Product/data/housekeeping docs exist | Product direction, data structure, and maintenance rules stay discoverable | | Version synchronizes from `package.json` to Android | Single source of version truth | ### Naming Conventions @@ -195,6 +199,8 @@ describe("fieldEncryption", () => { | Guardrails: API guidelines | Yes | Enforce API_GUIDELINES.md patterns | | Guardrails: Android standards | Yes | Enforce Android coding and theme conventions | | Guardrails: dependency hygiene | Yes | Enforce config, CI, and documentation completeness | +| Mobile Local Mode and sync behavior | Recommended | Prevent offline/local regressions | +| Android/iOS parity for visible mobile features | Manual + tests where practical | Avoid product drift | | CRUD routes (happy path) | Recommended | Catch regressions | | Error paths in routes | Recommended | Ensure proper status codes | @@ -267,8 +273,10 @@ android-compose/app/src/ | Area | Type | Priority | |------|------|----------| | Repository data mapping (DTO → domain) | Unit | High | -| Offline cache serialization/deserialization | Unit | High | +| Room cache mapping and legacy cache migration | Unit | High | +| Pending mutation creation/replay behavior | Unit | High | | ViewModel state transitions | Unit | High | +| Local Mode server-only affordances | Unit/manual | High | | Notification scheduling logic | Unit | Medium | | Screen composition (renders, interactions) | Instrumented | Medium | | End-to-end auth flow | Instrumented | Low (manual for now) | @@ -285,6 +293,43 @@ fun `should clear local data when session is invalidated`() { ... } Use backtick-quoted descriptive names: `should when `. +## iOS Testing + +### Tooling + +| Tool | Purpose | +|------|---------| +| XCTest | Unit and integration tests | +| Xcode test runner | Simulator/device execution | +| SwiftData in-memory containers | Repository/cache tests where practical | + +### Running Tests + +```bash +xcodebuild test -project ios-swiftUI/TdayApp.xcodeproj -scheme Tday -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' +``` + +### Test Locations + +```text +ios-swiftUI/Tests/ +└── TdayCoreTests/ +``` + +### What Should Be Tested (iOS) + +| Area | Type | Priority | +|------|------|----------| +| SwiftData cache mapping | Unit | High | +| Repository data mapping (API → domain/cache) | Unit | High | +| Pending mutation creation/replay behavior | Unit | High | +| ViewModel state transitions | Unit | Medium | +| Local Mode server-only affordances | Unit/manual | High | +| Reminder scheduling helpers | Unit | Medium | +| Navigation/deep-link routing helpers | Unit | Medium | + +For visual polish, build the app and do a simulator/device spot check when automated UI tests are not practical. + ## Coverage Expectations ### Web @@ -306,6 +351,12 @@ Use backtick-quoted descriptive names: `should when { "README.md", "CONTRIBUTING.md", "SECURITY.md", + "AGENTS.md", + "docs/PRODUCT_DIRECTION.md", + "docs/DATA_MODEL.md", + "docs/REPO_HOUSEKEEPING.md", "docs/ARCHITECTURE.md", "docs/CODING_STANDARDS.md", "docs/API_GUIDELINES.md", From 1c5effd6790eb9947e12b875f0f995e2bb22e1c0 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 29 May 2026 15:26:05 -0400 Subject: [PATCH 11/11] docs: update project documentation to reflect Local Mode, Floater tasks, and mobile parity Comprehensive update of the repository documentation to align with recent architectural shifts, specifically focusing on the introduction of Local Mode, Floater/Anytime tasks, and synchronized mobile data models across Android (Room) and iOS (SwiftData). - **New Documentation**: - Created `docs/PRODUCT_DIRECTION.md`: Defines the "north star" for T'Day, including the separation of scheduled tasks vs. floaters and the priority of mobile parity. - Created `docs/DATA_MODEL.md`: Centralizes the schema for backend tables, shared KMP DTOs, and mirrored mobile cache entities (Room/SwiftData). - Created `docs/REPO_HOUSEKEEPING.md`: Establishes maintenance expectations, markdown audit history, and rules for generated files. - **Mobile Alignment**: - Updated `android-compose/README.md` and `ios-swiftUI/README.md` to reflect identical feature surfaces: Local Mode, Server Mode, `RootFeedDock` navigation, and persistent cache logic. - Standardized "Mobile Parity" rules in `CONTRIBUTING.md` and `AGENTS.md`, requiring behavior synchronization between platforms even if implementations differ. - **Architecture & API**: - Updated `docs/ARCHITECTURE.md` to detail the local-first sync flow and the transition from encrypted JSON snapshots to Room/SwiftData persistence. - Updated `docs/API_GUIDELINES.md` with new endpoints for Floaters, Floater Lists, and mobile server discovery (`/api/mobile/probe`). - Refined `docs/CODING_STANDARDS.md` and `docs/TESTING.md` to include Swift/iOS patterns and enforcement of data contract consistency. - **Security & Telemetry**: - Updated `SECURITY.md` and `docs/TELEMETRY.md` to explicitly state that Local Mode data remains on-device and is excluded from server sync and crash reporting. --- .../007-local-mode-and-floater-workspace.md | 45 +++++++++++++++++++ docs/remote-access/zerotier.md | 2 + 2 files changed, 47 insertions(+) create mode 100644 docs/adr/007-local-mode-and-floater-workspace.md diff --git a/docs/adr/007-local-mode-and-floater-workspace.md b/docs/adr/007-local-mode-and-floater-workspace.md new file mode 100644 index 00000000..9e20317e --- /dev/null +++ b/docs/adr/007-local-mode-and-floater-workspace.md @@ -0,0 +1,45 @@ +# ADR 007: Local Mode and Floater Workspace + +**Status:** Accepted +**Date:** 2026-05-29 + +## Context + +T'Day has grown from a self-hosted web planner with native clients into a mobile-first personal planning app. Recent work added: + +- Native Android and iOS offline caches. +- Optimistic writes with pending mutation replay. +- A root mobile feed with Home and Floater/Anytime modes. +- Unscheduled "Floater" tasks and floater lists. +- Local Mode for offline-only usage without a server login. + +The project needs a clear decision so future work does not blur scheduled tasks, unscheduled tasks, local-only data, and server-backed sync. + +## Decision + +- Treat **Local Mode** as a first-class mobile workspace on Android and iOS. +- Treat **Server Mode** as the authenticated self-hosted workspace with optimistic local writes and sync replay. +- Store mobile screen data in local platform databases: Room on Android and SwiftData on iOS. +- Keep scheduled tasks and floaters as separate domain concepts: + - `Todo` has due/recurrence/calendar semantics. + - `Floater` has Anytime semantics and no due date. + - `List` groups scheduled tasks. + - `FloaterList` groups floaters. +- Use `RootFeedDock` as the mobile root switch between Home and Floater/Anytime. +- Keep Local Mode data local unless a future migration/import design explicitly asks the user to move it to a server workspace. + +## Rationale + +- Users should be able to start using T'Day immediately without configuring a server. +- Mobile apps must remain fast and useful during network failures. +- Separating floaters from scheduled todos avoids nullable due dates becoming ambiguous. +- Mirrored Room/SwiftData cache records make Android and iOS parity easier to reason about. +- Explicit Local Mode boundaries reduce accidental sync, privacy, and conflict-resolution surprises. + +## Consequences + +- Mobile repositories must handle both Local Mode and Server Mode. +- Server-only features such as pull-to-refresh, manual sync, realtime reconnect, and admin AI settings must be disabled or hidden in Local Mode. +- Backend APIs must preserve scheduled-task and floater endpoints separately. +- Data model changes often require updates across shared DTOs, backend tables/migrations, Android Room, iOS SwiftData, sync mappers, and docs. +- Future import/export or server-migration work needs a dedicated product decision and cannot be assumed. diff --git a/docs/remote-access/zerotier.md b/docs/remote-access/zerotier.md index f7f9da63..63ab3aec 100644 --- a/docs/remote-access/zerotier.md +++ b/docs/remote-access/zerotier.md @@ -2,6 +2,8 @@ Access T'Day over a peer-to-peer virtual LAN. ZeroTier creates a software-defined network that works like a flat Ethernet segment — every device gets a private IP and can reach every other device directly, regardless of NAT or firewalls. +This guide is only needed for Server Mode. Local Mode on Android and iOS works without remote access. + ## Overview | | |