From 906cf0a6f9d2e69ce28e9c6088498e4fde31554a Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 14:09:00 -0400 Subject: [PATCH 01/26] Fix mobile realtime refresh parsing --- .../compose/core/network/RealtimeClient.kt | 49 ++++++++++++++----- .../core/network/RealtimeClientTest.kt | 40 +++++++++++++++ .../Tday/Core/Network/RealtimeClient.swift | 41 ++++++++++++---- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 ++ .../TdayCoreTests/RealtimeClientTests.swift | 32 ++++++++++++ 5 files changed, 144 insertions(+), 22 deletions(-) create mode 100644 android-compose/app/src/test/java/com/ohmz/tday/compose/core/network/RealtimeClientTest.kt create mode 100644 ios-swiftUI/Tests/TdayCoreTests/RealtimeClientTests.swift 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/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/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/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 71597654..4b15d98d 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -31,6 +31,7 @@ 3E0BE8F327DB1A2EE5B101C4 /* ReminderPreferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */; }; 4A7B9C0D1E2F3456789ABC01 /* CompletedSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */; }; 4D2A9B7E2CE3424C9D111001 /* ConnectivityClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.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 */; }; @@ -101,6 +102,7 @@ 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 = ""; }; + 4F52544D434C49454E543032 /* RealtimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeClientTests.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 = ""; }; @@ -212,6 +214,7 @@ children = ( 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */, 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */, + 4F52544D434C49454E543032 /* RealtimeClientTests.swift */, D206496BC88FF6272CFC286C /* ServerURLPersistenceTests.swift */, 19F61DADBD9B8EFC300698C4 /* SystemCredentialLoginTests.swift */, ); @@ -643,6 +646,7 @@ files = ( 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 */, ); 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) + } +} From a3a3c66e6dd26af06feacd6ba91d785dd7f857f4 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 14:16:16 -0400 Subject: [PATCH 02/26] Fix mobile reminder deep links --- .../com/ohmz/tday/compose/MainActivity.kt | 24 ++- ios-swiftUI/README.md | 2 +- .../Core/Data/Cache/OfflineCacheManager.swift | 1 + .../NotificationDeepLinkRouter.swift | 51 +++++++ .../Notification/TaskReminderScheduler.swift | 11 +- .../TodayTasksWidgetSnapshotStore.swift | 95 ++++++++++++ .../Tday/Feature/App/AppRootView.swift | 13 ++ ios-swiftUI/Tday/TdayApp.swift | 1 + ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 20 +++ ios-swiftUI/TdayWidget/TodayTasksWidget.swift | 143 ++++++++++++++++-- .../TodayTasksWidgetSnapshotStoreTests.swift | 89 +++++++++++ 11 files changed, 435 insertions(+), 15 deletions(-) create mode 100644 ios-swiftUI/Tday/Core/Notification/NotificationDeepLinkRouter.swift create mode 100644 ios-swiftUI/Tday/Core/Widget/TodayTasksWidgetSnapshotStore.swift create mode 100644 ios-swiftUI/Tests/TdayCoreTests/TodayTasksWidgetSnapshotStoreTests.swift 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/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/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/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/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 4b15d98d..ac029342 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -27,11 +27,13 @@ 2E1D50F9EEE6B8D7D9B14AEA /* TodoListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AD2C2DD2233FCBB4611E95 /* TodoListScreen.swift */; }; 3172D88F15D3395465E578E3 /* TaskReminderScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7ACBCBA57903A93BEC3BFA /* TaskReminderScheduler.swift */; }; 3667E1D45490DE558553F39F /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC35A6A50C3BFA68468FEDF9 /* OfflineBanner.swift */; }; + 4E4F544946444545504C3031 /* NotificationDeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.swift */; }; 384B88FF643D87A6157C76C2 /* Nunito.ttf in Resources */ = {isa = PBXBuildFile; fileRef = D9D2A99D6C098B63352D4FB8 /* Nunito.ttf */; }; 3E0BE8F327DB1A2EE5B101C4 /* ReminderPreferenceStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */; }; 4A7B9C0D1E2F3456789ABC01 /* CompletedSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */; }; 4D2A9B7E2CE3424C9D111001 /* ConnectivityClassificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */; }; 4F52544D434C49454E543031 /* RealtimeClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F52544D434C49454E543032 /* RealtimeClientTests.swift */; }; + 494E748270A233BECED5A359 /* TodayTasksWidgetSnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC92D6152EADEDF915AE116B /* TodayTasksWidgetSnapshotStoreTests.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 */; }; @@ -44,6 +46,7 @@ 5D93F6903E05BD9902C43A51 /* SystemCredentialService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC4FF9081195466EE7C3E89 /* SystemCredentialService.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 */; }; @@ -116,6 +119,7 @@ 42749601AFF38EAE17BD3213 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletedSyncMergeTests.swift; sourceTree = ""; }; 53EFEAE1EE18AB86477A56BC /* CalendarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewModel.swift; sourceTree = ""; }; + 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationDeepLinkRouter.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 = ""; }; @@ -149,6 +153,7 @@ AF0DB445B9FEE9B047FBB708 /* TodoListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoListViewModel.swift; sourceTree = ""; }; B3F7D3043A1EFC1A4EBBDA02 /* Tday.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tday.entitlements; sourceTree = ""; }; C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderPreferenceStore.swift; sourceTree = ""; }; + CC80072C8B232C0C09CE3139 /* TodayTasksWidgetSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTasksWidgetSnapshotStore.swift; sourceTree = ""; }; C9D2CB40ACEE85331512EEC2 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.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 = ""; }; @@ -168,6 +173,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 */ @@ -217,6 +223,7 @@ 4F52544D434C49454E543032 /* RealtimeClientTests.swift */, D206496BC88FF6272CFC286C /* ServerURLPersistenceTests.swift */, 19F61DADBD9B8EFC300698C4 /* SystemCredentialLoginTests.swift */, + FC92D6152EADEDF915AE116B /* TodayTasksWidgetSnapshotStoreTests.swift */, ); path = TdayCoreTests; sourceTree = ""; @@ -265,6 +272,14 @@ path = Security; sourceTree = ""; }; + 5477B1D0058C97A680F19FA5 /* Widget */ = { + isa = PBXGroup; + children = ( + CC80072C8B232C0C09CE3139 /* TodayTasksWidgetSnapshotStore.swift */, + ); + path = Widget; + sourceTree = ""; + }; 52B2765D1F90C3C5C936DBD0 /* Auth */ = { isa = PBXGroup; children = ( @@ -320,6 +335,7 @@ 72FDFB18FAE7DA7B2B569510 /* Notification */ = { isa = PBXGroup; children = ( + 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.swift */, 2FBC41C56EEEFC8869E762B3 /* ReminderOption.swift */, C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */, 5D7ACBCBA57903A93BEC3BFA /* TaskReminderScheduler.swift */, @@ -517,6 +533,7 @@ 72FDFB18FAE7DA7B2B569510 /* Notification */, 4E5771B69142214019F82B21 /* Security */, DF657ECAF439ED3F03187CCE /* UI */, + 5477B1D0058C97A680F19FA5 /* Widget */, ); path = Core; sourceTree = ""; @@ -649,6 +666,7 @@ 4F52544D434C49454E543031 /* RealtimeClientTests.swift in Sources */, 03A0CCC6A8AAE2DB065E6DE6 /* ServerURLPersistenceTests.swift in Sources */, C8B96DDF0DC7AD0B8DD54E4F /* SystemCredentialLoginTests.swift in Sources */, + 494E748270A233BECED5A359 /* TodayTasksWidgetSnapshotStoreTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -684,6 +702,7 @@ 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 */, @@ -712,6 +731,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 */, 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/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) + } +} From 30a6113642754518cdc79a9bbc61070a90106f88 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 14:23:09 -0400 Subject: [PATCH 03/26] Refine Android calendar paging --- .../feature/calendar/CalendarScreen.kt | 580 ++++++++++++------ 1 file changed, 377 insertions(+), 203 deletions(-) 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..325f0447 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,9 @@ 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.HorizontalPager +import androidx.compose.foundation.pager.PagerState +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 +105,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 +112,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 +168,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 +262,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 +282,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 +329,11 @@ fun CalendarScreen( onBack = onBack, collapseProgress = collapseProgress, onJumpToday = { - selectDate(LocalDate.now(zoneId)) + todayJumpRequestId += 1 + todayJumpRequest = CalendarTodayJumpRequest( + id = todayJumpRequestId, + targetDate = LocalDate.now(zoneId), + ) }, ) }, @@ -405,6 +411,8 @@ fun CalendarScreen( selectedDate = selectedDate, today = today, tasksByDate = tasksByDate, + todayJumpRequest = todayJumpRequest, + onTodayJumpHandled = ::clearTodayJumpRequest, onPrevMonth = { if (visibleMonth > minNavigableMonth) { visibleMonthIso = visibleMonth.minusMonths(1).toString() @@ -421,6 +429,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 +442,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, ) } } @@ -552,6 +566,22 @@ private enum class CalendarViewMode { DAY, } +private data class CalendarTodayJumpRequest( + val id: Int, + val targetDate: LocalDate, +) + +private enum class CalendarPagerSlot { + PREVIOUS, + CURRENT, + NEXT, +} + +private data class CalendarPagerPage( + val slot: CalendarPagerSlot, + val value: T, +) + @Composable private fun CalendarViewModeTabs( selectedMode: CalendarViewMode, @@ -569,60 +599,147 @@ private fun CalendarViewModeTabs( ) } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private 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) + } + } +} + +private fun List>.indexOfSlot(slot: CalendarPagerSlot): Int = + indexOfFirst { it.slot == slot } + @Composable private fun CalendarWeekCard( selectedDate: LocalDate, 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 +765,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 +784,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 +811,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 +834,7 @@ private fun CalendarWeekDayCell( taskCount: Int, isSelected: Boolean, isToday: Boolean, + isEnabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -757,7 +863,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 +878,7 @@ private fun CalendarWeekDayCell( color = borderColor, ), elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + enabled = isEnabled, onClick = onClick, ) { Column( @@ -783,7 +891,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 +922,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 +1039,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 +1058,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 +1288,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 +1408,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 +1428,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 +1451,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( From b2075e5f6b305fa734e79a1cc148feb1d19405ec Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 14:25:53 -0400 Subject: [PATCH 04/26] Extract mobile calendar pager helpers --- .../compose/feature/calendar/CalendarPager.kt | 71 +++++++ .../feature/calendar/CalendarScreen.kt | 60 ------ .../Calendar/CalendarPagingScrollView.swift | 184 ++++++++++++++++++ .../Feature/Calendar/CalendarScreen.swift | 182 ----------------- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 + 5 files changed, 259 insertions(+), 242 deletions(-) create mode 100644 android-compose/app/src/main/java/com/ohmz/tday/compose/feature/calendar/CalendarPager.kt create mode 100644 ios-swiftUI/Tday/Feature/Calendar/CalendarPagingScrollView.swift 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 325f0447..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 @@ -41,8 +41,6 @@ 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.HorizontalPager -import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -566,22 +564,6 @@ private enum class CalendarViewMode { DAY, } -private data class CalendarTodayJumpRequest( - val id: Int, - val targetDate: LocalDate, -) - -private enum class CalendarPagerSlot { - PREVIOUS, - CURRENT, - NEXT, -} - -private data class CalendarPagerPage( - val slot: CalendarPagerSlot, - val value: T, -) - @Composable private fun CalendarViewModeTabs( selectedMode: CalendarViewMode, @@ -599,48 +581,6 @@ private fun CalendarViewModeTabs( ) } -@OptIn(ExperimentalFoundationApi::class) -@Composable -private 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) - } - } -} - -private fun List>.indexOfSlot(slot: CalendarPagerSlot): Int = - indexOfFirst { it.slot == slot } - @Composable private fun CalendarWeekCard( selectedDate: LocalDate, 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/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index ac029342..b1b892c9 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 58BD5E982FB87AE6986E3E79 /* CreateTodoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 718528F64C339E527223D93F /* CreateTodoUseCase.swift */; }; 58EB6EF803693EB982E331E4 /* SyncAndRefreshUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0FD6CA5EE999F99D54CF94 /* SyncAndRefreshUseCase.swift */; }; 5B82443B89507719EDD7215C /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF6FD5D2845A10D7052467D /* AppContainer.swift */; }; + 5DB03218BE341B7C186F01AE /* CalendarPagingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E5A511171F96ABDEC46926 /* CalendarPagingScrollView.swift */; }; 5D93F6903E05BD9902C43A51 /* SystemCredentialService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC4FF9081195466EE7C3E89 /* SystemCredentialService.swift */; }; 64E0A2B205D80764F2BF52FB /* CalendarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EFEAE1EE18AB86477A56BC /* CalendarViewModel.swift */; }; 701E03BE9BBC8792CAD5919C /* CreateTaskSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFA6E39FBCB063E54C61AF7 /* CreateTaskSheet.swift */; }; @@ -140,6 +141,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 = ""; }; @@ -387,6 +389,7 @@ 867DC72A3F5B3C04E5C36879 /* Calendar */ = { isa = PBXGroup; children = ( + 95E5A511171F96ABDEC46926 /* CalendarPagingScrollView.swift */, 25A90A443440754855071C9D /* CalendarScreen.swift */, 53EFEAE1EE18AB86477A56BC /* CalendarViewModel.swift */, ); @@ -696,6 +699,7 @@ 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 */, From e9865632af238c2500fee9a6b8d2b206834bdf38 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 14:31:33 -0400 Subject: [PATCH 05/26] Align mobile contracts and sync fetch --- .../compose/core/data/sync/SyncManager.kt | 60 ++++---- .../ohmz/tday/compose/core/model/ApiModels.kt | 2 + .../compose/core/network/TdayApiService.kt | 6 +- .../Tday/Core/Data/Todo/TodoRepository.swift | 8 +- ios-swiftUI/Tday/Core/Model/ApiModels.swift | 69 ++++++++- .../Tday/Core/Network/TdayAPIService.swift | 10 +- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 + .../TdayCoreTests/ApiModelContractTests.swift | 135 ++++++++++++++++++ 8 files changed, 260 insertions(+), 34 deletions(-) create mode 100644 ios-swiftUI/Tests/TdayCoreTests/ApiModelContractTests.swift 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/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/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/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/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index b1b892c9..6f19c71f 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -62,6 +62,7 @@ A03B553E316CEEDFAB8EBA69 /* OfflineSyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB49A43AFEE66EEDB3315F86 /* OfflineSyncModels.swift */; }; A316B4B3BB1AEA5FDF9998DF /* AppRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BFFE8167D3639FF09C033A2 /* AppRootView.swift */; }; A7319C922AE54DC944A9ECC5 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E5E1DBE3F7999D219BE2EE /* AuthRepository.swift */; }; + B4995200636F408E4296FF0D /* ApiModelContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629820F1BA29236313992076 /* ApiModelContractTests.swift */; }; B34A31B1812FC676A3AC1871 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DCE08ADACC8F11CE2113C13B /* Assets.xcassets */; }; B5D28682AD0623E39CCD4F8E /* CompleteTodoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70F09E74879D0AAABCF70E20 /* CompleteTodoUseCase.swift */; }; B648A67EAD215BE5B74F7460 /* ApiModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6049166B683EE082F8C551DD /* ApiModels.swift */; }; @@ -124,6 +125,7 @@ 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 = ""; }; @@ -220,6 +222,7 @@ 0887DFEE17FD8E0DCDABDD10 /* TdayCoreTests */ = { isa = PBXGroup; children = ( + 629820F1BA29236313992076 /* ApiModelContractTests.swift */, 4A7B9C0D1E2F3456789ABC02 /* CompletedSyncMergeTests.swift */, 4D2A9B7E2CE3424C9D111002 /* ConnectivityClassificationTests.swift */, 4F52544D434C49454E543032 /* RealtimeClientTests.swift */, @@ -664,6 +667,7 @@ 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 */, 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, []) + } +} From f02452ecf3754e442e52f66e9de598cbd7c37c01 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 15:20:04 -0400 Subject: [PATCH 06/26] Remember mobile server URL credentials --- .../java/com/ohmz/tday/compose/TdayApp.kt | 6 +- .../core/data/auth/SystemCredentialService.kt | 90 ++++++++++- .../data/server/ServerConfigRepository.kt | 2 +- .../tday/compose/feature/app/AppViewModel.kt | 4 +- .../compose/feature/auth/AuthViewModel.kt | 18 +++ .../onboarding/OnboardingWizardOverlay.kt | 48 +++++- .../data/auth/SystemCredentialRecordsTest.kt | 50 +++++++ .../compose/feature/auth/AuthViewModelTest.kt | 26 ++++ .../Data/Auth/SystemCredentialService.swift | 140 +++++++++++++++--- .../Tday/Feature/App/AppViewModel.swift | 11 +- .../Onboarding/OnboardingWizardOverlay.swift | 39 ++++- .../SystemCredentialLoginTests.swift | 38 +++++ 12 files changed, 432 insertions(+), 40 deletions(-) create mode 100644 android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialRecordsTest.kt diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt index 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..ad06145f 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 @@ -43,6 +43,12 @@ interface SystemCredentialServicing { credential: SystemCredential, ): SystemCredentialSaveResult + suspend fun requestSavedServerUrl(context: Context): String? + suspend fun offerSaveOrUpdateServerUrl( + context: Context, + serverUrl: String, + ): SystemCredentialSaveResult + suspend fun clearCredentialState() } @@ -65,8 +71,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 +117,67 @@ 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(isAutoSelectAllowed = true), + ), + ) + + 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 +192,25 @@ 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() + 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/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..5a05006d 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 @@ -195,6 +195,24 @@ class AuthViewModel @Inject constructor( suspend fun requestSavedCredential(context: Context): SystemCredential? = systemCredentialService.requestSavedCredential(context) + 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) { snackbarManager.showError( 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..809bb91f 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,7 @@ 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.launch private enum class WizardStep { SERVER, @@ -105,7 +107,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: ( @@ -115,6 +117,8 @@ fun OnboardingWizardOverlay( onSuccess: () -> Unit, ) -> Unit, onRequestSavedCredential: suspend (Context) -> SystemCredential?, + onRequestSavedServerUrl: suspend (Context) -> String?, + onSaveServerUrlCredential: suspend (Context, String) -> Unit, onClearAuthStatus: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -122,6 +126,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,6 +144,7 @@ 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) } val passwordFocusRequester = remember { FocusRequester() } val registerPasswordFocusRequester = remember { FocusRequester() } val registerConfirmFocusRequester = remember { FocusRequester() } @@ -152,12 +158,17 @@ fun OnboardingWizardOverlay( serverError = null isConnecting = true onConnectServer(value) { result -> - isConnecting = false result.onSuccess { - step = WizardStep.LOGIN - authMode = AuthPanelMode.SIGN_IN - onClearAuthStatus() + serverUrl = it + coroutineScope.launch { + onSaveServerUrlCredential(context, it) + isConnecting = false + step = WizardStep.LOGIN + authMode = AuthPanelMode.SIGN_IN + onClearAuthStatus() + } }.onFailure { error -> + isConnecting = false step = WizardStep.SERVER serverError = onboardingServerErrorMessage( error = error, @@ -261,6 +272,22 @@ fun OnboardingWizardOverlay( } } + LaunchedEffect(step, isConnecting, serverUrl) { + if (step != WizardStep.SERVER || + isConnecting || + serverUrl.isNotBlank() || + hasRequestedSavedServerUrl + ) { + return@LaunchedEffect + } + + hasRequestedSavedServerUrl = true + onRequestSavedServerUrl(context)?.let { savedServerUrl -> + serverUrl = savedServerUrl + serverError = null + } + } + LaunchedEffect(step, authMode, authUiState.isLoading) { if (step != WizardStep.LOGIN || authMode != AuthPanelMode.SIGN_IN) return@LaunchedEffect credentialCoordinator.requestSavedCredentialIfAvailable( @@ -425,11 +452,16 @@ fun OnboardingWizardOverlay( serverError = null isConnecting = true onConnectServer(value) { connectResult -> - isConnecting = false connectResult.onSuccess { - step = WizardStep.LOGIN - onClearAuthStatus() + serverUrl = it + coroutineScope.launch { + onSaveServerUrlCredential(context, it) + isConnecting = false + step = WizardStep.LOGIN + onClearAuthStatus() + } }.onFailure { error -> + isConnecting = false step = WizardStep.SERVER serverError = onboardingServerErrorMessage( error = error, 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..1b909843 --- /dev/null +++ b/android-compose/app/src/test/java/com/ohmz/tday/compose/core/data/auth/SystemCredentialRecordsTest.kt @@ -0,0 +1,50 @@ +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 record ignores normal password records`() { + val serverUrl = SystemCredentialRecords.serverUrl( + id = "user@example.com", + password = "Password!1", + ) + + assertNull(serverUrl) + } + + @Test + fun `server url record 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/feature/auth/AuthViewModelTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/AuthViewModelTest.kt index 835d1f2c..99e233f0 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 system credential 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,6 +189,8 @@ class MainDispatcherRule( private class FakeSystemCredentialService : SystemCredentialServicing { val savedCredentials = mutableListOf() + val savedServerUrls = mutableListOf() + var savedServerUrl: String? = null override suspend fun requestSavedCredential(context: Context): SystemCredential? = null @@ -188,9 +202,21 @@ private class FakeSystemCredentialService : SystemCredentialServicing { return SystemCredentialSaveResult.SAVED } + override suspend fun requestSavedServerUrl(context: Context): String? = savedServerUrl + + 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() + savedServerUrl = null } } diff --git a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift index 18c43ad7..85641165 100644 --- a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift +++ b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift @@ -33,12 +33,37 @@ enum LoginCredentialSource { protocol SystemCredentialServicing: AnyObject { func requestSavedCredential() async -> SystemCredential? func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult + func requestSavedServerURL() async -> String? + func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult } enum SystemCredentialScope { static let appCredentialHost = "tday.ohmz.cloud" } +enum SystemCredentialRecord { + static let serverURLUser = "T'Day Server URL" + + static func loginCredential(user: String, password: String) -> SystemCredential? { + let normalizedUser = user.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedUser != serverURLUser, + !normalizedUser.isEmpty, + !password.isEmpty else { + return nil + } + + return SystemCredential(email: normalizedUser, password: password) + } + + static func serverURL(user: String, password: String) -> String? { + guard user.trimmingCharacters(in: .whitespacesAndNewlines) == serverURLUser else { + return nil + } + + return password.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } +} + @MainActor final class SystemCredentialService: SystemCredentialServicing { private var activeAuthorizationSession: PasswordAuthorizationSession? @@ -52,20 +77,71 @@ final class SystemCredentialService: SystemCredentialServicing { } func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult { - guard !credential.email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let normalizedEmail = credential.email.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedEmail.isEmpty, !credential.password.isEmpty else { return .skipped } + return await savePasswordRecord( + user: normalizedEmail, + password: credential.password, + title: "Tday", + failurePurpose: "login" + ) + } + + func requestSavedServerURL() async -> String? { + let session = PasswordAuthorizationSession() + activeAuthorizationSession = session + let serverURL = await session.requestSavedServerURL() + activeAuthorizationSession = nil + return serverURL + } + + func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult { + let normalizedServerURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedServerURL.isEmpty else { + return .skipped + } + + return await savePasswordRecord( + user: SystemCredentialRecord.serverURLUser, + password: normalizedServerURL, + title: "Tday Server", + failurePurpose: "server URL" + ) + } + + private func savePasswordRecord( + user: String, + password: String, + title: String, + failurePurpose: String + ) async -> SystemCredentialSaveResult { if #available(iOS 26.2, *) { - return await saveWithCredentialDataManager(credential) - } else { - return await saveWithSharedWebCredential(credential) + return await saveWithCredentialDataManager( + user: user, + password: password, + title: title, + failurePurpose: failurePurpose + ) } + + return await saveWithSharedWebCredential( + user: user, + password: password, + failurePurpose: failurePurpose + ) } @available(iOS 26.2, *) - private func saveWithCredentialDataManager(_ credential: SystemCredential) async -> SystemCredentialSaveResult { + private func saveWithCredentialDataManager( + user: String, + password: String, + title: String, + failurePurpose: String + ) async -> SystemCredentialSaveResult { let host = SystemCredentialScope.appCredentialHost let scope = ASAutoFillURLScope( scheme: .https, @@ -73,13 +149,13 @@ final class SystemCredentialService: SystemCredentialServicing { port: nil, path: "" ) - let passwordCredential = ASPasswordCredential(user: credential.email, password: credential.password) + let passwordCredential = ASPasswordCredential(user: user, password: password) do { try await ASCredentialDataManager().save( password: passwordCredential, for: scope, - title: "Tday", + title: title, anchor: PasswordAuthorizationSession.presentationAnchor() ) return .saved @@ -89,17 +165,21 @@ final class SystemCredentialService: SystemCredentialServicing { nsError.code == ASAuthorizationError.canceled.rawValue { return .cancelled } - return .failed("Apple Passwords could not save this Tday login. Check that \(host) is associated with the Tday iOS app.") + return .failed("Apple Passwords could not save this Tday \(failurePurpose). Check that \(host) is associated with the Tday iOS app.") } } - private func saveWithSharedWebCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult { + private func saveWithSharedWebCredential( + user: String, + password: String, + failurePurpose: String + ) async -> SystemCredentialSaveResult { let host = SystemCredentialScope.appCredentialHost return await withCheckedContinuation { (continuation: CheckedContinuation) in SecAddSharedWebCredential( host as CFString, - credential.email as CFString, - credential.password as CFString + user as CFString, + password as CFString ) { error in guard let error else { continuation.resume(returning: .saved) @@ -112,7 +192,7 @@ final class SystemCredentialService: SystemCredentialServicing { return } - continuation.resume(returning: .failed("Apple Passwords could not save this Tday login. Check that \(host) is associated with the Tday iOS app.")) + continuation.resume(returning: .failed("Apple Passwords could not save this Tday \(failurePurpose). Check that \(host) is associated with the Tday iOS app.")) } } } @@ -121,6 +201,7 @@ final class SystemCredentialService: SystemCredentialServicing { @MainActor private final class PasswordAuthorizationSession: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { private var continuation: CheckedContinuation? + private var serverURLContinuation: CheckedContinuation? private var controller: ASAuthorizationController? func requestSavedCredential() async -> SystemCredential? { @@ -137,17 +218,34 @@ private final class PasswordAuthorizationSession: NSObject, ASAuthorizationContr } } + func requestSavedServerURL() async -> String? { + await withCheckedContinuation { continuation in + self.serverURLContinuation = continuation + + let provider = ASAuthorizationPasswordProvider() + let request = provider.createRequest() + let controller = ASAuthorizationController(authorizationRequests: [request]) + self.controller = controller + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests(options: [.preferImmediatelyAvailableCredentials]) + } + } + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { let credential = authorization.credential as? ASPasswordCredential finish( - credential.map { - SystemCredential(email: $0.user, password: $0.password) + loginCredential: credential.flatMap { + SystemCredentialRecord.loginCredential(user: $0.user, password: $0.password) + }, + serverURL: credential.flatMap { + SystemCredentialRecord.serverURL(user: $0.user, password: $0.password) } ) } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - finish(nil) + finish(loginCredential: nil, serverURL: nil) } func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { @@ -165,9 +263,17 @@ private final class PasswordAuthorizationSession: NSObject, ASAuthorizationContr return UIWindow(frame: UIScreen.main.bounds) } - private func finish(_ credential: SystemCredential?) { + private func finish(loginCredential: SystemCredential?, serverURL: String?) { controller = nil - continuation?.resume(returning: credential) + continuation?.resume(returning: loginCredential) continuation = nil + serverURLContinuation?.resume(returning: serverURL) + serverURLContinuation = nil + } +} + +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self } } diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index de79456b..d6e6ae2d 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -179,7 +179,7 @@ final class AppViewModel { await bootstrap() } - func connectServer(rawURL: String) async -> Result { + func connectServer(rawURL: String) async -> Result { do { let probeResult = try await container.serverConfigRepository.probeAndSave(rawURL) serverURL = probeResult.serverURL @@ -190,7 +190,7 @@ final class AppViewModel { requiresLogin = !isBlocking error = nil canResetServerTrust = true - return .success(()) + return .success(probeResult.serverURL) } catch { let msg = serverConnectionMessage(for: error) self.error = msg @@ -212,14 +212,15 @@ final class AppViewModel { } } - func resetTrustedServer(rawURL: String) async -> Result { + 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 - return .success(()) + return .success(savedServerURL) } catch { let msg = serverConnectionMessage(for: error) self.error = msg diff --git a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift index 99b70fce..87e16991 100644 --- a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift +++ b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift @@ -27,8 +27,8 @@ struct OnboardingWizardOverlay: View { let pendingApprovalMessage: String? let authViewModel: AuthViewModel let systemCredentialService: SystemCredentialServicing - let onConnectServer: (String) async -> Result - let onResetServerTrust: (String) async -> Result + let onConnectServer: (String) async -> Result + let onResetServerTrust: (String) async -> Result let onLogin: (String, String, LoginCredentialSource) async -> Bool let onRegister: (String, String, String) async -> Bool let onClearAuthStatus: () -> Void @@ -46,6 +46,7 @@ struct OnboardingWizardOverlay: View { @State private var isConnecting = false @State private var isCompletingAuthentication = false @State private var credentialCoordinator = LoginCredentialCoordinator() + @State private var hasRequestedSavedServerURL = false var body: some View { ZStack { @@ -69,11 +70,14 @@ struct OnboardingWizardOverlay: View { serverURL = initialServerURL ?? "" email = authViewModel.savedEmail step = (initialServerURL?.isEmpty == false) ? .login : .server + requestSavedServerURLIfAvailable() requestSavedCredentialIfAvailable() } .onChange(of: step) { _, newStep in if newStep == .login { requestSavedCredentialIfAvailable() + } else { + requestSavedServerURLIfAvailable() } } .onChange(of: isCreatingAccount) { _, creatingAccount in @@ -195,6 +199,7 @@ struct OnboardingWizardOverlay: View { title: "Server URL", text: $serverURL, keyboardType: .URL, + textContentType: .URL, autocapitalization: .never, disableAutocorrection: true, submitLabel: .go, @@ -387,7 +392,9 @@ struct OnboardingWizardOverlay: View { let result = await onConnectServer(serverURL) isConnecting = false switch result { - case .success: + case let .success(savedServerURL): + serverURL = savedServerURL + await saveServerURLCredential(savedServerURL) step = .login case let .failure(error): localError = error.message @@ -401,13 +408,37 @@ struct OnboardingWizardOverlay: View { let result = await onResetServerTrust(serverURL) isConnecting = false switch result { - case .success: + case let .success(savedServerURL): + serverURL = savedServerURL + await saveServerURLCredential(savedServerURL) step = .login case let .failure(error): localError = error.message } } + private func requestSavedServerURLIfAvailable() { + guard step == .server, + serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !isConnecting, + !hasRequestedSavedServerURL else { + return + } + + hasRequestedSavedServerURL = true + Task { @MainActor in + guard let savedServerURL = await systemCredentialService.requestSavedServerURL() else { + return + } + serverURL = savedServerURL + localError = nil + } + } + + private func saveServerURLCredential(_ savedServerURL: String) async { + _ = await systemCredentialService.offerSaveOrUpdateServerURL(savedServerURL) + } + private func requestSavedCredentialIfAvailable() { guard isLoginStep, !isCompletingAuthentication else { return diff --git a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift index 7c1801aa..012381ec 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift @@ -167,13 +167,42 @@ final class SystemCredentialLoginTests: XCTestCase { XCTAssertTrue(success) XCTAssertTrue(service.offeredCredentials.isEmpty) } + + func testLoginCredentialRecordIgnoresServerURLRecord() { + let credential = SystemCredentialRecord.loginCredential( + user: SystemCredentialRecord.serverURLUser, + password: "https://tday.example.com" + ) + + XCTAssertNil(credential) + } + + func testServerURLRecordIgnoresLoginCredentialRecord() { + let serverURL = SystemCredentialRecord.serverURL( + user: "user@example.com", + password: "Password!1" + ) + + XCTAssertNil(serverURL) + } + + func testServerURLRecordTrimsSavedURL() { + let serverURL = SystemCredentialRecord.serverURL( + user: SystemCredentialRecord.serverURLUser, + password: " https://tday.example.com " + ) + + XCTAssertEqual(serverURL, "https://tday.example.com") + } } @MainActor private final class FakeSystemCredentialService: SystemCredentialServicing { var requestCount = 0 var offeredCredentials: [SystemCredential] = [] + var offeredServerURLs: [String] = [] var nextCredential: SystemCredential? + var nextServerURL: String? var saveResult: SystemCredentialSaveResult init(nextCredential: SystemCredential? = nil, saveResult: SystemCredentialSaveResult = .saved) { @@ -190,6 +219,15 @@ private final class FakeSystemCredentialService: SystemCredentialServicing { offeredCredentials.append(credential) return saveResult } + + func requestSavedServerURL() async -> String? { + return nextServerURL + } + + func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult { + offeredServerURLs.append(serverURL) + return saveResult + } } private final class FakeAuthRepository: AuthRepositoryServicing { From e946caf79911174972640e7ae0803069d5461fc6 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 15:44:00 -0400 Subject: [PATCH 07/26] Set the development team and reorder file references in the iOS Xcode project. This update explicitly configures the development team ID for the primary build targets and performs a non-functional cleanup of the project file by reordering various source and resource references to maintain alphabetical consistency. - **Project Configuration**: - Set `DEVELOPMENT_TEAM` to `JUFACN2FS3` for both Debug and Release configurations. - **Build System Clean-up**: - Alphabetized entries in `PBXBuildFile`, `PBXFileReference`, and `PBXGroup` sections to resolve minor ordering inconsistencies. - Specifically reordered references for `NotificationDeepLinkRouter.swift`, `TodayTasksWidgetSnapshotStoreTests.swift`, `CalendarPagingScrollView.swift`, and other internal source files. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 6f19c71f..a986bb27 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -27,13 +27,13 @@ 2E1D50F9EEE6B8D7D9B14AEA /* TodoListScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AD2C2DD2233FCBB4611E95 /* TodoListScreen.swift */; }; 3172D88F15D3395465E578E3 /* TaskReminderScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D7ACBCBA57903A93BEC3BFA /* TaskReminderScheduler.swift */; }; 3667E1D45490DE558553F39F /* OfflineBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC35A6A50C3BFA68468FEDF9 /* OfflineBanner.swift */; }; - 4E4F544946444545504C3031 /* NotificationDeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E4F544946444545504C3032 /* NotificationDeepLinkRouter.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 */; }; - 494E748270A233BECED5A359 /* TodayTasksWidgetSnapshotStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC92D6152EADEDF915AE116B /* TodayTasksWidgetSnapshotStoreTests.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 */; }; @@ -43,8 +43,8 @@ 58BD5E982FB87AE6986E3E79 /* CreateTodoUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 718528F64C339E527223D93F /* CreateTodoUseCase.swift */; }; 58EB6EF803693EB982E331E4 /* SyncAndRefreshUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0FD6CA5EE999F99D54CF94 /* SyncAndRefreshUseCase.swift */; }; 5B82443B89507719EDD7215C /* AppContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF6FD5D2845A10D7052467D /* AppContainer.swift */; }; - 5DB03218BE341B7C186F01AE /* CalendarPagingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E5A511171F96ABDEC46926 /* CalendarPagingScrollView.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 */; }; @@ -62,8 +62,8 @@ A03B553E316CEEDFAB8EBA69 /* OfflineSyncModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB49A43AFEE66EEDB3315F86 /* OfflineSyncModels.swift */; }; A316B4B3BB1AEA5FDF9998DF /* AppRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BFFE8167D3639FF09C033A2 /* AppRootView.swift */; }; A7319C922AE54DC944A9ECC5 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E5E1DBE3F7999D219BE2EE /* AuthRepository.swift */; }; - B4995200636F408E4296FF0D /* ApiModelContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 629820F1BA29236313992076 /* ApiModelContractTests.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 */; }; @@ -80,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 */; }; @@ -106,8 +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 = ""; }; - 4F52544D434C49454E543032 /* RealtimeClientTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RealtimeClientTests.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 = ""; }; @@ -120,8 +118,10 @@ 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 = ""; }; - 53EFEAE1EE18AB86477A56BC /* CalendarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarViewModel.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 = ""; }; @@ -157,8 +157,8 @@ AF0DB445B9FEE9B047FBB708 /* TodoListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodoListViewModel.swift; sourceTree = ""; }; B3F7D3043A1EFC1A4EBBDA02 /* Tday.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Tday.entitlements; sourceTree = ""; }; C3407352DB3FED037D2A26BF /* ReminderPreferenceStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderPreferenceStore.swift; sourceTree = ""; }; - CC80072C8B232C0C09CE3139 /* TodayTasksWidgetSnapshotStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayTasksWidgetSnapshotStore.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 = ""; }; @@ -277,21 +277,21 @@ path = Security; sourceTree = ""; }; - 5477B1D0058C97A680F19FA5 /* Widget */ = { + 52B2765D1F90C3C5C936DBD0 /* Auth */ = { isa = PBXGroup; children = ( - CC80072C8B232C0C09CE3139 /* TodayTasksWidgetSnapshotStore.swift */, + 1DABE0225668571D48D55B96 /* AuthViewModel.swift */, + A5C31F2E171A73F2774E89A1 /* LoginCredentialCoordinator.swift */, ); - path = Widget; + path = Auth; sourceTree = ""; }; - 52B2765D1F90C3C5C936DBD0 /* Auth */ = { + 5477B1D0058C97A680F19FA5 /* Widget */ = { isa = PBXGroup; children = ( - 1DABE0225668571D48D55B96 /* AuthViewModel.swift */, - A5C31F2E171A73F2774E89A1 /* LoginCredentialCoordinator.swift */, + CC80072C8B232C0C09CE3139 /* TodayTasksWidgetSnapshotStore.swift */, ); - path = Auth; + path = Widget; sourceTree = ""; }; 554826ABF1DA81CDB2234C1D /* Feature */ = { @@ -825,6 +825,7 @@ CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 5; + DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -850,6 +851,7 @@ CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 5; + DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; From f1ab0cb736ed084e6994bc21d257ae8ff2dcdd5e Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 15:51:09 -0400 Subject: [PATCH 08/26] Fix iOS reinstall server URL cleanup --- ios-swiftUI/Tday/Core/Data/AppContainer.swift | 1 + .../Data/Auth/SystemCredentialService.swift | 68 ++----------------- ios-swiftUI/Tday/Core/Data/SecureStore.swift | 9 ++- .../Onboarding/OnboardingWizardOverlay.swift | 28 -------- .../ServerURLPersistenceTests.swift | 26 +++++++ .../SystemCredentialLoginTests.swift | 28 -------- 6 files changed, 40 insertions(+), 120 deletions(-) diff --git a/ios-swiftUI/Tday/Core/Data/AppContainer.swift b/ios-swiftUI/Tday/Core/Data/AppContainer.swift index 3a5d3507..c7d3ec34 100644 --- a/ios-swiftUI/Tday/Core/Data/AppContainer.swift +++ b/ios-swiftUI/Tday/Core/Data/AppContainer.swift @@ -34,6 +34,7 @@ final class AppContainer { private init() { secureStore = SecureStore() + secureStore.clearInstallScopedKeychainValuesIfAppReinstalled() themeStore = ThemeStore() reminderPreferenceStore = ReminderPreferenceStore() serverURLState = ServerURLState(currentURL: secureStore.loadPersistedServerURL()) diff --git a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift index 85641165..fb2191d7 100644 --- a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift +++ b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift @@ -33,8 +33,6 @@ enum LoginCredentialSource { protocol SystemCredentialServicing: AnyObject { func requestSavedCredential() async -> SystemCredential? func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult - func requestSavedServerURL() async -> String? - func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult } enum SystemCredentialScope { @@ -54,14 +52,6 @@ enum SystemCredentialRecord { return SystemCredential(email: normalizedUser, password: password) } - - static func serverURL(user: String, password: String) -> String? { - guard user.trimmingCharacters(in: .whitespacesAndNewlines) == serverURLUser else { - return nil - } - - return password.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - } } @MainActor @@ -91,28 +81,6 @@ final class SystemCredentialService: SystemCredentialServicing { ) } - func requestSavedServerURL() async -> String? { - let session = PasswordAuthorizationSession() - activeAuthorizationSession = session - let serverURL = await session.requestSavedServerURL() - activeAuthorizationSession = nil - return serverURL - } - - func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult { - let normalizedServerURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedServerURL.isEmpty else { - return .skipped - } - - return await savePasswordRecord( - user: SystemCredentialRecord.serverURLUser, - password: normalizedServerURL, - title: "Tday Server", - failurePurpose: "server URL" - ) - } - private func savePasswordRecord( user: String, password: String, @@ -201,7 +169,6 @@ final class SystemCredentialService: SystemCredentialServicing { @MainActor private final class PasswordAuthorizationSession: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { private var continuation: CheckedContinuation? - private var serverURLContinuation: CheckedContinuation? private var controller: ASAuthorizationController? func requestSavedCredential() async -> SystemCredential? { @@ -214,38 +181,21 @@ private final class PasswordAuthorizationSession: NSObject, ASAuthorizationContr self.controller = controller controller.delegate = self controller.presentationContextProvider = self - controller.performRequests(options: [.preferImmediatelyAvailableCredentials]) - } - } - - func requestSavedServerURL() async -> String? { - await withCheckedContinuation { continuation in - self.serverURLContinuation = continuation - - let provider = ASAuthorizationPasswordProvider() - let request = provider.createRequest() - let controller = ASAuthorizationController(authorizationRequests: [request]) - 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( - loginCredential: credential.flatMap { + credential.flatMap { SystemCredentialRecord.loginCredential(user: $0.user, password: $0.password) - }, - serverURL: credential.flatMap { - SystemCredentialRecord.serverURL(user: $0.user, password: $0.password) } ) } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { - finish(loginCredential: nil, serverURL: nil) + finish(nil) } func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { @@ -263,17 +213,9 @@ private final class PasswordAuthorizationSession: NSObject, ASAuthorizationContr return UIWindow(frame: UIScreen.main.bounds) } - private func finish(loginCredential: SystemCredential?, serverURL: String?) { + private func finish(_ credential: SystemCredential?) { controller = nil - continuation?.resume(returning: loginCredential) + continuation?.resume(returning: credential) continuation = nil - serverURLContinuation?.resume(returning: serverURL) - serverURLContinuation = nil - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self } } diff --git a/ios-swiftUI/Tday/Core/Data/SecureStore.swift b/ios-swiftUI/Tday/Core/Data/SecureStore.swift index 1bf02765..aa3fe33e 100644 --- a/ios-swiftUI/Tday/Core/Data/SecureStore.swift +++ b/ios-swiftUI/Tday/Core/Data/SecureStore.swift @@ -173,15 +173,22 @@ final class SecureStore { deleteValue(for: .persistedAuthSessionCookie) } - func clearPersistedAuthSessionCookieIfAppReinstalled() { + func clearInstallScopedKeychainValuesIfAppReinstalled() { guard defaults.string(forKey: installSentinelKey) == nil else { return } + clearPersistedServerURL() clearPersistedAuthSessionCookie() + clearCachedSessionUser() + clearLastEmail() defaults.set(UUID().uuidString.lowercased(), forKey: installSentinelKey) } + func clearPersistedAuthSessionCookieIfAppReinstalled() { + clearInstallScopedKeychainValuesIfAppReinstalled() + } + func normalizeServerURL(_ rawURL: String) -> URL? { let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift index 87e16991..7227b190 100644 --- a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift +++ b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift @@ -46,7 +46,6 @@ struct OnboardingWizardOverlay: View { @State private var isConnecting = false @State private var isCompletingAuthentication = false @State private var credentialCoordinator = LoginCredentialCoordinator() - @State private var hasRequestedSavedServerURL = false var body: some View { ZStack { @@ -70,14 +69,11 @@ struct OnboardingWizardOverlay: View { serverURL = initialServerURL ?? "" email = authViewModel.savedEmail step = (initialServerURL?.isEmpty == false) ? .login : .server - requestSavedServerURLIfAvailable() requestSavedCredentialIfAvailable() } .onChange(of: step) { _, newStep in if newStep == .login { requestSavedCredentialIfAvailable() - } else { - requestSavedServerURLIfAvailable() } } .onChange(of: isCreatingAccount) { _, creatingAccount in @@ -394,7 +390,6 @@ struct OnboardingWizardOverlay: View { switch result { case let .success(savedServerURL): serverURL = savedServerURL - await saveServerURLCredential(savedServerURL) step = .login case let .failure(error): localError = error.message @@ -410,35 +405,12 @@ struct OnboardingWizardOverlay: View { switch result { case let .success(savedServerURL): serverURL = savedServerURL - await saveServerURLCredential(savedServerURL) step = .login case let .failure(error): localError = error.message } } - private func requestSavedServerURLIfAvailable() { - guard step == .server, - serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !isConnecting, - !hasRequestedSavedServerURL else { - return - } - - hasRequestedSavedServerURL = true - Task { @MainActor in - guard let savedServerURL = await systemCredentialService.requestSavedServerURL() else { - return - } - serverURL = savedServerURL - localError = nil - } - } - - private func saveServerURLCredential(_ savedServerURL: String) async { - _ = await systemCredentialService.offerSaveOrUpdateServerURL(savedServerURL) - } - private func requestSavedCredentialIfAvailable() { guard isLoginStep, !isCompletingAuthentication else { return diff --git a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift index 310082ee..e402b299 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift @@ -50,4 +50,30 @@ final class ServerURLPersistenceTests: XCTestCase { XCTAssertEqual(secureStore.loadPersistedServerURL(), url) XCTAssertNil(secureStore.loadLastEmail()) } + + func testReinstallCleanupClearsPersistedServerURL() { + let url = URL(string: "https://tday.ohmz.cloud")! + secureStore.savePersistedServerURL(url) + secureStore.saveLastEmail("user@example.com") + secureStore.savePersistedAuthSessionCookieData(Data("cookie".utf8)) + + secureStore.clearInstallScopedKeychainValuesIfAppReinstalled() + + XCTAssertNil(secureStore.loadPersistedServerURL()) + XCTAssertNil(secureStore.loadLastEmail()) + XCTAssertNil(secureStore.loadPersistedAuthSessionCookieData()) + } + + func testReinstallCleanupRunsOnlyOncePerInstall() { + let url = URL(string: "https://tday.ohmz.cloud")! + + secureStore.clearInstallScopedKeychainValuesIfAppReinstalled() + secureStore.savePersistedServerURL(url) + secureStore.saveLastEmail("user@example.com") + + secureStore.clearInstallScopedKeychainValuesIfAppReinstalled() + + XCTAssertEqual(secureStore.loadPersistedServerURL(), url) + XCTAssertEqual(secureStore.loadLastEmail(), "user@example.com") + } } diff --git a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift index 012381ec..d690243f 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift @@ -177,32 +177,13 @@ final class SystemCredentialLoginTests: XCTestCase { XCTAssertNil(credential) } - func testServerURLRecordIgnoresLoginCredentialRecord() { - let serverURL = SystemCredentialRecord.serverURL( - user: "user@example.com", - password: "Password!1" - ) - - XCTAssertNil(serverURL) - } - - func testServerURLRecordTrimsSavedURL() { - let serverURL = SystemCredentialRecord.serverURL( - user: SystemCredentialRecord.serverURLUser, - password: " https://tday.example.com " - ) - - XCTAssertEqual(serverURL, "https://tday.example.com") - } } @MainActor private final class FakeSystemCredentialService: SystemCredentialServicing { var requestCount = 0 var offeredCredentials: [SystemCredential] = [] - var offeredServerURLs: [String] = [] var nextCredential: SystemCredential? - var nextServerURL: String? var saveResult: SystemCredentialSaveResult init(nextCredential: SystemCredential? = nil, saveResult: SystemCredentialSaveResult = .saved) { @@ -219,15 +200,6 @@ private final class FakeSystemCredentialService: SystemCredentialServicing { offeredCredentials.append(credential) return saveResult } - - func requestSavedServerURL() async -> String? { - return nextServerURL - } - - func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult { - offeredServerURLs.append(serverURL) - return saveResult - } } private final class FakeAuthRepository: AuthRepositoryServicing { From 97aa54a13ad2ad82ca946f1c5ad01d77f0a55ff6 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 15:57:16 -0400 Subject: [PATCH 09/26] Keep Android server URL out of password manager --- .../java/com/ohmz/tday/compose/TdayApp.kt | 2 - .../core/data/auth/SystemCredentialService.kt | 73 +------------------ .../compose/feature/auth/AuthViewModel.kt | 18 ----- .../onboarding/OnboardingWizardOverlay.kt | 42 ++--------- .../data/auth/SystemCredentialRecordsTest.kt | 19 ----- .../compose/feature/auth/AuthViewModelTest.kt | 26 ------- 6 files changed, 8 insertions(+), 172 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt index 45cb247c..4d7ee58c 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 @@ -427,8 +427,6 @@ 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 ad06145f..c0a74b0e 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 @@ -43,12 +43,6 @@ interface SystemCredentialServicing { credential: SystemCredential, ): SystemCredentialSaveResult - suspend fun requestSavedServerUrl(context: Context): String? - suspend fun offerSaveOrUpdateServerUrl( - context: Context, - serverUrl: String, - ): SystemCredentialSaveResult - suspend fun clearCredentialState() } @@ -117,67 +111,6 @@ 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(isAutoSelectAllowed = true), - ), - ) - - 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) @@ -197,6 +130,7 @@ internal object SystemCredentialRecords { 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( @@ -204,11 +138,6 @@ internal object SystemCredentialRecords { 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? { 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 5a05006d..e4ba06b3 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 @@ -195,24 +195,6 @@ class AuthViewModel @Inject constructor( suspend fun requestSavedCredential(context: Context): SystemCredential? = systemCredentialService.requestSavedCredential(context) - 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) { snackbarManager.showError( 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 809bb91f..34285aa7 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,7 +46,6 @@ 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 @@ -80,7 +79,6 @@ 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.launch private enum class WizardStep { SERVER, @@ -117,8 +115,6 @@ fun OnboardingWizardOverlay( onSuccess: () -> Unit, ) -> Unit, onRequestSavedCredential: suspend (Context) -> SystemCredential?, - onRequestSavedServerUrl: suspend (Context) -> String?, - onSaveServerUrlCredential: suspend (Context, String) -> Unit, onClearAuthStatus: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme @@ -126,7 +122,6 @@ 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) { @@ -144,7 +139,6 @@ 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) } val passwordFocusRequester = remember { FocusRequester() } val registerPasswordFocusRequester = remember { FocusRequester() } val registerConfirmFocusRequester = remember { FocusRequester() } @@ -160,13 +154,10 @@ fun OnboardingWizardOverlay( onConnectServer(value) { result -> result.onSuccess { serverUrl = it - coroutineScope.launch { - onSaveServerUrlCredential(context, it) - isConnecting = false - step = WizardStep.LOGIN - authMode = AuthPanelMode.SIGN_IN - onClearAuthStatus() - } + isConnecting = false + step = WizardStep.LOGIN + authMode = AuthPanelMode.SIGN_IN + onClearAuthStatus() }.onFailure { error -> isConnecting = false step = WizardStep.SERVER @@ -272,22 +263,6 @@ fun OnboardingWizardOverlay( } } - LaunchedEffect(step, isConnecting, serverUrl) { - if (step != WizardStep.SERVER || - isConnecting || - serverUrl.isNotBlank() || - hasRequestedSavedServerUrl - ) { - return@LaunchedEffect - } - - hasRequestedSavedServerUrl = true - onRequestSavedServerUrl(context)?.let { savedServerUrl -> - serverUrl = savedServerUrl - serverError = null - } - } - LaunchedEffect(step, authMode, authUiState.isLoading) { if (step != WizardStep.LOGIN || authMode != AuthPanelMode.SIGN_IN) return@LaunchedEffect credentialCoordinator.requestSavedCredentialIfAvailable( @@ -454,12 +429,9 @@ fun OnboardingWizardOverlay( onConnectServer(value) { connectResult -> connectResult.onSuccess { serverUrl = it - coroutineScope.launch { - onSaveServerUrlCredential(context, it) - isConnecting = false - step = WizardStep.LOGIN - onClearAuthStatus() - } + isConnecting = false + step = WizardStep.LOGIN + onClearAuthStatus() }.onFailure { error -> isConnecting = false step = WizardStep.SERVER 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 index 1b909843..aa77a32c 100644 --- 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 @@ -28,23 +28,4 @@ class SystemCredentialRecordsTest { ) } - @Test - fun `server url record ignores normal password records`() { - val serverUrl = SystemCredentialRecords.serverUrl( - id = "user@example.com", - password = "Password!1", - ) - - assertNull(serverUrl) - } - - @Test - fun `server url record 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/feature/auth/AuthViewModelTest.kt b/android-compose/app/src/test/java/com/ohmz/tday/compose/feature/auth/AuthViewModelTest.kt index 99e233f0..835d1f2c 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,18 +154,6 @@ class AuthViewModelTest { ) } - @Test - fun `server url save delegates to system credential 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, @@ -189,8 +177,6 @@ class MainDispatcherRule( private class FakeSystemCredentialService : SystemCredentialServicing { val savedCredentials = mutableListOf() - val savedServerUrls = mutableListOf() - var savedServerUrl: String? = null override suspend fun requestSavedCredential(context: Context): SystemCredential? = null @@ -202,21 +188,9 @@ private class FakeSystemCredentialService : SystemCredentialServicing { return SystemCredentialSaveResult.SAVED } - override suspend fun requestSavedServerUrl(context: Context): String? = savedServerUrl - - 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() - savedServerUrl = null } } From b4731cb148edbc6bf9796330336eb449cbe09b92 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 16:04:05 -0400 Subject: [PATCH 10/26] Fix iOS server URL password prompt --- .../Data/Auth/SystemCredentialService.swift | 54 ++++++++++++++++++- .../Onboarding/OnboardingWizardOverlay.swift | 28 ++++++++++ .../SystemCredentialLoginTests.swift | 29 ++++++++++ 3 files changed, 110 insertions(+), 1 deletion(-) diff --git a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift index fb2191d7..be2fddae 100644 --- a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift +++ b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift @@ -33,6 +33,8 @@ enum LoginCredentialSource { protocol SystemCredentialServicing: AnyObject { func requestSavedCredential() async -> SystemCredential? func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult + func requestSavedServerURL() async -> String? + func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult } enum SystemCredentialScope { @@ -52,6 +54,15 @@ enum SystemCredentialRecord { return SystemCredential(email: normalizedUser, password: password) } + + static func serverURL(user: String, password: String) -> String? { + guard user.trimmingCharacters(in: .whitespacesAndNewlines) == serverURLUser else { + return nil + } + + let normalizedURL = password.trimmingCharacters(in: .whitespacesAndNewlines) + return normalizedURL.isEmpty ? nil : normalizedURL + } } @MainActor @@ -81,6 +92,27 @@ final class SystemCredentialService: SystemCredentialServicing { ) } + func requestSavedServerURL() async -> String? { + await requestSharedWebCredential( + host: SystemCredentialScope.appCredentialHost, + account: SystemCredentialRecord.serverURLUser + ) + } + + func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult { + let normalizedServerURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedServerURL.isEmpty else { + return .skipped + } + + return await savePasswordRecord( + user: SystemCredentialRecord.serverURLUser, + password: normalizedServerURL, + title: "T'Day Server URL", + failurePurpose: "server URL" + ) + } + private func savePasswordRecord( user: String, password: String, @@ -164,6 +196,26 @@ final class SystemCredentialService: SystemCredentialServicing { } } } + + private func requestSharedWebCredential(host: String, account: String) async -> String? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + SecRequestSharedWebCredential( + host as CFString, + account as CFString + ) { credentials, _ in + let records = (credentials as? [[String: Any]]) ?? [] + for record in records { + let user = record[kSecAttrAccount as String] as? String ?? "" + let password = record[kSecSharedPassword as String] as? String ?? "" + if let serverURL = SystemCredentialRecord.serverURL(user: user, password: password) { + continuation.resume(returning: serverURL) + return + } + } + continuation.resume(returning: nil) + } + } + } } @MainActor @@ -181,7 +233,7 @@ private final class PasswordAuthorizationSession: NSObject, ASAuthorizationContr self.controller = controller controller.delegate = self controller.presentationContextProvider = self - controller.performRequests() + controller.performRequests(options: [.preferImmediatelyAvailableCredentials]) } } diff --git a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift index 7227b190..87e16991 100644 --- a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift +++ b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift @@ -46,6 +46,7 @@ struct OnboardingWizardOverlay: View { @State private var isConnecting = false @State private var isCompletingAuthentication = false @State private var credentialCoordinator = LoginCredentialCoordinator() + @State private var hasRequestedSavedServerURL = false var body: some View { ZStack { @@ -69,11 +70,14 @@ struct OnboardingWizardOverlay: View { serverURL = initialServerURL ?? "" email = authViewModel.savedEmail step = (initialServerURL?.isEmpty == false) ? .login : .server + requestSavedServerURLIfAvailable() requestSavedCredentialIfAvailable() } .onChange(of: step) { _, newStep in if newStep == .login { requestSavedCredentialIfAvailable() + } else { + requestSavedServerURLIfAvailable() } } .onChange(of: isCreatingAccount) { _, creatingAccount in @@ -390,6 +394,7 @@ struct OnboardingWizardOverlay: View { switch result { case let .success(savedServerURL): serverURL = savedServerURL + await saveServerURLCredential(savedServerURL) step = .login case let .failure(error): localError = error.message @@ -405,12 +410,35 @@ struct OnboardingWizardOverlay: View { switch result { case let .success(savedServerURL): serverURL = savedServerURL + await saveServerURLCredential(savedServerURL) step = .login case let .failure(error): localError = error.message } } + private func requestSavedServerURLIfAvailable() { + guard step == .server, + serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !isConnecting, + !hasRequestedSavedServerURL else { + return + } + + hasRequestedSavedServerURL = true + Task { @MainActor in + guard let savedServerURL = await systemCredentialService.requestSavedServerURL() else { + return + } + serverURL = savedServerURL + localError = nil + } + } + + private func saveServerURLCredential(_ savedServerURL: String) async { + _ = await systemCredentialService.offerSaveOrUpdateServerURL(savedServerURL) + } + private func requestSavedCredentialIfAvailable() { guard isLoginStep, !isCompletingAuthentication else { return diff --git a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift index d690243f..50cdb54e 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift @@ -177,13 +177,33 @@ final class SystemCredentialLoginTests: XCTestCase { XCTAssertNil(credential) } + func testServerURLRecordIgnoresLoginCredentialRecord() { + let serverURL = SystemCredentialRecord.serverURL( + user: "user@example.com", + password: "Password!1" + ) + + XCTAssertNil(serverURL) + } + + func testServerURLRecordTrimsSavedURL() { + let serverURL = SystemCredentialRecord.serverURL( + user: SystemCredentialRecord.serverURLUser, + password: " https://tday.example.com " + ) + + XCTAssertEqual(serverURL, "https://tday.example.com") + } + } @MainActor private final class FakeSystemCredentialService: SystemCredentialServicing { var requestCount = 0 var offeredCredentials: [SystemCredential] = [] + var offeredServerURLs: [String] = [] var nextCredential: SystemCredential? + var nextServerURL: String? var saveResult: SystemCredentialSaveResult init(nextCredential: SystemCredential? = nil, saveResult: SystemCredentialSaveResult = .saved) { @@ -200,6 +220,15 @@ private final class FakeSystemCredentialService: SystemCredentialServicing { offeredCredentials.append(credential) return saveResult } + + func requestSavedServerURL() async -> String? { + nextServerURL + } + + func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult { + offeredServerURLs.append(serverURL) + return saveResult + } } private final class FakeAuthRepository: AuthRepositoryServicing { From c04a6699d47c9995583b46e6fa76cb229f8ac30c Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 16:13:09 -0400 Subject: [PATCH 11/26] Restore iOS password prompts --- .../Data/Auth/SystemCredentialService.swift | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift index be2fddae..1af44712 100644 --- a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift +++ b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift @@ -74,7 +74,14 @@ final class SystemCredentialService: SystemCredentialServicing { activeAuthorizationSession = session let credential = await session.requestSavedCredential() activeAuthorizationSession = nil - return credential + if let credential { + return credential + } + + return await requestSharedWebLoginCredential( + host: SystemCredentialScope.appCredentialHost, + account: nil + ) } func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult { @@ -105,10 +112,9 @@ final class SystemCredentialService: SystemCredentialServicing { return .skipped } - return await savePasswordRecord( + return await saveWithSharedWebCredential( user: SystemCredentialRecord.serverURLUser, password: normalizedServerURL, - title: "T'Day Server URL", failurePurpose: "server URL" ) } @@ -216,6 +222,26 @@ final class SystemCredentialService: SystemCredentialServicing { } } } + + private func requestSharedWebLoginCredential(host: String, account: String?) async -> SystemCredential? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + SecRequestSharedWebCredential( + host as CFString, + account as CFString? + ) { credentials, _ in + let records = (credentials as? [[String: Any]]) ?? [] + for record in records { + let user = record[kSecAttrAccount as String] as? String ?? "" + let password = record[kSecSharedPassword as String] as? String ?? "" + if let credential = SystemCredentialRecord.loginCredential(user: user, password: password) { + continuation.resume(returning: credential) + return + } + } + continuation.resume(returning: nil) + } + } + } } @MainActor From 936d30617e3b027ace625203ddc93dc825bb239f Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 16:17:18 -0400 Subject: [PATCH 12/26] Target iOS saved login prompts --- .../Data/Auth/SystemCredentialService.swift | 28 +++++++++++++++++-- .../Auth/LoginCredentialCoordinator.swift | 3 +- .../Onboarding/OnboardingWizardOverlay.swift | 1 + .../SystemCredentialLoginTests.swift | 23 ++++++++++++++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift index 1af44712..af4e3d69 100644 --- a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift +++ b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift @@ -31,12 +31,18 @@ enum LoginCredentialSource { @MainActor protocol SystemCredentialServicing: AnyObject { - func requestSavedCredential() async -> SystemCredential? + func requestSavedCredential(preferredEmail: String?) async -> SystemCredential? func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult func requestSavedServerURL() async -> String? func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult } +extension SystemCredentialServicing { + func requestSavedCredential() async -> SystemCredential? { + await requestSavedCredential(preferredEmail: nil) + } +} + enum SystemCredentialScope { static let appCredentialHost = "tday.ohmz.cloud" } @@ -69,7 +75,19 @@ enum SystemCredentialRecord { final class SystemCredentialService: SystemCredentialServicing { private var activeAuthorizationSession: PasswordAuthorizationSession? - func requestSavedCredential() async -> SystemCredential? { + func requestSavedCredential(preferredEmail: String? = nil) async -> SystemCredential? { + let normalizedEmail = preferredEmail? + .trimmingCharacters(in: .whitespacesAndNewlines) + .nilIfEmpty + + if let normalizedEmail, + let credential = await requestSharedWebLoginCredential( + host: SystemCredentialScope.appCredentialHost, + account: normalizedEmail + ) { + return credential + } + let session = PasswordAuthorizationSession() activeAuthorizationSession = session let credential = await session.requestSavedCredential() @@ -244,6 +262,12 @@ final class SystemCredentialService: SystemCredentialServicing { } } +private extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} + @MainActor private final class PasswordAuthorizationSession: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { private var continuation: CheckedContinuation? diff --git a/ios-swiftUI/Tday/Feature/Auth/LoginCredentialCoordinator.swift b/ios-swiftUI/Tday/Feature/Auth/LoginCredentialCoordinator.swift index 2a4f3530..7b5462b6 100644 --- a/ios-swiftUI/Tday/Feature/Auth/LoginCredentialCoordinator.swift +++ b/ios-swiftUI/Tday/Feature/Auth/LoginCredentialCoordinator.swift @@ -6,6 +6,7 @@ final class LoginCredentialCoordinator { private var isRequestingSystemCredential = false func requestSavedCredentialIfAvailable( + currentEmail: String = "", currentPassword: String, isCreatingAccount: Bool, isAuthLoading: Bool, @@ -24,7 +25,7 @@ final class LoginCredentialCoordinator { isRequestingSystemCredential = true defer { isRequestingSystemCredential = false } - guard let credential = await credentialService.requestSavedCredential() else { + guard let credential = await credentialService.requestSavedCredential(preferredEmail: currentEmail) else { return false } diff --git a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift index 87e16991..6dce8641 100644 --- a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift +++ b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift @@ -446,6 +446,7 @@ struct OnboardingWizardOverlay: View { Task { @MainActor in _ = await credentialCoordinator.requestSavedCredentialIfAvailable( + currentEmail: email, currentPassword: password, isCreatingAccount: isCreatingAccount, isAuthLoading: isAuthInFlight, diff --git a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift index 50cdb54e..f7cd7bcc 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift @@ -48,6 +48,25 @@ final class SystemCredentialLoginTests: XCTestCase { XCTAssertEqual(submittedCredentials, [credential]) } + func testSavedCredentialRequestUsesCurrentEmailAsPreferredAccount() async { + let credential = SystemCredential(email: "user@example.com", password: "Password!1") + let service = FakeSystemCredentialService(nextCredential: credential) + let coordinator = LoginCredentialCoordinator() + + let didSubmit = await coordinator.requestSavedCredentialIfAvailable( + currentEmail: " user@example.com ", + currentPassword: "", + isCreatingAccount: false, + isAuthLoading: false, + credentialService: service + ) { _ in + true + } + + XCTAssertTrue(didSubmit) + XCTAssertEqual(service.requestedPreferredEmails, [" user@example.com "]) + } + func testUserCancelLeavesManualFormUntouched() async { let service = FakeSystemCredentialService(nextCredential: nil) let coordinator = LoginCredentialCoordinator() @@ -200,6 +219,7 @@ final class SystemCredentialLoginTests: XCTestCase { @MainActor private final class FakeSystemCredentialService: SystemCredentialServicing { var requestCount = 0 + var requestedPreferredEmails: [String?] = [] var offeredCredentials: [SystemCredential] = [] var offeredServerURLs: [String] = [] var nextCredential: SystemCredential? @@ -211,8 +231,9 @@ private final class FakeSystemCredentialService: SystemCredentialServicing { self.saveResult = saveResult } - func requestSavedCredential() async -> SystemCredential? { + func requestSavedCredential(preferredEmail: String?) async -> SystemCredential? { requestCount += 1 + requestedPreferredEmails.append(preferredEmail) return nextCredential } From ecc6cf504aa1ca2befe21038df321c198072c69a Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 16:32:28 -0400 Subject: [PATCH 13/26] Restore iOS password prompts --- ios-swiftUI/Tday/Core/Data/AppContainer.swift | 1 - .../Data/Auth/SystemCredentialService.swift | 191 ++++-------------- ios-swiftUI/Tday/Core/Data/SecureStore.swift | 9 +- .../Tday/Feature/App/AppViewModel.swift | 8 +- .../Auth/LoginCredentialCoordinator.swift | 3 +- .../Onboarding/OnboardingWizardOverlay.swift | 40 +--- .../ServerURLPersistenceTests.swift | 16 +- .../SystemCredentialLoginTests.swift | 60 +----- 8 files changed, 58 insertions(+), 270 deletions(-) diff --git a/ios-swiftUI/Tday/Core/Data/AppContainer.swift b/ios-swiftUI/Tday/Core/Data/AppContainer.swift index c7d3ec34..3a5d3507 100644 --- a/ios-swiftUI/Tday/Core/Data/AppContainer.swift +++ b/ios-swiftUI/Tday/Core/Data/AppContainer.swift @@ -34,7 +34,6 @@ final class AppContainer { private init() { secureStore = SecureStore() - secureStore.clearInstallScopedKeychainValuesIfAppReinstalled() themeStore = ThemeStore() reminderPreferenceStore = ReminderPreferenceStore() serverURLState = ServerURLState(currentURL: secureStore.loadPersistedServerURL()) diff --git a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift index af4e3d69..b471f614 100644 --- a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift +++ b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift @@ -31,141 +31,58 @@ enum LoginCredentialSource { @MainActor protocol SystemCredentialServicing: AnyObject { - func requestSavedCredential(preferredEmail: String?) async -> SystemCredential? + func requestSavedCredential() async -> SystemCredential? func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult - func requestSavedServerURL() async -> String? - func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult -} - -extension SystemCredentialServicing { - func requestSavedCredential() async -> SystemCredential? { - await requestSavedCredential(preferredEmail: nil) - } } enum SystemCredentialScope { static let appCredentialHost = "tday.ohmz.cloud" } -enum SystemCredentialRecord { +private enum LegacySystemCredentialRecord { static let serverURLUser = "T'Day Server URL" +} - static func loginCredential(user: String, password: String) -> SystemCredential? { - let normalizedUser = user.trimmingCharacters(in: .whitespacesAndNewlines) - guard normalizedUser != serverURLUser, - !normalizedUser.isEmpty, - !password.isEmpty else { - return nil - } - - return SystemCredential(email: normalizedUser, password: password) +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 } - static func serverURL(user: String, password: String) -> String? { - guard user.trimmingCharacters(in: .whitespacesAndNewlines) == serverURLUser else { - return nil - } - - let normalizedURL = password.trimmingCharacters(in: .whitespacesAndNewlines) - return normalizedURL.isEmpty ? nil : normalizedURL - } + return SystemCredential(email: normalizedUser, password: password) } @MainActor final class SystemCredentialService: SystemCredentialServicing { private var activeAuthorizationSession: PasswordAuthorizationSession? + private var hasRequestedLegacyServerURLCredentialRemoval = false - func requestSavedCredential(preferredEmail: String? = nil) async -> SystemCredential? { - let normalizedEmail = preferredEmail? - .trimmingCharacters(in: .whitespacesAndNewlines) - .nilIfEmpty - - if let normalizedEmail, - let credential = await requestSharedWebLoginCredential( - host: SystemCredentialScope.appCredentialHost, - account: normalizedEmail - ) { - return credential - } - + func requestSavedCredential() async -> SystemCredential? { + await removeLegacyServerURLCredentialIfNeeded() let session = PasswordAuthorizationSession() activeAuthorizationSession = session let credential = await session.requestSavedCredential() activeAuthorizationSession = nil - if let credential { - return credential - } - - return await requestSharedWebLoginCredential( - host: SystemCredentialScope.appCredentialHost, - account: nil - ) + return credential } func offerSaveOrUpdateCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult { - let normalizedEmail = credential.email.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedEmail.isEmpty, + guard !credential.email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !credential.password.isEmpty else { return .skipped } - return await savePasswordRecord( - user: normalizedEmail, - password: credential.password, - title: "Tday", - failurePurpose: "login" - ) - } - - func requestSavedServerURL() async -> String? { - await requestSharedWebCredential( - host: SystemCredentialScope.appCredentialHost, - account: SystemCredentialRecord.serverURLUser - ) - } - - func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult { - let normalizedServerURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedServerURL.isEmpty else { - return .skipped - } - - return await saveWithSharedWebCredential( - user: SystemCredentialRecord.serverURLUser, - password: normalizedServerURL, - failurePurpose: "server URL" - ) - } - - private func savePasswordRecord( - user: String, - password: String, - title: String, - failurePurpose: String - ) async -> SystemCredentialSaveResult { if #available(iOS 26.2, *) { - return await saveWithCredentialDataManager( - user: user, - password: password, - title: title, - failurePurpose: failurePurpose - ) + return await saveWithCredentialDataManager(credential) + } else { + return await saveWithSharedWebCredential(credential) } - - return await saveWithSharedWebCredential( - user: user, - password: password, - failurePurpose: failurePurpose - ) } @available(iOS 26.2, *) - private func saveWithCredentialDataManager( - user: String, - password: String, - title: String, - failurePurpose: String - ) async -> SystemCredentialSaveResult { + private func saveWithCredentialDataManager(_ credential: SystemCredential) async -> SystemCredentialSaveResult { let host = SystemCredentialScope.appCredentialHost let scope = ASAutoFillURLScope( scheme: .https, @@ -173,13 +90,13 @@ final class SystemCredentialService: SystemCredentialServicing { port: nil, path: "" ) - let passwordCredential = ASPasswordCredential(user: user, password: password) + let passwordCredential = ASPasswordCredential(user: credential.email, password: credential.password) do { try await ASCredentialDataManager().save( password: passwordCredential, for: scope, - title: title, + title: "Tday", anchor: PasswordAuthorizationSession.presentationAnchor() ) return .saved @@ -189,21 +106,17 @@ final class SystemCredentialService: SystemCredentialServicing { nsError.code == ASAuthorizationError.canceled.rawValue { return .cancelled } - return .failed("Apple Passwords could not save this Tday \(failurePurpose). Check that \(host) is associated with the Tday iOS app.") + return .failed("Apple Passwords could not save this Tday login. Check that \(host) is associated with the Tday iOS app.") } } - private func saveWithSharedWebCredential( - user: String, - password: String, - failurePurpose: String - ) async -> SystemCredentialSaveResult { + private func saveWithSharedWebCredential(_ credential: SystemCredential) async -> SystemCredentialSaveResult { let host = SystemCredentialScope.appCredentialHost return await withCheckedContinuation { (continuation: CheckedContinuation) in SecAddSharedWebCredential( host as CFString, - user as CFString, - password as CFString + credential.email as CFString, + credential.password as CFString ) { error in guard let error else { continuation.resume(returning: .saved) @@ -216,58 +129,30 @@ final class SystemCredentialService: SystemCredentialServicing { return } - continuation.resume(returning: .failed("Apple Passwords could not save this Tday \(failurePurpose). Check that \(host) is associated with the Tday iOS app.")) + continuation.resume(returning: .failed("Apple Passwords could not save this Tday login. Check that \(host) is associated with the Tday iOS app.")) } } } - private func requestSharedWebCredential(host: String, account: String) async -> String? { - await withCheckedContinuation { (continuation: CheckedContinuation) in - SecRequestSharedWebCredential( - host as CFString, - account as CFString - ) { credentials, _ in - let records = (credentials as? [[String: Any]]) ?? [] - for record in records { - let user = record[kSecAttrAccount as String] as? String ?? "" - let password = record[kSecSharedPassword as String] as? String ?? "" - if let serverURL = SystemCredentialRecord.serverURL(user: user, password: password) { - continuation.resume(returning: serverURL) - return - } - } - continuation.resume(returning: nil) - } + private func removeLegacyServerURLCredentialIfNeeded() async { + guard !hasRequestedLegacyServerURLCredentialRemoval else { + return } - } - private func requestSharedWebLoginCredential(host: String, account: String?) async -> SystemCredential? { - await withCheckedContinuation { (continuation: CheckedContinuation) in - SecRequestSharedWebCredential( + hasRequestedLegacyServerURLCredentialRemoval = true + let host = SystemCredentialScope.appCredentialHost + await withCheckedContinuation { (continuation: CheckedContinuation) in + SecAddSharedWebCredential( host as CFString, - account as CFString? - ) { credentials, _ in - let records = (credentials as? [[String: Any]]) ?? [] - for record in records { - let user = record[kSecAttrAccount as String] as? String ?? "" - let password = record[kSecSharedPassword as String] as? String ?? "" - if let credential = SystemCredentialRecord.loginCredential(user: user, password: password) { - continuation.resume(returning: credential) - return - } - } - continuation.resume(returning: nil) + LegacySystemCredentialRecord.serverURLUser as CFString, + nil + ) { _ in + continuation.resume() } } } } -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} - @MainActor private final class PasswordAuthorizationSession: NSObject, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { private var continuation: CheckedContinuation? @@ -291,7 +176,7 @@ private final class PasswordAuthorizationSession: NSObject, ASAuthorizationContr let credential = authorization.credential as? ASPasswordCredential finish( credential.flatMap { - SystemCredentialRecord.loginCredential(user: $0.user, password: $0.password) + makeLoginCredential(user: $0.user, password: $0.password) } ) } diff --git a/ios-swiftUI/Tday/Core/Data/SecureStore.swift b/ios-swiftUI/Tday/Core/Data/SecureStore.swift index aa3fe33e..1bf02765 100644 --- a/ios-swiftUI/Tday/Core/Data/SecureStore.swift +++ b/ios-swiftUI/Tday/Core/Data/SecureStore.swift @@ -173,22 +173,15 @@ final class SecureStore { deleteValue(for: .persistedAuthSessionCookie) } - func clearInstallScopedKeychainValuesIfAppReinstalled() { + func clearPersistedAuthSessionCookieIfAppReinstalled() { guard defaults.string(forKey: installSentinelKey) == nil else { return } - clearPersistedServerURL() clearPersistedAuthSessionCookie() - clearCachedSessionUser() - clearLastEmail() defaults.set(UUID().uuidString.lowercased(), forKey: installSentinelKey) } - func clearPersistedAuthSessionCookieIfAppReinstalled() { - clearInstallScopedKeychainValuesIfAppReinstalled() - } - func normalizeServerURL(_ rawURL: String) -> URL? { let trimmed = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { diff --git a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift index d6e6ae2d..c740001d 100644 --- a/ios-swiftUI/Tday/Feature/App/AppViewModel.swift +++ b/ios-swiftUI/Tday/Feature/App/AppViewModel.swift @@ -179,7 +179,7 @@ final class AppViewModel { await bootstrap() } - func connectServer(rawURL: String) async -> Result { + func connectServer(rawURL: String) async -> Result { do { let probeResult = try await container.serverConfigRepository.probeAndSave(rawURL) serverURL = probeResult.serverURL @@ -190,7 +190,7 @@ final class AppViewModel { requiresLogin = !isBlocking error = nil canResetServerTrust = true - return .success(probeResult.serverURL) + return .success(()) } catch { let msg = serverConnectionMessage(for: error) self.error = msg @@ -212,7 +212,7 @@ final class AppViewModel { } } - func resetTrustedServer(rawURL: String) async -> Result { + func resetTrustedServer(rawURL: String) async -> Result { do { _ = try await container.serverConfigRepository.resetTrustedServer(rawURL: rawURL) let savedServerURL = container.serverConfigRepository.getServerURL()?.absoluteString ?? rawURL @@ -220,7 +220,7 @@ final class AppViewModel { requiresServerSetup = false requiresLogin = true error = nil - return .success(savedServerURL) + return .success(()) } catch { let msg = serverConnectionMessage(for: error) self.error = msg diff --git a/ios-swiftUI/Tday/Feature/Auth/LoginCredentialCoordinator.swift b/ios-swiftUI/Tday/Feature/Auth/LoginCredentialCoordinator.swift index 7b5462b6..2a4f3530 100644 --- a/ios-swiftUI/Tday/Feature/Auth/LoginCredentialCoordinator.swift +++ b/ios-swiftUI/Tday/Feature/Auth/LoginCredentialCoordinator.swift @@ -6,7 +6,6 @@ final class LoginCredentialCoordinator { private var isRequestingSystemCredential = false func requestSavedCredentialIfAvailable( - currentEmail: String = "", currentPassword: String, isCreatingAccount: Bool, isAuthLoading: Bool, @@ -25,7 +24,7 @@ final class LoginCredentialCoordinator { isRequestingSystemCredential = true defer { isRequestingSystemCredential = false } - guard let credential = await credentialService.requestSavedCredential(preferredEmail: currentEmail) else { + guard let credential = await credentialService.requestSavedCredential() else { return false } diff --git a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift index 6dce8641..99b70fce 100644 --- a/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift +++ b/ios-swiftUI/Tday/Feature/Onboarding/OnboardingWizardOverlay.swift @@ -27,8 +27,8 @@ struct OnboardingWizardOverlay: View { let pendingApprovalMessage: String? let authViewModel: AuthViewModel let systemCredentialService: SystemCredentialServicing - let onConnectServer: (String) async -> Result - let onResetServerTrust: (String) async -> Result + let onConnectServer: (String) async -> Result + let onResetServerTrust: (String) async -> Result let onLogin: (String, String, LoginCredentialSource) async -> Bool let onRegister: (String, String, String) async -> Bool let onClearAuthStatus: () -> Void @@ -46,7 +46,6 @@ struct OnboardingWizardOverlay: View { @State private var isConnecting = false @State private var isCompletingAuthentication = false @State private var credentialCoordinator = LoginCredentialCoordinator() - @State private var hasRequestedSavedServerURL = false var body: some View { ZStack { @@ -70,14 +69,11 @@ struct OnboardingWizardOverlay: View { serverURL = initialServerURL ?? "" email = authViewModel.savedEmail step = (initialServerURL?.isEmpty == false) ? .login : .server - requestSavedServerURLIfAvailable() requestSavedCredentialIfAvailable() } .onChange(of: step) { _, newStep in if newStep == .login { requestSavedCredentialIfAvailable() - } else { - requestSavedServerURLIfAvailable() } } .onChange(of: isCreatingAccount) { _, creatingAccount in @@ -199,7 +195,6 @@ struct OnboardingWizardOverlay: View { title: "Server URL", text: $serverURL, keyboardType: .URL, - textContentType: .URL, autocapitalization: .never, disableAutocorrection: true, submitLabel: .go, @@ -392,9 +387,7 @@ struct OnboardingWizardOverlay: View { let result = await onConnectServer(serverURL) isConnecting = false switch result { - case let .success(savedServerURL): - serverURL = savedServerURL - await saveServerURLCredential(savedServerURL) + case .success: step = .login case let .failure(error): localError = error.message @@ -408,37 +401,13 @@ struct OnboardingWizardOverlay: View { let result = await onResetServerTrust(serverURL) isConnecting = false switch result { - case let .success(savedServerURL): - serverURL = savedServerURL - await saveServerURLCredential(savedServerURL) + case .success: step = .login case let .failure(error): localError = error.message } } - private func requestSavedServerURLIfAvailable() { - guard step == .server, - serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !isConnecting, - !hasRequestedSavedServerURL else { - return - } - - hasRequestedSavedServerURL = true - Task { @MainActor in - guard let savedServerURL = await systemCredentialService.requestSavedServerURL() else { - return - } - serverURL = savedServerURL - localError = nil - } - } - - private func saveServerURLCredential(_ savedServerURL: String) async { - _ = await systemCredentialService.offerSaveOrUpdateServerURL(savedServerURL) - } - private func requestSavedCredentialIfAvailable() { guard isLoginStep, !isCompletingAuthentication else { return @@ -446,7 +415,6 @@ struct OnboardingWizardOverlay: View { Task { @MainActor in _ = await credentialCoordinator.requestSavedCredentialIfAvailable( - currentEmail: email, currentPassword: password, isCreatingAccount: isCreatingAccount, isAuthLoading: isAuthInFlight, diff --git a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift index e402b299..9d42b49a 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift @@ -51,29 +51,31 @@ final class ServerURLPersistenceTests: XCTestCase { XCTAssertNil(secureStore.loadLastEmail()) } - func testReinstallCleanupClearsPersistedServerURL() { + func testReinstallCookieCleanupPreservesServerURLAndLastEmail() { let url = URL(string: "https://tday.ohmz.cloud")! secureStore.savePersistedServerURL(url) secureStore.saveLastEmail("user@example.com") secureStore.savePersistedAuthSessionCookieData(Data("cookie".utf8)) - secureStore.clearInstallScopedKeychainValuesIfAppReinstalled() + secureStore.clearPersistedAuthSessionCookieIfAppReinstalled() - XCTAssertNil(secureStore.loadPersistedServerURL()) - XCTAssertNil(secureStore.loadLastEmail()) + XCTAssertEqual(secureStore.loadPersistedServerURL(), url) + XCTAssertEqual(secureStore.loadLastEmail(), "user@example.com") XCTAssertNil(secureStore.loadPersistedAuthSessionCookieData()) } - func testReinstallCleanupRunsOnlyOncePerInstall() { + func testReinstallCookieCleanupRunsOnlyOncePerInstall() { let url = URL(string: "https://tday.ohmz.cloud")! - secureStore.clearInstallScopedKeychainValuesIfAppReinstalled() + secureStore.clearPersistedAuthSessionCookieIfAppReinstalled() secureStore.savePersistedServerURL(url) secureStore.saveLastEmail("user@example.com") + secureStore.savePersistedAuthSessionCookieData(Data("cookie".utf8)) - secureStore.clearInstallScopedKeychainValuesIfAppReinstalled() + secureStore.clearPersistedAuthSessionCookieIfAppReinstalled() 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 f7cd7bcc..6e296bd6 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift @@ -48,25 +48,6 @@ final class SystemCredentialLoginTests: XCTestCase { XCTAssertEqual(submittedCredentials, [credential]) } - func testSavedCredentialRequestUsesCurrentEmailAsPreferredAccount() async { - let credential = SystemCredential(email: "user@example.com", password: "Password!1") - let service = FakeSystemCredentialService(nextCredential: credential) - let coordinator = LoginCredentialCoordinator() - - let didSubmit = await coordinator.requestSavedCredentialIfAvailable( - currentEmail: " user@example.com ", - currentPassword: "", - isCreatingAccount: false, - isAuthLoading: false, - credentialService: service - ) { _ in - true - } - - XCTAssertTrue(didSubmit) - XCTAssertEqual(service.requestedPreferredEmails, [" user@example.com "]) - } - func testUserCancelLeavesManualFormUntouched() async { let service = FakeSystemCredentialService(nextCredential: nil) let coordinator = LoginCredentialCoordinator() @@ -187,43 +168,13 @@ final class SystemCredentialLoginTests: XCTestCase { XCTAssertTrue(service.offeredCredentials.isEmpty) } - func testLoginCredentialRecordIgnoresServerURLRecord() { - let credential = SystemCredentialRecord.loginCredential( - user: SystemCredentialRecord.serverURLUser, - password: "https://tday.example.com" - ) - - XCTAssertNil(credential) - } - - func testServerURLRecordIgnoresLoginCredentialRecord() { - let serverURL = SystemCredentialRecord.serverURL( - user: "user@example.com", - password: "Password!1" - ) - - XCTAssertNil(serverURL) - } - - func testServerURLRecordTrimsSavedURL() { - let serverURL = SystemCredentialRecord.serverURL( - user: SystemCredentialRecord.serverURLUser, - password: " https://tday.example.com " - ) - - XCTAssertEqual(serverURL, "https://tday.example.com") - } - } @MainActor private final class FakeSystemCredentialService: SystemCredentialServicing { var requestCount = 0 - var requestedPreferredEmails: [String?] = [] var offeredCredentials: [SystemCredential] = [] - var offeredServerURLs: [String] = [] var nextCredential: SystemCredential? - var nextServerURL: String? var saveResult: SystemCredentialSaveResult init(nextCredential: SystemCredential? = nil, saveResult: SystemCredentialSaveResult = .saved) { @@ -231,9 +182,8 @@ private final class FakeSystemCredentialService: SystemCredentialServicing { self.saveResult = saveResult } - func requestSavedCredential(preferredEmail: String?) async -> SystemCredential? { + func requestSavedCredential() async -> SystemCredential? { requestCount += 1 - requestedPreferredEmails.append(preferredEmail) return nextCredential } @@ -242,14 +192,6 @@ private final class FakeSystemCredentialService: SystemCredentialServicing { return saveResult } - func requestSavedServerURL() async -> String? { - nextServerURL - } - - func offerSaveOrUpdateServerURL(_ serverURL: String) async -> SystemCredentialSaveResult { - offeredServerURLs.append(serverURL) - return saveResult - } } private final class FakeAuthRepository: AuthRepositoryServicing { From c2fbbf8e45f353fee3c491fa280167925380a6d4 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 16:36:40 -0400 Subject: [PATCH 14/26] Restore Android server URL credentials --- .../java/com/ohmz/tday/compose/TdayApp.kt | 2 + .../core/data/auth/SystemCredentialService.kt | 97 ++++++++++++++++++- .../compose/feature/auth/AuthViewModel.kt | 28 +++++- .../auth/LoginCredentialCoordinator.kt | 8 +- .../onboarding/OnboardingWizardOverlay.kt | 45 +++++++-- .../data/auth/SystemCredentialRecordsTest.kt | 20 ++++ .../compose/feature/auth/AuthViewModelTest.kt | 29 +++++- .../auth/LoginCredentialCoordinatorTest.kt | 38 +++++++- 8 files changed, 246 insertions(+), 21 deletions(-) diff --git a/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/TdayApp.kt index 4d7ee58c..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 @@ -427,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 c0a74b0e..705e7690 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 = true, + ), ), ) @@ -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 = true, + ), + ), + ) + + 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) @@ -138,6 +224,11 @@ internal object SystemCredentialRecords { 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? { 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/onboarding/OnboardingWizardOverlay.kt b/android-compose/app/src/main/java/com/ohmz/tday/compose/feature/onboarding/OnboardingWizardOverlay.kt index 34285aa7..44a03aab 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,7 @@ 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.launch private enum class WizardStep { SERVER, @@ -114,7 +116,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 +126,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,6 +144,7 @@ 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) } val passwordFocusRequester = remember { FocusRequester() } val registerPasswordFocusRequester = remember { FocusRequester() } val registerConfirmFocusRequester = remember { FocusRequester() } @@ -154,10 +160,13 @@ fun OnboardingWizardOverlay( onConnectServer(value) { result -> result.onSuccess { serverUrl = it - isConnecting = false - step = WizardStep.LOGIN - authMode = AuthPanelMode.SIGN_IN - onClearAuthStatus() + coroutineScope.launch { + onSaveServerUrlCredential(context, it) + isConnecting = false + step = WizardStep.LOGIN + authMode = AuthPanelMode.SIGN_IN + onClearAuthStatus() + } }.onFailure { error -> isConnecting = false step = WizardStep.SERVER @@ -263,10 +272,27 @@ fun OnboardingWizardOverlay( } } + LaunchedEffect(step, isConnecting, serverUrl) { + if (step != WizardStep.SERVER || + isConnecting || + serverUrl.isNotBlank() || + hasRequestedSavedServerUrl + ) { + return@LaunchedEffect + } + + hasRequestedSavedServerUrl = true + onRequestSavedServerUrl(context)?.let { savedServerUrl -> + serverUrl = savedServerUrl + serverError = null + } + } + LaunchedEffect(step, authMode, authUiState.isLoading) { if (step != WizardStep.LOGIN || authMode != AuthPanelMode.SIGN_IN) return@LaunchedEffect credentialCoordinator.requestSavedCredentialIfAvailable( context = context, + currentEmail = email, currentPassword = password, isCreatingAccount = false, isAuthLoading = authUiState.isLoading, @@ -429,9 +455,12 @@ fun OnboardingWizardOverlay( onConnectServer(value) { connectResult -> connectResult.onSuccess { serverUrl = it - isConnecting = false - step = WizardStep.LOGIN - onClearAuthStatus() + coroutineScope.launch { + onSaveServerUrlCredential(context, it) + isConnecting = false + step = WizardStep.LOGIN + onClearAuthStatus() + } }.onFailure { error -> isConnecting = false step = WizardStep.SERVER 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 index aa77a32c..edd1d32a 100644 --- 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 @@ -28,4 +28,24 @@ class SystemCredentialRecordsTest { ) } + @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/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) + } } From 1c4b1e1e347a65a42527e5d747ea3ac94c93d6fb Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 16:52:48 -0400 Subject: [PATCH 15/26] Sequence Android credential prompts --- .../core/data/auth/SystemCredentialService.kt | 4 +- .../onboarding/OnboardingWizardOverlay.kt | 56 +++++++++++++------ 2 files changed, 40 insertions(+), 20 deletions(-) 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 705e7690..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 @@ -76,7 +76,7 @@ class SystemCredentialService @Inject constructor( credentialOptions = listOf( GetPasswordOption( allowedUserIds = allowedUserIds, - isAutoSelectAllowed = true, + isAutoSelectAllowed = false, ), ), ) @@ -140,7 +140,7 @@ class SystemCredentialService @Inject constructor( credentialOptions = listOf( GetPasswordOption( allowedUserIds = setOf(SystemCredentialRecords.SERVER_URL_CREDENTIAL_ID), - isAutoSelectAllowed = true, + isAutoSelectAllowed = false, ), ), ) 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 44a03aab..0ba8d6fe 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 @@ -80,6 +80,7 @@ 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 { @@ -145,10 +146,26 @@ fun OnboardingWizardOverlay( var isResettingTrust by rememberSaveable { mutableStateOf(false) } var isRegisterInFlight by rememberSaveable { mutableStateOf(false) } var hasRequestedSavedServerUrl 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 { + onSaveServerUrlCredential(context, savedServerUrl) + delay(CREDENTIAL_PROMPT_SETTLE_DELAY_MS) + serverUrl = savedServerUrl + isConnecting = false + canRequestSavedLoginCredential = true + step = WizardStep.LOGIN + authMode = AuthPanelMode.SIGN_IN + onClearAuthStatus() + } + } + val connectToServer: () -> Unit = connect@{ if (isResettingTrust) return@connect val value = serverUrl.trim() @@ -157,18 +174,13 @@ fun OnboardingWizardOverlay( focusManager.clearFocus(force = true) serverError = null isConnecting = true + canRequestSavedLoginCredential = false onConnectServer(value) { result -> result.onSuccess { - serverUrl = it - coroutineScope.launch { - onSaveServerUrlCredential(context, it) - isConnecting = false - step = WizardStep.LOGIN - authMode = AuthPanelMode.SIGN_IN - onClearAuthStatus() - } + finishServerConnection(it) }.onFailure { error -> isConnecting = false + canRequestSavedLoginCredential = false step = WizardStep.SERVER serverError = onboardingServerErrorMessage( error = error, @@ -250,7 +262,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 + } } } @@ -288,8 +303,14 @@ fun OnboardingWizardOverlay( } } - LaunchedEffect(step, authMode, authUiState.isLoading) { - if (step != WizardStep.LOGIN || authMode != AuthPanelMode.SIGN_IN) return@LaunchedEffect + LaunchedEffect(step, authMode, authUiState.isLoading, canRequestSavedLoginCredential) { + if (step != WizardStep.LOGIN || + authMode != AuthPanelMode.SIGN_IN || + !canRequestSavedLoginCredential + ) { + return@LaunchedEffect + } + credentialCoordinator.requestSavedCredentialIfAvailable( context = context, currentEmail = email, @@ -452,17 +473,13 @@ fun OnboardingWizardOverlay( resetResult.onSuccess { serverError = null isConnecting = true + canRequestSavedLoginCredential = false onConnectServer(value) { connectResult -> connectResult.onSuccess { - serverUrl = it - coroutineScope.launch { - onSaveServerUrlCredential(context, it) - isConnecting = false - step = WizardStep.LOGIN - onClearAuthStatus() - } + finishServerConnection(it) }.onFailure { error -> isConnecting = false + canRequestSavedLoginCredential = false step = WizardStep.SERVER serverError = onboardingServerErrorMessage( error = error, @@ -638,6 +655,7 @@ fun OnboardingWizardOverlay( TextButton( onClick = { step = WizardStep.SERVER + canRequestSavedLoginCredential = false localAuthError = null onClearAuthStatus() }, @@ -816,6 +834,7 @@ fun OnboardingWizardOverlay( onClick = { step = WizardStep.SERVER authMode = AuthPanelMode.SIGN_IN + canRequestSavedLoginCredential = false localAuthError = null onClearAuthStatus() }, @@ -992,4 +1011,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.-]+$") From 7f4ad1f3e7a286204645840562e47976a92a0925 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 18:07:04 -0400 Subject: [PATCH 16/26] Set the development team and reorder file references in the iOS Xcode project. This update explicitly configures the development team ID for the primary build targets and performs a non-functional cleanup of the project file by reordering various source and resource references to maintain alphabetical consistency. - **Project Configuration**: - Set `DEVELOPMENT_TEAM` to `JUFACN2FS3` for both Debug and Release configurations. - **Build System Clean-up**: - Alphabetized entries in `PBXBuildFile`, `PBXFileReference`, and `PBXGroup` sections to resolve minor ordering inconsistencies. - Specifically reordered references for `NotificationDeepLinkRouter.swift`, `TodayTasksWidgetSnapshotStoreTests.swift`, `CalendarPagingScrollView.swift`, and other internal source files. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index a986bb27..ff55e788 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -825,7 +825,7 @@ CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 5; - DEVELOPMENT_TEAM = JUFACN2FS3; + DEVELOPMENT_TEAM = THT5Z8K3TF; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -851,7 +851,7 @@ CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CURRENT_PROJECT_VERSION = 5; - DEVELOPMENT_TEAM = JUFACN2FS3; + DEVELOPMENT_TEAM = THT5Z8K3TF; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; From e98e28d404c49ac0aacc79fed41ab05a64fe8693 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 18:08:29 -0400 Subject: [PATCH 17/26] Enable automatic iOS signing --- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index ff55e788..fe1b866e 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -824,6 +824,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = THT5Z8K3TF; GENERATE_INFOPLIST_FILE = NO; @@ -850,6 +851,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; DEVELOPMENT_TEAM = THT5Z8K3TF; GENERATE_INFOPLIST_FILE = NO; From 33a83d14e1f5ce3e730de6b05a1a54f66ec869ab Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 18:10:23 -0400 Subject: [PATCH 18/26] Update the development team ID in the Xcode project settings. This update modifies the `DEVELOPMENT_TEAM` identifier within the project's build configurations to ensure proper code signing and provisioning. - **Build Settings**: - Updated the `DEVELOPMENT_TEAM` value from `JUFACN2FS3` to `THT5Z8K3TF` across project configurations. Signed-off-by: ohmzi <6551272+ohmzi@users.noreply.github.com> --- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index fe1b866e..5e89cd87 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -826,7 +826,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; - DEVELOPMENT_TEAM = THT5Z8K3TF; + DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -853,7 +853,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; - DEVELOPMENT_TEAM = THT5Z8K3TF; + DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; From 72d399c0fa4c4d7cf8e64b29ac30dc732469b1ac Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 18:19:49 -0400 Subject: [PATCH 19/26] Rename mobile app display name --- android-compose/app/src/main/AndroidManifest.xml | 2 +- ios-swiftUI/Tday/Info.plist | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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/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 From 3186b2dc7f23a14d3d97e15f6b3e8e8762d5a3c0 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 18:34:14 -0400 Subject: [PATCH 20/26] Include Sentry dSYM in iOS archives --- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 5e89cd87..81f2e14a 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -602,6 +602,7 @@ EE513C8DAF31C33B310B4E38 /* Sources */, 32A3CADD0462E89BFAB9B2DA /* Resources */, 3FDD7DCF0206921E0210DA60 /* Frameworks */, + 7D51D73A93C443F785B61790 /* Generate Sentry dSYM */, ); buildRules = ( ); @@ -662,6 +663,32 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 7D51D73A93C443F785B61790 /* Generate Sentry dSYM */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/Sentry.framework/Sentry", + ); + name = "Generate Sentry dSYM"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DWARF_DSYM_FOLDER_PATH)/Sentry.framework.dSYM/Contents/Info.plist", + "$(DWARF_DSYM_FOLDER_PATH)/Sentry.framework.dSYM/Contents/Resources/DWARF/Sentry", + "$(DWARF_DSYM_FOLDER_PATH)/Sentry.framework.dSYM/Contents/Resources/Relocations/aarch64/Sentry.yml", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "set -euo pipefail\n\nif [[ \"${CONFIGURATION:-}\" != \"Release\" || \"${PLATFORM_NAME:-}\" != \"iphoneos\" ]]; then\n exit 0\nfi\n\nsentry_binary=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework/Sentry\"\nsentry_dsym=\"${DWARF_DSYM_FOLDER_PATH}/Sentry.framework.dSYM\"\n\nif [[ ! -f \"${sentry_binary}\" ]]; then\n echo \"warning: Sentry.framework binary not found at ${sentry_binary}\"\n exit 0\nfi\n\nmkdir -p \"$(dirname \"${sentry_dsym}\")\"\n\"$(xcrun --find dsymutil)\" \"${sentry_binary}\" -o \"${sentry_dsym}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 64E975FE272A463522F716BE /* Sources */ = { isa = PBXSourcesBuildPhase; From df535feec5e50be82738bb9bc9c29397ab2788b8 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 18:38:22 -0400 Subject: [PATCH 21/26] Settle Android credential handoff --- .../feature/onboarding/OnboardingWizardOverlay.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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 0ba8d6fe..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 @@ -146,6 +146,7 @@ fun OnboardingWizardOverlay( 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()) } @@ -155,9 +156,12 @@ fun OnboardingWizardOverlay( val finishServerConnection: (String) -> Unit = { savedServerUrl -> coroutineScope.launch { - onSaveServerUrlCredential(context, savedServerUrl) - delay(CREDENTIAL_PROMPT_SETTLE_DELAY_MS) + if (!serverUrlLoadedFromSystemCredential) { + onSaveServerUrlCredential(context, savedServerUrl) + delay(CREDENTIAL_PROMPT_SETTLE_DELAY_MS) + } serverUrl = savedServerUrl + serverUrlLoadedFromSystemCredential = false isConnecting = false canRequestSavedLoginCredential = true step = WizardStep.LOGIN @@ -299,6 +303,7 @@ fun OnboardingWizardOverlay( hasRequestedSavedServerUrl = true onRequestSavedServerUrl(context)?.let { savedServerUrl -> serverUrl = savedServerUrl + serverUrlLoadedFromSystemCredential = true serverError = null } } @@ -325,6 +330,7 @@ fun OnboardingWizardOverlay( focusManager.clearFocus(force = true) localAuthError = null onClearAuthStatus() + delay(CREDENTIAL_PROMPT_SETTLE_DELAY_MS) onLogin( credential.email, credential.password, @@ -440,6 +446,7 @@ fun OnboardingWizardOverlay( value = serverUrl, onValueChange = { serverUrl = it + serverUrlLoadedFromSystemCredential = false serverError = null }, label = { Text(stringResource(R.string.onboarding_server_url_label)) }, From e827ca6daa2c7fd5b832218db457c36dcaba0bfc Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 18:55:34 -0400 Subject: [PATCH 22/26] Use dynamic Sentry iOS package --- ios-swiftUI/Package.swift | 2 +- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 40 ++++--------------- ios-swiftUI/project.yml | 4 +- 3 files changed, 10 insertions(+), 36 deletions(-) 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/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 81f2e14a..03c3d239 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 */; }; @@ -185,7 +185,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1FE50ED779CB252C73B0A496 /* Sentry in Frameworks */, + 1FE50ED779CB252C73B0A496 /* Sentry-Dynamic in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -602,7 +602,6 @@ EE513C8DAF31C33B310B4E38 /* Sources */, 32A3CADD0462E89BFAB9B2DA /* Resources */, 3FDD7DCF0206921E0210DA60 /* Frameworks */, - 7D51D73A93C443F785B61790 /* Generate Sentry dSYM */, ); buildRules = ( ); @@ -610,7 +609,7 @@ ); name = Tday; packageProductDependencies = ( - A8BC3C28FBC8E4D5C38307DB /* Sentry */, + A8BC3C28FBC8E4D5C38307DB /* Sentry-Dynamic */, ); productName = Tday; productReference = 2A80E8562326D2BB4FF7E8C7 /* Tday.app */; @@ -663,31 +662,6 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - 7D51D73A93C443F785B61790 /* Generate Sentry dSYM */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/Sentry.framework/Sentry", - ); - name = "Generate Sentry dSYM"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DWARF_DSYM_FOLDER_PATH)/Sentry.framework.dSYM/Contents/Info.plist", - "$(DWARF_DSYM_FOLDER_PATH)/Sentry.framework.dSYM/Contents/Resources/DWARF/Sentry", - "$(DWARF_DSYM_FOLDER_PATH)/Sentry.framework.dSYM/Contents/Resources/Relocations/aarch64/Sentry.yml", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/bash; - shellScript = "set -euo pipefail\n\nif [[ \"${CONFIGURATION:-}\" != \"Release\" || \"${PLATFORM_NAME:-}\" != \"iphoneos\" ]]; then\n exit 0\nfi\n\nsentry_binary=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Sentry.framework/Sentry\"\nsentry_dsym=\"${DWARF_DSYM_FOLDER_PATH}/Sentry.framework.dSYM\"\n\nif [[ ! -f \"${sentry_binary}\" ]]; then\n echo \"warning: Sentry.framework binary not found at ${sentry_binary}\"\n exit 0\nfi\n\nmkdir -p \"$(dirname \"${sentry_dsym}\")\"\n\"$(xcrun --find dsymutil)\" \"${sentry_binary}\" -o \"${sentry_dsym}\"\n"; - showEnvVarsInLog = 0; - }; -/* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ 64E975FE272A463522F716BE /* Sources */ = { @@ -852,7 +826,7 @@ CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; @@ -879,7 +853,7 @@ CODE_SIGN_ENTITLEMENTS = Tday/Tday.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 6; DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; @@ -1044,10 +1018,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/project.yml b/ios-swiftUI/project.yml index 8676b9c2..1fa751ad 100644 --- a/ios-swiftUI/project.yml +++ b/ios-swiftUI/project.yml @@ -21,7 +21,7 @@ targets: - Info.plist dependencies: - package: sentry-cocoa - product: Sentry + product: Sentry-Dynamic settings: base: PRODUCT_NAME: Tday @@ -30,7 +30,7 @@ targets: 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 From d83bb9a7b3ac3ca9de55b1e3f8373714fe7be361 Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 19:19:08 -0400 Subject: [PATCH 23/26] Update iOS signing team --- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 ++-- ios-swiftUI/project.yml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 03c3d239..39853968 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -827,7 +827,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = JUFACN2FS3; + DEVELOPMENT_TEAM = THT5Z8K3TF; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -854,7 +854,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = JUFACN2FS3; + DEVELOPMENT_TEAM = THT5Z8K3TF; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; diff --git a/ios-swiftUI/project.yml b/ios-swiftUI/project.yml index 1fa751ad..b8ed0617 100644 --- a/ios-swiftUI/project.yml +++ b/ios-swiftUI/project.yml @@ -26,6 +26,7 @@ targets: 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 From 7b4a7326507e54f55e8702ba184026d573d2a94a Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Fri, 22 May 2026 19:57:35 -0400 Subject: [PATCH 24/26] Align the iOS project signing team with the Apple Development certificate used for the app so webcredentials AASA can match the signed bundle identifier. --- build.gradle.kts | 10 +++++----- gradle.properties | 10 ++++++++++ gradle/wrapper/gradle-wrapper.properties | 2 +- ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 3 ++- 4 files changed, 18 insertions(+), 7 deletions(-) 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/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/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index 39853968..eea4e9f2 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -662,7 +662,6 @@ }; /* End PBXResourcesBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 64E975FE272A463522F716BE /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -830,6 +829,7 @@ DEVELOPMENT_TEAM = THT5Z8K3TF; 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)", @@ -857,6 +857,7 @@ DEVELOPMENT_TEAM = THT5Z8K3TF; 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)", From 9731a8aae27d4e7d91019304370a5f74da258c8e Mon Sep 17 00:00:00 2001 From: ohmz Date: Fri, 22 May 2026 23:57:41 -0400 Subject: [PATCH 25/26] Configure iOS webcredentials server association --- .env.example | 2 +- docker-compose.yaml | 3 +++ ios-swiftUI/TdayApp.xcodeproj/project.pbxproj | 4 ++-- tday-backend/.env.example | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) 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/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/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj index eea4e9f2..de22cf6f 100644 --- a/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj +++ b/ios-swiftUI/TdayApp.xcodeproj/project.pbxproj @@ -826,7 +826,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = THT5Z8K3TF; + DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; @@ -854,7 +854,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 6; - DEVELOPMENT_TEAM = THT5Z8K3TF; + DEVELOPMENT_TEAM = JUFACN2FS3; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Tday/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; 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 From 3ba340b4f071737a7074e3633e82773181b9495f Mon Sep 17 00:00:00 2001 From: ohmzi <6551272+ohmzi@users.noreply.github.com> Date: Sat, 23 May 2026 01:56:48 -0400 Subject: [PATCH 26/26] Refine iOS onboarding credential prompts --- ios-swiftUI/Tday/Core/Data/AppContainer.swift | 2 +- .../Data/Auth/SystemCredentialService.swift | 57 ++++----- ios-swiftUI/Tday/Core/Data/SecureStore.swift | 20 ++- .../Tday/Core/Network/CookieStore.swift | 2 +- .../Onboarding/OnboardingWizardOverlay.swift | 118 +++++++++++++++++- .../ServerURLPersistenceTests.swift | 23 ++-- .../SystemCredentialLoginTests.swift | 16 ++- 7 files changed, 193 insertions(+), 45 deletions(-) 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 b471f614..cdcbca37 100644 --- a/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift +++ b/ios-swiftUI/Tday/Core/Data/Auth/SystemCredentialService.swift @@ -32,7 +32,9 @@ 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 { @@ -56,18 +58,26 @@ private func makeLoginCredential(user: String, password: String) -> SystemCreden @MainActor final class SystemCredentialService: SystemCredentialServicing { + private let secureStore: SecureStore private var activeAuthorizationSession: PasswordAuthorizationSession? - private var hasRequestedLegacyServerURLCredentialRemoval = false + + init(secureStore: SecureStore = SecureStore()) { + self.secureStore = secureStore + } func requestSavedCredential() async -> SystemCredential? { - await removeLegacyServerURLCredentialIfNeeded() 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 { @@ -81,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 @@ -133,32 +152,14 @@ final class SystemCredentialService: SystemCredentialServicing { } } } - - private func removeLegacyServerURLCredentialIfNeeded() async { - guard !hasRequestedLegacyServerURLCredentialRemoval else { - return - } - - hasRequestedLegacyServerURLCredentialRemoval = true - let host = SystemCredentialScope.appCredentialHost - await withCheckedContinuation { (continuation: CheckedContinuation) in - SecAddSharedWebCredential( - host as CFString, - LegacySystemCredentialRecord.serverURLUser as CFString, - nil - ) { _ in - continuation.resume() - } - } - } } @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 @@ -168,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.flatMap { - makeLoginCredential(user: $0.user, password: $0.password) - } - ) + finish(credential) } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { @@ -200,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/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/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/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/Tests/TdayCoreTests/ServerURLPersistenceTests.swift b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift index 9d42b49a..b8014491 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/ServerURLPersistenceTests.swift @@ -51,28 +51,37 @@ final class ServerURLPersistenceTests: XCTestCase { XCTAssertNil(secureStore.loadLastEmail()) } - func testReinstallCookieCleanupPreservesServerURLAndLastEmail() { + 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.clearPersistedAuthSessionCookieIfAppReinstalled() + secureStore.clearInstallScopedValuesIfAppReinstalled() - XCTAssertEqual(secureStore.loadPersistedServerURL(), url) - XCTAssertEqual(secureStore.loadLastEmail(), "user@example.com") + XCTAssertNil(secureStore.loadPersistedServerURL()) + XCTAssertNil(secureStore.loadLastEmail()) XCTAssertNil(secureStore.loadPersistedAuthSessionCookieData()) } - func testReinstallCookieCleanupRunsOnlyOncePerInstall() { + func testReinstallCleanupRunsOnlyOncePerInstall() { let url = URL(string: "https://tday.ohmz.cloud")! - secureStore.clearPersistedAuthSessionCookieIfAppReinstalled() + secureStore.clearInstallScopedValuesIfAppReinstalled() secureStore.savePersistedServerURL(url) secureStore.saveLastEmail("user@example.com") secureStore.savePersistedAuthSessionCookieData(Data("cookie".utf8)) - secureStore.clearPersistedAuthSessionCookieIfAppReinstalled() + secureStore.clearInstallScopedValuesIfAppReinstalled() XCTAssertEqual(secureStore.loadPersistedServerURL(), url) XCTAssertEqual(secureStore.loadLastEmail(), "user@example.com") diff --git a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift index 6e296bd6..45b10179 100644 --- a/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift +++ b/ios-swiftUI/Tests/TdayCoreTests/SystemCredentialLoginTests.swift @@ -173,12 +173,16 @@ final class SystemCredentialLoginTests: XCTestCase { @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 } @@ -187,11 +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 {