diff --git a/.env.example b/.env.example index fd7494fe..ace963e3 100644 --- a/.env.example +++ b/.env.example @@ -162,5 +162,5 @@ TDAY_PROBE_ENCRYPTION_KEY= # Apple Developer Team ID used for Tday's canonical Apple Passwords webcredentials payload. # Native iOS saves Tday credentials under tday.ohmz.cloud regardless of the connected server URL. # Required in production for iOS Passwords / iCloud Keychain to trust the native app. -APPLE_TEAM_ID= +APPLE_TEAM_ID=THT5Z8K3TF IOS_BUNDLE_ID=com.ohmz.tday.ios diff --git a/android-compose/app/src/main/AndroidManifest.xml b/android-compose/app/src/main/AndroidManifest.xml index 762ce2b2..469a9314 100644 --- a/android-compose/app/src/main/AndroidManifest.xml +++ b/android-compose/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ android:allowBackup="false" android:fullBackupContent="false" android:icon="@mipmap/ic_launcher" - android:label="T'Day" + android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.Tday" diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/MainActivity.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/MainActivity.kt index 91361d2a..ad2a8045 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/MainActivity.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/MainActivity.kt @@ -4,6 +4,7 @@ import android.Manifest import android.app.NotificationManager import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity @@ -29,7 +30,9 @@ class MainActivity : ComponentActivity() { setTheme(R.style.Theme_Tday) super.onCreate(savedInstanceState) enableEdgeToEdge() - _deepLinkIntent.value = intent + val launchIntent = intent.withTdayDeepLinkData() + setIntent(launchIntent) + dispatchDeepLinkIntent(launchIntent) setContent { TdayApp( onFirstFrameDrawn = { @@ -43,9 +46,14 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - setIntent(intent) + val deepLinkIntent = intent.withTdayDeepLinkData() + setIntent(deepLinkIntent) dismissUpdateReadyNotification() - _deepLinkIntent.value = intent + dispatchDeepLinkIntent(deepLinkIntent) + } + + private fun dispatchDeepLinkIntent(intent: Intent) { + _deepLinkIntent.value = intent.withTdayDeepLinkData() } private fun dismissUpdateReadyNotification() { @@ -61,3 +69,13 @@ class MainActivity : ComponentActivity() { notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } } + +internal fun Intent.withTdayDeepLinkData(): Intent { + if (data != null) return this + val deepLink = getStringExtra(EXTRA_DEEP_LINK)?.takeIf { it.isNotBlank() } ?: return this + return Intent(this).apply { + data = Uri.parse(deepLink) + } +} + +private const val EXTRA_DEEP_LINK = "deepLink" 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 66b0c28a..45cb247c 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 @@ -387,7 +387,9 @@ fun TdayApp( onConnectServer = { rawUrl, onResult -> appViewModel.saveServerUrl( rawUrl = rawUrl, - onSuccess = { onResult(Result.success(Unit)) }, + onSuccess = { serverUrl -> + onResult(Result.success(serverUrl)) + }, onFailure = { message -> onResult(Result.failure(IllegalStateException(message))) }, @@ -425,6 +427,8 @@ fun TdayApp( } }, onRequestSavedCredential = authViewModel::requestSavedCredential, + onRequestSavedServerUrl = authViewModel::requestSavedServerUrl, + onSaveServerUrlCredential = authViewModel::offerSaveOrUpdateServerUrl, onClearAuthStatus = { authViewModel.clearStatus() appViewModel.clearPendingApprovalNotice() diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialService.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialService.kt index 0d1f3d58..68b7b004 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialService.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialService.kt @@ -37,12 +37,22 @@ enum class LoginCredentialSource { } interface SystemCredentialServicing { - suspend fun requestSavedCredential(context: Context): SystemCredential? + suspend fun requestSavedCredential( + context: Context, + preferredEmail: String? = null, + ): SystemCredential? + suspend fun offerSaveOrUpdateCredential( context: Context, credential: SystemCredential, ): SystemCredentialSaveResult + suspend fun requestSavedServerUrl(context: Context): String? + suspend fun offerSaveOrUpdateServerUrl( + context: Context, + serverUrl: String, + ): SystemCredentialSaveResult + suspend fun clearCredentialState() } @@ -50,12 +60,24 @@ interface SystemCredentialServicing { class SystemCredentialService @Inject constructor( @ApplicationContext private val appContext: Context, ) : SystemCredentialServicing { - override suspend fun requestSavedCredential(context: Context): SystemCredential? { + override suspend fun requestSavedCredential( + context: Context, + preferredEmail: String?, + ): SystemCredential? { val activity = context.findActivity() ?: return null val credentialManager = CredentialManager.create(activity) + val allowedUserIds = preferredEmail + ?.trim() + ?.lowercase(Locale.US) + ?.takeIf { it.isNotBlank() } + ?.let { setOf(it) } + ?: emptySet() val request = GetCredentialRequest( credentialOptions = listOf( - GetPasswordOption(isAutoSelectAllowed = true), + GetPasswordOption( + allowedUserIds = allowedUserIds, + isAutoSelectAllowed = false, + ), ), ) @@ -65,8 +87,8 @@ class SystemCredentialService @Inject constructor( request = request, ).credential when (credential) { - is PasswordCredential -> SystemCredential( - email = credential.id, + is PasswordCredential -> SystemCredentialRecords.loginCredential( + id = credential.id, password = credential.password, ) @@ -111,6 +133,70 @@ class SystemCredentialService @Inject constructor( } } + override suspend fun requestSavedServerUrl(context: Context): String? { + val activity = context.findActivity() ?: return null + val credentialManager = CredentialManager.create(activity) + val request = GetCredentialRequest( + credentialOptions = listOf( + GetPasswordOption( + allowedUserIds = setOf(SystemCredentialRecords.SERVER_URL_CREDENTIAL_ID), + isAutoSelectAllowed = false, + ), + ), + ) + + return try { + val credential = credentialManager.getCredential( + context = activity, + request = request, + ).credential + when (credential) { + is PasswordCredential -> SystemCredentialRecords.serverUrl( + id = credential.id, + password = credential.password, + ) + + else -> null + } + } catch (_: GetCredentialException) { + null + } + } + + override suspend fun offerSaveOrUpdateServerUrl( + context: Context, + serverUrl: String, + ): SystemCredentialSaveResult { + val normalizedServerUrl = serverUrl.trim() + if (normalizedServerUrl.isBlank()) { + return SystemCredentialSaveResult.SKIPPED + } + + val activity = context.findActivity() ?: return SystemCredentialSaveResult.FAILED + val credentialManager = CredentialManager.create(activity) + val request = CreatePasswordRequest( + id = SystemCredentialRecords.SERVER_URL_CREDENTIAL_ID, + password = normalizedServerUrl, + ) + + return try { + credentialManager.createCredential( + context = activity, + request = request, + ) + SystemCredentialSaveResult.SAVED + } catch (_: CreateCredentialCancellationException) { + SystemCredentialSaveResult.CANCELLED + } catch (error: CreateCredentialException) { + Log.w( + LOG_TAG, + "Android Password Manager could not save server URL: ${error.type}", + error + ) + SystemCredentialSaveResult.FAILED + } + } + override suspend fun clearCredentialState() { try { val credentialManager = CredentialManager.create(appContext) @@ -125,6 +211,26 @@ class SystemCredentialService @Inject constructor( } } +internal object SystemCredentialRecords { + const val SERVER_URL_CREDENTIAL_ID = "T'Day Server URL" + + fun loginCredential(id: String, password: String): SystemCredential? { + val normalizedId = id.trim() + // Older builds briefly saved server URLs as password records; never treat those as logins. + if (normalizedId == SERVER_URL_CREDENTIAL_ID) return null + if (normalizedId.isBlank() || password.isBlank()) return null + return SystemCredential( + email = normalizedId, + password = password, + ) + } + + fun serverUrl(id: String, password: String): String? { + if (id.trim() != SERVER_URL_CREDENTIAL_ID) return null + return password.trim().takeIf { it.isNotBlank() } + } +} + private tailrec fun Context.findActivity(): Activity? { return when (this) { is Activity -> this 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 849b81a1..9662a5ba 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 @@ -71,7 +71,7 @@ class ServerConfigRepository @Inject constructor( val saved = secureConfigStore.saveServerUrl( rawUrl = normalizedServerUrl, - persist = false, + persist = true, ).getOrThrow() secureConfigStore.clearOfflineSyncState() 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 50f2dc65..6026d6a9 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 @@ -38,6 +38,8 @@ import com.ohmz.tday.compose.core.model.UpdateTodoRequest import com.ohmz.tday.compose.core.network.TdayApiService import com.ohmz.tday.compose.feature.widget.TodayTasksWidget import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow @@ -170,36 +172,44 @@ class SyncManager @Inject constructor( return true } - private suspend fun fetchRemoteSnapshot(): RemoteSnapshot { - val todos = requireApiBody( - api.getTodos(timeline = true), - "Could not load timeline tasks", - ).todos.map(::mapTodoDto) - - val completed = requireApiBody( - api.getCompletedTodos(), - "Could not load completed tasks", - ).completedTodos.map(::mapCompletedDto) + private suspend fun fetchRemoteSnapshot(): RemoteSnapshot = coroutineScope { + val todos = async { + requireApiBody( + api.getTodos(timeline = true), + "Could not load timeline tasks", + ).todos.map(::mapTodoDto) + } - val lists = requireApiBody( - api.getLists(), - "Could not load lists", - ).lists.map { mapListDto(it, iconFallback = secureConfigStore.getListIcon(it.id)) } + val completed = async { + requireApiBody( + api.getCompletedTodos(), + "Could not load completed tasks", + ).completedTodos.map(::mapCompletedDto) + } - val aiSummaryEnabled = runCatching { + val lists = async { requireApiBody( - api.getAppSettings(), - "Could not load app settings", - ).aiSummaryEnabled - }.getOrElse { - cacheManager.loadOfflineState().aiSummaryEnabled + api.getLists(), + "Could not load lists", + ).lists.map { mapListDto(it, iconFallback = secureConfigStore.getListIcon(it.id)) } + } + + val aiSummaryEnabled = async { + runCatching { + requireApiBody( + api.getAppSettings(), + "Could not load app settings", + ).aiSummaryEnabled + }.getOrElse { + cacheManager.loadOfflineState().aiSummaryEnabled + } } - return RemoteSnapshot( - todos = todos, - completedItems = completed, - lists = lists, - aiSummaryEnabled = aiSummaryEnabled, + RemoteSnapshot( + todos = todos.await(), + completedItems = completed.await(), + lists = lists.await(), + aiSummaryEnabled = aiSummaryEnabled.await(), ) } 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 b81b5c8a..05602434 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 @@ -26,8 +26,10 @@ typealias ListsResponse = com.ohmz.tday.shared.model.ListsResponse typealias CreateListRequest = com.ohmz.tday.shared.model.CreateListRequest typealias ListDto = com.ohmz.tday.shared.model.ListDto typealias CreateListResponse = com.ohmz.tday.shared.model.CreateListResponse +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 CompletedTodosResponse = com.ohmz.tday.shared.model.CompletedTodosResponse typealias CompletedTodoDto = com.ohmz.tday.shared.model.CompletedTodoDto typealias UpdateCompletedTodoRequest = com.ohmz.tday.shared.model.UpdateCompletedTodoRequest diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/RealtimeClient.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/RealtimeClient.kt index 1e091fe1..95bab14e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/RealtimeClient.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/core/network/RealtimeClient.kt @@ -6,6 +6,10 @@ import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request @@ -61,7 +65,7 @@ class RealtimeClient @Inject constructor( } override fun onMessage(webSocket: WebSocket, text: String) { - val event = parseEvent(text) + val event = parseRealtimeEvent(text) if (event != null) _events.tryEmit(event) } @@ -94,19 +98,38 @@ class RealtimeClient @Inject constructor( } } - private fun parseEvent(raw: String): RealtimeEvent? { - val type = raw.trim().lowercase() - return when { - type.startsWith("todo.") -> RealtimeEvent.TodoChanged - type.startsWith("list.") -> RealtimeEvent.ListChanged - type.startsWith("completed.") || type.startsWith("completedtodo.") -> - RealtimeEvent.CompletedChanged - type.isNotBlank() -> RealtimeEvent.Unknown(type) - else -> null - } - } - private companion object { const val LOG_TAG = "RealtimeClient" } } + +internal fun parseRealtimeEvent(raw: String): RealtimeEvent? { + val type = extractRealtimeEventName(raw).trim().lowercase() + return when { + type.startsWith("todo.") || + type.contains("todocreated") || + type.contains("todoupdated") || + type.contains("tododeleted") -> RealtimeEvent.TodoChanged + type.startsWith("list.") || + type.contains("listchanged") -> RealtimeEvent.ListChanged + type.startsWith("completed.") || + type.startsWith("completedtodo.") || + type.contains("completedchanged") || + type.contains("completedtodo") -> RealtimeEvent.CompletedChanged + type.isNotBlank() -> RealtimeEvent.Unknown(type) + else -> null + } +} + +private fun extractRealtimeEventName(raw: String): String { + val trimmed = raw.trim() + if (trimmed.isBlank()) return trimmed + + return runCatching { + val jsonObject = Json.parseToJsonElement(trimmed) as? JsonObject + jsonObject?.let { eventObject -> + eventObject["event"]?.jsonPrimitive?.contentOrNull + ?: eventObject["type"]?.jsonPrimitive?.contentOrNull + } ?: trimmed + }.getOrDefault(trimmed) +} 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 c3ced562..504a54ce 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 @@ -12,8 +12,10 @@ 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.DeleteCompletedTodoRequest +import com.ohmz.tday.compose.core.model.DeleteListResponse import com.ohmz.tday.compose.core.model.DeleteListRequest import com.ohmz.tday.compose.core.model.DeleteTodoRequest +import com.ohmz.tday.compose.core.model.ListDetailResponse import com.ohmz.tday.compose.core.model.ListsResponse import com.ohmz.tday.compose.core.model.MessageResponse import com.ohmz.tday.compose.core.model.MobileProbeResponse @@ -184,7 +186,7 @@ interface TdayApiService { @Path("id") listId: String, @Query("start") start: Long, @Query("end") end: Long, - ): Response + ): Response @POST("/api/list") suspend fun createList( @@ -199,7 +201,7 @@ interface TdayApiService { @HTTP(method = "DELETE", path = "/api/list", hasBody = true) suspend fun deleteListByBody( @Body payload: DeleteListRequest, - ): Response + ): Response @GET("/api/preferences") suspend fun getPreferences(): Response 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 ea92bba0..c38d42ec 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 @@ -373,7 +373,7 @@ class AppViewModel @Inject constructor( fun saveServerUrl( rawUrl: String, - onSuccess: () -> Unit, + onSuccess: (String) -> Unit, onFailure: (String) -> Unit = {}, ) { viewModelScope.launch { @@ -394,7 +394,7 @@ class AppViewModel @Inject constructor( backendVersion = probeResult.backendVersion, ) } - onSuccess() + onSuccess(probeResult.serverUrl) }.onFailure { error -> val message = toServerSetupMessage(error) _uiState.update { diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/AuthViewModel.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/AuthViewModel.kt index e4ba06b3..4ebd0f3e 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/AuthViewModel.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/AuthViewModel.kt @@ -192,8 +192,32 @@ class AuthViewModel @Inject constructor( } } - suspend fun requestSavedCredential(context: Context): SystemCredential? = - systemCredentialService.requestSavedCredential(context) + suspend fun requestSavedCredential( + context: Context, + preferredEmail: String?, + ): SystemCredential? = + systemCredentialService.requestSavedCredential( + context = context, + preferredEmail = preferredEmail, + ) + + suspend fun requestSavedServerUrl(context: Context): String? = + systemCredentialService.requestSavedServerUrl(context) + + suspend fun offerSaveOrUpdateServerUrl( + context: Context, + serverUrl: String, + ) { + val result = systemCredentialService.offerSaveOrUpdateServerUrl( + context = context, + serverUrl = serverUrl, + ) + if (result == SystemCredentialSaveResult.FAILED) { + snackbarManager.showError( + "Android Password Manager could not save this server URL. Check that a password manager is enabled.", + ) + } + } private fun handleCredentialSaveResult(result: SystemCredentialSaveResult) { if (result == SystemCredentialSaveResult.FAILED) { diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinator.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinator.kt index 4784c447..d5c81b5b 100644 --- a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinator.kt +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinator.kt @@ -9,10 +9,11 @@ class LoginCredentialCoordinator { suspend fun requestSavedCredentialIfAvailable( context: Context, + currentEmail: String, currentPassword: String, isCreatingAccount: Boolean, isAuthLoading: Boolean, - requestSavedCredential: suspend (Context) -> SystemCredential?, + requestSavedCredential: suspend (Context, String?) -> SystemCredential?, login: suspend (SystemCredential) -> Boolean, ): Boolean { if (isCreatingAccount || @@ -27,7 +28,10 @@ class LoginCredentialCoordinator { hasRequestedSystemCredential = true isRequestingSystemCredential = true try { - val credential = requestSavedCredential(context) ?: return false + val credential = requestSavedCredential( + context, + currentEmail.takeIf { it.isNotBlank() }, + ) ?: return false return login(credential) } finally { isRequestingSystemCredential = false diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt new file mode 100644 index 00000000..ded95f04 --- /dev/null +++ b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt @@ -0,0 +1,71 @@ +package com.ohmz.tday.compose.feature.calendar + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.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.Modifier +import java.time.LocalDate + +internal data class CalendarTodayJumpRequest( + val id: Int, + val targetDate: LocalDate, +) + +internal enum class CalendarPagerSlot { + PREVIOUS, + CURRENT, + NEXT, +} + +internal data class CalendarPagerPage( + val slot: CalendarPagerSlot, + val value: T, +) + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun CalendarPagingContent( + pages: List>, + pagerState: PagerState, + centerPageIndex: Int, + onSettledAwayFromCenter: (CalendarPagerSlot) -> Unit, + modifier: Modifier = Modifier, + pageContent: @Composable (T) -> Unit, +) { + var handledSettledPage by remember { mutableStateOf(null) } + + LaunchedEffect(centerPageIndex, pages) { + handledSettledPage = null + if (pagerState.currentPage != centerPageIndex) { + pagerState.scrollToPage(centerPageIndex) + } + } + + LaunchedEffect(pagerState.settledPage, centerPageIndex, pages) { + val settledPage = pagerState.settledPage + if (settledPage == centerPageIndex || handledSettledPage == settledPage) return@LaunchedEffect + val settledSlot = pages.getOrNull(settledPage)?.slot ?: return@LaunchedEffect + handledSettledPage = settledPage + onSettledAwayFromCenter(settledSlot) + } + + HorizontalPager( + state = pagerState, + modifier = modifier, + key = { page -> pages.getOrNull(page)?.slot ?: page }, + beyondViewportPageCount = 1, + ) { page -> + pages.getOrNull(page)?.let { calendarPage -> + pageContent(calendarPage.value) + } + } +} + +internal fun List>.indexOfSlot(slot: CalendarPagerSlot): Int = + indexOfFirst { it.slot == slot } diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarScreen.kt index c7784556..41b75751 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 @@ -20,7 +20,6 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.animateScrollBy -import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.draggable import androidx.compose.foundation.gestures.rememberDraggableState import androidx.compose.foundation.interaction.MutableInteractionSource @@ -42,6 +41,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -103,7 +103,6 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource @@ -111,7 +110,6 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp @@ -168,11 +166,6 @@ private val CalendarPeriodWeekDayCellHeight = 72.dp private val CalendarPeriodPageHorizontalGutter = 2.dp private val CalendarPeriodCardBottomPadding = 18.dp -private fun calendarPageAnimationSpec() = tween( - durationMillis = 260, - easing = FastOutSlowInEasing, -) - @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun CalendarScreen( @@ -267,6 +260,8 @@ fun CalendarScreen( var visibleMonthIso by rememberSaveable { mutableStateOf(minNavigableMonth.toString()) } var selectedDateIso by rememberSaveable { mutableStateOf(today.toString()) } var selectedViewKey by rememberSaveable { mutableStateOf(CalendarViewMode.MONTH.name) } + var todayJumpRequestId by rememberSaveable { mutableStateOf(0) } + var todayJumpRequest by remember { mutableStateOf(null) } val visibleMonth = remember(visibleMonthIso) { YearMonth.parse(visibleMonthIso) } val selectedDate = remember(selectedDateIso) { LocalDate.parse(selectedDateIso) } @@ -285,6 +280,11 @@ fun CalendarScreen( visibleMonthIso = YearMonth.from(date).toString() selectedDateIso = date.toString() } + fun clearTodayJumpRequest(requestId: Int) { + if (todayJumpRequest?.id == requestId) { + todayJumpRequest = null + } + } var editTargetId by rememberSaveable { mutableStateOf(null) } var showCreateTaskSheet by rememberSaveable { mutableStateOf(false) } @@ -327,7 +327,11 @@ fun CalendarScreen( onBack = onBack, collapseProgress = collapseProgress, onJumpToday = { - selectDate(LocalDate.now(zoneId)) + todayJumpRequestId += 1 + todayJumpRequest = CalendarTodayJumpRequest( + id = todayJumpRequestId, + targetDate = LocalDate.now(zoneId), + ) }, ) }, @@ -405,6 +409,8 @@ fun CalendarScreen( selectedDate = selectedDate, today = today, tasksByDate = tasksByDate, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, onPrevMonth = { if (visibleMonth > minNavigableMonth) { visibleMonthIso = visibleMonth.minusMonths(1).toString() @@ -421,6 +427,9 @@ fun CalendarScreen( today = today, tasksByDate = tasksByDate, canGoPrevWeek = canNavigateTo(selectedDate.minusWeeks(1)), + canSelectDate = ::canNavigateTo, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, onPrevWeek = { selectDate(selectedDate.minusWeeks(1)) }, onNextWeek = { selectDate(selectedDate.plusWeeks(1)) }, onSelectDate = ::selectDate, @@ -431,8 +440,11 @@ fun CalendarScreen( today = today, tasksByDate = tasksByDate, canGoPrevDay = canNavigateTo(selectedDate.minusDays(1)), + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, onPrevDay = { selectDate(selectedDate.minusDays(1)) }, onNextDay = { selectDate(selectedDate.plusDays(1)) }, + onSelectDate = ::selectDate, ) } } @@ -575,54 +587,99 @@ private fun CalendarWeekCard( today: LocalDate, tasksByDate: Map>, canGoPrevWeek: Boolean, + canSelectDate: (LocalDate) -> Boolean, + todayJumpRequest: CalendarTodayJumpRequest?, + onTodayJumpHandled: (Int) -> Unit, onPrevWeek: () -> Unit, onNextWeek: () -> Unit, onSelectDate: (LocalDate) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme val weekStart = remember(selectedDate) { startOfWeek(selectedDate) } - val density = LocalDensity.current - val swipeThresholdPx = with(density) { 42.dp.toPx() } - val maxPreviewDragPx = with(density) { 64.dp.toPx() } - var horizontalDragAccumulated by remember(weekStart) { mutableFloatStateOf(0f) } - var dragOffsetPx by remember(weekStart) { mutableFloatStateOf(0f) } - val dragTranslationX by animateFloatAsState( - targetValue = dragOffsetPx, - animationSpec = tween(durationMillis = 120), - label = "calendarWeekDragTranslationX", - ) + val coroutineScope = rememberCoroutineScope() + var pendingTodayJump by remember { mutableStateOf(null) } + val todayJumpDirection = pendingTodayJump?.let { request -> + val targetWeek = startOfWeek(request.targetDate) + when { + targetWeek < weekStart -> CalendarPagerSlot.PREVIOUS + targetWeek > weekStart -> CalendarPagerSlot.NEXT + else -> null + } + } + val previousPageWeek = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { + pendingTodayJump?.targetDate?.let(::startOfWeek) + } else if (canGoPrevWeek) { + weekStart.minusWeeks(1) + } else { + null + } + val nextPageWeek = if (todayJumpDirection == CalendarPagerSlot.NEXT) { + pendingTodayJump?.targetDate?.let(::startOfWeek) ?: weekStart.plusWeeks(1) + } else { + weekStart.plusWeeks(1) + } + val pages = remember(previousPageWeek, weekStart, nextPageWeek) { + buildList { + previousPageWeek?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } + add(CalendarPagerPage(CalendarPagerSlot.CURRENT, weekStart)) + add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageWeek)) + } + } + val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) + val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } + val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress + + fun requestPage(slot: CalendarPagerSlot) { + val targetIndex = pages.indexOfSlot(slot) + if (targetIndex < 0 || !isPagingAtRest) return + coroutineScope.launch { + pagerState.animateScrollToPage(targetIndex) + } + } + + fun settlePage(slot: CalendarPagerSlot) { + pendingTodayJump?.let { request -> + pendingTodayJump = null + onSelectDate(request.targetDate) + onTodayJumpHandled(request.id) + return + } + + when (slot) { + CalendarPagerSlot.PREVIOUS -> onPrevWeek() + CalendarPagerSlot.NEXT -> onNextWeek() + CalendarPagerSlot.CURRENT -> Unit + } + } + + LaunchedEffect(todayJumpRequest) { + val request = todayJumpRequest ?: return@LaunchedEffect + if (!isPagingAtRest) { + onTodayJumpHandled(request.id) + return@LaunchedEffect + } + val targetWeek = startOfWeek(request.targetDate) + if (targetWeek == weekStart) { + onSelectDate(request.targetDate) + onTodayJumpHandled(request.id) + } else { + pendingTodayJump = request + onTodayJumpHandled(request.id) + } + } + + LaunchedEffect(pendingTodayJump?.id, pages) { + val request = pendingTodayJump ?: return@LaunchedEffect + val targetWeek = startOfWeek(request.targetDate) + val targetSlot = if (targetWeek < weekStart) CalendarPagerSlot.PREVIOUS else CalendarPagerSlot.NEXT + val targetIndex = pages.indexOfSlot(targetSlot) + if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { + pagerState.animateScrollToPage(targetIndex) + } + } Card( - modifier = Modifier - .fillMaxWidth() - .pointerInput(weekStart, canGoPrevWeek) { - detectHorizontalDragGestures( - onDragStart = { - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - onHorizontalDrag = { _, dragAmount -> - horizontalDragAccumulated += dragAmount - val maxRight = if (canGoPrevWeek) maxPreviewDragPx else 0f - dragOffsetPx = (dragOffsetPx + dragAmount).coerceIn( - minimumValue = -maxPreviewDragPx, - maximumValue = maxRight, - ) - }, - onDragCancel = { - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - onDragEnd = { - when { - horizontalDragAccumulated > swipeThresholdPx && canGoPrevWeek -> onPrevWeek() - horizontalDragAccumulated < -swipeThresholdPx -> onNextWeek() - } - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - ) - }, + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), @@ -648,8 +705,8 @@ private fun CalendarWeekCard( MiniCalendarNavButton( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_week), - enabled = canGoPrevWeek, - onClick = onPrevWeek, + enabled = canGoPrevWeek && isPagingAtRest, + onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, ) Box( modifier = Modifier.weight(1f), @@ -667,33 +724,19 @@ private fun CalendarWeekCard( MiniCalendarNavButton( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_week), - onClick = onNextWeek, + enabled = isPagingAtRest, + onClick = { requestPage(CalendarPagerSlot.NEXT) }, ) } - AnimatedContent( - targetState = weekStart, + CalendarPagingContent( + pages = pages, + pagerState = pagerState, + centerPageIndex = centerPageIndex, + onSettledAwayFromCenter = ::settlePage, modifier = Modifier .fillMaxWidth() - .height(CalendarPeriodCardPageHeight) - .graphicsLayer { translationX = dragTranslationX }, - transitionSpec = { - val movingToFuture = targetState > initialState - val enter = slideInHorizontally( - animationSpec = calendarPageAnimationSpec(), - initialOffsetX = { fullWidth -> - if (movingToFuture) fullWidth else -fullWidth - }, - ) - val exit = slideOutHorizontally( - animationSpec = calendarPageAnimationSpec(), - targetOffsetX = { fullWidth -> - if (movingToFuture) -fullWidth else fullWidth - }, - ) - (enter togetherWith exit).using(SizeTransform(clip = true)) - }, - label = "calendarWeekSwipeAnimatedContent", + .height(CalendarPeriodCardPageHeight), ) { displayWeekStart -> val weekDays = remember(displayWeekStart) { List(7) { offset -> displayWeekStart.plusDays(offset.toLong()) } @@ -708,11 +751,13 @@ private fun CalendarWeekCard( val isSelected = day == selectedDate val isToday = day == today val taskCount = tasksByDate[day]?.size ?: 0 + val isEnabled = canSelectDate(day) CalendarWeekDayCell( date = day, taskCount = taskCount, isSelected = isSelected, isToday = isToday, + isEnabled = isEnabled, onClick = { onSelectDate(day) }, modifier = Modifier.weight(1f), ) @@ -729,6 +774,7 @@ private fun CalendarWeekDayCell( taskCount: Int, isSelected: Boolean, isToday: Boolean, + isEnabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -757,7 +803,8 @@ private fun CalendarWeekDayCell( Box( modifier = modifier .height(CalendarPeriodCardPageHeight) - .minimumInteractiveComponentSize(), + .minimumInteractiveComponentSize() + .graphicsLayer { alpha = if (isEnabled) 1f else 0.48f }, contentAlignment = Alignment.Center, ) { Card( @@ -771,6 +818,7 @@ private fun CalendarWeekDayCell( color = borderColor, ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + enabled = isEnabled, onClick = onClick, ) { Column( @@ -783,7 +831,7 @@ private fun CalendarWeekDayCell( Text( text = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()), style = MaterialTheme.typography.labelMedium, - color = colorScheme.onSurfaceVariant.copy(alpha = 0.9f), + color = colorScheme.onSurfaceVariant.copy(alpha = if (isEnabled) 0.9f else 0.52f), ) Text( text = date.dayOfMonth.toString(), @@ -814,52 +862,98 @@ private fun CalendarDayCard( today: LocalDate, tasksByDate: Map>, canGoPrevDay: Boolean, + todayJumpRequest: CalendarTodayJumpRequest?, + onTodayJumpHandled: (Int) -> Unit, onPrevDay: () -> Unit, onNextDay: () -> Unit, + onSelectDate: (LocalDate) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val density = LocalDensity.current - val swipeThresholdPx = with(density) { 42.dp.toPx() } - val maxPreviewDragPx = with(density) { 64.dp.toPx() } - var horizontalDragAccumulated by remember(selectedDate) { mutableFloatStateOf(0f) } - var dragOffsetPx by remember(selectedDate) { mutableFloatStateOf(0f) } - val dragTranslationX by animateFloatAsState( - targetValue = dragOffsetPx, - animationSpec = tween(durationMillis = 120), - label = "calendarDayDragTranslationX", - ) + val coroutineScope = rememberCoroutineScope() + var pendingTodayJump by remember { mutableStateOf(null) } + val todayJumpDirection = pendingTodayJump?.let { request -> + when { + request.targetDate < selectedDate -> CalendarPagerSlot.PREVIOUS + request.targetDate > selectedDate -> CalendarPagerSlot.NEXT + else -> null + } + } + val previousPageDay = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { + pendingTodayJump?.targetDate + } else if (canGoPrevDay) { + selectedDate.minusDays(1) + } else { + null + } + val nextPageDay = if (todayJumpDirection == CalendarPagerSlot.NEXT) { + pendingTodayJump?.targetDate ?: selectedDate.plusDays(1) + } else { + selectedDate.plusDays(1) + } + val pages = remember(previousPageDay, selectedDate, nextPageDay) { + buildList { + previousPageDay?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } + add(CalendarPagerPage(CalendarPagerSlot.CURRENT, selectedDate)) + add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageDay)) + } + } + val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) + val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } + val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress + + fun requestPage(slot: CalendarPagerSlot) { + val targetIndex = pages.indexOfSlot(slot) + if (targetIndex < 0 || !isPagingAtRest) return + coroutineScope.launch { + pagerState.animateScrollToPage(targetIndex) + } + } + + fun settlePage(slot: CalendarPagerSlot) { + pendingTodayJump?.let { request -> + pendingTodayJump = null + onSelectDate(request.targetDate) + onTodayJumpHandled(request.id) + return + } + + when (slot) { + CalendarPagerSlot.PREVIOUS -> onPrevDay() + CalendarPagerSlot.NEXT -> onNextDay() + CalendarPagerSlot.CURRENT -> Unit + } + } + + LaunchedEffect(todayJumpRequest) { + val request = todayJumpRequest ?: return@LaunchedEffect + if (!isPagingAtRest) { + onTodayJumpHandled(request.id) + return@LaunchedEffect + } + if (request.targetDate == selectedDate) { + onSelectDate(request.targetDate) + onTodayJumpHandled(request.id) + } else { + pendingTodayJump = request + onTodayJumpHandled(request.id) + } + } + + LaunchedEffect(pendingTodayJump?.id, pages) { + val request = pendingTodayJump ?: return@LaunchedEffect + val targetSlot = if (request.targetDate < selectedDate) { + CalendarPagerSlot.PREVIOUS + } else { + CalendarPagerSlot.NEXT + } + val targetIndex = pages.indexOfSlot(targetSlot) + if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { + pagerState.animateScrollToPage(targetIndex) + } + } Card( - modifier = Modifier - .fillMaxWidth() - .pointerInput(selectedDate, canGoPrevDay) { - detectHorizontalDragGestures( - onDragStart = { - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - onHorizontalDrag = { _, dragAmount -> - horizontalDragAccumulated += dragAmount - val maxRight = if (canGoPrevDay) maxPreviewDragPx else 0f - dragOffsetPx = (dragOffsetPx + dragAmount).coerceIn( - minimumValue = -maxPreviewDragPx, - maximumValue = maxRight, - ) - }, - onDragCancel = { - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - onDragEnd = { - when { - horizontalDragAccumulated > swipeThresholdPx && canGoPrevDay -> onPrevDay() - horizontalDragAccumulated < -swipeThresholdPx -> onNextDay() - } - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - ) - }, + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), @@ -885,8 +979,8 @@ private fun CalendarDayCard( MiniCalendarNavButton( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_day), - enabled = canGoPrevDay, - onClick = onPrevDay, + enabled = canGoPrevDay && isPagingAtRest, + onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, ) Box( modifier = Modifier.weight(1f), @@ -904,33 +998,19 @@ private fun CalendarDayCard( MiniCalendarNavButton( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_day), - onClick = onNextDay, + enabled = isPagingAtRest, + onClick = { requestPage(CalendarPagerSlot.NEXT) }, ) } - AnimatedContent( - targetState = selectedDate, + CalendarPagingContent( + pages = pages, + pagerState = pagerState, + centerPageIndex = centerPageIndex, + onSettledAwayFromCenter = ::settlePage, modifier = Modifier .fillMaxWidth() - .height(CalendarPeriodCardPageHeight) - .graphicsLayer { translationX = dragTranslationX }, - transitionSpec = { - val movingToFuture = targetState > initialState - val enter = slideInHorizontally( - animationSpec = calendarPageAnimationSpec(), - initialOffsetX = { fullWidth -> - if (movingToFuture) fullWidth else -fullWidth - }, - ) - val exit = slideOutHorizontally( - animationSpec = calendarPageAnimationSpec(), - targetOffsetX = { fullWidth -> - if (movingToFuture) -fullWidth else fullWidth - }, - ) - (enter togetherWith exit).using(SizeTransform(clip = true)) - }, - label = "calendarDaySwipeAnimatedContent", + .height(CalendarPeriodCardPageHeight), ) { displayDate -> val taskCount = tasksByDate[displayDate]?.size ?: 0 Column( @@ -1148,53 +1228,101 @@ private fun CalendarMonthCard( selectedDate: LocalDate, today: LocalDate, tasksByDate: Map>, + todayJumpRequest: CalendarTodayJumpRequest?, + onTodayJumpHandled: (Int) -> Unit, onPrevMonth: () -> Unit, onNextMonth: () -> Unit, onSelectDate: (LocalDate) -> Unit, ) { val colorScheme = MaterialTheme.colorScheme - val density = LocalDensity.current - val swipeThresholdPx = with(density) { 42.dp.toPx() } - val maxPreviewDragPx = with(density) { 64.dp.toPx() } - var horizontalDragAccumulated by remember(visibleMonth) { mutableFloatStateOf(0f) } - var dragOffsetPx by remember(visibleMonth) { mutableFloatStateOf(0f) } - val dragTranslationX by animateFloatAsState( - targetValue = dragOffsetPx, - animationSpec = tween(durationMillis = 120), - label = "calendarMonthDragTranslationX", - ) + val coroutineScope = rememberCoroutineScope() + var pendingTodayJump by remember { mutableStateOf(null) } + val todayJumpDirection = pendingTodayJump?.let { request -> + val targetMonth = YearMonth.from(request.targetDate) + when { + targetMonth < visibleMonth -> CalendarPagerSlot.PREVIOUS + targetMonth > visibleMonth -> CalendarPagerSlot.NEXT + else -> null + } + } + val previousPageMonth = if (todayJumpDirection == CalendarPagerSlot.PREVIOUS) { + pendingTodayJump?.targetDate?.let(YearMonth::from) + } else if (canGoPrevMonth) { + visibleMonth.minusMonths(1) + } else { + null + } + val nextPageMonth = if (todayJumpDirection == CalendarPagerSlot.NEXT) { + pendingTodayJump?.targetDate?.let(YearMonth::from) ?: visibleMonth.plusMonths(1) + } else { + visibleMonth.plusMonths(1) + } + val pages = remember(previousPageMonth, visibleMonth, nextPageMonth) { + buildList { + previousPageMonth?.let { add(CalendarPagerPage(CalendarPagerSlot.PREVIOUS, it)) } + add(CalendarPagerPage(CalendarPagerSlot.CURRENT, visibleMonth)) + add(CalendarPagerPage(CalendarPagerSlot.NEXT, nextPageMonth)) + } + } + val centerPageIndex = pages.indexOfSlot(CalendarPagerSlot.CURRENT).coerceAtLeast(0) + val pagerState = rememberPagerState(initialPage = centerPageIndex) { pages.size } + val isPagingAtRest = pagerState.settledPage == centerPageIndex && !pagerState.isScrollInProgress + + fun requestPage(slot: CalendarPagerSlot) { + val targetIndex = pages.indexOfSlot(slot) + if (targetIndex < 0 || !isPagingAtRest) return + coroutineScope.launch { + pagerState.animateScrollToPage(targetIndex) + } + } + + fun settlePage(slot: CalendarPagerSlot) { + pendingTodayJump?.let { request -> + pendingTodayJump = null + onSelectDate(request.targetDate) + onTodayJumpHandled(request.id) + return + } + + when (slot) { + CalendarPagerSlot.PREVIOUS -> onPrevMonth() + CalendarPagerSlot.NEXT -> onNextMonth() + CalendarPagerSlot.CURRENT -> Unit + } + } + + LaunchedEffect(todayJumpRequest) { + val request = todayJumpRequest ?: return@LaunchedEffect + if (!isPagingAtRest) { + onTodayJumpHandled(request.id) + return@LaunchedEffect + } + val targetMonth = YearMonth.from(request.targetDate) + if (targetMonth == visibleMonth) { + onSelectDate(request.targetDate) + onTodayJumpHandled(request.id) + } else { + pendingTodayJump = request + onTodayJumpHandled(request.id) + } + } + + LaunchedEffect(pendingTodayJump?.id, pages) { + val request = pendingTodayJump ?: return@LaunchedEffect + val targetMonth = YearMonth.from(request.targetDate) + val targetSlot = if (targetMonth < visibleMonth) { + CalendarPagerSlot.PREVIOUS + } else { + CalendarPagerSlot.NEXT + } + val targetIndex = pages.indexOfSlot(targetSlot) + if (targetIndex >= 0 && pagerState.currentPage == centerPageIndex) { + pagerState.animateScrollToPage(targetIndex) + } + } Card( - modifier = Modifier - .fillMaxWidth() - .pointerInput(visibleMonth, canGoPrevMonth) { - detectHorizontalDragGestures( - onDragStart = { - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - onHorizontalDrag = { _, dragAmount -> - horizontalDragAccumulated += dragAmount - val maxRight = if (canGoPrevMonth) maxPreviewDragPx else 0f - dragOffsetPx = (dragOffsetPx + dragAmount).coerceIn( - minimumValue = -maxPreviewDragPx, - maximumValue = maxRight, - ) - }, - onDragCancel = { - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - onDragEnd = { - when { - horizontalDragAccumulated > swipeThresholdPx && canGoPrevMonth -> onPrevMonth() - horizontalDragAccumulated < -swipeThresholdPx -> onNextMonth() - } - horizontalDragAccumulated = 0f - dragOffsetPx = 0f - }, - ) - }, + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(CalendarCardCornerRadius), colors = CardDefaults.cardColors(containerColor = colorScheme.surface), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), @@ -1220,8 +1348,8 @@ private fun CalendarMonthCard( MiniCalendarNavButton( icon = Icons.Rounded.ChevronLeft, contentDescription = stringResource(R.string.calendar_prev_month), - enabled = canGoPrevMonth, - onClick = onPrevMonth, + enabled = canGoPrevMonth && isPagingAtRest, + onClick = { requestPage(CalendarPagerSlot.PREVIOUS) }, ) Box( modifier = Modifier.weight(1f), @@ -1240,7 +1368,8 @@ private fun CalendarMonthCard( MiniCalendarNavButton( icon = Icons.Rounded.ChevronRight, contentDescription = stringResource(R.string.calendar_next_month), - onClick = onNextMonth, + enabled = isPagingAtRest, + onClick = { requestPage(CalendarPagerSlot.NEXT) }, ) } @@ -1262,29 +1391,14 @@ private fun CalendarMonthCard( } } - AnimatedContent( - targetState = visibleMonth, + CalendarPagingContent( + pages = pages, + pagerState = pagerState, + centerPageIndex = centerPageIndex, + onSettledAwayFromCenter = ::settlePage, modifier = Modifier .fillMaxWidth() - .height(CalendarMonthGridHeight) - .graphicsLayer { translationX = dragTranslationX }, - transitionSpec = { - val movingToFuture = targetState > initialState - val enter = slideInHorizontally( - animationSpec = calendarPageAnimationSpec(), - initialOffsetX = { fullWidth -> - if (movingToFuture) fullWidth else -fullWidth - }, - ) - val exit = slideOutHorizontally( - animationSpec = calendarPageAnimationSpec(), - targetOffsetX = { fullWidth -> - if (movingToFuture) -fullWidth else fullWidth - }, - ) - (enter togetherWith exit).using(SizeTransform(clip = true)) - }, - label = "calendarMonthSwipeAnimatedContent", + .height(CalendarMonthGridHeight), ) { displayMonth -> val monthDays = remember(displayMonth) { buildMonthCells(displayMonth) } Column( 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 36ece867..67ff929f 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 @@ -46,6 +46,7 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -79,6 +80,8 @@ import com.ohmz.tday.compose.core.data.auth.LoginCredentialSource import com.ohmz.tday.compose.core.data.auth.SystemCredential import com.ohmz.tday.compose.feature.auth.AuthUiState import com.ohmz.tday.compose.feature.auth.LoginCredentialCoordinator +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch private enum class WizardStep { SERVER, @@ -105,7 +108,7 @@ fun OnboardingWizardOverlay( serverCanResetTrust: Boolean, pendingApprovalMessage: String?, authUiState: AuthUiState, - onConnectServer: (String, (Result) -> Unit) -> Unit, + onConnectServer: (String, (Result) -> Unit) -> Unit, onResetServerTrust: (String, (Result) -> Unit) -> Unit, onLogin: (String, String, LoginCredentialSource) -> Unit, onRegister: ( @@ -114,7 +117,9 @@ fun OnboardingWizardOverlay( password: String, onSuccess: () -> Unit, ) -> Unit, - onRequestSavedCredential: suspend (Context) -> SystemCredential?, + onRequestSavedCredential: suspend (Context, String?) -> SystemCredential?, + onRequestSavedServerUrl: suspend (Context) -> String?, + onSaveServerUrlCredential: suspend (Context, String) -> Unit, onClearAuthStatus: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -122,6 +127,7 @@ fun OnboardingWizardOverlay( val context = LocalContext.current val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current + val coroutineScope = rememberCoroutineScope() val credentialCoordinator = remember { LoginCredentialCoordinator() } var step by rememberSaveable(initialServerUrl) { @@ -139,10 +145,31 @@ fun OnboardingWizardOverlay( var isConnecting by rememberSaveable { mutableStateOf(false) } var isResettingTrust by rememberSaveable { mutableStateOf(false) } var isRegisterInFlight by rememberSaveable { mutableStateOf(false) } + var hasRequestedSavedServerUrl by rememberSaveable { mutableStateOf(false) } + var serverUrlLoadedFromSystemCredential by rememberSaveable { mutableStateOf(false) } + var canRequestSavedLoginCredential by rememberSaveable(initialServerUrl) { + mutableStateOf(!initialServerUrl.isNullOrBlank()) + } val passwordFocusRequester = remember { FocusRequester() } val registerPasswordFocusRequester = remember { FocusRequester() } val registerConfirmFocusRequester = remember { FocusRequester() } + val finishServerConnection: (String) -> Unit = { savedServerUrl -> + coroutineScope.launch { + if (!serverUrlLoadedFromSystemCredential) { + onSaveServerUrlCredential(context, savedServerUrl) + delay(CREDENTIAL_PROMPT_SETTLE_DELAY_MS) + } + serverUrl = savedServerUrl + serverUrlLoadedFromSystemCredential = false + isConnecting = false + canRequestSavedLoginCredential = true + step = WizardStep.LOGIN + authMode = AuthPanelMode.SIGN_IN + onClearAuthStatus() + } + } + val connectToServer: () -> Unit = connect@{ if (isResettingTrust) return@connect val value = serverUrl.trim() @@ -151,13 +178,13 @@ fun OnboardingWizardOverlay( focusManager.clearFocus(force = true) serverError = null isConnecting = true + canRequestSavedLoginCredential = false onConnectServer(value) { result -> - isConnecting = false result.onSuccess { - step = WizardStep.LOGIN - authMode = AuthPanelMode.SIGN_IN - onClearAuthStatus() + finishServerConnection(it) }.onFailure { error -> + isConnecting = false + canRequestSavedLoginCredential = false step = WizardStep.SERVER serverError = onboardingServerErrorMessage( error = error, @@ -239,7 +266,10 @@ fun OnboardingWizardOverlay( LaunchedEffect(initialServerUrl) { if (!initialServerUrl.isNullOrBlank()) { if (serverUrl.isBlank()) serverUrl = initialServerUrl - if (!isConnecting) step = WizardStep.LOGIN + if (!isConnecting) { + canRequestSavedLoginCredential = true + step = WizardStep.LOGIN + } } } @@ -261,10 +291,34 @@ fun OnboardingWizardOverlay( } } - LaunchedEffect(step, authMode, authUiState.isLoading) { - if (step != WizardStep.LOGIN || authMode != AuthPanelMode.SIGN_IN) return@LaunchedEffect + LaunchedEffect(step, isConnecting, serverUrl) { + if (step != WizardStep.SERVER || + isConnecting || + serverUrl.isNotBlank() || + hasRequestedSavedServerUrl + ) { + return@LaunchedEffect + } + + hasRequestedSavedServerUrl = true + onRequestSavedServerUrl(context)?.let { savedServerUrl -> + serverUrl = savedServerUrl + serverUrlLoadedFromSystemCredential = true + serverError = null + } + } + + LaunchedEffect(step, authMode, authUiState.isLoading, canRequestSavedLoginCredential) { + if (step != WizardStep.LOGIN || + authMode != AuthPanelMode.SIGN_IN || + !canRequestSavedLoginCredential + ) { + return@LaunchedEffect + } + credentialCoordinator.requestSavedCredentialIfAvailable( context = context, + currentEmail = email, currentPassword = password, isCreatingAccount = false, isAuthLoading = authUiState.isLoading, @@ -276,6 +330,7 @@ fun OnboardingWizardOverlay( focusManager.clearFocus(force = true) localAuthError = null onClearAuthStatus() + delay(CREDENTIAL_PROMPT_SETTLE_DELAY_MS) onLogin( credential.email, credential.password, @@ -391,6 +446,7 @@ fun OnboardingWizardOverlay( value = serverUrl, onValueChange = { serverUrl = it + serverUrlLoadedFromSystemCredential = false serverError = null }, label = { Text(stringResource(R.string.onboarding_server_url_label)) }, @@ -424,12 +480,13 @@ fun OnboardingWizardOverlay( resetResult.onSuccess { serverError = null isConnecting = true + canRequestSavedLoginCredential = false onConnectServer(value) { connectResult -> - isConnecting = false connectResult.onSuccess { - step = WizardStep.LOGIN - onClearAuthStatus() + finishServerConnection(it) }.onFailure { error -> + isConnecting = false + canRequestSavedLoginCredential = false step = WizardStep.SERVER serverError = onboardingServerErrorMessage( error = error, @@ -605,6 +662,7 @@ fun OnboardingWizardOverlay( TextButton( onClick = { step = WizardStep.SERVER + canRequestSavedLoginCredential = false localAuthError = null onClearAuthStatus() }, @@ -783,6 +841,7 @@ fun OnboardingWizardOverlay( onClick = { step = WizardStep.SERVER authMode = AuthPanelMode.SIGN_IN + canRequestSavedLoginCredential = false localAuthError = null onClearAuthStatus() }, @@ -959,4 +1018,5 @@ private fun WizardStepChip( } } +private const val CREDENTIAL_PROMPT_SETTLE_DELAY_MS = 600L private val EMAIL_REGEX = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$") diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialRecordsTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialRecordsTest.kt new file mode 100644 index 00000000..edd1d32a --- /dev/null +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialRecordsTest.kt @@ -0,0 +1,51 @@ +package com.ohmz.tday.compose.core.data.auth + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SystemCredentialRecordsTest { + @Test + fun `login credential ignores saved server url records`() { + val credential = SystemCredentialRecords.loginCredential( + id = SystemCredentialRecords.SERVER_URL_CREDENTIAL_ID, + password = "https://tday.example.com", + ) + + assertNull(credential) + } + + @Test + fun `login credential accepts normal password records`() { + val credential = SystemCredentialRecords.loginCredential( + id = " User@Example.com ", + password = "Password!1", + ) + + assertEquals( + SystemCredential(email = "User@Example.com", password = "Password!1"), + credential, + ) + } + + @Test + fun `server url credential ignores login records`() { + val serverUrl = SystemCredentialRecords.serverUrl( + id = "user@example.com", + password = "Password!1", + ) + + assertNull(serverUrl) + } + + @Test + fun `server url credential trims saved url`() { + val serverUrl = SystemCredentialRecords.serverUrl( + id = SystemCredentialRecords.SERVER_URL_CREDENTIAL_ID, + password = " https://tday.example.com ", + ) + + assertEquals("https://tday.example.com", serverUrl) + } + +} diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/core/network/RealtimeClientTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/network/RealtimeClientTest.kt new file mode 100644 index 00000000..b21896b8 --- /dev/null +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/network/RealtimeClientTest.kt @@ -0,0 +1,40 @@ +package com.ohmz.tday.compose.core.network + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class RealtimeClientTest { + + @Test + fun `parses backend serialized todo domain events`() { + val event = parseRealtimeEvent( + """{"type":"com.ohmz.tday.domain.DomainEvent.TodoUpdated","todo":{"id":"todo-1"}}""", + ) + + assertEquals(RealtimeEvent.TodoChanged, event) + } + + @Test + fun `parses backend serialized list domain events`() { + val event = parseRealtimeEvent( + """{"type":"com.ohmz.tday.domain.DomainEvent.ListChanged","list":{"id":"list-1"}}""", + ) + + assertEquals(RealtimeEvent.ListChanged, event) + } + + @Test + fun `keeps old plain event names compatible`() { + val event = parseRealtimeEvent("todo.created") + + assertEquals(RealtimeEvent.TodoChanged, event) + } + + @Test + fun `preserves unknown events for diagnostics`() { + val event = parseRealtimeEvent("""{"type":"custom.event"}""") + + assertTrue(event is RealtimeEvent.Unknown) + } +} diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/AuthViewModelTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/AuthViewModelTest.kt index 835d1f2c..f2e7acd4 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/AuthViewModelTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/AuthViewModelTest.kt @@ -154,6 +154,18 @@ class AuthViewModelTest { ) } + @Test + fun `server url save delegates to password manager service`() = runTest { + val viewModel = makeViewModel() + + viewModel.offerSaveOrUpdateServerUrl( + context = credentialContext, + serverUrl = "https://tday.example.com", + ) + + assertEquals(listOf("https://tday.example.com"), credentialService.savedServerUrls) + } + private fun makeViewModel(): AuthViewModel = AuthViewModel( authRepository = authRepository, @@ -177,8 +189,12 @@ class MainDispatcherRule( private class FakeSystemCredentialService : SystemCredentialServicing { val savedCredentials = mutableListOf() + val savedServerUrls = mutableListOf() - override suspend fun requestSavedCredential(context: Context): SystemCredential? = null + override suspend fun requestSavedCredential( + context: Context, + preferredEmail: String?, + ): SystemCredential? = null override suspend fun offerSaveOrUpdateCredential( context: Context, @@ -188,9 +204,20 @@ private class FakeSystemCredentialService : SystemCredentialServicing { return SystemCredentialSaveResult.SAVED } + override suspend fun requestSavedServerUrl(context: Context): String? = null + + override suspend fun offerSaveOrUpdateServerUrl( + context: Context, + serverUrl: String, + ): SystemCredentialSaveResult { + savedServerUrls += serverUrl + return SystemCredentialSaveResult.SAVED + } + override suspend fun clearCredentialState() = Unit fun reset() { savedCredentials.clear() + savedServerUrls.clear() } } diff --git a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinatorTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinatorTest.kt index bd12f512..76866f2b 100644 --- a/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinatorTest.kt +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/LoginCredentialCoordinatorTest.kt @@ -21,10 +21,11 @@ class LoginCredentialCoordinatorTest { val firstResult = coordinator.requestSavedCredentialIfAvailable( context = context, + currentEmail = "test@example.com", currentPassword = "", isCreatingAccount = false, isAuthLoading = false, - requestSavedCredential = { + requestSavedCredential = { _, _ -> requestCount += 1 credential }, @@ -36,10 +37,11 @@ class LoginCredentialCoordinatorTest { val secondResult = coordinator.requestSavedCredentialIfAvailable( context = context, + currentEmail = "test@example.com", currentPassword = "", isCreatingAccount = false, isAuthLoading = false, - requestSavedCredential = { + requestSavedCredential = { _, _ -> requestCount += 1 credential }, @@ -62,10 +64,11 @@ class LoginCredentialCoordinatorTest { val result = coordinator.requestSavedCredentialIfAvailable( context = context, + currentEmail = "", currentPassword = "", isCreatingAccount = true, isAuthLoading = false, - requestSavedCredential = { + requestSavedCredential = { _, _ -> requestCount += 1 SystemCredential(email = "test@example.com", password = "password") }, @@ -83,10 +86,11 @@ class LoginCredentialCoordinatorTest { val result = coordinator.requestSavedCredentialIfAvailable( context = context, + currentEmail = "", currentPassword = "", isCreatingAccount = false, isAuthLoading = true, - requestSavedCredential = { + requestSavedCredential = { _, _ -> requestCount += 1 SystemCredential(email = "test@example.com", password = "password") }, @@ -104,10 +108,11 @@ class LoginCredentialCoordinatorTest { val result = coordinator.requestSavedCredentialIfAvailable( context = context, + currentEmail = "", currentPassword = "typed", isCreatingAccount = false, isAuthLoading = false, - requestSavedCredential = { + requestSavedCredential = { _, _ -> requestCount += 1 SystemCredential(email = "test@example.com", password = "password") }, @@ -117,4 +122,27 @@ class LoginCredentialCoordinatorTest { assertFalse(result) assertEquals(0, requestCount) } + + @Test + fun `passes current email as preferred credential id`() = runTest { + val coordinator = LoginCredentialCoordinator() + val credential = SystemCredential(email = "test@example.com", password = "password") + var requestedEmail: String? = null + + val result = coordinator.requestSavedCredentialIfAvailable( + context = context, + currentEmail = "test@example.com", + currentPassword = "", + isCreatingAccount = false, + isAuthLoading = false, + requestSavedCredential = { _, preferredEmail -> + requestedEmail = preferredEmail + credential + }, + login = { true }, + ) + + assertTrue(result) + assertEquals("test@example.com", requestedEmail) + } } diff --git a/build.gradle.kts b/build.gradle.kts index 7ac875cc..fc998ac3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,12 +1,12 @@ plugins { - id("com.android.application") version "8.13.2" apply false - id("com.android.library") version "8.13.2" apply false - id("org.jetbrains.kotlin.android") version "2.1.0" apply false + id("com.android.application") version "9.2.1" apply false + id("com.android.library") version "9.2.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.10" apply false id("org.jetbrains.kotlin.jvm") version "2.1.0" apply false id("org.jetbrains.kotlin.multiplatform") version "2.1.0" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" apply false - id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false - id("com.google.devtools.ksp") version "2.1.0-1.0.29" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false + id("com.google.devtools.ksp") version "2.3.2" apply false id("com.google.dagger.hilt.android") version "2.57.2" apply false id("io.ktor.plugin") version "3.0.3" apply false id("io.sentry.jvm.gradle") version "5.7.0" apply false diff --git a/docker-compose.yaml b/docker-compose.yaml index 17352a5c..1a72154e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -41,6 +41,9 @@ services: - "${TDAY_HOST_BIND:-127.0.0.1}:${TDAY_HOST_PORT:-2525}:8080" env_file: - .env.docker + environment: + APPLE_TEAM_ID: ${APPLE_TEAM_ID:-THT5Z8K3TF} + IOS_BUNDLE_ID: ${IOS_BUNDLE_ID:-com.ohmz.tday.ios} security_opt: - no-new-privileges:true cap_drop: diff --git a/gradle.properties b/gradle.properties index c441daa9..034d2add 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,3 +2,13 @@ org.gradle.jvmargs=-Xmx3072m -Dfile.encoding=UTF-8 kotlin.code.style=official android.useAndroidX=true android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b1..c61a118f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/ios-swiftUI/Package.swift b/ios-swiftUI/Package.swift index 95215551..59bfbbe6 100644 --- a/ios-swiftUI/Package.swift +++ b/ios-swiftUI/Package.swift @@ -21,7 +21,7 @@ let package = Package( .target( name: "TdayCore", dependencies: [ - .product(name: "Sentry", package: "sentry-cocoa"), + .product(name: "Sentry-Dynamic", package: "sentry-cocoa"), ], path: "Tday", exclude: [ diff --git a/ios-swiftUI/README.md b/ios-swiftUI/README.md index ead07c0f..60ff55bc 100644 --- a/ios-swiftUI/README.md +++ b/ios-swiftUI/README.md @@ -24,7 +24,7 @@ ios/ - Shared models, URLSession API layer, Keychain-backed secure store, cookie handling, and CryptoKit credential envelope login. - SwiftData-backed offline cache plus pending mutation replay and remote merge logic. - Home, todo list, calendar, completed history, and settings screens. -- Reminder scheduling and a WidgetKit placeholder entry point for the future app extension target. +- Reminder scheduling and a WidgetKit today-tasks snapshot entry point for the future app extension target. ## Environment Notes diff --git a/ios-swiftUI/Tday/Core/Data/AppContainer.swift b/ios-swiftUI/Tday/Core/Data/AppContainer.swift index 3a5d3507..1797bb85 100644 --- a/ios-swiftUI/Tday/Core/Data/AppContainer.swift +++ b/ios-swiftUI/Tday/Core/Data/AppContainer.swift @@ -57,7 +57,7 @@ final class AppContainer { serverURLState: serverURLState, api: apiService ) - systemCredentialService = SystemCredentialService() + systemCredentialService = SystemCredentialService(secureStore: secureStore) authRepository = AuthRepository( api: apiService, secureStore: secureStore, diff --git a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift index 18c43ad7..cdcbca37 100644 --- a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift +++ b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift @@ -32,25 +32,52 @@ enum LoginCredentialSource { @MainActor protocol SystemCredentialServicing: AnyObject { func requestSavedCredential() async -> SystemCredential? + func requestSavedServerURL() async -> String? func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult + func offerSaveOrUpdateServerURL(_ rawURL: String) async -> SystemCredentialSaveResult } enum SystemCredentialScope { static let appCredentialHost = "tday.ohmz.cloud" } +private enum LegacySystemCredentialRecord { + static let serverURLUser = "T'Day Server URL" +} + +private func makeLoginCredential(user: String, password: String) -> SystemCredential? { + let normalizedUser = user.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedUser != LegacySystemCredentialRecord.serverURLUser, + !normalizedUser.isEmpty, + !password.isEmpty else { + return nil + } + + return SystemCredential(email: normalizedUser, password: password) +} + @MainActor final class SystemCredentialService: SystemCredentialServicing { + private let secureStore: SecureStore private var activeAuthorizationSession: PasswordAuthorizationSession? + init(secureStore: SecureStore = SecureStore()) { + self.secureStore = secureStore + } + func requestSavedCredential() async -> SystemCredential? { let session = PasswordAuthorizationSession() activeAuthorizationSession = session - let credential = await session.requestSavedCredential() + let credential = await session.requestPasswordCredential() + .flatMap { makeLoginCredential(user: $0.user, password: $0.password) } activeAuthorizationSession = nil return credential } + func requestSavedServerURL() async -> String? { + secureStore.loadServerURLSuggestion()?.absoluteString + } + func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult { guard !credential.email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !credential.password.isEmpty else { @@ -64,6 +91,15 @@ final class SystemCredentialService: SystemCredentialServicing { } } + func offerSaveOrUpdateServerURL(_ rawURL: String) async -> SystemCredentialSaveResult { + guard let normalizedURL = secureStore.normalizeServerURL(rawURL) else { + return .skipped + } + + secureStore.saveServerURLSuggestion(normalizedURL) + return .saved + } + @available(iOS 26.2, *) private func saveWithCredentialDataManager(_ credential: SystemCredential) async -> SystemCredentialSaveResult { let host = SystemCredentialScope.appCredentialHost @@ -120,10 +156,10 @@ final class SystemCredentialService: SystemCredentialServicing { @MainActor private final class PasswordAuthorizationSession: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { - private var continuation: CheckedContinuation? + private var continuation: CheckedContinuation? private var controller: ASAuthorizationController? - func requestSavedCredential() async -> SystemCredential? { + func requestPasswordCredential() async -> ASPasswordCredential? { await withCheckedContinuation { continuation in self.continuation = continuation @@ -133,17 +169,13 @@ private final class PasswordAuthorizationSession: NSObject, ASAuthorizationContr self.controller = controller controller.delegate = self controller.presentationContextProvider = self - controller.performRequests(options: [.preferImmediatelyAvailableCredentials]) + controller.performRequests() } } func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { let credential = authorization.credential as? ASPasswordCredential - finish( - credential.map { - SystemCredential(email: $0.user, password: $0.password) - } - ) + finish(credential) } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { @@ -165,7 +197,7 @@ private final class PasswordAuthorizationSession: NSObject, ASAuthorizationContr return UIWindow(frame: UIScreen.main.bounds) } - private func finish(_ credential: SystemCredential?) { + private func finish(_ credential: ASPasswordCredential?) { controller = nil continuation?.resume(returning: credential) continuation = nil diff --git a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift index 1a827326..e1469ba5 100644 --- a/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift +++ b/ios-swiftUI/Tday/Core/Data/Cache/OfflineCacheManager.swift @@ -135,6 +135,7 @@ final class OfflineCacheManager { try? modelContext.save() lastState = state + TodayTasksWidgetSnapshotStore.saveTodayTasks(from: state) cacheDataVersion += 1 NotificationCenter.default.post(name: .offlineCacheDidChange, object: nil) } diff --git a/ios-swiftUI/Tday/Core/Data/SecureStore.swift b/ios-swiftUI/Tday/Core/Data/SecureStore.swift index 1bf02765..a4699d33 100644 --- a/ios-swiftUI/Tday/Core/Data/SecureStore.swift +++ b/ios-swiftUI/Tday/Core/Data/SecureStore.swift @@ -23,6 +23,7 @@ final class SecureStore { case lastEmail = "last-email" case persistedAuthSessionCookie = "persisted-auth-session-cookie" case cachedSessionUser = "cached-session-user" + case savedServerURLSuggestion = "saved-server-url-suggestion" } func loadPersistedServerURL() -> URL? { @@ -40,6 +41,17 @@ final class SecureStore { deleteValue(for: .persistedServerURL) } + func saveServerURLSuggestion(_ url: URL) { + saveString(url.absoluteString, for: .savedServerURLSuggestion) + } + + func loadServerURLSuggestion() -> URL? { + guard let raw = loadString(for: .savedServerURLSuggestion) else { + return nil + } + return URL(string: raw) + } + func loadOrCreateDeviceID() -> String { if let existing = loadString(for: .deviceID), !existing.isEmpty { return existing @@ -173,12 +185,18 @@ final class SecureStore { deleteValue(for: .persistedAuthSessionCookie) } - func clearPersistedAuthSessionCookieIfAppReinstalled() { + func clearInstallScopedValuesIfAppReinstalled() { guard defaults.string(forKey: installSentinelKey) == nil else { return } + clearPersistedServerURL() clearPersistedAuthSessionCookie() + clearCachedSessionUser() + clearLastEmail() + clearAllTrustedFingerprints() + defaults.removeObject(forKey: runtimeServerURLKey) + defaults.removeObject(forKey: listIconsKey) defaults.set(UUID().uuidString.lowercased(), forKey: installSentinelKey) } diff --git a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift index 25863173..ac3b906d 100644 --- a/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift +++ b/ios-swiftUI/Tday/Core/Data/Todo/TodoRepository.swift @@ -286,7 +286,13 @@ final class TodoRepository { } func summarizeTodos(mode: TodoListMode, listId: String? = nil) async throws -> TodoSummaryResponse { - try await api.summarizeTodos(payload: TodoSummaryRequest(mode: mode.rawValue, listId: listId)) + try await api.summarizeTodos( + payload: TodoSummaryRequest( + mode: mode.rawValue, + listId: listId, + timeZone: TimeZone.current.identifier + ) + ) } func parseTodoTitleNlp(text: String, referenceDueEpochMs: Int64) async -> TodoTitleNlpResponse? { diff --git a/ios-swiftUI/Tday/Core/Model/ApiModels.swift b/ios-swiftUI/Tday/Core/Model/ApiModels.swift index 6248f4a9..0c9dcb1d 100644 --- a/ios-swiftUI/Tday/Core/Model/ApiModels.swift +++ b/ios-swiftUI/Tday/Core/Model/ApiModels.swift @@ -70,10 +70,12 @@ struct CredentialsCallbackRequest: Codable { struct AppSettingsResponse: Codable, Equatable { let aiSummaryEnabled: Bool + let updatedAt: String? } struct AdminSettingsResponse: Codable, Equatable { let aiSummaryEnabled: Bool + let updatedAt: String? let validationError: String? } @@ -88,15 +90,17 @@ struct TodosResponse: Codable { struct TodoSummaryRequest: Codable { let mode: String let listId: String? + let timeZone: String? } struct TodoSummaryResponse: Codable, Equatable { - let summary: String + let summary: String? let source: String? let mode: String? let taskCount: Int? let generatedAt: String? let fallbackReason: String? + let reason: String? } struct TodoTitleNlpRequest: Codable { @@ -131,9 +135,12 @@ struct TodoDTO: Codable, Equatable { let priority: String let due: String let rrule: String? + let timeZone: String? let instanceDate: String? let completed: Bool + let order: Int? let listID: String? + let userID: String? let updatedAt: String? let createdAt: String? } @@ -230,7 +237,64 @@ struct UpdateListRequest: Codable { } struct DeleteListRequest: 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] + + init(list: ListDTO, todos: [ListTodoDTO] = []) { + self.list = list + self.todos = todos + } + + private enum CodingKeys: String, CodingKey { + case list + case todos + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + list = try container.decode(ListDTO.self, forKey: .list) + todos = try container.decodeIfPresent([ListTodoDTO].self, forKey: .todos) ?? [] + } +} + +struct DeleteListResponse: 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 + let priority: String + let due: String + let completed: Bool + let order: Int } struct CompletedTodosResponse: Codable { @@ -245,7 +309,10 @@ struct CompletedTodoDTO: Codable, Equatable { let priority: String let due: String let completedAt: String? + let completedOnTime: Bool? + let daysToComplete: Double? let rrule: String? + let userID: String? let instanceDate: String? let listName: String? let listColor: String? diff --git a/ios-swiftUI/Tday/Core/Network/CookieStore.swift b/ios-swiftUI/Tday/Core/Network/CookieStore.swift index b226b729..5ec2b9fc 100644 --- a/ios-swiftUI/Tday/Core/Network/CookieStore.swift +++ b/ios-swiftUI/Tday/Core/Network/CookieStore.swift @@ -25,7 +25,7 @@ final class CookieStore { self.secureStore = secureStore self.storage = storage storage.cookieAcceptPolicy = .always - secureStore.clearPersistedAuthSessionCookieIfAppReinstalled() + secureStore.clearInstallScopedValuesIfAppReinstalled() if currentAuthCookie() == nil { restorePersistedAuthCookie() } diff --git a/ios-swiftUI/Tday/Core/Network/RealtimeClient.swift b/ios-swiftUI/Tday/Core/Network/RealtimeClient.swift index 6c4ae209..05f142b5 100644 --- a/ios-swiftUI/Tday/Core/Network/RealtimeClient.swift +++ b/ios-swiftUI/Tday/Core/Network/RealtimeClient.swift @@ -5,10 +5,17 @@ struct RealtimeEvent: Equatable { let rawPayload: String var requiresRefresh: Bool { - name.hasPrefix("todo.") || - name.hasPrefix("list.") || - name.hasPrefix("completed.") || - name.hasPrefix("completedtodo.") + let normalizedName = name.lowercased() + return normalizedName.hasPrefix("todo.") || + normalizedName.contains("todocreated") || + normalizedName.contains("todoupdated") || + normalizedName.contains("tododeleted") || + normalizedName.hasPrefix("list.") || + normalizedName.contains("listchanged") || + normalizedName.hasPrefix("completed.") || + normalizedName.hasPrefix("completedtodo.") || + normalizedName.contains("completedchanged") || + normalizedName.contains("completedtodo") } } @@ -51,7 +58,8 @@ actor RealtimeClient { } components.scheme = components.scheme == "http" ? "ws" : "wss" - components.path = "/api/realtime" + components.path = "/ws" + components.query = nil guard let url = components.url else { return } @@ -87,15 +95,30 @@ actor RealtimeClient { } private func parse(text: String) -> RealtimeEvent? { - guard let data = text.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let eventName = json["event"] as? String - else { + let eventName = Self.eventName(from: text) + guard !eventName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } return RealtimeEvent(name: eventName, rawPayload: text) } + nonisolated static func eventName(from text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let data = trimmed.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { + return trimmed + } + + if let eventName = json["event"] as? String { + return eventName + } + if let typeName = json["type"] as? String { + return typeName + } + return trimmed + } + private func scheduleReconnect() { reconnectTask?.cancel() reconnectTask = Task { diff --git a/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift b/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift index c88cea71..2918995d 100644 --- a/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift +++ b/ios-swiftUI/Tday/Core/Network/TdayAPIService.swift @@ -267,7 +267,7 @@ final class TdayAPIService { try await request(path: "/api/list", method: "GET", responseType: ListsResponse.self) } - func getListTodos(listID: String, start: Int64, end: Int64) async throws -> TodosResponse { + func getListTodos(listID: String, start: Int64, end: Int64) async throws -> ListDetailResponse { try await request( path: "/api/list/\(listID)", method: "GET", @@ -275,7 +275,7 @@ final class TdayAPIService { URLQueryItem(name: "start", value: String(start)), URLQueryItem(name: "end", value: String(end)), ], - responseType: TodosResponse.self + responseType: ListDetailResponse.self ) } @@ -291,11 +291,11 @@ final class TdayAPIService { try await patchListByBody(payload: payload) } - func deleteListByBody(payload: DeleteListRequest) async throws -> MessageResponse { - try await request(path: "/api/list", method: "DELETE", body: payload, responseType: MessageResponse.self) + func deleteListByBody(payload: DeleteListRequest) async throws -> DeleteListResponse { + try await request(path: "/api/list", method: "DELETE", body: payload, responseType: DeleteListResponse.self) } - func deleteList(payload: DeleteListRequest) async throws -> MessageResponse { + func deleteList(payload: DeleteListRequest) async throws -> DeleteListResponse { try await deleteListByBody(payload: payload) } diff --git a/ios-swiftUI/Tday/Core/Notification/NotificationDeepLinkRouter.swift b/ios-swiftUI/Tday/Core/Notification/NotificationDeepLinkRouter.swift new file mode 100644 index 00000000..6f493738 --- /dev/null +++ b/ios-swiftUI/Tday/Core/Notification/NotificationDeepLinkRouter.swift @@ -0,0 +1,51 @@ +import Foundation +import Observation +import UserNotifications + +@MainActor +@Observable +final class NotificationDeepLinkRouter { + static let shared = NotificationDeepLinkRouter() + + var pendingURL: URL? + + private init() {} + + func route(_ url: URL) { + pendingURL = url + } + + func clearPendingURL() { + pendingURL = nil + } +} + +final class NotificationDeepLinkDelegate: NSObject, UNUserNotificationCenterDelegate { + static let shared = NotificationDeepLinkDelegate() + + private override init() {} + + func install() { + UNUserNotificationCenter.current().delegate = self + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + guard let url = Self.deepLinkURL(from: response.notification.request.content.userInfo) else { + return + } + + await MainActor.run { + NotificationDeepLinkRouter.shared.route(url) + } + } + + static func deepLinkURL(from userInfo: [AnyHashable: Any]) -> URL? { + guard let deepLink = userInfo["deepLink"] as? String else { + return nil + } + return URL(string: deepLink) + } +} diff --git a/ios-swiftUI/Tday/Core/Notification/TaskReminderScheduler.swift b/ios-swiftUI/Tday/Core/Notification/TaskReminderScheduler.swift index 3143c898..dd3cc830 100644 --- a/ios-swiftUI/Tday/Core/Notification/TaskReminderScheduler.swift +++ b/ios-swiftUI/Tday/Core/Notification/TaskReminderScheduler.swift @@ -36,7 +36,7 @@ final class TaskReminderScheduler { content.title = task.title content.body = task.description ?? "Due soon" content.sound = .default - content.userInfo = ["deepLink": "tday://todos/all?highlightTodoId=\(task.id)"] + content.userInfo = ["deepLink": Self.deepLinkURLString(for: task.id)] let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute], from: triggerDate) let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) @@ -49,6 +49,15 @@ final class TaskReminderScheduler { "tday.todo.\(task.id)" } + private static func deepLinkURLString(for taskID: String) -> String { + var components = URLComponents() + components.scheme = "tday" + components.host = "todos" + components.path = "/all" + components.queryItems = [URLQueryItem(name: "highlightTodoId", value: taskID)] + return components.url?.absoluteString ?? "tday://todos/all" + } + private var notificationCenter: UNUserNotificationCenter? { guard Bundle.main.bundleURL.pathExtension == "app" else { return nil diff --git a/ios-swiftUI/Tday/Core/Widget/TodayTasksWidgetSnapshotStore.swift b/ios-swiftUI/Tday/Core/Widget/TodayTasksWidgetSnapshotStore.swift new file mode 100644 index 00000000..7b029128 --- /dev/null +++ b/ios-swiftUI/Tday/Core/Widget/TodayTasksWidgetSnapshotStore.swift @@ -0,0 +1,95 @@ +import Foundation + +#if canImport(WidgetKit) +import WidgetKit +#endif + +struct TodayTasksWidgetSnapshot: Codable, Equatable { + let generatedAtEpochMs: Int64 + let title: String + let taskCount: Int + let tasks: [TodayTasksWidgetTaskSnapshot] +} + +struct TodayTasksWidgetTaskSnapshot: Codable, Equatable, Identifiable { + let id: String + let title: String + let dueEpochMs: Int64 + let priority: String +} + +enum TodayTasksWidgetSnapshotStore { + static let widgetKind = "TodayTasksWidget" + static let appGroupSuiteName = "group.com.ohmz.tday" + static let snapshotKey = "tday.widget.todayTasksSnapshot" + + static func makeSnapshot( + from state: OfflineSyncState, + now: Date = Date(), + calendar: Calendar = .current + ) -> TodayTasksWidgetSnapshot { + let dayStart = calendar.startOfDay(for: now) + let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(86_400) + let dayStartEpochMs = Int64(dayStart.timeIntervalSince1970 * 1_000) + let dayEndEpochMs = Int64(dayEnd.timeIntervalSince1970 * 1_000) + + let todayTasks = state.todos + .filter { !$0.completed && $0.dueEpochMs >= dayStartEpochMs && $0.dueEpochMs < dayEndEpochMs } + .sorted { left, right in + if left.dueEpochMs == right.dueEpochMs { + return left.title.localizedStandardCompare(right.title) == .orderedAscending + } + return left.dueEpochMs < right.dueEpochMs + } + + return TodayTasksWidgetSnapshot( + generatedAtEpochMs: Int64(now.timeIntervalSince1970 * 1_000), + title: "Today's Tasks", + taskCount: todayTasks.count, + tasks: todayTasks.prefix(8).map { + TodayTasksWidgetTaskSnapshot( + id: $0.id, + title: $0.title, + dueEpochMs: $0.dueEpochMs, + priority: $0.priority + ) + } + ) + } + + static func saveTodayTasks(from state: OfflineSyncState) { + let snapshot = makeSnapshot(from: state) + guard let data = try? JSONEncoder().encode(snapshot) else { + return + } + + let stores = defaultsStores() + stores.forEach { store in + store.set(data, forKey: snapshotKey) + } + + #if canImport(WidgetKit) + WidgetCenter.shared.reloadTimelines(ofKind: widgetKind) + #endif + } + + static func loadSnapshot() -> TodayTasksWidgetSnapshot? { + for store in defaultsStores() { + guard let data = store.data(forKey: snapshotKey), + let snapshot = try? JSONDecoder().decode(TodayTasksWidgetSnapshot.self, from: data) else { + continue + } + return snapshot + } + return nil + } + + private static func defaultsStores() -> [UserDefaults] { + var stores = [UserDefaults]() + if let shared = UserDefaults(suiteName: appGroupSuiteName) { + stores.append(shared) + } + stores.append(.standard) + return stores + } +} diff --git a/ios-swiftUI/Tday/Feature/App/AppRootView.swift b/ios-swiftUI/Tday/Feature/App/AppRootView.swift index 3e308df5..5ec192a7 100644 --- a/ios-swiftUI/Tday/Feature/App/AppRootView.swift +++ b/ios-swiftUI/Tday/Feature/App/AppRootView.swift @@ -5,6 +5,7 @@ struct AppRootView: View { @State private var appViewModel: AppViewModel @State private var authViewModel: AuthViewModel + @State private var notificationDeepLinkRouter = NotificationDeepLinkRouter.shared @State private var hasLeftActiveScene = false @State private var isLaunchSplashHeld = false @Environment(\.scenePhase) private var scenePhase @@ -154,10 +155,14 @@ struct AppRootView: View { if !appViewModel.hasCompletedInitialBootstrap { await appViewModel.bootstrap() } + routePendingNotificationDeepLink() } .onOpenURL { url in handleDeepLink(url) } + .onChange(of: notificationDeepLinkRouter.pendingURL) { _, _ in + routePendingNotificationDeepLink() + } .onChange(of: scenePhase) { _, phase in switch phase { case .active: @@ -186,6 +191,14 @@ struct AppRootView: View { } handleRoute(route) } + + private func routePendingNotificationDeepLink() { + guard let url = notificationDeepLinkRouter.pendingURL else { + return + } + handleDeepLink(url) + notificationDeepLinkRouter.clearPendingURL() + } } struct AppLaunchSplashView: View { diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index de79456b..c740001d 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -215,7 +215,8 @@ final class AppViewModel { func resetTrustedServer(rawURL: String) async -> Result { do { _ = try await container.serverConfigRepository.resetTrustedServer(rawURL: rawURL) - serverURL = container.serverConfigRepository.getServerURL()?.absoluteString + let savedServerURL = container.serverConfigRepository.getServerURL()?.absoluteString ?? rawURL + serverURL = savedServerURL requiresServerSetup = false requiresLogin = true error = nil diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarPagingScrollView.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarPagingScrollView.swift new file mode 100644 index 00000000..098354e5 --- /dev/null +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarPagingScrollView.swift @@ -0,0 +1,184 @@ +import SwiftUI +import UIKit + +let calendarNativePagerCenterIndex = 1 + +enum CalendarPagerDirection { + case previous + case next + + var pageIndex: Int { + switch self { + case .previous: + return 0 + case .next: + return 2 + } + } +} + +struct CalendarPagerPage: Identifiable { + let id: Int + let content: AnyView +} + +struct CalendarPagingScrollView: UIViewRepresentable { + let pages: [CalendarPagerPage] + @Binding var selection: Int + let onSettledSelection: (Int) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.isPagingEnabled = true + scrollView.bounces = false + scrollView.alwaysBounceHorizontal = false + scrollView.alwaysBounceVertical = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.decelerationRate = .fast + scrollView.delegate = context.coordinator + scrollView.backgroundColor = .clear + scrollView.clipsToBounds = true + + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.alignment = .fill + stackView.distribution = .fill + stackView.spacing = 0 + stackView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + + context.coordinator.stackView = stackView + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + context.coordinator.parent = self + context.coordinator.rebuildPagesIfNeeded(pages, in: scrollView) + context.coordinator.scrollToSelection( + selection, + in: scrollView, + animated: selection != calendarNativePagerCenterIndex + ) + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + var parent: CalendarPagingScrollView? + var stackView: UIStackView? + private var hostedControllers: [UIHostingController] = [] + private var pageIDs: [Int] = [] + private var isProgrammaticScroll = false + private var programmaticSelection: Int? + + func rebuildPagesIfNeeded(_ pages: [CalendarPagerPage], in scrollView: UIScrollView) { + let incomingIDs = pages.map(\.id) + guard incomingIDs != pageIDs else { + for (controller, page) in zip(hostedControllers, pages) { + controller.rootView = page.content + } + return + } + + hostedControllers.forEach { controller in + controller.view.removeFromSuperview() + } + hostedControllers.removeAll() + pageIDs = incomingIDs + + guard let stackView else { return } + stackView.arrangedSubviews.forEach { view in + stackView.removeArrangedSubview(view) + view.removeFromSuperview() + } + + for page in pages { + let controller = UIHostingController(rootView: page.content) + controller.view.backgroundColor = .clear + controller.view.translatesAutoresizingMaskIntoConstraints = false + hostedControllers.append(controller) + stackView.addArrangedSubview(controller.view) + + NSLayoutConstraint.activate([ + controller.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + controller.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + } + } + + func scrollToSelection(_ selection: Int, in scrollView: UIScrollView, animated: Bool) { + guard let index = pageIDs.firstIndex(of: selection) else { return } + + scrollView.layoutIfNeeded() + guard scrollView.bounds.width > 0 else { + DispatchQueue.main.async { [weak self, weak scrollView] in + guard let self, let scrollView else { return } + self.scrollToSelection(selection, in: scrollView, animated: false) + } + return + } + + let targetX = CGFloat(index) * scrollView.bounds.width + guard abs(scrollView.contentOffset.x - targetX) > 0.5 else { return } + guard !animated || programmaticSelection != selection else { return } + + isProgrammaticScroll = true + programmaticSelection = animated ? selection : nil + scrollView.setContentOffset(CGPoint(x: targetX, y: 0), animated: animated) + if !animated { + isProgrammaticScroll = false + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateSelection(from: scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + updateSelection(from: scrollView) + } + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + isProgrammaticScroll = false + programmaticSelection = nil + notifySettledSelection(from: scrollView) + } + + private func updateSelection(from scrollView: UIScrollView) { + guard !isProgrammaticScroll else { return } + notifySettledSelection(from: scrollView) + } + + private func notifySettledSelection(from scrollView: UIScrollView) { + guard scrollView.bounds.width > 0 else { return } + guard let selectedID = settledPageID(from: scrollView) else { return } + notifyParentIfNeeded(selectedID) + } + + private func settledPageID(from scrollView: UIScrollView) -> Int? { + let index = Int(round(scrollView.contentOffset.x / scrollView.bounds.width)) + guard pageIDs.indices.contains(index) else { return nil } + return pageIDs[index] + } + + private func notifyParentIfNeeded(_ selectedID: Int) { + guard selectedID != calendarNativePagerCenterIndex else { return } + DispatchQueue.main.async { + self.parent?.onSettledSelection(selectedID) + } + } + } +} diff --git a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift index 500bd2b0..70073b9f 100644 --- a/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift +++ b/ios-swiftUI/Tday/Feature/Calendar/CalendarScreen.swift @@ -41,27 +41,11 @@ private enum CalendarMonthGridMetrics { private let calendarTodayTintColor = Color(red: 80.0 / 255.0, green: 154.0 / 255.0, blue: 230.0 / 255.0) -private let calendarNativePagerCenterIndex = 1 - private struct CalendarTodayJumpRequest: Equatable { let id: Int let targetDate: Date } -private enum CalendarPagerDirection { - case previous - case next - - var pageIndex: Int { - switch self { - case .previous: - return 0 - case .next: - return 2 - } - } -} - struct CalendarScreen: View { @State private var viewModel: CalendarViewModel @Environment(\.tdayColors) private var colors @@ -1109,172 +1093,6 @@ private struct CalendarNavButton: View { } } -private struct CalendarPagerPage: Identifiable { - let id: Int - let content: AnyView -} - -private struct CalendarPagingScrollView: UIViewRepresentable { - let pages: [CalendarPagerPage] - @Binding var selection: Int - let onSettledSelection: (Int) -> Void - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeUIView(context: Context) -> UIScrollView { - let scrollView = UIScrollView() - scrollView.isPagingEnabled = true - scrollView.bounces = false - scrollView.alwaysBounceHorizontal = false - scrollView.alwaysBounceVertical = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.decelerationRate = .fast - scrollView.delegate = context.coordinator - scrollView.backgroundColor = .clear - scrollView.clipsToBounds = true - - let stackView = UIStackView() - stackView.axis = .horizontal - stackView.alignment = .fill - stackView.distribution = .fill - stackView.spacing = 0 - stackView.translatesAutoresizingMaskIntoConstraints = false - scrollView.addSubview(stackView) - - NSLayoutConstraint.activate([ - stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - stackView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) - ]) - - context.coordinator.stackView = stackView - return scrollView - } - - func updateUIView(_ scrollView: UIScrollView, context: Context) { - context.coordinator.parent = self - context.coordinator.rebuildPagesIfNeeded(pages, in: scrollView) - context.coordinator.scrollToSelection( - selection, - in: scrollView, - animated: selection != calendarNativePagerCenterIndex - ) - } - - final class Coordinator: NSObject, UIScrollViewDelegate { - var parent: CalendarPagingScrollView? - var stackView: UIStackView? - private var hostedControllers: [UIHostingController] = [] - private var pageIDs: [Int] = [] - private var isProgrammaticScroll = false - private var programmaticSelection: Int? - - func rebuildPagesIfNeeded(_ pages: [CalendarPagerPage], in scrollView: UIScrollView) { - let incomingIDs = pages.map(\.id) - guard incomingIDs != pageIDs else { - for (controller, page) in zip(hostedControllers, pages) { - controller.rootView = page.content - } - return - } - - hostedControllers.forEach { controller in - controller.view.removeFromSuperview() - } - hostedControllers.removeAll() - pageIDs = incomingIDs - - guard let stackView else { return } - stackView.arrangedSubviews.forEach { view in - stackView.removeArrangedSubview(view) - view.removeFromSuperview() - } - - for page in pages { - let controller = UIHostingController(rootView: page.content) - controller.view.backgroundColor = .clear - controller.view.translatesAutoresizingMaskIntoConstraints = false - hostedControllers.append(controller) - stackView.addArrangedSubview(controller.view) - - NSLayoutConstraint.activate([ - controller.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - controller.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) - ]) - } - } - - func scrollToSelection(_ selection: Int, in scrollView: UIScrollView, animated: Bool) { - guard let index = pageIDs.firstIndex(of: selection) else { return } - - scrollView.layoutIfNeeded() - guard scrollView.bounds.width > 0 else { - DispatchQueue.main.async { [weak self, weak scrollView] in - guard let self, let scrollView else { return } - self.scrollToSelection(selection, in: scrollView, animated: false) - } - return - } - - let targetX = CGFloat(index) * scrollView.bounds.width - guard abs(scrollView.contentOffset.x - targetX) > 0.5 else { return } - guard !animated || programmaticSelection != selection else { return } - - isProgrammaticScroll = true - programmaticSelection = animated ? selection : nil - scrollView.setContentOffset(CGPoint(x: targetX, y: 0), animated: animated) - if !animated { - isProgrammaticScroll = false - } - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - updateSelection(from: scrollView) - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - updateSelection(from: scrollView) - } - } - - func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - isProgrammaticScroll = false - programmaticSelection = nil - notifySettledSelection(from: scrollView) - } - - private func updateSelection(from scrollView: UIScrollView) { - guard !isProgrammaticScroll else { return } - notifySettledSelection(from: scrollView) - } - - private func notifySettledSelection(from scrollView: UIScrollView) { - guard scrollView.bounds.width > 0 else { return } - guard let selectedID = settledPageID(from: scrollView) else { return } - notifyParentIfNeeded(selectedID) - } - - private func settledPageID(from scrollView: UIScrollView) -> Int? { - let index = Int(round(scrollView.contentOffset.x / scrollView.bounds.width)) - guard pageIDs.indices.contains(index) else { return nil } - return pageIDs[index] - } - - private func notifyParentIfNeeded(_ selectedID: Int) { - guard selectedID != calendarNativePagerCenterIndex else { return } - DispatchQueue.main.async { - self.parent?.onSettledSelection(selectedID) - } - } - } -} - private struct CalendarMonthDayCell: View { let day: CalendarMonthDay let isSelected: Bool diff --git a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift index 99b70fce..2b4a981e 100644 --- a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift +++ b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift @@ -45,6 +45,10 @@ struct OnboardingWizardOverlay: View { @State private var localError: String? @State private var isConnecting = false @State private var isCompletingAuthentication = false + @State private var serverURLCameFromSystemCredential = false + @State private var hasRequestedSavedServerURL = false + @State private var pendingServerURLUsePrompt: String? + @State private var pendingServerURLSavePrompt: String? @State private var credentialCoordinator = LoginCredentialCoordinator() var body: some View { @@ -67,13 +71,18 @@ struct OnboardingWizardOverlay: View { } .onAppear { serverURL = initialServerURL ?? "" - email = authViewModel.savedEmail step = (initialServerURL?.isEmpty == false) ? .login : .server - requestSavedCredentialIfAvailable() + if step == .login { + requestSavedCredentialIfAvailable() + } else { + requestSavedServerURLIfAvailable() + } } .onChange(of: step) { _, newStep in if newStep == .login { requestSavedCredentialIfAvailable() + } else { + requestSavedServerURLIfAvailable() } } .onChange(of: isCreatingAccount) { _, creatingAccount in @@ -86,6 +95,39 @@ struct OnboardingWizardOverlay: View { .animation(.easeInOut(duration: 0.2), value: isConnecting) .animation(.easeInOut(duration: 0.2), value: authViewModel.isLoading) .animation(.easeInOut(duration: 0.2), value: isCompletingAuthentication) + .alert("Save server URL?", isPresented: serverURLSavePromptBinding) { + Button("Not Now", role: .cancel) { + pendingServerURLSavePrompt = nil + step = .login + } + Button("Save") { + guard let serverURL = pendingServerURLSavePrompt else { + step = .login + return + } + pendingServerURLSavePrompt = nil + Task { + _ = await systemCredentialService.offerSaveOrUpdateServerURL(serverURL) + step = .login + } + } + } message: { + Text("T'Day can save this server URL securely on this device so you can reuse it during setup.") + } + .alert("Use saved server URL?", isPresented: serverURLUsePromptBinding) { + Button("Not Now", role: .cancel) { + pendingServerURLUsePrompt = nil + } + Button("Use") { + guard let savedServerURL = pendingServerURLUsePrompt else { + return + } + pendingServerURLUsePrompt = nil + useSavedServerURL(savedServerURL) + } + } message: { + Text("T'Day found a server URL saved on this device.") + } } private var stableCardLayout: some View { @@ -221,6 +263,15 @@ struct OnboardingWizardOverlay: View { .foregroundStyle(colors.primary) } + if serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Button("Use saved server URL") { + requestSavedServerURL() + } + .buttonStyle(WizardTextButtonStyle()) + .font(.tdayRounded(size: 15, weight: .bold)) + .foregroundStyle(colors.primary) + } + WizardPrimaryButton( title: isConnecting ? "Connecting..." : "Connect", enabled: !serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isConnecting @@ -334,6 +385,28 @@ struct OnboardingWizardOverlay: View { step == .login } + private var serverURLSavePromptBinding: Binding { + Binding( + get: { pendingServerURLSavePrompt != nil }, + set: { isPresented in + if !isPresented { + pendingServerURLSavePrompt = nil + } + } + ) + } + + private var serverURLUsePromptBinding: Binding { + Binding( + get: { pendingServerURLUsePrompt != nil }, + set: { isPresented in + if !isPresented { + pendingServerURLUsePrompt = nil + } + } + ) + } + private var isAuthInFlight: Bool { authViewModel.isLoading || isCompletingAuthentication } @@ -388,8 +461,14 @@ struct OnboardingWizardOverlay: View { isConnecting = false switch result { case .success: - step = .login + if !serverURLCameFromSystemCredential { + pendingServerURLSavePrompt = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + } else { + step = .login + } + serverURLCameFromSystemCredential = false case let .failure(error): + serverURLCameFromSystemCredential = false localError = error.message } } @@ -402,12 +481,43 @@ struct OnboardingWizardOverlay: View { isConnecting = false switch result { case .success: - step = .login + pendingServerURLSavePrompt = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) case let .failure(error): localError = error.message } } + private func requestSavedServerURL() { + guard step == .server, + serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !isConnecting else { + return + } + + Task { @MainActor in + guard let savedServerURL = await systemCredentialService.requestSavedServerURL() else { + return + } + + pendingServerURLUsePrompt = savedServerURL + } + } + + private func useSavedServerURL(_ savedServerURL: String) { + serverURL = savedServerURL + serverURLCameFromSystemCredential = true + Task { await connectServer() } + } + + private func requestSavedServerURLIfAvailable() { + guard !hasRequestedSavedServerURL else { + return + } + + hasRequestedSavedServerURL = true + requestSavedServerURL() + } + private func requestSavedCredentialIfAvailable() { guard isLoginStep, !isCompletingAuthentication else { return diff --git a/ios-swiftUI/Tday/Info.plist b/ios-swiftUI/Tday/Info.plist index f9b929da..ae9b0955 100644 --- a/ios-swiftUI/Tday/Info.plist +++ b/ios-swiftUI/Tday/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - Tday + T'Day CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +13,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - Tday + T'Day CFBundlePackageType APPL CFBundleShortVersionString diff --git a/ios-swiftUI/Tday/TdayApp.swift b/ios-swiftUI/Tday/TdayApp.swift index f2abaf52..6dbfa22d 100644 --- a/ios-swiftUI/Tday/TdayApp.swift +++ b/ios-swiftUI/Tday/TdayApp.swift @@ -10,6 +10,7 @@ struct TdayApp: App { init() { TdayFont.applyGlobalAppearances() SentryConfiguration.start() + NotificationDeepLinkDelegate.shared.install() } var body: some Scene { diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 71597654..de22cf6f 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -19,7 +19,7 @@ 1618367E71392D77BC8C61B6 /* NavigationBackHistoryTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15B9BC191869EBEA7E8340E6 /* NavigationBackHistoryTitle.swift */; }; 17A2E96F8BEA64247551A742 /* AppViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22BFAB0C2FA0BB186AEBFC5F /* AppViewModel.swift */; }; 1AC138341BE3A2841CF05908 /* StringHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 593DCCDF3ADC95BE1CDC78FC /* StringHelpers.swift */; }; - 1FE50ED779CB252C73B0A496 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = A8BC3C28FBC8E4D5C38307DB /* Sentry */; }; + 1FE50ED779CB252C73B0A496 /* Sentry-Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = A8BC3C28FBC8E4D5C38307DB /* Sentry-Dynamic */; }; 222184A155C5A7B9F178007B /* SwiftDataModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A754219844F42A2230F08B /* SwiftDataModels.swift */; }; 271C111B35309CA229AC1400 /* CompletedRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E76499BCA95FB33B03366C1 /* CompletedRepository.swift */; }; 2C1549AFC2F29218C879306C /* SwipeActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84798381A9E2DF3C9468ED8F /* SwipeActions.swift */; }; @@ -29,8 +29,11 @@ 3667E1D45490DE558553F39F /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC35A6A50C3BFA68468FEDF9 /* OfflineBanner.swift */; }; 384B88FF643D87A6157C76C2 /* Nunito.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D9D2A99D6C098B63352D4FB8 /* Nunito.ttf */; }; 3E0BE8F327DB1A2EE5B101C4 /* ReminderPreferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */; }; + 494E748270A233BECED5A359 /* TodayTasksWidgetSnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC92D6152EADEDF915AE116B /* TodayTasksWidgetSnapshotStoreTests.swift */; }; 4A7B9C0D1E2F3456789ABC01 /* CompletedSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */; }; 4D2A9B7E2CE3424C9D111001 /* ConnectivityClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */; }; + 4E4F544946444545504C3031 /* NotificationDeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.swift */; }; + 4F52544D434C49454E543031 /* RealtimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F52544D434C49454E543032 /* RealtimeClientTests.swift */; }; 51A46F8E627C26BF34E91F7B /* TodoListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF0DB445B9FEE9B047FBB708 /* TodoListViewModel.swift */; }; 527A4947BD0D1BE156122F96 /* BootstrapSessionUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E70F2999E1965A77EB21FF8 /* BootstrapSessionUseCase.swift */; }; 535CABAC18AD47FEF15D2C11 /* CredentialEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = E30D3242D00BC6C1C4BA862D /* CredentialEnvelope.swift */; }; @@ -41,8 +44,10 @@ 58EB6EF803693EB982E331E4 /* SyncAndRefreshUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0FD6CA5EE999F99D54CF94 /* SyncAndRefreshUseCase.swift */; }; 5B82443B89507719EDD7215C /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF6FD5D2845A10D7052467D /* AppContainer.swift */; }; 5D93F6903E05BD9902C43A51 /* SystemCredentialService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC4FF9081195466EE7C3E89 /* SystemCredentialService.swift */; }; + 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 */; }; + 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 */; }; 84B6C4F440AD30E4EFA1FA3F /* ReminderOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FBC41C56EEEFC8869E762B3 /* ReminderOption.swift */; }; @@ -58,6 +63,7 @@ A316B4B3BB1AEA5FDF9998DF /* AppRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BFFE8167D3639FF09C033A2 /* AppRootView.swift */; }; A7319C922AE54DC944A9ECC5 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E5E1DBE3F7999D219BE2EE /* AuthRepository.swift */; }; B34A31B1812FC676A3AC1871 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DCE08ADACC8F11CE2113C13B /* Assets.xcassets */; }; + B4995200636F408E4296FF0D /* ApiModelContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629820F1BA29236313992076 /* ApiModelContractTests.swift */; }; B5D28682AD0623E39CCD4F8E /* CompleteTodoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70F09E74879D0AAABCF70E20 /* CompleteTodoUseCase.swift */; }; B648A67EAD215BE5B74F7460 /* ApiModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6049166B683EE082F8C551DD /* ApiModels.swift */; }; BA2D51423497337836ADD5E9 /* TdayTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39ADD9E07BF8B32C3FC7B170 /* TdayTheme.swift */; }; @@ -74,8 +80,8 @@ 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 */; }; - EAFB2C3D4E5F678901ABCDEE /* LaunchSplashStack.png in Resources */ = {isa = PBXBuildFile; fileRef = EAFB2C3D4E5F678901ABCDEF /* LaunchSplashStack.png */; }; 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 */; }; F5D16EE508D6244152B8E4B2 /* ServerURLState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24DBD8CA3EE648D4560B880 /* ServerURLState.swift */; }; FCDE195DEB990E58558699B1 /* ErrorRetryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE3D896A638869384B4CF1EF /* ErrorRetryView.swift */; }; @@ -100,7 +106,6 @@ 19F61DADBD9B8EFC300698C4 /* SystemCredentialLoginTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemCredentialLoginTests.swift; sourceTree = ""; }; 1DABE0225668571D48D55B96 /* AuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewModel.swift; sourceTree = ""; }; 22BFAB0C2FA0BB186AEBFC5F /* AppViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModel.swift; sourceTree = ""; }; - 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityClassificationTests.swift; sourceTree = ""; }; 25A90A443440754855071C9D /* CalendarScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarScreen.swift; sourceTree = ""; }; 2A80E8562326D2BB4FF7E8C7 /* Tday.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Tday.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2BFFE8167D3639FF09C033A2 /* AppRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRootView.swift; sourceTree = ""; }; @@ -113,10 +118,14 @@ 39ADD9E07BF8B32C3FC7B170 /* TdayTheme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TdayTheme.swift; sourceTree = ""; }; 42749601AFF38EAE17BD3213 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedSyncMergeTests.swift; sourceTree = ""; }; + 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectivityClassificationTests.swift; sourceTree = ""; }; + 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDeepLinkRouter.swift; sourceTree = ""; }; + 4F52544D434C49454E543032 /* RealtimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeClientTests.swift; sourceTree = ""; }; 53EFEAE1EE18AB86477A56BC /* CalendarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewModel.swift; sourceTree = ""; }; 593DCCDF3ADC95BE1CDC78FC /* StringHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringHelpers.swift; sourceTree = ""; }; 5D7ACBCBA57903A93BEC3BFA /* TaskReminderScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskReminderScheduler.swift; sourceTree = ""; }; 6049166B683EE082F8C551DD /* ApiModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiModels.swift; sourceTree = ""; }; + 629820F1BA29236313992076 /* ApiModelContractTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiModelContractTests.swift; sourceTree = ""; }; 62AD2C2DD2233FCBB4611E95 /* TodoListScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoListScreen.swift; sourceTree = ""; }; 6CF6FD5D2845A10D7052467D /* AppContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContainer.swift; sourceTree = ""; }; 6D0FD6CA5EE999F99D54CF94 /* SyncAndRefreshUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncAndRefreshUseCase.swift; sourceTree = ""; }; @@ -134,6 +143,7 @@ 8A7EC85EB5D9686D401249C6 /* UserFacingError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFacingError.swift; sourceTree = ""; }; 8E76499BCA95FB33B03366C1 /* CompletedRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedRepository.swift; sourceTree = ""; }; 91CFC3C0B9ADD376389DF63C /* NetworkConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkConfiguration.swift; sourceTree = ""; }; + 95E5A511171F96ABDEC46926 /* CalendarPagingScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarPagingScrollView.swift; sourceTree = ""; }; 9BA781886DCE6963CA364F6F /* CompletedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedScreen.swift; sourceTree = ""; }; 9CC7B17CE45842F8DF7D522B /* RealtimeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeClient.swift; sourceTree = ""; }; A5C31F2E171A73F2774E89A1 /* LoginCredentialCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginCredentialCoordinator.swift; sourceTree = ""; }; @@ -148,6 +158,7 @@ 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 = ""; }; C9D2CB40ACEE85331512EEC2 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; + CC80072C8B232C0C09CE3139 /* TodayTasksWidgetSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTasksWidgetSnapshotStore.swift; sourceTree = ""; }; CE3297DCE3ECD21F5B0A4635 /* VersionCompatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionCompatibility.swift; sourceTree = ""; }; CEFF55971ADA0C60CD0F3074 /* AppRoute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRoute.swift; sourceTree = ""; }; D0ACC8B538184310B385746A /* ServerConfigRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfigRepository.swift; sourceTree = ""; }; @@ -166,6 +177,7 @@ E867FA22FEB66EDCE837CF6D /* SecureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStore.swift; sourceTree = ""; }; EAFB2C3D4E5F678901ABCDEF /* LaunchSplashStack.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LaunchSplashStack.png; sourceTree = ""; }; FC47AEF5EB0B44524D8C20B1 /* SettingsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreen.swift; sourceTree = ""; }; + FC92D6152EADEDF915AE116B /* TodayTasksWidgetSnapshotStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTasksWidgetSnapshotStoreTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -173,7 +185,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1FE50ED779CB252C73B0A496 /* Sentry in Frameworks */, + 1FE50ED779CB252C73B0A496 /* Sentry-Dynamic in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -210,10 +222,13 @@ 0887DFEE17FD8E0DCDABDD10 /* TdayCoreTests */ = { isa = PBXGroup; children = ( + 629820F1BA29236313992076 /* ApiModelContractTests.swift */, 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */, 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */, + 4F52544D434C49454E543032 /* RealtimeClientTests.swift */, D206496BC88FF6272CFC286C /* ServerURLPersistenceTests.swift */, 19F61DADBD9B8EFC300698C4 /* SystemCredentialLoginTests.swift */, + FC92D6152EADEDF915AE116B /* TodayTasksWidgetSnapshotStoreTests.swift */, ); path = TdayCoreTests; sourceTree = ""; @@ -271,6 +286,14 @@ path = Auth; sourceTree = ""; }; + 5477B1D0058C97A680F19FA5 /* Widget */ = { + isa = PBXGroup; + children = ( + CC80072C8B232C0C09CE3139 /* TodayTasksWidgetSnapshotStore.swift */, + ); + path = Widget; + sourceTree = ""; + }; 554826ABF1DA81CDB2234C1D /* Feature */ = { isa = PBXGroup; children = ( @@ -317,6 +340,7 @@ 72FDFB18FAE7DA7B2B569510 /* Notification */ = { isa = PBXGroup; children = ( + 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.swift */, 2FBC41C56EEEFC8869E762B3 /* ReminderOption.swift */, C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */, 5D7ACBCBA57903A93BEC3BFA /* TaskReminderScheduler.swift */, @@ -368,6 +392,7 @@ 867DC72A3F5B3C04E5C36879 /* Calendar */ = { isa = PBXGroup; children = ( + 95E5A511171F96ABDEC46926 /* CalendarPagingScrollView.swift */, 25A90A443440754855071C9D /* CalendarScreen.swift */, 53EFEAE1EE18AB86477A56BC /* CalendarViewModel.swift */, ); @@ -514,6 +539,7 @@ 72FDFB18FAE7DA7B2B569510 /* Notification */, 4E5771B69142214019F82B21 /* Security */, DF657ECAF439ED3F03187CCE /* UI */, + 5477B1D0058C97A680F19FA5 /* Widget */, ); path = Core; sourceTree = ""; @@ -583,7 +609,7 @@ ); name = Tday; packageProductDependencies = ( - A8BC3C28FBC8E4D5C38307DB /* Sentry */, + A8BC3C28FBC8E4D5C38307DB /* Sentry-Dynamic */, ); productName = Tday; productReference = 2A80E8562326D2BB4FF7E8C7 /* Tday.app */; @@ -641,10 +667,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B4995200636F408E4296FF0D /* ApiModelContractTests.swift in Sources */, 4A7B9C0D1E2F3456789ABC01 /* CompletedSyncMergeTests.swift in Sources */, 4D2A9B7E2CE3424C9D111001 /* ConnectivityClassificationTests.swift in Sources */, + 4F52544D434C49454E543031 /* RealtimeClientTests.swift in Sources */, 03A0CCC6A8AAE2DB065E6DE6 /* ServerURLPersistenceTests.swift in Sources */, C8B96DDF0DC7AD0B8DD54E4F /* SystemCredentialLoginTests.swift in Sources */, + 494E748270A233BECED5A359 /* TodayTasksWidgetSnapshotStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -674,12 +703,14 @@ 535CABAC18AD47FEF15D2C11 /* CredentialEnvelope.swift in Sources */, 765ED719B3CCBB90176C55EB /* DomainModels.swift in Sources */, FCDE195DEB990E58558699B1 /* ErrorRetryView.swift in Sources */, + 5DB03218BE341B7C186F01AE /* CalendarPagingScrollView.swift in Sources */, C0768A60B1B807FCA7A6D37F /* HomeScreen.swift in Sources */, 9D9B4F301D7261C3F4F95B6D /* HomeViewModel.swift in Sources */, DEA51B1F722372A094C125F9 /* ListRepository.swift in Sources */, 957E83469CA6C09174B1B374 /* LoginCredentialCoordinator.swift in Sources */, 1618367E71392D77BC8C61B6 /* NavigationBackHistoryTitle.swift in Sources */, C013C84CCBF84A849CE93963 /* NetworkConfiguration.swift in Sources */, + 4E4F544946444545504C3031 /* NotificationDeepLinkRouter.swift in Sources */, 3667E1D45490DE558553F39F /* OfflineBanner.swift in Sources */, DCD00FC940427B88E235F7F4 /* OfflineCacheManager.swift in Sources */, A03B553E316CEEDFAB8EBA69 /* OfflineSyncModels.swift in Sources */, @@ -708,6 +739,7 @@ 861A548A6A3DDE0A78D35D83 /* TdayApp.swift in Sources */, BA2D51423497337836ADD5E9 /* TdayTheme.swift in Sources */, 0536D5C87C017F1DAC4F316C /* ThemeStore.swift in Sources */, + 72304EA28CF49303A8CCB6B0 /* TodayTasksWidgetSnapshotStore.swift in Sources */, 2E1D50F9EEE6B8D7D9B14AEA /* TodoListScreen.swift in Sources */, 51A46F8E627C26BF34E91F7B /* TodoListViewModel.swift in Sources */, C1B29C5E4D5D247E8D0B5C0C /* TodoRepository.swift in Sources */, @@ -792,9 +824,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 5; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -817,9 +852,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 5; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -981,10 +1019,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - A8BC3C28FBC8E4D5C38307DB /* Sentry */ = { + A8BC3C28FBC8E4D5C38307DB /* Sentry-Dynamic */ = { isa = XCSwiftPackageProductDependency; package = 25F077B213506555FEDF5480 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; - productName = Sentry; + productName = "Sentry-Dynamic"; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/ios-swiftUI/TdayWidget/TodayTasksWidget.swift b/ios-swiftUI/TdayWidget/TodayTasksWidget.swift index 196bea8b..f151ed62 100644 --- a/ios-swiftUI/TdayWidget/TodayTasksWidget.swift +++ b/ios-swiftUI/TdayWidget/TodayTasksWidget.swift @@ -5,23 +5,82 @@ import WidgetKit private struct TodayTasksEntry: TimelineEntry { let date: Date let title: String - let tasks: [String] + let taskCount: Int + let tasks: [TodayTaskSnapshot] +} + +private struct TodayTaskSnapshot: Codable, Identifiable { + let id: String + let title: String + let dueEpochMs: Int64 + let priority: String +} + +private struct TodayTasksSnapshot: Codable { + let generatedAtEpochMs: Int64 + let title: String + let taskCount: Int + let tasks: [TodayTaskSnapshot] } private struct TodayTasksProvider: TimelineProvider { func placeholder(in context: Context) -> TodayTasksEntry { - TodayTasksEntry(date: Date(), title: "Today", tasks: ["Open Tday on iPhone", "Finish widget App Group wiring"]) + TodayTasksEntry( + date: Date(), + title: "Today's Tasks", + taskCount: 2, + tasks: [ + TodayTaskSnapshot(id: "placeholder-1", title: "Plan the morning", dueEpochMs: Date().timeIntervalEpochMs, priority: "medium"), + TodayTaskSnapshot(id: "placeholder-2", title: "Review today", dueEpochMs: Date().addingTimeInterval(3_600).timeIntervalEpochMs, priority: "high") + ] + ) } func getSnapshot(in context: Context, completion: @escaping (TodayTasksEntry) -> Void) { - completion(placeholder(in: context)) + completion(loadEntry() ?? placeholder(in: context)) } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = placeholder(in: context) + let entry = loadEntry() ?? placeholder(in: context) let nextRefresh = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date().addingTimeInterval(1800) completion(Timeline(entries: [entry], policy: .after(nextRefresh))) } + + private func loadEntry() -> TodayTasksEntry? { + guard let snapshot = Self.loadSnapshot() else { + return nil + } + + return TodayTasksEntry( + date: Date(timeIntervalSince1970: TimeInterval(snapshot.generatedAtEpochMs) / 1_000), + title: snapshot.title, + taskCount: snapshot.taskCount, + tasks: snapshot.tasks + ) + } + + private static func loadSnapshot() -> TodayTasksSnapshot? { + for store in defaultsStores() { + guard let data = store.data(forKey: snapshotKey), + let snapshot = try? JSONDecoder().decode(TodayTasksSnapshot.self, from: data) else { + continue + } + return snapshot + } + return nil + } + + private static func defaultsStores() -> [UserDefaults] { + var stores = [UserDefaults]() + if let shared = UserDefaults(suiteName: appGroupSuiteName) { + stores.append(shared) + } + stores.append(.standard) + return stores + } + + private static let appGroupSuiteName = "group.com.ohmz.tday" + private static let snapshotKey = "tday.widget.todayTasksSnapshot" } private struct TodayTasksWidgetView: View { @@ -29,16 +88,75 @@ private struct TodayTasksWidgetView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(entry.title) - .font(.headline) - ForEach(entry.tasks.prefix(4), id: \.self) { task in - Label(task, systemImage: "circle") - .font(.caption) + HStack { + Text(entry.title) + .font(.headline) + Spacer() + Text("\(entry.taskCount)") + .font(.caption.bold()) + .foregroundStyle(.secondary) + } + + if entry.tasks.isEmpty { + Text("No tasks due today") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } else { + ForEach(entry.tasks.prefix(4)) { task in + if let url = Self.taskDeepLinkURL(for: task.id) { + Link(destination: url) { + taskRow(task) + } + .foregroundStyle(.primary) + } else { + taskRow(task) + } + } } Spacer(minLength: 0) } + .widgetURL(URL(string: "tday://todos/today")) .containerBackground(.fill.tertiary, for: .widget) } + + private func priorityColor(for priority: String) -> Color { + switch priority.lowercased() { + case "high": + return .red + case "medium": + return .orange + default: + return .secondary + } + } + + private static func dueTimeText(from epochMs: Int64) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(epochMs) / 1_000) + return date.formatted(date: .omitted, time: .shortened) + } + + private static func taskDeepLinkURL(for taskID: String) -> URL? { + var components = URLComponents() + components.scheme = "tday" + components.host = "todos" + components.path = "/all" + components.queryItems = [URLQueryItem(name: "highlightTodoId", value: taskID)] + return components.url + } + + private func taskRow(_ task: TodayTaskSnapshot) -> some View { + HStack(spacing: 6) { + Circle() + .fill(priorityColor(for: task.priority)) + .frame(width: 7, height: 7) + Text(task.title) + .lineLimit(1) + Spacer(minLength: 4) + Text(Self.dueTimeText(from: task.dueEpochMs)) + .foregroundStyle(.secondary) + } + .font(.caption) + } } struct TodayTasksWidget: Widget { @@ -47,11 +165,16 @@ struct TodayTasksWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: TodayTasksProvider()) { entry in TodayTasksWidgetView(entry: entry) - .widgetURL(URL(string: "tday://todos/today")) } .configurationDisplayName("Today's Tasks") .description("Shows the current Tday tasks for today.") .supportedFamilies([.systemSmall, .systemMedium]) } } + +private extension Date { + var timeIntervalEpochMs: Int64 { + Int64(timeIntervalSince1970 * 1_000) + } +} #endif diff --git a/ios-swiftUI/Tests/TdayCoreTests/ApiModelContractTests.swift b/ios-swiftUI/Tests/TdayCoreTests/ApiModelContractTests.swift new file mode 100644 index 00000000..a44b06af --- /dev/null +++ b/ios-swiftUI/Tests/TdayCoreTests/ApiModelContractTests.swift @@ -0,0 +1,135 @@ +import XCTest +@testable import Tday + +final class ApiModelContractTests: XCTestCase { + func testTodoDTOAcceptsSharedContractFields() throws { + let json = """ + { + "id": "todo-1", + "title": "Ship the thing", + "description": null, + "pinned": false, + "priority": "High", + "due": "2026-05-22T18:00:00Z", + "rrule": null, + "timeZone": "America/Toronto", + "instanceDate": null, + "completed": false, + "order": 4, + "listID": "list-1", + "userID": "user-1", + "updatedAt": "2026-05-22T17:30:00Z", + "createdAt": "2026-05-21T12:00:00Z" + } + """.data(using: .utf8)! + + let dto = try JSONDecoder().decode(TodoDTO.self, from: json) + + XCTAssertEqual(dto.timeZone, "America/Toronto") + XCTAssertEqual(dto.order, 4) + XCTAssertEqual(dto.userID, "user-1") + } + + func testSummaryResponseAcceptsFallbackOnlyContract() throws { + let json = """ + { + "summary": null, + "source": null, + "mode": "TODAY", + "taskCount": 0, + "generatedAt": null, + "fallbackReason": "disabled", + "reason": "AI summary is disabled" + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(TodoSummaryResponse.self, from: json) + + XCTAssertNil(response.summary) + XCTAssertEqual(response.fallbackReason, "disabled") + XCTAssertEqual(response.reason, "AI summary is disabled") + } + + func testListDeleteContractSupportsBulkPayload() throws { + let payload = DeleteListRequest(id: nil, ids: ["list-1", "list-2"]) + let data = try JSONEncoder().encode(payload) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + XCTAssertNil(object["id"]) + XCTAssertEqual(object["ids"] as? [String], ["list-1", "list-2"]) + } + + func testListDetailResponseAcceptsSharedContractShape() throws { + let json = """ + { + "list": { + "id": "list-1", + "name": "Home", + "color": "#3B82F6", + "todoCount": 1, + "iconKey": "home", + "userID": "user-1", + "updatedAt": null, + "createdAt": null + }, + "todos": [ + { + "id": "todo-1", + "title": "Take out trash", + "priority": "Low", + "due": "2026-05-22T18:00:00Z", + "completed": false, + "order": 0 + } + ] + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(ListDetailResponse.self, from: json) + + XCTAssertEqual(response.list.id, "list-1") + XCTAssertEqual(response.todos.first?.id, "todo-1") + } + + func testDeleteListResponseAcceptsDeletedIds() throws { + let json = """ + { + "message": "2 lists deleted", + "deletedIds": ["list-1", "list-2"] + } + """.data(using: .utf8)! + + let response = try JSONDecoder().decode(DeleteListResponse.self, from: json) + + XCTAssertEqual(response.message, "2 lists deleted") + XCTAssertEqual(response.deletedIds, ["list-1", "list-2"]) + } + + func testListResponsesDefaultMissingSharedArraysToEmpty() throws { + let detailData = """ + { + "list": { + "id": "list-1", + "name": "Home", + "color": null, + "todoCount": 0, + "iconKey": null, + "userID": null, + "updatedAt": null, + "createdAt": null + } + } + """.data(using: .utf8)! + let deleteData = """ + { + "message": "list deleted" + } + """.data(using: .utf8)! + + let detail = try JSONDecoder().decode(ListDetailResponse.self, from: detailData) + let delete = try JSONDecoder().decode(DeleteListResponse.self, from: deleteData) + + XCTAssertEqual(detail.todos, []) + XCTAssertEqual(delete.deletedIds, []) + } +} diff --git a/ios-swiftUI/Tests/TdayCoreTests/RealtimeClientTests.swift b/ios-swiftUI/Tests/TdayCoreTests/RealtimeClientTests.swift new file mode 100644 index 00000000..f423e794 --- /dev/null +++ b/ios-swiftUI/Tests/TdayCoreTests/RealtimeClientTests.swift @@ -0,0 +1,32 @@ +import XCTest + +#if SWIFT_PACKAGE +@testable import TdayCore +#else +@testable import Tday +#endif + +final class RealtimeClientTests: XCTestCase { + func testParsesBackendSerializedTodoDomainEvents() { + let payload = #"{"type":"com.ohmz.tday.domain.DomainEvent.TodoUpdated","todo":{"id":"todo-1"}}"# + let event = RealtimeEvent(name: RealtimeClient.eventName(from: payload), rawPayload: payload) + + XCTAssertEqual(event.name, "com.ohmz.tday.domain.DomainEvent.TodoUpdated") + XCTAssertTrue(event.requiresRefresh) + } + + func testParsesBackendSerializedListDomainEvents() { + let payload = #"{"type":"com.ohmz.tday.domain.DomainEvent.ListChanged","list":{"id":"list-1"}}"# + let event = RealtimeEvent(name: RealtimeClient.eventName(from: payload), rawPayload: payload) + + XCTAssertEqual(event.name, "com.ohmz.tday.domain.DomainEvent.ListChanged") + XCTAssertTrue(event.requiresRefresh) + } + + func testKeepsOldPlainEventNamesCompatible() { + let event = RealtimeEvent(name: RealtimeClient.eventName(from: "todo.created"), rawPayload: "todo.created") + + XCTAssertEqual(event.name, "todo.created") + XCTAssertTrue(event.requiresRefresh) + } +} diff --git a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift index 310082ee..b8014491 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift @@ -50,4 +50,41 @@ final class ServerURLPersistenceTests: XCTestCase { XCTAssertEqual(secureStore.loadPersistedServerURL(), url) XCTAssertNil(secureStore.loadLastEmail()) } + + func testSavedServerURLSuggestionSurvivesReinstallCleanup() { + let url = URL(string: "https://demo.tday.example")! + secureStore.saveServerURLSuggestion(url) + + secureStore.clearInstallScopedValuesIfAppReinstalled() + + XCTAssertEqual(secureStore.loadServerURLSuggestion(), url) + } + + func testReinstallCleanupClearsInstallScopedValues() { + let url = URL(string: "https://tday.ohmz.cloud")! + secureStore.savePersistedServerURL(url) + secureStore.saveLastEmail("user@example.com") + secureStore.savePersistedAuthSessionCookieData(Data("cookie".utf8)) + + secureStore.clearInstallScopedValuesIfAppReinstalled() + + XCTAssertNil(secureStore.loadPersistedServerURL()) + XCTAssertNil(secureStore.loadLastEmail()) + XCTAssertNil(secureStore.loadPersistedAuthSessionCookieData()) + } + + func testReinstallCleanupRunsOnlyOncePerInstall() { + let url = URL(string: "https://tday.ohmz.cloud")! + + secureStore.clearInstallScopedValuesIfAppReinstalled() + secureStore.savePersistedServerURL(url) + secureStore.saveLastEmail("user@example.com") + secureStore.savePersistedAuthSessionCookieData(Data("cookie".utf8)) + + secureStore.clearInstallScopedValuesIfAppReinstalled() + + XCTAssertEqual(secureStore.loadPersistedServerURL(), url) + XCTAssertEqual(secureStore.loadLastEmail(), "user@example.com") + XCTAssertEqual(secureStore.loadPersistedAuthSessionCookieData(), Data("cookie".utf8)) + } } diff --git a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift index 7c1801aa..45b10179 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift @@ -167,17 +167,22 @@ final class SystemCredentialLoginTests: XCTestCase { XCTAssertTrue(success) XCTAssertTrue(service.offeredCredentials.isEmpty) } + } @MainActor private final class FakeSystemCredentialService: SystemCredentialServicing { var requestCount = 0 + var serverURLRequestCount = 0 var offeredCredentials: [SystemCredential] = [] + var offeredServerURLs: [String] = [] var nextCredential: SystemCredential? + var nextServerURL: String? var saveResult: SystemCredentialSaveResult - init(nextCredential: SystemCredential? = nil, saveResult: SystemCredentialSaveResult = .saved) { + init(nextCredential: SystemCredential? = nil, nextServerURL: String? = nil, saveResult: SystemCredentialSaveResult = .saved) { self.nextCredential = nextCredential + self.nextServerURL = nextServerURL self.saveResult = saveResult } @@ -186,10 +191,21 @@ private final class FakeSystemCredentialService: SystemCredentialServicing { return nextCredential } + func requestSavedServerURL() async -> String? { + serverURLRequestCount += 1 + return nextServerURL + } + func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult { offeredCredentials.append(credential) return saveResult } + + func offerSaveOrUpdateServerURL(_ rawURL: String) async -> SystemCredentialSaveResult { + offeredServerURLs.append(rawURL) + return saveResult + } + } private final class FakeAuthRepository: AuthRepositoryServicing { diff --git a/ios-swiftUI/Tests/TdayCoreTests/TodayTasksWidgetSnapshotStoreTests.swift b/ios-swiftUI/Tests/TdayCoreTests/TodayTasksWidgetSnapshotStoreTests.swift new file mode 100644 index 00000000..70f1172c --- /dev/null +++ b/ios-swiftUI/Tests/TdayCoreTests/TodayTasksWidgetSnapshotStoreTests.swift @@ -0,0 +1,89 @@ +import XCTest +@testable import Tday + +final class TodayTasksWidgetSnapshotStoreTests: XCTestCase { + func testSnapshotIncludesOnlyPendingTasksDueToday() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = Date(timeIntervalSince1970: 1_764_072_600) + let startOfDay = calendar.startOfDay(for: now) + + let yesterday = startOfDay.addingTimeInterval(-60).epochMs + let dueSoon = startOfDay.addingTimeInterval(9 * 3_600).epochMs + let dueLater = startOfDay.addingTimeInterval(17 * 3_600).epochMs + let tomorrow = startOfDay.addingTimeInterval(24 * 3_600).epochMs + + let state = OfflineSyncState( + todos: [ + todo(id: "yesterday", title: "Yesterday", dueEpochMs: yesterday), + todo(id: "completed", title: "Completed", dueEpochMs: dueSoon, completed: true), + todo(id: "later", title: "Later", dueEpochMs: dueLater), + todo(id: "soon", title: "Soon", dueEpochMs: dueSoon), + todo(id: "tomorrow", title: "Tomorrow", dueEpochMs: tomorrow) + ] + ) + + let snapshot = TodayTasksWidgetSnapshotStore.makeSnapshot( + from: state, + now: now, + calendar: calendar + ) + + XCTAssertEqual(snapshot.title, "Today's Tasks") + XCTAssertEqual(snapshot.taskCount, 2) + XCTAssertEqual(snapshot.tasks.map(\.id), ["soon", "later"]) + } + + func testSnapshotCapsTasksForWidgetDisplay() { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + let now = Date(timeIntervalSince1970: 1_764_072_600) + let startOfDay = calendar.startOfDay(for: now) + let todos = (0..<10).map { index in + todo( + id: "task-\(index)", + title: "Task \(index)", + dueEpochMs: startOfDay.addingTimeInterval(TimeInterval(index * 600)).epochMs + ) + } + + let snapshot = TodayTasksWidgetSnapshotStore.makeSnapshot( + from: OfflineSyncState(todos: todos), + now: now, + calendar: calendar + ) + + XCTAssertEqual(snapshot.taskCount, 10) + XCTAssertEqual(snapshot.tasks.count, 8) + XCTAssertEqual(snapshot.tasks.first?.id, "task-0") + XCTAssertEqual(snapshot.tasks.last?.id, "task-7") + } + + private func todo( + id: String, + title: String, + dueEpochMs: Int64, + completed: Bool = false + ) -> CachedTodoRecord { + CachedTodoRecord( + id: id, + canonicalId: id, + title: title, + description: nil, + priority: "low", + dueEpochMs: dueEpochMs, + rrule: nil, + instanceDateEpochMs: nil, + pinned: false, + completed: completed, + listId: nil, + updatedAtEpochMs: dueEpochMs + ) + } +} + +private extension Date { + var epochMs: Int64 { + Int64(timeIntervalSince1970 * 1_000) + } +} diff --git a/ios-swiftUI/project.yml b/ios-swiftUI/project.yml index 8676b9c2..b8ed0617 100644 --- a/ios-swiftUI/project.yml +++ b/ios-swiftUI/project.yml @@ -21,16 +21,17 @@ targets: - Info.plist dependencies: - package: sentry-cocoa - product: Sentry + product: Sentry-Dynamic settings: base: PRODUCT_NAME: Tday PRODUCT_BUNDLE_IDENTIFIER: com.ohmz.tday.ios + DEVELOPMENT_TEAM: THT5Z8K3TF INFOPLIST_FILE: Tday/Info.plist CODE_SIGN_ENTITLEMENTS: Tday/Tday.entitlements GENERATE_INFOPLIST_FILE: NO SWIFT_VERSION: 5.0 - CURRENT_PROJECT_VERSION: 5 + CURRENT_PROJECT_VERSION: 6 MARKETING_VERSION: 1.23.0 TARGETED_DEVICE_FAMILY: 1 SUPPORTS_MACCATALYST: NO diff --git a/tday-backend/.env.example b/tday-backend/.env.example index d4cbff10..ce9b2111 100644 --- a/tday-backend/.env.example +++ b/tday-backend/.env.example @@ -82,5 +82,5 @@ TDAY_PROBE_ENCRYPTION_KEY= # ======================== # Apple Developer Team ID used for Tday's canonical Apple Passwords webcredentials payload. # Native iOS saves Tday credentials under tday.ohmz.cloud regardless of the connected server URL. -APPLE_TEAM_ID= +APPLE_TEAM_ID=THT5Z8K3TF IOS_BUNDLE_ID=com.ohmz.tday.ios