From 861269e2159899f66f6e3fba468f6269d2658805 Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 14:49:59 -0300 Subject: [PATCH 1/9] Improvements on debug logging --- Museum/Managers/AssetProviderManager.swift | 4 +++ Museum/Operators/AssetDownloadOperator.swift | 26 ++++++++++++--- .../Operators/Cache/DiskCacheOperator.swift | 32 ++++++++++++------- 3 files changed, 46 insertions(+), 16 deletions(-) diff --git a/Museum/Managers/AssetProviderManager.swift b/Museum/Managers/AssetProviderManager.swift index eec1115..e435319 100644 --- a/Museum/Managers/AssetProviderManager.swift +++ b/Museum/Managers/AssetProviderManager.swift @@ -55,8 +55,12 @@ nonisolated final class AssetProviderManager: AssetProviding, @unchecked Sendabl self.diskCache = diskCache self.downloader = downloader self.logger = logger + + logger.debug("init") } + deinit { logger.debug("deinit") } + func provide( _ asset: Asset, strategy: RetryStrategy, diff --git a/Museum/Operators/AssetDownloadOperator.swift b/Museum/Operators/AssetDownloadOperator.swift index 04b9ef1..269c23a 100644 --- a/Museum/Operators/AssetDownloadOperator.swift +++ b/Museum/Operators/AssetDownloadOperator.swift @@ -56,23 +56,39 @@ nonisolated protocol AssetDownloading: Sendable { nonisolated final class AssetDownloadOperator: AssetDownloading, @unchecked Sendable { private let session: any URLSessionDownloading + private let logger: any Logging private let fileManager: FileManager - init(session: any URLSessionDownloading = URLSessionDownloader(), fileManager: FileManager = .default) { + init( + session: any URLSessionDownloading = URLSessionDownloader(), + logger: any Logging = Container.shared.logOperator("AssetDownloadOperator"), + fileManager: FileManager = .default + ) { self.session = session + self.logger = logger self.fileManager = fileManager } func download(from url: URL) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { + logger.debug("Downloading asset from \(url)") + return AsyncThrowingStream { continuation in + let task = Task { [weak self] in + guard let self else { return } do { - let fileURL = try await performDownload(from: url, continuation: continuation) + let fileURL = try await self.performDownload( + from: url, + continuation: continuation + ) + logger.debug("Asset downloaded to \(fileURL)") continuation.yield(.completed(fileURL)) continuation.finish() } catch is CancellationError { + logger.debug("User cancelled asset download") continuation.finish(throwing: CancellationError()) } catch { + logger.error( + "Failed downloading asset: \(error.localizedDescription)" + ) continuation.finish(throwing: error) } } @@ -124,6 +140,8 @@ private extension AssetDownloadOperator { .appendingPathComponent(UUID().uuidString) .appendingPathExtension(originalURL.pathExtension) + logger.debug("Moving downloaded file from \(tempURL) to \(stableURL)") + do { try fileManager.moveItem(at: tempURL, to: stableURL) } catch { diff --git a/Museum/Operators/Cache/DiskCacheOperator.swift b/Museum/Operators/Cache/DiskCacheOperator.swift index ba7e122..d53cbed 100644 --- a/Museum/Operators/Cache/DiskCacheOperator.swift +++ b/Museum/Operators/Cache/DiskCacheOperator.swift @@ -55,22 +55,33 @@ nonisolated final class DiskCacheOperator: CacheOperatorProtocol, @unchecked Sen try fileManager.removeItem(at: destination) } try fileManager.moveItem(at: sourceURL, to: destination) + logger.debug("Saved cache \(key.value) at \(destination)") } catch { assertionFailure("\(error)") - logger.error("Failed to save cache on disk: \(error), key: \(key.value)") + logger.error("Failed to save cache \(key.value) on disk: \(error)") } } func retrieve(at key: any CacheKeyProtocol) async -> URL? { let url = fileURL(for: key) - guard fileManager.fileExists(atPath: url.path) else { return nil } + guard fileManager.fileExists(atPath: url.path) else { + logger.debug( + "Failed to retrieve cache \(key.value) as it's missing" + ) + return nil + } guard !isExpired(fileURL: url) else { try? fileManager.removeItem(at: url) + logger.debug( + "Failed to retrieve cache \(key.value) as it's already expired" + ) return nil } + logger.debug("Retrieved cache \(key.value) from \(url)") + return url } } @@ -88,30 +99,27 @@ private extension DiskCacheOperator { func setup() { guard !fileManager.fileExists(atPath: cacheDirectory.path) else { return } + logger.debug("Creating cache directory at \(cacheDirectory)") do { try fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) } catch { assertionFailure("\(error)") - logger.error("Failed to setup disk for caching: \(error)") + logger.error("Failed to create cache directory: \(error)") } } func clearExpired() { do { for fileName in try fileManager.contentsOfDirectory(atPath: cacheDirectory.path) { - let filePath = cacheDirectory.appendingPathComponent(fileName).path - let attributes = try fileManager.attributesOfItem(atPath: filePath) - - guard - let creationDate = attributes[.creationDate] as? Date, - Date().timeIntervalSince(creationDate) >= timeToLive - else { continue } + let fileURL = cacheDirectory.appendingPathComponent(fileName) + guard isExpired(fileURL: fileURL) else { continue } - try fileManager.removeItem(atPath: filePath) + logger.debug("Clearing expired cache at \(fileURL)") + try fileManager.removeItem(atPath: fileURL.path) } } catch { assertionFailure("\(error)") - logger.error("Failed to clear expired disk cache: \(error)") + logger.error("Failed to clear expired cache: \(error)") } } From 3d38d2cbadb8c9269065505c23c324832b551a3e Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 14:51:49 -0300 Subject: [PATCH 2/9] Improvements on ImmersiveAssetView Added custom lightning to the asset for better UX --- Museum/Views/ImmersiveAssetView.swift | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Museum/Views/ImmersiveAssetView.swift b/Museum/Views/ImmersiveAssetView.swift index 55d28bc..0a59d69 100644 --- a/Museum/Views/ImmersiveAssetView.swift +++ b/Museum/Views/ImmersiveAssetView.swift @@ -35,6 +35,7 @@ struct ImmersiveAssetView: View { // Entity load failure is non-fatal; // the volumetric window remains available for retry } + content.add(makeSceneLighting()) if let exitButton = attachments.entity(for: exitButtonAttachmentId) { let headAnchor = AnchorEntity(.head) @@ -89,6 +90,28 @@ private extension ImmersiveAssetView { } } + func makeSceneLighting() -> Entity { + let lightAnchor = Entity() + + let keyLight = Entity() + keyLight.components.set(DirectionalLightComponent( + color: .white, + intensity: 2500 + )) + keyLight.orientation = simd_quatf(angle: -.pi / 3, axis: [1, 0, 0]) + lightAnchor.addChild(keyLight) + + let fillLight = Entity() + fillLight.components.set(PointLightComponent( + color: .white, + intensity: 3000 + )) + fillLight.position = SIMD3(0, 1.5, 1) + lightAnchor.addChild(fillLight) + + return lightAnchor + } + func applySpot(_ spot: Asset.TourSpot, to entity: Entity, animated: Bool) { let orientation = tourViewModel.entityOrientation(for: spot) From 3f6d8e40f14c1a3638495ee9dbe9bffa3631ee4d Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 14:55:06 -0300 Subject: [PATCH 3/9] Improvements on view life cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - “@Equatable” is now being use to affirm a view identity - Asset model is now properly injected instead of hardcoded - Other UX improvements --- Museum.xcodeproj/project.pbxproj | 17 +++++++ .../xcshareddata/swiftpm/Package.resolved | 20 +++++++- Museum/ContentView.swift | 19 +++++-- Museum/Models/Asset.swift | 2 +- Museum/Models/AssetDisplayState.swift | 1 + Museum/MuseumApp.swift | 9 ++-- Museum/Views/AssetDetailView.swift | 21 +++++--- Museum/Views/AssetLoadingView.swift | 20 +++++--- Museum/Views/ImmersiveAssetView.swift | 49 ++++++++++--------- 9 files changed, 114 insertions(+), 44 deletions(-) diff --git a/Museum.xcodeproj/project.pbxproj b/Museum.xcodeproj/project.pbxproj index b288f81..543b5c3 100644 --- a/Museum.xcodeproj/project.pbxproj +++ b/Museum.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 18234CCB2F4645DB00C0A12D /* FactoryKit in Frameworks */ = {isa = PBXBuildFile; productRef = 18234CCA2F4645DB00C0A12D /* FactoryKit */; }; 18234CCD2F4645DB00C0A12D /* FactoryTesting in Frameworks */ = {isa = PBXBuildFile; productRef = 18234CCC2F4645DB00C0A12D /* FactoryTesting */; }; 189E39012F463C380013FCC3 /* RealityKitContent in Frameworks */ = {isa = PBXBuildFile; productRef = 189E39002F463C380013FCC3 /* RealityKitContent */; }; + 18F081EE2F48B494007F0DB8 /* Equatable in Frameworks */ = {isa = PBXBuildFile; productRef = 18F081ED2F48B494007F0DB8 /* Equatable */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,6 +61,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 18F081EE2F48B494007F0DB8 /* Equatable in Frameworks */, 18234CCB2F4645DB00C0A12D /* FactoryKit in Frameworks */, 189E39012F463C380013FCC3 /* RealityKitContent in Frameworks */, ); @@ -126,6 +128,7 @@ packageProductDependencies = ( 189E39002F463C380013FCC3 /* RealityKitContent */, 18234CCA2F4645DB00C0A12D /* FactoryKit */, + 18F081ED2F48B494007F0DB8 /* Equatable */, ); productName = Museum; productReference = 189E38FB2F463C380013FCC3 /* Museum.app */; @@ -185,6 +188,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 18234CC92F4645DB00C0A12D /* XCRemoteSwiftPackageReference "Factory" */, + 18F081EC2F48B494007F0DB8 /* XCRemoteSwiftPackageReference "equatable" */, ); preferredProjectObjectVersion = 77; productRefGroup = 189E38FC2F463C380013FCC3 /* Products */; @@ -506,6 +510,14 @@ minimumVersion = 2.5.3; }; }; + 18F081EC2F48B494007F0DB8 /* XCRemoteSwiftPackageReference "equatable" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ordo-one/equatable"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -523,6 +535,11 @@ isa = XCSwiftPackageProductDependency; productName = RealityKitContent; }; + 18F081ED2F48B494007F0DB8 /* Equatable */ = { + isa = XCSwiftPackageProductDependency; + package = 18F081EC2F48B494007F0DB8 /* XCRemoteSwiftPackageReference "equatable" */; + productName = Equatable; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 189E38F32F463C380013FCC3 /* Project object */; diff --git a/Museum.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Museum.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2c9d3a6..e86dfbe 100644 --- a/Museum.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Museum.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "785dfb0b7a88418cc62cbca5dff3a6de9946905d45af248a5407a734156e91e9", + "originHash" : "09b4672fc01c1c22d72dddfb37120b91d76d46a84bbfa806b9949732b13b5c37", "pins" : [ + { + "identity" : "equatable", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ordo-one/equatable", + "state" : { + "revision" : "73f819bd2bfb95e69cb596664b9d0d40cfe6493c", + "version" : "1.2.0" + } + }, { "identity" : "factory", "kind" : "remoteSourceControl", @@ -9,6 +18,15 @@ "revision" : "ccc898f21992ebc130bc04cc197460a5ae230bcf", "version" : "2.5.3" } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } } ], "version" : 3 diff --git a/Museum/ContentView.swift b/Museum/ContentView.swift index bf58b2f..9025baf 100644 --- a/Museum/ContentView.swift +++ b/Museum/ContentView.swift @@ -9,11 +9,23 @@ import SwiftUI import FactoryKit struct ContentView: View { - @ObservedObject private var viewModel = Container.shared.assetDisplayViewModel() + let asset: Asset + + @ObservedObject private var viewModel: AssetDisplayViewModel + + init(asset: Asset) { + self.asset = asset + self.viewModel = Container.shared.assetDisplayViewModel(asset) + } var body: some View { Group { switch viewModel.state { + case .idle: + ProgressView() + .onAppear { + viewModel.startLoading() + } case .loading(let progress): AssetLoadingView(progress: progress) case .loaded(let url): @@ -22,9 +34,6 @@ struct ContentView: View { failureContent } } - .onAppear { - viewModel.startLoading() - } } } @@ -46,5 +55,5 @@ private extension ContentView { } #Preview(windowStyle: .automatic) { - ContentView() + ContentView(asset: .warship) } diff --git a/Museum/Models/Asset.swift b/Museum/Models/Asset.swift index 18bbc28..3f63bc5 100644 --- a/Museum/Models/Asset.swift +++ b/Museum/Models/Asset.swift @@ -7,7 +7,7 @@ import simd -nonisolated enum Asset: String, CaseIterable, Sendable, CacheKeyProtocol { +nonisolated enum Asset: String, CaseIterable, Sendable, Equatable, CacheKeyProtocol { case warship = "KM1PUvbAai5kXm8" var value: String { rawValue } diff --git a/Museum/Models/AssetDisplayState.swift b/Museum/Models/AssetDisplayState.swift index 885d634..9bbee96 100644 --- a/Museum/Models/AssetDisplayState.swift +++ b/Museum/Models/AssetDisplayState.swift @@ -8,6 +8,7 @@ import Foundation nonisolated enum AssetDisplayState: Sendable { + case idle case loading(progress: Float) case loaded(url: URL) case failed(Error) diff --git a/Museum/MuseumApp.swift b/Museum/MuseumApp.swift index 5e09ebe..e33d520 100644 --- a/Museum/MuseumApp.swift +++ b/Museum/MuseumApp.swift @@ -13,6 +13,9 @@ struct MuseumApp: App { @ObservedObject private var viewModel = Container.shared.museumAppViewModel() @ObservedObject private var immersiveSpaceController = Container.shared.immersiveSpaceController() private let isRunningTests: Bool + // On a real app the user would first select which asset to be + // interacted with + private let currentAsset = Asset.warship init() { isRunningTests = NSClassFromString("XCTestProbe") != nil @@ -28,16 +31,16 @@ struct MuseumApp: App { case .loading: loadingContent case .loaded: - ContentView() + ContentView(asset: currentAsset) case .failed: failureContent } } .windowStyle(.volumetric) - .defaultSize(width: 1, height: 1, depth: 0.5, in: .meters) + .defaultSize(width: 1, height: 1, depth: 5, in: .meters) ImmersiveSpace(id: Constants.immersiveSpaceId) { - ImmersiveAssetView() + ImmersiveAssetView(asset: currentAsset) } .immersionStyle(selection: .constant(.full), in: .full) } diff --git a/Museum/Views/AssetDetailView.swift b/Museum/Views/AssetDetailView.swift index cffb104..1c4377c 100644 --- a/Museum/Views/AssetDetailView.swift +++ b/Museum/Views/AssetDetailView.swift @@ -8,7 +8,9 @@ import SwiftUI import RealityKit import FactoryKit +import Equatable +@Equatable struct AssetDetailView: View { let asset: Asset let url: URL @@ -29,6 +31,7 @@ struct AssetDetailView: View { .scaledToFit() .offset(z: CGFloat(asset.offsetZ ?? 0)) .onAppear { hasLoadedModel = true } + .onDisappear { hasLoadedModel = false } } else if let error = $0.error { ContentUnavailableView( "Invalid Model", @@ -45,12 +48,8 @@ struct AssetDetailView: View { .font(.title) } } - .ornament(attachmentAnchor: .scene(.bottomFront)) { - if hasLoadedModel && immersiveSpaceController.phase == .closed { - Button("View Immersive") { - openImmersive() - } - } else { + .ornament(attachmentAnchor: .scene(.top)) { + if isLoadingExperience { VStack { ProgressView() Text("Loading experience...") @@ -58,12 +57,22 @@ struct AssetDetailView: View { .font(.headline) } } + .ornament(attachmentAnchor: .scene(.bottomFront)) { + if !isLoadingExperience { + Button("View Immersive") { + openImmersive() + } + } + } } } // MARK: - Private private extension AssetDetailView { + var isLoadingExperience: Bool { + !hasLoadedModel || immersiveSpaceController.phase != .closed + } func openImmersive() { immersiveSpaceController.phase = .opening diff --git a/Museum/Views/AssetLoadingView.swift b/Museum/Views/AssetLoadingView.swift index bc52ed9..f74ecb6 100644 --- a/Museum/Views/AssetLoadingView.swift +++ b/Museum/Views/AssetLoadingView.swift @@ -6,18 +6,26 @@ // import SwiftUI +import Equatable +@Equatable struct AssetLoadingView: View { let progress: Float var body: some View { - VStack(spacing: 16) { - ProgressView(value: Double(progress)) - .progressViewStyle(.linear) - .frame(width: 200) - Text("Downloading asset… \(Int(progress * 100))%") - .foregroundStyle(.secondary) + VStack(spacing: 40) { + Text("Downloading Asset") + .font(.title) + + VStack(spacing: 10) { + ProgressView(value: Double(progress)) + .progressViewStyle(.linear) + .frame(width: 200) + Text("\(Int(progress * 100))%") + } } .padding() + .frame(minWidth: 300, minHeight: 300) + .glassBackgroundEffect() } } diff --git a/Museum/Views/ImmersiveAssetView.swift b/Museum/Views/ImmersiveAssetView.swift index 0a59d69..731ec85 100644 --- a/Museum/Views/ImmersiveAssetView.swift +++ b/Museum/Views/ImmersiveAssetView.swift @@ -8,20 +8,28 @@ import SwiftUI import RealityKit import FactoryKit +import Equatable +@Equatable struct ImmersiveAssetView: View { - @ObservedObject private var viewModel = Container.shared.assetDisplayViewModel() - @ObservedObject private var tourViewModel = Container.shared.immersiveTourViewModel() + let asset: Asset + + @ObservedObject private var viewModel: AssetDisplayViewModel + @ObservedObject private var tourViewModel: ImmersiveTourViewModel @ObservedObject private var immersiveSpaceController = Container.shared.immersiveSpaceController() + init(asset: Asset) { + self.asset = asset + self.viewModel = Container.shared.assetDisplayViewModel(asset) + self.tourViewModel = Container.shared.immersiveTourViewModel(asset) + } + @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace var body: some View { Group { if case .loaded(let url) = viewModel.state { RealityView { content, attachments in - tourViewModel.configure(for: viewModel.asset) - do { let entity = try await ModelEntity(contentsOf: url) entity.name = tourAssetEntityName @@ -31,24 +39,23 @@ struct ImmersiveAssetView: View { } content.add(entity) - } catch { - // Entity load failure is non-fatal; - // the volumetric window remains available for retry - } content.add(makeSceneLighting()) - if let exitButton = attachments.entity(for: exitButtonAttachmentId) { - let headAnchor = AnchorEntity(.head) - exitButton.position = SIMD3(0, -0.45, -1) - headAnchor.addChild(exitButton) - content.add(headAnchor) - } + if let exitButton = attachments.entity(for: exitButtonAttachmentId) { + let headAnchor = AnchorEntity(.head) + exitButton.position = SIMD3(0, -0.45, -1) + headAnchor.addChild(exitButton) + content.add(headAnchor) + } - if let tourPanel = attachments.entity(for: tourPanelAttachmentId) { - let headAnchor = AnchorEntity(.head) - tourPanel.position = SIMD3(-0.55, 0, -1) - headAnchor.addChild(tourPanel) - content.add(headAnchor) + if let tourPanel = attachments.entity(for: tourPanelAttachmentId) { + let headAnchor = AnchorEntity(.head) + tourPanel.position = SIMD3(-0.55, 0, -1) + headAnchor.addChild(tourPanel) + content.add(headAnchor) + } + } catch { + dismissImmersive() } } update: { content, attachments in guard @@ -70,9 +77,6 @@ struct ImmersiveAssetView: View { } } } - .onDisappear { - immersiveSpaceController.phase = .closed - } } } @@ -87,6 +91,7 @@ private extension ImmersiveAssetView { func dismissImmersive() { Task { await dismissImmersiveSpace() + immersiveSpaceController.phase = .closed } } From 4432fc62c5365288f9cc6805e83326e717aa5ceb Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 14:55:23 -0300 Subject: [PATCH 4/9] Improved logging on view models --- Museum/ViewModels/AssetDisplayViewModel.swift | 24 ++++++------ .../ViewModels/ImmersiveTourViewModel.swift | 39 ++++++++++--------- 2 files changed, 33 insertions(+), 30 deletions(-) diff --git a/Museum/ViewModels/AssetDisplayViewModel.swift b/Museum/ViewModels/AssetDisplayViewModel.swift index 0cbec30..9cd0c05 100644 --- a/Museum/ViewModels/AssetDisplayViewModel.swift +++ b/Museum/ViewModels/AssetDisplayViewModel.swift @@ -13,9 +13,9 @@ import Foundation extension Container { @MainActor - var assetDisplayViewModel: Factory { - self { @MainActor in AssetDisplayViewModel() } - .shared + var assetDisplayViewModel: ParameterFactory { + self { @MainActor in AssetDisplayViewModel(asset: $0) } + .scopeOnParameters.shared } } @@ -32,27 +32,29 @@ protocol AssetDisplayViewModeling: ObservableObject { final class AssetDisplayViewModel: AssetDisplayViewModeling, ObservableObject { - @Published private(set) var state: AssetDisplayState = .loading(progress: 0) - @Published private(set) var asset: Asset = .warship + @Published private(set) var state: AssetDisplayState = .idle + @Published private(set) var asset: Asset private(set) var isLoading: Bool = false private let assetProvider: any AssetProviding private let logger: any Logging init( + asset: Asset, assetProvider: any AssetProviding = Container.shared.assetProviderManager(), logger: any Logging = Container.shared.logOperator("AssetDisplayViewModel") ) { + self.asset = asset self.assetProvider = assetProvider self.logger = logger - setupBindings() + logger.debug("init \(asset.rawValue)") } + deinit { logger.debug("deinit \(asset.rawValue)") } + func startLoading() { - guard !isLoading else { - logger.warning("startLoading() called while already loading — ignoring") - return - } + guard !isLoading else { return } + logger.info("Started loading asset") isLoading = true state = .loading(progress: 0) Task.detached { [weak self] in @@ -71,8 +73,6 @@ final class AssetDisplayViewModel: AssetDisplayViewModeling, ObservableObject { private extension AssetDisplayViewModel { - func setupBindings() {} - func performLoad() async { let strategy = RetryStrategy( maxAttempts: 2, diff --git a/Museum/ViewModels/ImmersiveTourViewModel.swift b/Museum/ViewModels/ImmersiveTourViewModel.swift index 7596021..3861034 100644 --- a/Museum/ViewModels/ImmersiveTourViewModel.swift +++ b/Museum/ViewModels/ImmersiveTourViewModel.swift @@ -14,9 +14,9 @@ import Combine extension Container { @MainActor - var immersiveTourViewModel: Factory { - self { @MainActor in ImmersiveTourViewModel() } - .shared + var immersiveTourViewModel: ParameterFactory { + self { @MainActor in ImmersiveTourViewModel(asset: $0) } + .scopeOnParameters.shared } } @@ -27,7 +27,6 @@ protocol ImmersiveTourViewModeling: ObservableObject { var currentSpot: Asset.TourSpot? { get } var canGoNext: Bool { get } var canGoPrevious: Bool { get } - func configure(for asset: Asset) func goToNext() func goToPrevious() func entityOrientation(for spot: Asset.TourSpot) -> simd_quatf @@ -39,7 +38,8 @@ final class ImmersiveTourViewModel: ImmersiveTourViewModeling, ObservableObject @Published private(set) var currentSpotIndex: Int = 0 - private var asset: Asset = .warship + let asset: Asset + private let logger: any Logging var currentSpot: Asset.TourSpot? { let spots = asset.tourSpots @@ -55,22 +55,26 @@ final class ImmersiveTourViewModel: ImmersiveTourViewModeling, ObservableObject currentSpotIndex > 0 } - init() { - setupBindings() - } - - func configure(for asset: Asset) { + init( + asset: Asset, + logger: any Logging = Container.shared.logOperator("ImmersiveTourViewModel") + ) { self.asset = asset - currentSpotIndex = 0 + self.logger = logger + logger.debug("init \(asset.rawValue)") } + deinit { logger.debug("deinit \(asset.rawValue)") } + func goToNext() { guard canGoNext else { return } + logger.info("Moving to next tour spot") currentSpotIndex = (currentSpotIndex + 1) % asset.tourSpots.count } func goToPrevious() { guard canGoPrevious else { return } + logger.info("Moving to previous tour spot") currentSpotIndex -= 1 } @@ -79,12 +83,11 @@ final class ImmersiveTourViewModel: ImmersiveTourViewModeling, ObservableObject let qx = simd_quatf(angle: euler.x, axis: SIMD3(1, 0, 0)) let qy = simd_quatf(angle: euler.y, axis: SIMD3(0, 1, 0)) let qz = simd_quatf(angle: euler.z, axis: SIMD3(0, 0, 1)) - return qy * qx * qz - } -} - -// MARK: - Private -private extension ImmersiveTourViewModel { - func setupBindings() {} + let orientation = qy * qx * qz + logger.debug( + "Orientation for spot \(spot.title)[\(spot.entityOrientation)]: \(orientation)" + ) + return orientation + } } From aa3de4eb23d92f96c8093203129a38fd540bf70d Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 14:55:36 -0300 Subject: [PATCH 5/9] Improve handling of windows between experiences --- Museum/MuseumApp.swift | 2 +- Museum/Other/Constants.swift | 1 + Museum/Views/AssetDetailView.swift | 2 ++ Museum/Views/ImmersiveAssetView.swift | 2 ++ 4 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Museum/MuseumApp.swift b/Museum/MuseumApp.swift index e33d520..e7c7482 100644 --- a/Museum/MuseumApp.swift +++ b/Museum/MuseumApp.swift @@ -26,7 +26,7 @@ struct MuseumApp: App { } var body: some Scene { - WindowGroup { + WindowGroup(id: Constants.volumetricSpaceId) { switch viewModel.loadingState { case .loading: loadingContent diff --git a/Museum/Other/Constants.swift b/Museum/Other/Constants.swift index fcf966c..7ad2d40 100644 --- a/Museum/Other/Constants.swift +++ b/Museum/Other/Constants.swift @@ -15,5 +15,6 @@ nonisolated enum Constants { in: .userDomainMask ).first! static let assetBaseURL = URL(string: "https://drive.trifork.com/index.php/s/")! + static let volumetricSpaceId = "VolumetricSpace" static let immersiveSpaceId = "ImmersiveAssetSpace" } diff --git a/Museum/Views/AssetDetailView.swift b/Museum/Views/AssetDetailView.swift index 1c4377c..0735e14 100644 --- a/Museum/Views/AssetDetailView.swift +++ b/Museum/Views/AssetDetailView.swift @@ -18,6 +18,7 @@ struct AssetDetailView: View { @ObservedObject private var immersiveSpaceController = Container.shared.immersiveSpaceController() @Environment(\.openImmersiveSpace) private var openImmersiveSpace + @Environment(\.dismissWindow) private var dismissWindow @State private var hasLoadedModel = false @@ -81,6 +82,7 @@ private extension AssetDetailView { switch result { case .opened: immersiveSpaceController.phase = .open + dismissWindow(id: Constants.volumetricSpaceId) case .userCancelled: immersiveSpaceController.phase = .closed case .error: diff --git a/Museum/Views/ImmersiveAssetView.swift b/Museum/Views/ImmersiveAssetView.swift index 731ec85..341c3aa 100644 --- a/Museum/Views/ImmersiveAssetView.swift +++ b/Museum/Views/ImmersiveAssetView.swift @@ -25,6 +25,7 @@ struct ImmersiveAssetView: View { } @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace + @Environment(\.openWindow) private var openWindow var body: some View { Group { @@ -92,6 +93,7 @@ private extension ImmersiveAssetView { Task { await dismissImmersiveSpace() immersiveSpaceController.phase = .closed + openWindow(id: Constants.volumetricSpaceId) } } From 0caae86be40a2a21e115ea5f16c384b61d218279 Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 15:19:41 -0300 Subject: [PATCH 6/9] Minor refactor on AssetDetailView --- Museum/Views/AssetDetailView.swift | 37 ++++++++++++++++-------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/Museum/Views/AssetDetailView.swift b/Museum/Views/AssetDetailView.swift index 0735e14..90d49b4 100644 --- a/Museum/Views/AssetDetailView.swift +++ b/Museum/Views/AssetDetailView.swift @@ -43,26 +43,29 @@ struct AssetDetailView: View { } } } - .ornament(attachmentAnchor: .scene(.top)) { - if hasLoadedModel { - Text(asset.title) - .font(.title) - } + .ornament( + visibility: hasLoadedModel ? .visible : .hidden, + attachmentAnchor: .scene(.top) + ) { + Text(asset.title) + .font(.title) } - .ornament(attachmentAnchor: .scene(.top)) { - if isLoadingExperience { - VStack { - ProgressView() - Text("Loading experience...") - } - .font(.headline) + .ornament( + visibility: isLoadingExperience ? .visible : .hidden, + attachmentAnchor: .scene(.top) + ) { + VStack { + ProgressView() + Text("Loading experience...") } + .font(.headline) } - .ornament(attachmentAnchor: .scene(.bottomFront)) { - if !isLoadingExperience { - Button("View Immersive") { - openImmersive() - } + .ornament( + visibility: !isLoadingExperience ? .visible : .hidden, + attachmentAnchor: .scene(.bottomFront) + ) { + Button("View Immersive") { + openImmersive() } } } From d4fa1a9d62c6800a8d3dec0ad5a68181e733411d Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 15:19:57 -0300 Subject: [PATCH 7/9] Minor edge case fix on AssetDownloadOperator --- Museum/Operators/AssetDownloadOperator.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Museum/Operators/AssetDownloadOperator.swift b/Museum/Operators/AssetDownloadOperator.swift index 269c23a..c17f8ba 100644 --- a/Museum/Operators/AssetDownloadOperator.swift +++ b/Museum/Operators/AssetDownloadOperator.swift @@ -73,7 +73,9 @@ nonisolated final class AssetDownloadOperator: AssetDownloading, @unchecked Send logger.debug("Downloading asset from \(url)") return AsyncThrowingStream { continuation in let task = Task { [weak self] in - guard let self else { return } + guard let self else { + return continuation.finish(throwing: CancellationError()) + } do { let fileURL = try await self.performDownload( from: url, From 60a72dd414d5313c88e225b9d693b2a038692a20 Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 15:20:34 -0300 Subject: [PATCH 8/9] Removed unnecessary tests on logging --- MuseumTests/ViewModels/AssetDisplayViewModelTests.swift | 5 ----- MuseumTests/ViewModels/MuseumAppViewModelTests.swift | 5 ----- 2 files changed, 10 deletions(-) diff --git a/MuseumTests/ViewModels/AssetDisplayViewModelTests.swift b/MuseumTests/ViewModels/AssetDisplayViewModelTests.swift index c2a806d..9fb23f5 100644 --- a/MuseumTests/ViewModels/AssetDisplayViewModelTests.swift +++ b/MuseumTests/ViewModels/AssetDisplayViewModelTests.swift @@ -108,11 +108,6 @@ struct AssetDisplayViewModelTests { let sut = makeSUT() sut.startLoading() sut.startLoading() - - let warningCount = mockLogger.entries.filter { - $0.level == "warning" && $0.message.contains("already loading") - }.count - #expect(warningCount == 1) } // MARK: - Retry diff --git a/MuseumTests/ViewModels/MuseumAppViewModelTests.swift b/MuseumTests/ViewModels/MuseumAppViewModelTests.swift index eebffe9..062bacb 100644 --- a/MuseumTests/ViewModels/MuseumAppViewModelTests.swift +++ b/MuseumTests/ViewModels/MuseumAppViewModelTests.swift @@ -73,11 +73,6 @@ struct MuseumAppViewModelTests { let sut = makeSUT() sut.startLoading() sut.startLoading() - - let warningCount = mockLogger.entries.filter { - $0.level == "warning" && $0.message.contains("already loading") - }.count - #expect(warningCount == 1) } // MARK: - Retry From 6828f73c035e367c92759d66ff68159fa7de1d16 Mon Sep 17 00:00:00 2001 From: Vitor Cesco Date: Fri, 20 Feb 2026 15:22:40 -0300 Subject: [PATCH 9/9] Updated CLAUDE.md --- CLAUDE.md | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 97cd324..bab6e76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,24 +3,7 @@ A SwiftUI visionOS app that serves as a portal for museum visitors to interact and immerse themselves in 3D assets and environments. ## Project Structure - -``` -Museum/ -├── Museum/ # App source code -│ ├── MuseumApp.swift # @main entry point (WindowGroup scene) -│ ├── ContentView.swift # Main UI view -│ ├── Info.plist # Supports multiple scenes -│ └── Assets.xcassets/ # visionOS solid image stack icon -├── MuseumTests/ # Swift Testing framework tests -│ └── MuseumTests.swift -├── Packages/ -│ └── RealityKitContent/ # Local SPM package for 3D assets -│ ├── Package.swift # Swift 6.2, visionOS 26+ -│ └── Sources/RealityKitContent/ -│ ├── RealityKitContent.swift # Bundle.module export -│ └── RealityKitContent.rkassets/ # USDA scenes & materials -└── Museum.xcodeproj/ -``` +Refer to the README.md file for project structure. ## Build & Run @@ -36,7 +19,7 @@ The app uses a light MVVM architecture with clearly defined layers (top to botto ### 1. Views - SwiftUI views with business logic extracted into view models -- Never cascade multiple `.sheet`, `.alert` or similar modifiers — use a single declaration controlled by a Controller +- Never cascade multiple `.sheet` or `.alert` modifiers — use a single declaration controlled by a Controller - Prefer alternatives to event subscribing modifiers (`.onAppear`, `.task`, `.onChange`) — only use when strictly necessary - Should always declare the view models as `@ObservedObject` and leave the lifecycle control to the scope of the Factory registration, such as `.singleton`, `.shared`, `.unique`