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
128 changes: 128 additions & 0 deletions Artifact/Model/ArtifactScenesViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Foundation
import RealityKit
import ArtifactScenes

enum SceneLoadingState {
case notLoaded
case loading
case loaded
case failed(Error)
}

class ArtifactScenesViewModel: ObservableObject {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using the Observation framework instead of ObservableObjects. Your app target is 18.1, so there is no reason to use the old API.

@Published var selectedSceneName: String
@Published private(set) var sceneLoadingState: SceneLoadingState = .notLoaded

private var sceneCache: [String: Entity] = [:]
private var preloadTasks: Set<Task<Void, Never>> = []
private let maxCacheSize = 4

private let journeyProgressManager = JourneyProgressManager()

private var currentIndex: Int = 0
private var artifacts: [Artifact] = []
private var journeyPrefix: String = ""

init(initialSceneName: String, artifacts: [Artifact] = [], journeyPrefix: String = "") {
self.selectedSceneName = initialSceneName
self.artifacts = artifacts
self.journeyPrefix = journeyPrefix

if let initialIndex = artifacts.firstIndex(where: { $0.sceneName == initialSceneName }) {
self.currentIndex = initialIndex
}
}

func selectScene(named sceneName: String) async {
// Run UI updates on main thread
await MainActor.run {
if let newIndex = artifacts.firstIndex(where: { $0.sceneName == sceneName }) {
currentIndex = newIndex
selectedSceneName = sceneName
}
}

cancelPreloadTasks()
await loadSelectedAndPreloadNext()

// if the scene loaded successfully,
// mark it as viewed in UserDefaults
if case .loaded = sceneLoadingState {
journeyProgressManager.markArtifactViewed(journeyPrefix: journeyPrefix, artifactName: sceneName)
}
}

private func loadSelectedAndPreloadNext() async {
// Load current scene if needed
await loadSceneIfNeeded(named: selectedSceneName, priority: .high)

// Preload next scenes
await preloadUpcomingScenes()
cleanupCache()
}

private func loadSceneIfNeeded(named sceneName: String, priority: TaskPriority = .medium) async {
guard sceneCache[sceneName] == nil else { return }

// Update loading state on main thread
await MainActor.run {
sceneLoadingState = .loading
}

do {
let scene = try await Entity(named: "\(journeyPrefix)/\(journeyPrefix)_\(sceneName)",
in: artifactScenesBundle)
// Cache and update state on main thread
await MainActor.run {
sceneCache[sceneName] = scene
sceneLoadingState = .loaded
}
} catch {
print("Error loading scene \(sceneName): \(error)")
await MainActor.run {
sceneLoadingState = .failed(error)
}
}
}

private func cancelPreloadTasks() {
preloadTasks.forEach { $0.cancel() }
preloadTasks.removeAll()
}

private func preloadUpcomingScenes() async {
// Determine next scenes to preload
let upcomingIndices = getUpcomingIndices()

// Create preload tasks
for index in upcomingIndices {
let sceneName = artifacts[index].sceneName
let task = Task(priority: .low) {
await loadSceneIfNeeded(named: sceneName)
}
preloadTasks.insert(task)
}
}

private func getUpcomingIndices() -> [Int] {
let nextIndices = (currentIndex + 1)...(currentIndex + 2)
return nextIndices.filter { $0 < artifacts.count }
}

private func cleanupCache() {
// Keep only current and adjacent models in cache
let keepIndices = Set((max(0, currentIndex - 1)...min(artifacts.count - 1, currentIndex + 2)))
let keepSceneNames = Set(keepIndices.map { artifacts[$0].sceneName })

// Remove scenes that are no longer needed
sceneCache = sceneCache.filter { keepSceneNames.contains($0.key) }
}

func getCachedScene(named sceneName: String) -> Entity? {
return sceneCache[sceneName]
}

deinit {
cancelPreloadTasks()
}
}
11 changes: 6 additions & 5 deletions Artifact/Model/DataModels.swift
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
struct Journey {
struct Journey: Codable, Identifiable {
let imageUrl: String
let title: String
let description: String
let artifactPrefix: String
let artifacts: [Artifact]

var id: String { title }

static let sampleJourneys = [
Journey(imageUrl: "https://artifact-ios.s3.us-east-2.amazonaws.com/7.jpg", title: "7 wonders", description: "Embark on a journey to explore the legendary New 7 Wonders of the World, marvels that span continents and time. From the ancient walls that stretch across China to the towering statue overlooking Rio, each wonder tells a story of human achievement and cultural legacy. Encounter the architectural splendor of Petra, the timeless beauty of the Taj Mahal, and the majestic terraces of Machu Picchu. Stand before the enduring grandeur of Chichen Itza and the epic arches of the Roman Colosseum. This journey isn’t just about visiting places—it’s a path through history, mystery, and the extraordinary tales behind the world’s most iconic structures. Let each wonder inspire awe, ignite curiosity, and deepen your connection to the world’s shared heritage.", artifactPrefix: "7", artifacts: [Artifact(sceneName: "Colosseum", info: "This ancient amphitheater in Rome hosted gladiator battles and public spectacles, embodying the grandeur of Roman engineering and the era’s complex social life."), Artifact(sceneName: "Great_Wall", info: "A massive architectural feat, the Great Wall stretches thousands of miles across northern China, originally built to protect against invasions and now a symbol of resilience and history."), Artifact(sceneName: "Petra", info: "Known as the “Rose City” for its pink sandstone cliffs, Petra is an ancient city carved into rock, showcasing the Nabatean civilization's architectural and engineering prowess."),Artifact(sceneName: "Christ_the_Redeemer", info: "Towering above Rio de Janeiro, this colossal statue of Jesus Christ stands with open arms, symbolizing peace, protection, and a warm welcome to visitors from all over the world."), Artifact(sceneName: "Machu_Picchu", info: "Nestled high in the Andes, this Inca citadel is a marvel of ancient engineering, blending seamlessly with the rugged mountain landscape and offering insights into a lost civilization."), Artifact(sceneName: "Taj_Mahal", info: "Built as a monument of love, the Taj Mahal is a stunning marble mausoleum with intricate inlay work, gardens, and symmetry, reflecting the beauty and devotion of the Mughal era."), Artifact(sceneName: "Chichen_Itza", info: "Once a thriving Maya city, Chichen Itza is home to the iconic El Castillo pyramid, an architectural masterpiece that reveals the Maya’s advanced understanding of astronomy.")]),
Journey(imageUrl: "https://artifact-ios.s3.us-east-2.amazonaws.com/solar-system.jpg", title: "Solar System", description: "Embark on a journey across the vast expanses of our solar system, where each planet holds unique mysteries and characteristics. From the fiery surface of Mercury to the distant, icy reaches of Neptune, explore the wonders orbiting our Sun. Glide past the swirling clouds of Jupiter, marvel at the rings of Saturn, and witness the vibrant landscapes of Mars. This journey invites you to experience the solar system’s scale, diversity, and the celestial beauty that lies beyond Earth, connecting us to the greater cosmos with every orbit.", artifactPrefix: "Solar", artifacts: [Artifact(sceneName: "Mercury", info: "The smallest planet and closest to the Sun, Mercury has extreme temperature shifts and a cratered surface, resembling Earth’s moon in appearance."), Artifact(sceneName: "Venus", info: "Often called Earth’s “sister planet” due to its similar size, Venus has a thick, toxic atmosphere and surface temperatures hot enough to melt lead."), Artifact(sceneName: "Earth", info: "The only planet known to support life, Earth has diverse ecosystems, abundant water, and a dynamic climate, making it uniquely habitable."), Artifact(sceneName: "Mars", info: "Known as the “Red Planet” for its rusty surface, Mars has vast deserts, canyons, and polar ice caps, and continues to intrigue scientists with the possibility of past water."), Artifact(sceneName: "Jupiter", info: "The largest planet, Jupiter is a gas giant with powerful storms, including the iconic Great Red Spot, and dozens of moons, making it a mini solar system."), Artifact(sceneName: "Saturn", info: "Famous for its stunning ring system, Saturn is a gas giant with a unique atmosphere and many moons, including Titan, which has its own weather patterns."), Artifact(sceneName: "Uranus", info: "Known for its unusual tilt, Uranus orbits the Sun on its side, has faint rings, and displays a pale blue color due to its icy atmosphere."), Artifact(sceneName: "Neptune", info: "The windiest planet in the solar system, Neptune is a deep blue gas giant with faint rings and a distant, cold orbit that marks the edge of the known planets.")])]
}

struct Artifact: Identifiable {
struct Artifact: Identifiable, Codable {
let sceneName: String
let info: String
var id: String {
sceneName
}

var id: String { sceneName }
}
18 changes: 18 additions & 0 deletions Artifact/Model/JourneyProgressManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

class JourneyProgressManager {
private let defaults = UserDefaults.standard
private let viewedArtifactsKey = "viewedArtifacts"

func getViewedArtifacts(for journeyPrefix: String) -> Set<String> {
let key = "\(viewedArtifactsKey)_\(journeyPrefix)"
return Set(defaults.stringArray(forKey: key) ?? [])
}

func markArtifactViewed(journeyPrefix: String, artifactName: String) {
let key = "\(viewedArtifactsKey)_\(journeyPrefix)"
var viewed = getViewedArtifacts(for: journeyPrefix)
viewed.insert(artifactName)
defaults.set(Array(viewed), forKey: key)
}
}
13 changes: 13 additions & 0 deletions Artifact/Model/JourneyService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

class JourneyService {
private let apiURL = "https://gn4xt2b916.execute-api.us-east-2.amazonaws.com/prod/journeys"
private let apiKey = "tP731AxMWA61ISM5XIaUf3XSdLQf8n3EnC8Jc660" // throttled, so ok to be public

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API keys should not be in Git Hub repositories.


func fetchJourneys() async throws -> [Journey] {
var request = URLRequest(url: URL(string: apiURL)!)
request.setValue(apiKey, forHTTPHeaderField: "x-api-key")
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode([Journey].self, from: data)
}
}
9 changes: 0 additions & 9 deletions Artifact/Model/JourneyViewModel.swift

This file was deleted.

24 changes: 24 additions & 0 deletions Artifact/Model/JourneysViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Foundation

@MainActor
class JourneysViewModel: ObservableObject {
@Published var journeys: [Journey] = []
@Published var isLoading = false
@Published var error: Error?

private let service = JourneyService()

func loadJourneys() {
isLoading = true

Task {
do {
journeys = try await service.fetchJourneys()
} catch {
self.error = error
}
isLoading = false
}
}
}

11 changes: 8 additions & 3 deletions Artifact/Views/BottomSheetView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import SwiftUI
struct BottomSheetView: View {
let sceneName: String
let info: String
@ObservedObject var viewModel: JourneyViewModel

let journeyProgressManager = JourneyProgressManager()

@ObservedObject var viewModel: ArtifactScenesViewModel
@State private var showingDetail = false

var body: some View {
Button(action: {
viewModel.selectedSceneName = sceneName
Task {
await viewModel.selectScene(named: sceneName)
}
}) {
VStack {
HStack {
Expand Down Expand Up @@ -48,5 +53,5 @@ struct BottomSheetView: View {
}

#Preview {
BottomSheetView(sceneName: "Chichen Itza", info: "Once a thriving Maya city, Chichen Itza is home to the iconic El Castillo pyramid, an architectural masterpiece that reveals the Maya’s advanced understanding of astronomy.", viewModel: .init(initialSceneName: "Chichen Itza"))
BottomSheetView(sceneName: "Chichen Itza", info: "Once a thriving Maya city, Chichen Itza is home to the iconic El Castillo pyramid, an architectural masterpiece that reveals the Maya’s advanced understanding of astronomy.", viewModel: .init(initialSceneName: "Chichen Itza", artifacts: [], journeyPrefix: ""))
}
7 changes: 6 additions & 1 deletion Artifact/Views/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import SwiftUI

struct ContentView: View {
@StateObject private var journeysViewModel = JourneysViewModel()

var body: some View {
NavigationView {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NavigationView is deprecated. You should consider using NavigationStack instead.

ScrollView {
VStack(alignment: .leading, spacing: 16) {
ForEach(Journey.sampleJourneys, id: \.self.title) { journey in
ForEach(journeysViewModel.journeys, id: \.self.title) { journey in
NavigationLink(destination: JourneyDetailView(journey)) {
JourneyCardView(journey)
}
Expand All @@ -15,6 +17,9 @@ struct ContentView: View {
.padding()
}
}
.onAppear {
journeysViewModel.loadJourneys()
}
}
}

Expand Down
52 changes: 32 additions & 20 deletions Artifact/Views/JourneyDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,47 @@ import SwiftUI

struct JourneyDetailView: View {
let journey: Journey
let journeyProgressManager = JourneyProgressManager()

@State private var viewedArtifactsCount: Int = 0

init(_ journey: Journey) {
self.journey = journey
}

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
headerContent

NavigationLink(destination: JourneyRealityView(journey)) {
Text("Start Journey")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
.padding(.horizontal)
}
.padding(.vertical, 8)

Text(journey.description)
.font(.body)
ScrollView {
VStack(alignment: .leading, spacing: 16) {
headerContent

Text("\(viewedArtifactsCount)/\(journey.artifacts.count) Artifacts discovered")
.font(.subheadline)
.foregroundColor(.secondary)
.padding(.horizontal)

NavigationLink(destination: JourneyRealityView(journey)) {
Text("Start Journey")
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
.padding(.horizontal)
}
.padding()
.padding(.vertical, 8)

Text(journey.description)
.font(.body)
.padding(.horizontal)
}
.navigationTitle(journey.title)
.padding()
}
.onAppear {
viewedArtifactsCount = journeyProgressManager.getViewedArtifacts(for: journey.artifactPrefix).count
}
.navigationTitle(journey.title)

}

@ViewBuilder
Expand Down
Loading