Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion RxNote/RxNote.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
DF407A362F54C7340062B319 /* Exceptions for "RxNoteUITests" folder in "RxNoteClipsUITests" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
utils/accessibility.swift,
utils/DotEnv.swift,
utils/launch.swift,
utils/signin.swift,
);
target = DF6625AA2F29138400333552 /* RxNoteClipsUITests */;
};
DF44CC952F548B6100A2E4A4 /* Exceptions for "RxNote" folder in "RxNoteClips" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Expand Down Expand Up @@ -129,8 +139,8 @@
DF66255F2F29136F00333552 /* RxNote */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
DF44CC952F548B6100A2E4A4 /* Exceptions for "RxNote" folder in "RxNoteClips" target */,
DF44CCB02F548C4800A2E4A4 /* Exceptions for "RxNote" folder in "RxNote" target */,
DF44CC952F548B6100A2E4A4 /* Exceptions for "RxNote" folder in "RxNoteClips" target */,
);
path = RxNote;
sourceTree = "<group>";
Expand All @@ -142,6 +152,9 @@
};
DF66257B2F29137000333552 /* RxNoteUITests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
DF407A362F54C7340062B319 /* Exceptions for "RxNoteUITests" folder in "RxNoteClipsUITests" target */,
);
path = RxNoteUITests;
sourceTree = "<group>";
};
Expand Down
23 changes: 22 additions & 1 deletion RxNote/RxNote/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@ import SwiftUI

struct ContentView: View {
var authManager: OAuthManager

/// Pending deep link URL received before authentication completed
@State private var pendingDeepLinkURL: URL?

var body: some View {
Group {
switch authManager.authState {
case .unknown:
AuthLoadingView()
case .authenticated:
AdaptiveRootView()
AdaptiveRootView(pendingDeepLinkURL: $pendingDeepLinkURL)
case .unauthenticated:
RxSignInView(
manager: authManager,
Expand All @@ -33,6 +36,24 @@ struct ContentView: View {
#endif
}
}
// Handle deep links at the top level to capture them before auth completes
.onOpenURL { url in
if authManager.authState == .authenticated {
// Will be handled by AdaptiveRootView's onOpenURL
} else {
// Store for later processing after authentication
pendingDeepLinkURL = url
}
}
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { userActivity in
if let url = userActivity.webpageURL {
if authManager.authState == .authenticated {
// Will be handled by AdaptiveRootView
} else {
pendingDeepLinkURL = url
}
}
}
}
}

Expand Down
28 changes: 22 additions & 6 deletions RxNote/RxNote/Navigation/NavigationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ import Observation
import RxNoteCore
import SwiftUI

/// Errors that can occur during deep link handling
enum DeepLinkError: LocalizedError {
case invalidURL(String)

var errorDescription: String? {
switch self {
case .invalidURL(let url):
return "Unable to open link: \(url)"
}
}
}

/// Navigation destinations for NavigationStack
enum AppDestination: Hashable {
case noteDetail(id: Int)
Expand Down Expand Up @@ -107,15 +119,19 @@ final class NavigationManager {
let scanResponse = try await qrCodeService.scanQrCode(qrcontent: url.absoluteString)

// Extract note ID from the resolved URL
if let noteId = extractNoteId(from: scanResponse.url) {
if selectedTab != .notes {
selectedTab = .notes
}
selectedNoteId = noteId
notesNavigationPath.append(AppDestination.noteDetail(id: noteId))
guard let noteId = extractNoteId(from: scanResponse.url) else {
throw DeepLinkError.invalidURL(url.absoluteString)
}

if selectedTab != .notes {
selectedTab = .notes
}
selectedNoteId = noteId
notesNavigationPath.append(AppDestination.noteDetail(id: noteId))
} catch {
deepLinkError = error
// Small delay to ensure view hierarchy is stable before showing alert
try? await Task.sleep(for: .milliseconds(100))
showDeepLinkError = true
}
}
Expand Down
15 changes: 14 additions & 1 deletion RxNote/RxNote/Views/AdaptiveRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import SwiftUI
struct AdaptiveRootView: View {
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var navigationManager = NavigationManager()

/// Binding to pending deep link URL from ContentView (received before auth completed)
@Binding var pendingDeepLinkURL: URL?

var body: some View {
Group {
Expand All @@ -37,6 +40,16 @@ struct AdaptiveRootView: View {
await navigationManager.handleDeepLink(url)
}
}
// Process pending deep link once when view first appears
.onAppear {
if let url = pendingDeepLinkURL {
let urlToProcess = url
pendingDeepLinkURL = nil
Task {
await navigationManager.handleDeepLink(urlToProcess)
}
}
}
.alert("Deep Link Error", isPresented: $navigationManager.showDeepLinkError) {
Button("OK", role: .cancel) {}
.accessibilityIdentifier("deep-link-error-ok-button")
Expand All @@ -62,5 +75,5 @@ struct AdaptiveRootView: View {
}

#Preview {
AdaptiveRootView()
AdaptiveRootView(pendingDeepLinkURL: .constant(nil))
}
131 changes: 95 additions & 36 deletions RxNote/RxNote/Views/AppClip/AppClipRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import SwiftUI
/// 4. If 403, show access denied
struct AppClipRootView: View {
@State private var noteId: Int?
@State private var noteDetail: NoteDetail?
@State private var parseError: String?
@State private var oauthManager = OAuthManager(
configuration: AppConfiguration.shared.rxAuthConfiguration
Expand All @@ -25,6 +26,9 @@ struct AppClipRootView: View {
/// Service for QR code resolution
private let qrCodeService = QrCodeService()

/// Token storage for optional auth
private let tokenStorage = KeychainTokenStorage(serviceName: "com.rxlab.RxNote")

/// Store resolved URL for retry after authentication
@State private var resolvedNoteUrl: String?

Expand Down Expand Up @@ -74,49 +78,43 @@ struct AppClipRootView: View {
.accessibilityIdentifier("invalid-url")
} else if isLoading {
loadingView
} else if noteId != nil {
// TODO: Replace with NoteDetailView after OpenAPI regen
ContentUnavailableView(
"Note Preview",
systemImage: "note.text",
description: Text("Note detail view coming soon")
)
.toolbar {
if oauthManager.currentUser != nil {
ToolbarItem(placement: .primaryAction) {
Menu {
Button(role: .destructive) {
showSignOutConfirmation = true
} else if let note = noteDetail, let id = noteId {
// Display note in read-only mode (no edit button for App Clips)
NoteEditorView(mode: .view(noteId: id, existing: note))
.toolbar {
if oauthManager.currentUser != nil {
ToolbarItem(placement: .primaryAction) {
Menu {
Button(role: .destructive) {
showSignOutConfirmation = true
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
}
.accessibilityIdentifier("app-clips-sign-out-button")
} label: {
Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right")
Image(systemName: "ellipsis.circle")
}
.accessibilityIdentifier("app-clips-sign-out-button")
} label: {
Image(systemName: "ellipsis.circle")
.accessibilityIdentifier("app-clips-more-menu")
}
.accessibilityIdentifier("app-clips-more-menu")
}
}
}
.confirmationDialog(
title: "Sign Out",
message: "Are you sure you want to sign out?",
confirmButtonTitle: "Sign Out",
isPresented: $showSignOutConfirmation
) {
Task {
await oauthManager.logout()
if let qrcontent = originalQrContent {
await fetchNoteFromQrCode(qrcontent)
.confirmationDialog(
title: "Sign Out",
message: "Are you sure you want to sign out?",
confirmButtonTitle: "Sign Out",
isPresented: $showSignOutConfirmation
) {
Task {
await oauthManager.logout()
if let qrcontent = originalQrContent {
await fetchNoteFromQrCode(qrcontent)
}
}
}
}
} else if let error = loadError {
errorView(error: error)
} else if noteId == nil {
ProgressView("Waiting for URL...")
} else {
ProgressView("Loading...")
ProgressView("Waiting for URL...")
}
}
}
Expand Down Expand Up @@ -187,6 +185,7 @@ struct AppClipRootView: View {
accessDenied = false
parseError = nil
loadError = nil
noteDetail = nil
isLoading = true

// Store the original QR content for retry after auth
Expand All @@ -195,15 +194,20 @@ struct AppClipRootView: View {
defer { isLoading = false }

do {
// Step 1: Scan QR code to get the API URL
let scanResponse = try await qrCodeService.scanQrCode(qrcontent: qrcontent)
resolvedNoteUrl = scanResponse.url

// Extract note ID from URL
if let id = extractNoteId(from: scanResponse.url) {
noteId = id
} else {
guard let id = extractNoteId(from: scanResponse.url) else {
parseError = "Could not parse note ID from URL"
return
}
noteId = id

// Step 2: Fetch note detail directly from the resolved URL
let note = try await fetchNoteFromUrl(scanResponse.url)
noteDetail = note

} catch let error as APIError {
switch error {
Expand All @@ -221,6 +225,61 @@ struct AppClipRootView: View {
}
}

/// Fetch note detail directly from the API URL with optional authentication
private func fetchNoteFromUrl(_ urlString: String) async throws -> NoteDetail {
guard let url = URL(string: urlString) else {
throw APIError.invalidURL
}

var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "Accept")

// Add auth token if available (optional auth for App Clips)
if let accessToken = tokenStorage.getAccessToken() {
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}

let (data, response) = try await URLSession.shared.data(for: request)

guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}

switch httpResponse.statusCode {
case 200:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)

// Try ISO8601 with fractional seconds first
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: dateString) {
return date
}

// Fallback to standard ISO8601
formatter.formatOptions = [.withInternetDateTime]
if let date = formatter.date(from: dateString) {
return date
}

throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date: \(dateString)")
}
return try decoder.decode(NoteDetail.self, from: data)
case 401:
throw APIError.unauthorized
case 403:
throw APIError.forbidden
case 404:
throw APIError.notFound
default:
throw APIError.serverError("HTTP \(httpResponse.statusCode)")
}
}

// MARK: - Helpers

private func extractNoteId(from urlString: String) -> Int? {
Expand Down
8 changes: 5 additions & 3 deletions RxNote/RxNote/Views/Notes/NoteDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ struct NoteDetailView: View {
if isEditing {
NoteEditorView(
mode: .edit(noteId: noteId, existing: note),
onSave: { _ in
isEditing = false
Task { await viewModel.fetchNote(id: noteId) }
onSave: { updatedNote in
Task {
await viewModel.fetchNote(id: noteId)
isEditing = false
}
},
onCancel: {
isEditing = false
Expand Down
Loading
Loading